Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Focusing only on signals interoperability #259

Open
divdavem opened this issue Feb 7, 2025 · 3 comments
Open

Focusing only on signals interoperability #259

divdavem opened this issue Feb 7, 2025 · 3 comments

Comments

@divdavem
Copy link

divdavem commented Feb 7, 2025

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.

I have implemented this suggestion in our Tansu signal library in this PR

What do you think?

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;
  }
};

}
@EisenbergEffect
Copy link
Collaborator

Given some code like the following:

const a = library1.signal(1);
const b = library2.signal(1);
const sum = library3.computed(() => a.get() + b.get());

effect(() => console.log(sum.get()); // 2

a.set(2); // 3

Can you explain in further detail how the computed/effect functions would be written so that this all works together correctly?

@divdavem
Copy link
Author

divdavem commented Feb 10, 2025

@EisenbergEffect Thank you for your answer.

To answer your question, I have written below a very simple implementation of signals that relies (only) on the above SignalInterop namespace. Note that in an existing library, the (equivalent of the) _addDependency method could check whether the given signal object is from its own implementation and do something different (more optimized).

Also, maybe we should move the batch and afterBatch functions to the SignalInterop namespace (cf similar suggestion in #239 ) to make sure we can have synchronous effects working across signal implementations.

Sample implementation of signals that relies on the above SignalInterop namespace
let inBatch = false;
const batchQueue: (() => void)[] = [];
export const batch = <T>(fn: () => T): T => {
  if (inBatch) {
    return fn();
  }
  let res: T;
  let hasError = false;
  let error;
  inBatch = true;
  try {
    res = fn();
  } catch (e) {
    hasError = true;
    error = e;
  }
  while (batchQueue.length > 0) {
    try {
      batchQueue.shift()!();
    } catch (e) {
      if (!hasError) {
        hasError = true;
        error = e;
      }
    }
  }
  inBatch = false;
  if (hasError) {
    throw error;
  }
  return res!;
};

let plannedAsyncBatch = false;
const noop = () => {};
const asyncBatch = () => {
  plannedAsyncBatch = false;
  batch(noop);
};
const afterBatch = (fn: () => void) => {
  batchQueue.push(fn);
  if (!inBatch && !plannedAsyncBatch) {
    plannedAsyncBatch = true;
    Promise.resolve().then(asyncBatch);
  }
};

abstract class BaseSignal<T> {
  protected _version = 0;
  protected abstract _getValue(): T;
  private _watchers: { notify: () => void; version: number; dirty: boolean }[] = [];

  [SignalInterop.watchSignal](notify: () => void): SignalInterop.Watcher<T> {
    const object = { notify, version: -1, dirty: true };
    this._watchers.push(object);
    const res: SignalInterop.Watcher<T> = {
      isUpToDate: () => !object.dirty,
      get: () => {
        if (object.dirty) {
          throw new Error('Watcher is not up to date');
        }
        return this._getValue();
      },
      update: () => {
        if (object.dirty) {
          object.dirty = false;
          this._update();
          const changed = this._version !== object.version;
          object.version = this._version;
          return changed;
        }
        return false;
      },
      destroy: () => {
        const index = this._watchers.indexOf(object);
        if (index !== -1) {
          this._watchers.splice(index, 1);
        }
      },
    };
    return res;
  }

  get(): T {
    this._update();
    SignalInterop.callCurrentConsumer(this);
    return this._getValue();
  }

  protected _update() {}

  protected _markWatchersDirty() {
    for (const watcher of this._watchers) {
      if (!watcher.dirty) {
        watcher.dirty = true;
        const notify = watcher.notify;
        notify();
      }
    }
  }
}

class Signal<T> extends BaseSignal<T> implements SignalInterop.Signal<T> {
  constructor(private _value: T) {
    super();
  }

  protected override _getValue(): T {
    return this._value;
  }

  set(value: T) {
    if (!Object.is(value, this._value)) {
      batch(() => {
        this._version++;
        this._value = value;
        this._markWatchersDirty();
      });
    }
  }
}

const ERROR_VALUE: any = Symbol('error');

class Computed<T> extends BaseSignal<T> {
  private _computing = false;
  private _dirty = true;
  private _error: any = null;
  private _value: T = ERROR_VALUE;
  private _depIndex = 0;
  private _dependencies: {
    signal: SignalInterop.Signal<any>;
    watcher: SignalInterop.Watcher<any>;
    changed: boolean;
  }[] = [];

  constructor(private _fn: () => T) {
    super();
    this._addDependency = this._addDependency.bind(this);
    this._markDirty = this._markDirty.bind(this);
  }

  private _markDirty() {
    this._dirty = true;
    this._markWatchersDirty();
  }

  private _addDependency(signal: SignalInterop.Signal<any>) {
    const index = this._depIndex;
    const curDep = this._dependencies[index];
    let dep = curDep;
    if (curDep?.signal !== signal) {
      const watcher = signal[SignalInterop.watchSignal](this._markDirty);
      dep = { signal, watcher, changed: true };
      this._dependencies[index] = dep;
      if (curDep) {
        this._dependencies.push(curDep);
      }
    }
    dep.watcher.update();
    dep.changed = false;
    this._depIndex++;
  }

  protected override _getValue(): T {
    const value = this._value;
    if (value === ERROR_VALUE) {
      throw this._error;
    }
    return value;
  }

  private _areDependenciesUpToDate() {
    if (this._version === 0) {
      return false;
    }
    for (let i = 0; i < this._depIndex; i++) {
      const dep = this._dependencies[i];
      if (dep.changed) {
        return false;
      }
      if (dep.watcher.update()) {
        dep.changed = true;
        return false;
      }
    }
    return true;
  }

  protected override _update(): void {
    if (this._computing) {
      throw new Error('Circular dependency detected');
    }
    if (this._dirty) {
      let value;
      let error;
      this._computing = true;
      try {
        if (this._areDependenciesUpToDate()) {
          return;
        }
        this._depIndex = 0;
        value = SignalInterop.runWithConsumer(this._fn, this._addDependency);
        const depIndex = this._depIndex;
        const dependencies = this._dependencies;
        while (dependencies.length > depIndex) {
          dependencies.pop()!.watcher.destroy();
        }
        error = null;
      } catch (e) {
        value = ERROR_VALUE;
        error = e;
      } finally {
        this._dirty = false;
        this._computing = false;
      }
      if (!Object.is(value, this._value) || !Object.is(error, this._error)) {
        this._version++;
        this._value = value;
        this._error = error;
      }
    }
  }
}

export const signal = <T>(value: T): Signal<T> => new Signal(value);
export const computed = <T>(fn: () => T): Computed<T> => new Computed(fn);
export const effect = <T>(fn: () => T): (() => void) => {
  let destroyed = false;
  const c = new Computed(fn);
  const watcher = c[SignalInterop.watchSignal](() => {
    if (!destroyed) {
      afterBatch(update);
    }
  });
  const update = () => {
    if (!destroyed) {
      watcher.update();
    }
  };
  watcher.update();
  return () => {
    destroyed = true;
    watcher.destroy();
  };
};

@EisenbergEffect
Copy link
Collaborator

It would be great to get feedback on this from authors of various signal libraries. Because, if it looks feasible, this could be done as a community protocol effort, independent of standardization, giving us interoperability virtually overnight. We've done a few things like that in Web Components, where we created and documented various protocols, and then all the Web Component libraries implemented them in order to be interoperable with one another.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants