chromium/ash/webui/recorder_app_ui/resources/core/reactive/lit.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Extended LitElement class to automatically rerender on signal
 * change.
 *
 * This is similar to SignalWatcher in lit-labs/preact-signal.
 */

import {
  LitElement,
  ReactiveController,
  ReactiveControllerHost,
} from 'chrome://resources/mwc/lit/index.js';

import {
  batch,
  Dispose,
  effect,
  ReadonlySignal,
  Signal,
  signal,
} from './signal.js';

interface PropertySignalBinding {
  signal: Signal<unknown>;
  prop: string;
}

export class ReactiveLitElement extends LitElement {
  private disposeUpdateEffect: Dispose|null = null;

  private inPerformUpdate = false;

  private readonly updateTrigger = signal<boolean>(false);

  // TODO(pihsun): Use a key to signal map if performance is an issue.
  private readonly propertySignalBindings: PropertySignalBinding[] = [];

  override performUpdate(): void {
    // We deviates with default lit semantic here and don't update while
    // disconnected. This ensures proper teardown of the update effect.
    if (!this.isUpdatePending || !this.isConnected) {
      return;
    }

    this.inPerformUpdate = true;
    if (this.disposeUpdateEffect !== null) {
      // Effect had already been set up, trigger it.
      // This lines triggers a rerun of the effect.
      this.updateTrigger.update((x) => !x);
      return;
    }

    this.disposeUpdateEffect = effect(() => {
      // Read the value of updateTrigger, so changing the value of
      // updateTrigger will triggers this effect.
      void this.updateTrigger.value;
      if (this.inPerformUpdate) {
        this.inPerformUpdate = false;
        super.performUpdate();
      } else {
        // This is being triggered by signal dependency changes in
        // super.performUpdate().
        this.requestUpdate();
      }
    });
  }

  override connectedCallback(): void {
    super.connectedCallback();
    // Request render on connect, so we know the signals used in render.
    this.requestUpdate();
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();
    this.disposeUpdateEffect?.();
    this.disposeUpdateEffect = null;
  }

  registerPropertySignal(signal: Signal<unknown>, prop: string): void {
    this.propertySignalBindings.push({signal, prop});
  }

  override willUpdate(changedProperties: Map<PropertyKey, unknown>): void {
    batch(() => {
      for (const {signal, prop} of this.propertySignalBindings) {
        if (changedProperties.has(prop)) {
          // TODO(pihsun): Do we need to check changedProperties since signal
          // already does change check internally?
          signal.value = Reflect.get(this, prop);
        }
      }
    });
  }

  propSignal<T extends ReactiveLitElement, P extends string&keyof T>(
    this: T,
    prop: P,
  ): ReadonlySignal<T[P]> {
    const sig = signal(Reflect.get(this, prop));
    this.registerPropertySignal(sig, prop);
    return sig;
  }
}

export enum ComputedState {
  DONE = 'DONE',
  ERROR = 'ERROR',
  RUNNING = 'RUNNING',
  UNINITIALIZED = 'UNINITIALIZED',
}

export class ScopedAsyncComputed<T> implements ReactiveController {
  private dispose: Dispose|null = null;

  private readonly stateInternal = signal(ComputedState.UNINITIALIZED);

  // Note that this retains the latest successfully computed value even when
  // the value is being recomputing / the latest compute failed.
  // TODO(pihsun): Check if this behavior is expected.
  // TODO(pihsun): Save latest error.
  private readonly valueInternal = signal<T|null>(null);

  private readonly forceRerunToggle = signal(false);

  // Note that only the signal values before the first async point of callback
  // is tracked.
  constructor(
    host: ReactiveControllerHost,
    private readonly callback: (signal: AbortSignal) => Promise<T>,
  ) {
    host.addController(this);
  }

  rerun(): void {
    this.forceRerunToggle.update((x) => !x);
  }

  get state(): ComputedState {
    return this.stateInternal.value;
  }

  get value(): T|null {
    return this.valueInternal.value;
  }

  get valueSignal(): Signal<T|null> {
    return this.valueInternal;
  }

  hostConnected(): void {
    let latestRun!: symbol;
    let abortController: AbortController|null = null;
    this.dispose = effect(() => {
      void this.forceRerunToggle.value;

      abortController?.abort();
      abortController = new AbortController();

      const thisRun = Symbol();
      latestRun = thisRun;

      this.stateInternal.value = ComputedState.RUNNING;
      this.callback(abortController.signal)
        .then(
          (val) => {
            if (latestRun === thisRun) {
              this.valueInternal.value = val;
              this.stateInternal.value = ComputedState.DONE;
            }
          },
          (e) => {
            if (latestRun === thisRun) {
              console.error(e);
              // TODO(pihsun): Save the latest error.
              this.stateInternal.value = ComputedState.ERROR;
            }
          },
        );
    });
  }

  hostDisconnected(): void {
    this.dispose?.();
    this.dispose = null;
    this.stateInternal.value = ComputedState.UNINITIALIZED;
    this.valueInternal.value = null;
  }

  // TODO(pihsun): render() similar to @lit/task
}

// This is for exporting the class alias.
// eslint-disable-next-line @typescript-eslint/naming-convention
export const ScopedAsyncEffect = ScopedAsyncComputed<void>;