Focusing only on signals interoperability
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):
- angular: https://github.com/angular/angular/pull/60132
- signal-polyfill: https://github.com/proposal-signals/signal-polyfill/pull/50
- tansu: https://github.com/AmadeusITGroup/tansu/pull/151
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;
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?
@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();
};
};
Update (04/03/2025): updated sample implementation of signals relying on the above signal interoperability API
import {
afterBatch,
beginBatch,
getActiveConsumer,
setActiveConsumer,
type Consumer,
type Signal as InteropSignal,
type Watcher,
} from 'signal-interop-lib';
abstract class BaseSignal<T> {
protected _version = 0;
protected abstract _getValue(): T;
private _watchers: { notify: () => void; dirty: boolean }[] = [];
protected abstract _start(): void;
protected abstract _stop(): void;
protected _isStarted() {
return this._watchers.length > 0;
}
watchSignal(notify: () => void): Watcher {
const object = { notify, dirty: true };
let version = -1;
let started = false;
const res: Watcher = {
isUpToDate: () => !object.dirty,
isStarted: () => started,
update: () => {
if (object.dirty) {
if (started) {
object.dirty = false;
}
this._update();
const changed = this._version !== version;
version = this._version;
return changed;
}
return false;
},
start: () => {
if (!started) {
this._watchers.push(object);
started = true;
if (this._watchers.length === 1) {
this._start();
}
}
},
stop: () => {
object.dirty = true;
if (started) {
const index = this._watchers.indexOf(object);
if (index !== -1) {
this._watchers.splice(index, 1);
started = false;
if (this._watchers.length === 0) {
this._stop();
}
}
}
},
};
return res;
}
get(): T {
this._update();
getActiveConsumer()?.addProducer(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 InteropSignal {
constructor(private _value: T) {
super();
}
protected override _start(): void {}
protected override _stop(): void {}
protected override _getValue(): T {
return this._value;
}
set(value: T) {
if (!Object.is(value, this._value)) {
const endBatch = beginBatch();
let queueError;
try {
this._version++;
this._value = value;
this._markWatchersDirty();
} finally {
queueError = endBatch();
}
if (queueError) {
throw queueError.error;
}
}
}
}
const ERROR_VALUE: any = Symbol('error');
class Computed<T> extends BaseSignal<T> implements Consumer {
private _computing = false;
private _dirty = true;
private _error: any = null;
private _value: T = ERROR_VALUE;
private _depIndex = 0;
private _dependencies: {
signal: InteropSignal;
watcher: Watcher;
changed: boolean;
}[] = [];
constructor(private _fn: () => T) {
super();
this._markDirty = this._markDirty.bind(this);
}
private _markDirty() {
this._dirty = true;
this._markWatchersDirty();
}
protected override _start(): void {
this._dirty = true;
for (const dep of this._dependencies) {
dep.watcher.start();
}
}
protected override _stop(): void {
for (const dep of this._dependencies) {
dep.watcher.stop();
}
}
addProducer(signal: InteropSignal) {
const index = this._depIndex;
const curDep = this._dependencies[index];
let dep = curDep;
if (curDep?.signal !== signal) {
const watcher = signal.watchSignal(this._markDirty);
dep = { signal, watcher, changed: true };
this._dependencies[index] = dep;
if (curDep) {
this._dependencies.push(curDep);
}
if (this._isStarted()) {
dep.watcher.start();
}
}
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 || !this._isStarted()) {
let value;
let error;
const prevConsumer = getActiveConsumer();
this._computing = true;
try {
if (this._areDependenciesUpToDate()) {
return;
}
this._depIndex = 0;
setActiveConsumer(this);
const fn = this._fn;
value = fn();
setActiveConsumer(null);
const depIndex = this._depIndex;
const dependencies = this._dependencies;
while (dependencies.length > depIndex) {
dependencies.pop()!.watcher.stop();
}
error = null;
} catch (e) {
value = ERROR_VALUE;
error = e;
} finally {
this._dirty = false;
this._computing = false;
setActiveConsumer(prevConsumer);
}
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 alive = true;
const c = new Computed(fn);
const watcher = c.watchSignal(() => {
if (alive) {
afterBatch(update);
}
});
const update = () => {
if (alive) {
watcher.update();
}
};
watcher.start();
watcher.update();
return () => {
alive = false;
watcher.stop();
};
};
export 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;
};
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.
@EisenbergEffect Thank you for your answer. I have opened this PR on Angular with an implementation of my proposal for Angular signals. I would be happy indeed to collect feedback from authors of various signal libraries to go forward with this idea.
My primary concern is performance. It would be interesting to run these interop implementations through some benchmarks on to see if they are slowed down overly much.
My understanding of this issue is that it would also be "resolved" if we get this library through tc39. In that situation, if various framework authors all wrapped the JS Signal under the hood, you'd get the same interop, right? I think this is a nice idea and would be cool if there was buy-in from framework authors but it also feels like our main focus should stay encouraging applications to end up with a single, internal, Signals graph. Does that sound right? Tagging with "Possible Future Feature" but please let me know if I've misunderstood!