Skip to content

Focusing only on signals interoperability #259

@divdavem

Description

@divdavem

Hello,

I am opening this issue to suggest a specification that would only focus on making different signal implementations interoperable.

By this, I mean: if I have multiple signal implementations, if I create a signal with one implementation, and I use in it a computed of a different implementation, when the first signal changes, the computed of the second implementation should be recomputed correctly.

With the following code, I think we can achieve signals interoperability.
Each signal just have to expose a watchSignal method that returns a watcher.

What do you think?

Initial implementation
export namespace SignalInterop {

export const watchSignal = Symbol('watchSignal');

export interface Signal<T> {
  /**
   * Create a new watcher for the signal. The watcher is created out-of-date.
   * Once a watcher is out-of-date, it remains out-of-date until its update function is called.
   * @param notify - function to call synchronously when the watcher passes from the up-to-date state to the out-of-date state
   * (i.e. one of the transitive dependencies of the signal or the signal itself has changed).
   * The notify function must not read any signal synchronously. It can schedule an asynchronous task to read signals.
   * It should not throw any error. If other up-to-date watched signals depend on the value from this watcher, this notify function
   * should synchronously call their notify function.
   */
  [watchSignal](notify: () => void): Watcher<T>;
}

/**
 * A watcher is an object that keeps track of the value of a signal.
 */
export interface Watcher<T> {
  /**
   * Return true if the watcher is up-to-date, false otherwise.
   */
  isUpToDate(): boolean;

  /**
   * Recompute the value of the signal (if not already up-to-date) and return true if the value has changed since the last call of update.
   */
  update(): boolean;

  /**
   * Return the current value of the signal, or throw an error if the signal is in an error state.
   * Also throw an error if the watcher is not up-to-date.
   */
  get(): T;

  /**
   * Destroy the watcher and release any resources associated with it.
   * After calling destroy, the watcher should not be used anymore and the notify function will not be called anymore.
   */
  destroy(): void;
}

/**
 * A consumer is a function that can register signals as dependencies.
 * @param signal - the signal to register as a dependency.
 */
export type Consumer = <T>(signal: Signal<T>) => void;

let currentConsumer: Consumer | null = null;

/**
 * Call the current consumer to register a signal as a dependency.
 * @param signal - the signal to register as a dependency
 */
export const callCurrentConsumer: Consumer = (signal) => {
  currentConsumer?.(signal);
};

/**
 * @returns true if there is a current consumer, false otherwise
 */
export const hasCurrentConsumer = (): boolean => !!currentConsumer;

/**
 * Run a function with a given consumer.
 * @param f - the function to run
 * @param consumer - the consumer, if not defined, the current consumer is set to null (equivalent to untrack)
 * @returns the result of the function
 */
export const runWithConsumer = <T>(f: () => T, consumer: Consumer | null = null): T => {
  const prevConsumer = currentConsumer;
  currentConsumer = consumer;
  try {
    return f();
  } finally {
    currentConsumer = prevConsumer;
  }
};

}

List of signal libraries with the corresponding PRs implementing this API (as of 04/03/2025):

Update (04/03/2025): Here is a more detailed description and improved implementation:

To make signals interoperable, the most important part is to have a common way to get and to set the active consumer, so that when a signal from any library is read, it can notify the active consumer (which can be any computed or effect from any library) that there is a dependency on this signal.

The getActiveConsumer and setActiveConsumer functions are provided to get and set the active consumer, respectively:

/**
 * Set the active consumer.
 * @param consumer - the new active consumer
 * @returns the previous active consumer
 */
export declare const setActiveConsumer: (
  consumer: Consumer | null
) => Consumer | null;

/**
 * Get the active consumer.
 * @returns the active consumer
 */
export declare const getActiveConsumer: () => Consumer | null;

The Consumer interface is defined as follows:

/**
 * A consumer of signals.
 *
 * @remarks
 *
 * A consumer is an object that can be notified when a signal is being used.
 */
export interface Consumer {
  /**
   * Add a producer to the consumer. This method is called by the producer when it is used.
   */
  addProducer: (signal: Signal) => void;
}

When a signal is being used, it should notify the active consumer by calling the addProducer method of the active consumer:

getActiveConsumer()?.addProducer(signal);

This way, the active consumer can collect the list of signals it depends on.

Now, we should also define the Signal interface:

/**
 * An interoperable signal.
 */
export interface Signal {
  /**
   * Create a watcher on the signal with the given notify function.
   *
   * @remarks
   * The watcher is initially not started and out-of-date. Call {@link Watcher.start|start}
   * and {@link Watcher.update|update} to start it and make it up-to-date.
   *
   * @param notify - the function that will be called synchronously when the signal or any
   * of its (transitive) dependencies changes while the watcher is started and up-to-date
   * @returns a watcher on the signal
   */
  watchSignal(notify: () => void): Watcher;
}

The Signal interface only defines the watchSignal method, which is used to create a watcher on the signal.
Calling this method with a notify function will return a watcher on the signal.

The Watcher interface is defined as follows:

/**
 * A watcher on a signal.
 */
export interface Watcher {
  /**
   * Start the watcher.
   *
   * @remarks
   * Starting the watcher does not make it up-to-date.
   *
   * Call the {@link Watcher.update|update} method to make it up-to-date.
   *
   * Call {@link Watcher.stop|stop} to stop the watcher.
   */
  start(): void;
  /**
   * Stop the watcher.
   *
   * @remarks
   * As long as the watcher is stopped, it stays out-of-date and the notify function is not
   * called.
   */
  stop(): void;
  /**
   * Update the watcher.
   *
   * @remarks
   * It is possible to call this method whether the watcher is started or not:
   * - if the watcher is started, calling this method will make it up-to-date
   * - if the watcher is not started, the signal will be updated but calling
   * {@link Watcher.isUpToDate|isUpToDate} afterward will still return false.
   *
   * @returns true if the value of the watched signal changed since the last call of this
   * method, false otherwise.
   */
  update(): boolean;
  /**
   * Return whether the watcher is started.
   *
   * @remarks
   * A watcher is started if the {@link Watcher.start|start} method has been called and the
   * {@link Watcher.stop|stop} method has not been called since.
   *
   * @returns true if the watcher is started, false otherwise
   */
  isStarted(): boolean;
  /**
   * Return whether the watcher is up-to-date (and started).
   *
   * @remarks
   * A watcher is up-to-date if the watcher is started and its {@link Watcher.update|update}
   * method has been called afterward, and the notify function has not been called since the
   * last call of the update method.
   *
   * @returns true if the watcher is up-to-date (and started), false otherwise
   */
  isUpToDate(): boolean;
}

With this proposal, any signal library can implement the Signal interface and the Watcher interface, and any consumer can implement the Consumer interface. This way, signals from different libraries can work together seamlessly.

To finish with, the interoperability API also includes the beginBatch and afterBatch functions to provide interoperability between different signal libraries that support batching:

/**
 * Start batching signal updates.
 * Return a function to call at the end of the batch.
 * At the end of the top-level batch, all the functions planned with afterBatch are called.
 *
 * @returns a function to call at the end of the batch
 */
export declare const beginBatch: () => () => {
  error: any;
} | void;
/**
 * Plan a function to be called after the current batch.
 * If the current code is not running in a batch, the function is scheduled to be called after the current microtask.
 * @param fn - the function to call after the current batch
 */
export declare const afterBatch: (fn: () => void) => void;

Note that some signal libraries schedule effects asynchronously and thus do not have a batch function because all synchronous signal changes are inherently batched.
In this case, I think it is good to still use the beginBatch function around code that update signals in order to provide better interoperability with signal libraries that expect synchronous effects to be triggered after signal changes:

const endBatch = beginBatch();
let queueError;
try {
  // update signals
} finally {
  queueError = endBatch();
}
if (queueError) {
  throw queueError.error; // the first error that occurred in an afterBatch function
}

This implementation registers its API on the global object as signalInterop. If this object is already defined, it will not override it and just use it, so that there is only one instance in the global scope. So having multiple copies of this library in the same project (even though it is not recommended) will not cause any issue.

Full implementation
interface SignalInterop {
  getActiveConsumer: () => Consumer | null;
  setActiveConsumer: (consumer: Consumer | null) => Consumer | null;
  beginBatch: () => () => { error: any } | void;
  afterBatch: (fn: () => void) => void;
}

const createSignalInterop = (): SignalInterop => {
  let activeConsumer: Consumer | null = null;
  let inBatch = false;
  let batchQueue: (() => void)[] = [];
  let asyncBatchQueue: (() => void)[] = [];
  let plannedAsyncBatch = false;
  const noop = () => {};
  const asyncBatch = () => {
    plannedAsyncBatch = false;
    batchQueue = asyncBatchQueue;
    asyncBatchQueue = [];
    inBatch = true;
    endBatch();
  };
  const endBatch = () => {
    let queueError: { error: any } | undefined;
    while (batchQueue.length > 0) {
      try {
        batchQueue.shift()!();
      } catch (error) {
        if (!queueError) {
          queueError = { error };
        }
      }
    }
    inBatch = false;
    return queueError;
  };
  const beginBatch = () => {
    if (inBatch) {
      return noop;
    }
    inBatch = true;
    return endBatch;
  };
  return {
    getActiveConsumer: () => activeConsumer,
    setActiveConsumer: (consumer: Consumer | null): Consumer | null => {
      const prevConsumer = activeConsumer;
      activeConsumer = consumer;
      return prevConsumer;
    },
    beginBatch,
    afterBatch: (fn) => {
      if (inBatch) {
        batchQueue.push(fn);
      } else {
        asyncBatchQueue.push(fn);
        if (!plannedAsyncBatch) {
          plannedAsyncBatch = true;
          Promise.resolve().then(asyncBatch);
        }
      }
    },
  };
};

const signalInterop: SignalInterop = (() => {
  let res: SignalInterop | undefined = (globalThis as any).signalInterop;
  if (!res) {
    res = createSignalInterop();
    (globalThis as any).signalInterop = res;
  }
  return res;
})();

/**
 * An interoperable signal.
 */
export interface Signal {
  /**
   * Create a watcher on the signal with the given notify function.
   *
   * @remarks
   * The watcher is initially not started and out-of-date. Call {@link Watcher.start|start}
   * and {@link Watcher.update|update} to start it and make it up-to-date.
   *
   * @param notify - the function that will be called synchronously when the signal or any
   * of its (transitive) dependencies changes while the watcher is started and up-to-date
   * @returns a watcher on the signal
   */
  watchSignal(notify: () => void): Watcher;
}

/**
 * A watcher on a signal.
 */
export interface Watcher {
  /**
   * Start the watcher.
   *
   * @remarks
   * Starting the watcher does not make it up-to-date.
   *
   * Call the {@link Watcher.update|update} method to make it up-to-date.
   *
   * Call {@link Watcher.stop|stop} to stop the watcher.
   */
  start(): void;

  /**
   * Stop the watcher.
   *
   * @remarks
   * As long as the watcher is stopped, it stays out-of-date and the notify function is not
   * called.
   */
  stop(): void;

  /**
   * Update the watcher.
   *
   * @remarks
   * It is possible to call this method whether the watcher is started or not:
   * - if the watcher is started, calling this method will make it up-to-date
   * - if the watcher is not started, the signal will be updated but calling
   * {@link Watcher.isUpToDate|isUpToDate} afterward will still return false.
   *
   * @returns true if the value of the watched signal changed since the last call of this
   * method, false otherwise.
   */
  update(): boolean;

  /**
   * Return whether the watcher is started.
   *
   * @remarks
   * A watcher is started if the {@link Watcher.start|start} method has been called and the
   * {@link Watcher.stop|stop} method has not been called since.
   *
   * @returns true if the watcher is started, false otherwise
   */
  isStarted(): boolean;

  /**
   * Return whether the watcher is up-to-date (and started).
   *
   * @remarks
   * A watcher is up-to-date if the watcher is started and its {@link Watcher.update|update}
   * method has been called afterward, and the notify function has not been called since the
   * last call of the update method.
   *
   * @returns true if the watcher is up-to-date (and started), false otherwise
   */
  isUpToDate(): boolean;
}

/**
 * A consumer of signals.
 *
 * @remarks
 *
 * A consumer is an object that can be notified when a signal is being used.
 */
export interface Consumer {
  /**
   * Add a producer to the consumer. This method is called by the producer when it is used.
   */
  addProducer: (signal: Signal) => void;
}

/**
 * Set the active consumer.
 * @param consumer - the new active consumer
 * @returns the previous active consumer
 */
export const setActiveConsumer = signalInterop.setActiveConsumer;

/**
 * Get the active consumer.
 * @returns the active consumer
 */
export const getActiveConsumer = signalInterop.getActiveConsumer;

/**
 * Start batching signal updates.
 * Return a function to call at the end of the batch.
 * At the end of the top-level batch, all the functions planned with afterBatch are called.
 *
 * @returns a function to call at the end of the batch
 *
 * @example
 * ```ts
 * const endBatch = beginBatch();
 * let queueError;
 * try {
 *   // update signals
 * } finally {
 *   queueError = endBatch();
 * }
 * if (queueError) {
 *   throw queueError.error; // the first error that occurred in an afterBatch function
 * }
 * ```
 *
 * @example
 * ```ts
 * const batch = <T>(fn: () => T): T => {
 *   let res;
 *   let queueError;
 *   const endBatch = beginBatch();
 *   try {
 *     res = fn();
 *   } finally {
 *     queueError = endBatch();
 *   }
 *   if (queueError) {
 *     throw queueError.error;
 *   }
 *   return res;
 * };
 * ```
 */
export const beginBatch = signalInterop.beginBatch;

/**
 * Plan a function to be called after the current batch.
 * If the current code is not running in a batch, the function is scheduled to be called after the current microtask.
 * @param fn - the function to call after the current batch
 */
export const afterBatch = signalInterop.afterBatch;

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions