From fe15a5e85e8e520897fa2f39a56a5c13a170922a Mon Sep 17 00:00:00 2001 From: Devin Weaver Date: Fri, 17 May 2024 12:53:15 -0400 Subject: [PATCH 1/8] Attempt a monotonic revision based signals polyfill Prior to this change, we used the current signals polyfill which had some performance troubles. This change implements the signals API but uses Pzurek's monotonic revision to handle tracking and reactivity. This is an attempt to see if it shows performance improvements. Paired-with: NullVoxPopulli --- src/signal-polyfill.ts | 207 +++++++++++++++++++++++++++++++++++++++++ src/utils/reactive.ts | 2 +- src/utils/signals.ts | 2 +- src/utils/vm.ts | 2 +- 4 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 src/signal-polyfill.ts diff --git a/src/signal-polyfill.ts b/src/signal-polyfill.ts new file mode 100644 index 00000000..f309b0af --- /dev/null +++ b/src/signal-polyfill.ts @@ -0,0 +1,207 @@ +type Revision = number; + +interface Signal { + get(): T; +} + +const $WATCHED = Symbol('watched'); +const $UNWATCHED = Symbol('unwatched'); +const $REVISION = Symbol('revision'); +const $WATCHER_NOTIFY = Symbol('watcher notify'); + +const WATCHERS = new Set(); + +let CONSUME_TAGS: boolean = true; +let CURRENT_REVISION: Revision = 0; +let CURRENT_COMPUTATION: Set | null = null; +let CURRENT_COMPUTED: Computed | null = null; + +class Tag { + [$REVISION]: Revision = CURRENT_REVISION; +} + +function dirtyTag(tag: Tag): void { + if (CURRENT_COMPUTATION?.has(tag)) + throw new Error('cannot dirty tag that has been used during a computation'); + tag[$REVISION] = ++CURRENT_REVISION; + notifyWatchers(); +} + +function consumeTag(tag: Tag): void { + if (CONSUME_TAGS) CURRENT_COMPUTATION?.add(tag); +} + +function notifyWatchers(): void { + for (let watcher of WATCHERS) watcher[$WATCHER_NOTIFY](); +} + +function getMax(tags: Tag[]): Revision { + return Math.max(...tags.map((t) => t[$REVISION])); +} + +class State implements Signal { + private tag = new Tag(); + private equals = (a: T, b: T): boolean => a === b; + private [$WATCHED] = (): void => {}; + private [$UNWATCHED] = (): void => {}; + + constructor(private value: T, options: SignalOptions = {}) { + this.equals = options.equals ?? this.equals; + this[$WATCHED] = options[$WATCHED] ?? this[$WATCHED]; + this[$UNWATCHED] = options[$UNWATCHED] ?? this[$UNWATCHED]; + } + + get(): T { + consumeTag(this.tag); + return this.value; + } + + set(value: T): void { + if (this.equals(this.value, value)) return; + this.value = value; + dirtyTag(this.tag); + } +} + +class Computed implements Signal { + private lastTags: Tag[] | undefined; + private lastRevision: Revision | undefined; + private declare lastValue: T; + private equals = (a: T, b: T): boolean => a === b; + private [$WATCHED] = (): void => {}; + private [$UNWATCHED] = (): void => {}; + + constructor(private cb: (this: Computed) => T, options: SignalOptions = {}) { + this.equals = options.equals ?? this.equals; + this[$WATCHED] = options[$WATCHED] ?? this[$WATCHED]; + this[$UNWATCHED] = options[$UNWATCHED] ?? this[$UNWATCHED]; + } + + get(): T { + if (this.lastTags && getMax(this.lastTags) === this.lastRevision) { + if (CURRENT_COMPUTATION && this.lastTags.length > 0) + for (let tag of this.lastTags) CURRENT_COMPUTATION.add(tag); + return this.lastValue; + } + + let previousComputation = CURRENT_COMPUTATION; + + try { + this.lastValue = this.cb.call(this); + } finally { + let tags = Array.from(CURRENT_COMPUTATION ?? []); + this.lastTags = tags; + this.lastRevision = getMax(tags); + + if (previousComputation && tags.length > 0) + for (let tag of tags) previousComputation.add(tag); + + CURRENT_COMPUTATION = previousComputation; + CURRENT_COMPUTED = null; + } + + return this.lastValue; + } +} + +// This namespace includes "advanced" features that are better to +// leave for framework authors rather than application developers. +// Analogous to `crypto.subtle` +function untrack(cb: () => T): T { + try { + CONSUME_TAGS = false; + return cb(); + } finally { + CONSUME_TAGS = true; + } +} + +// Get the current computed signal which is tracking any signal reads, if any +function currentComputed(): Computed | null { + return CURRENT_COMPUTED; +} + +// Returns ordered list of all signals which this one referenced +// during the last time it was evaluated. +// For a Watcher, lists the set of signals which it is watching. +// function introspectSources(s: Computed | Watcher): (State | Computed)[]; + +// Returns the Watchers that this signal is contained in, plus any +// Computed signals which read this signal last time they were evaluated, +// if that computed signal is (recursively) watched. +// function introspectSinks(s: State | Computed): (Computed | Watcher)[]; + +// True if this signal is "live", in that it is watched by a Watcher, +// or it is read by a Computed signal which is (recursively) live. +// function hasSinks(s: State | Computed): boolean; + +// True if this element is "reactive", in that it depends +// on some other signal. A Computed where hasSources is false +// will always return the same constant. +// function hasSources(s: Computed | Watcher): boolean; + +class Watcher { + private signals = new Set>(); + + // When a (recursive) source of Watcher is written to, call this callback, + // if it hasn't already been called since the last `watch` call. + // No signals may be read or written during the notify. + constructor(readonly notify: (this: Watcher) => void) {} + + // Add these signals to the Watcher's set, and set the watcher to run its + // notify callback next time any signal in the set (or one of its dependencies) changes. + // Can be called with no arguments just to reset the "notified" state, so that + // the notify callback will be invoked again. + watch(...signals: Signal[]): void { + for (let signal of signals) { + this.signals.add(signal); + } + if (this.signals.size > 0) WATCHERS.add(this); + } + + // Remove these signals from the watched set (e.g., for an effect which is disposed) + unwatch(...signals: Signal[]): void { + for (let signal of signals) { + this.signals.delete(signal); + } + if (this.signals.size === 0) WATCHERS.delete(this); + } + + // Returns the set of sources in the Watcher's set which are still dirty, or is a computed signal + // with a source which is dirty or pending and hasn't yet been re-evaluated + getPending(): Signal[] { + return Array.from(this.signals); + } + + [$WATCHER_NOTIFY](): void { + this.notify(); + } +} + +// Hooks to observe being watched or no longer watched +const watched = $WATCHED; +const unwatched = $UNWATCHED; + +export const Signal = { + State, + Computed, + subtle: { + Watcher, + currentComputed, + untrack, + watched, + unwatched, + }, +}; + +interface SignalOptions { + // Custom comparison function between old and new value. Default: Object.is. + // The signal is passed in as the this value for context. + equals?: (this: Signal, t: T, t2: T) => boolean; + + // Callback called when isWatched becomes true, if it was previously false + [$WATCHED]?: (this: Signal) => void; + + // Callback called whenever isWatched becomes false, if it was previously true + [$UNWATCHED]?: (this: Signal) => void; +} diff --git a/src/utils/reactive.ts b/src/utils/reactive.ts index 13c7e8c8..627d7a36 100644 --- a/src/utils/reactive.ts +++ b/src/utils/reactive.ts @@ -4,7 +4,7 @@ We explicitly update DOM only when it's needed and only if tags are changed. */ import { isFn, isTag, isTagLike, debugContext } from '@/utils/shared'; -import { Signal } from "signal-polyfill"; +import { Signal } from "../signal-polyfill"; export const asyncOpcodes = new WeakSet(); diff --git a/src/utils/signals.ts b/src/utils/signals.ts index 013a14da..64a89e33 100644 --- a/src/utils/signals.ts +++ b/src/utils/signals.ts @@ -1,4 +1,4 @@ -import { Signal } from "signal-polyfill"; +import { Signal } from "../signal-polyfill"; import { isRehydrationScheduled } from "./rehydration"; import { setIsRendering } from "./reactive"; diff --git a/src/utils/vm.ts b/src/utils/vm.ts index a5d391ce..29703408 100644 --- a/src/utils/vm.ts +++ b/src/utils/vm.ts @@ -8,7 +8,7 @@ import { inNewTrackingFrame, } from './reactive'; import { isFn } from './shared'; -import { Signal } from "signal-polyfill"; +import { Signal } from "../signal-polyfill"; import { w } from './signals'; type maybeDestructor = undefined | (() => void); From 9f609258cf7977023e7d78469ab3664cd8340886 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Fri, 17 May 2024 20:05:13 +0300 Subject: [PATCH 2/8] run perf on check-signals branch --- .github/workflows/perf.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index f8a0c9a5..e549b693 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -5,8 +5,9 @@ on: push: branches: - master + - check-signals pull_request: - branches: [master] + branches: [master, check-signals] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} From 57bb29c48425ff6519a898856672102e48957cdf Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Fri, 17 May 2024 21:03:50 +0300 Subject: [PATCH 3/8] fix build issues --- src/utils/dom.ts | 1 - src/utils/glimmer-validator.ts | 1 - src/utils/if.ts | 1 - src/utils/list.ts | 3 --- src/utils/reactive.ts | 4 +--- 5 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 2661636d..d745de4c 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -517,7 +517,6 @@ export function $_inElement( } else if (isTagLike(elementRef)) { appendRef = elementRef.value; } else { - // @ts-expect-error appendRef = elementRef; } const destructors: Destructors = []; diff --git a/src/utils/glimmer-validator.ts b/src/utils/glimmer-validator.ts index e1c64b14..ac1f6a02 100644 --- a/src/utils/glimmer-validator.ts +++ b/src/utils/glimmer-validator.ts @@ -41,7 +41,6 @@ export function trackedData( let hasInitializer = typeof initializer === 'function'; function getter(self: T) { - // @ts-expect-error consumeTag(cellFor(self, key)); let value; diff --git a/src/utils/if.ts b/src/utils/if.ts index ab38b0d0..1c7a07af 100644 --- a/src/utils/if.ts +++ b/src/utils/if.ts @@ -83,7 +83,6 @@ export function ifCondition( } }, runExistingDestructors, - // @ts-expect-error opcodeFor(cell, (value) => { if (throwedError) { Promise.resolve().then(() => { diff --git a/src/utils/list.ts b/src/utils/list.ts index 04847e8e..6934c41e 100644 --- a/src/utils/list.ts +++ b/src/utils/list.ts @@ -110,7 +110,6 @@ export class BasicListComponent { if (!isTagLike(tag)) { if (isArray(tag)) { console.warn('iterator for @each should be a cell'); - // @ts-expect-error tag = new Cell(tag, 'list tag'); } else if (isFn(originalTag)) { tag = formula(() => deepFnValue(originalTag), 'list tag'); @@ -298,7 +297,6 @@ export class SyncListComponent< constructor(params: ListComponentArgs, outlet: RenderTarget) { super(params, outlet); associateDestroyable(params.ctx, [ - // @ts-expect-error opcodeFor(this.tag, (value) => { this.syncList(value as T[]); }), @@ -333,7 +331,6 @@ export class AsyncListComponent< constructor(params: ListComponentArgs, outlet: RenderTarget) { super(params, outlet); associateDestroyable(params.ctx, [ - // @ts-expect-error opcodeFor(this.tag, async (value) => { await this.syncList(value as T[]); }), diff --git a/src/utils/reactive.ts b/src/utils/reactive.ts index 627d7a36..0e9d243c 100644 --- a/src/utils/reactive.ts +++ b/src/utils/reactive.ts @@ -104,7 +104,7 @@ export function setIsRendering(value: boolean) { // "data" cell, it's value can be updated, and it's used to create derived cells export class Cell { - _value!: Signal.State; + _value!: any; declare toHTML: () => string; [Symbol.toPrimitive]() { return this.value; @@ -115,7 +115,6 @@ export class Cell { this._value = new Signal.State(value); if (IS_DEV_MODE) { this._debugName = debugContext(debugName); - // @ts-expect-error DEBUG_CELLS.add(this); } } @@ -202,7 +201,6 @@ export function cellFor( obj[key], `${obj.constructor.name}.${String(key)}`, ); - // @ts-expect-error refs.set(key, cellValue); cellsMap.set(obj, refs); Object.defineProperty(obj, key, { From 362718f29b2ebba11ddb82473c3ca6a4ed3198a4 Mon Sep 17 00:00:00 2001 From: Devin Weaver Date: Mon, 20 May 2024 20:46:51 -0400 Subject: [PATCH 4/8] WIP check implementation with benchmark [skip ci] --- src/signal-polyfill.ts | 61 +++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/src/signal-polyfill.ts b/src/signal-polyfill.ts index f309b0af..03535f47 100644 --- a/src/signal-polyfill.ts +++ b/src/signal-polyfill.ts @@ -2,6 +2,7 @@ type Revision = number; interface Signal { get(): T; + isDirty: boolean; } const $WATCHED = Symbol('watched'); @@ -11,24 +12,24 @@ const $WATCHER_NOTIFY = Symbol('watcher notify'); const WATCHERS = new Set(); -let CONSUME_TAGS: boolean = true; -let CURRENT_REVISION: Revision = 0; -let CURRENT_COMPUTATION: Set | null = null; -let CURRENT_COMPUTED: Computed | null = null; +let consumeTags: boolean = true; +let currentRevision: Revision = 0; +let currentComputation: Set | null = null; +// let currentComputed: Computed | null = null; class Tag { - [$REVISION]: Revision = CURRENT_REVISION; + [$REVISION]: Revision = currentRevision; } function dirtyTag(tag: Tag): void { - if (CURRENT_COMPUTATION?.has(tag)) + if (currentComputation?.has(tag)) throw new Error('cannot dirty tag that has been used during a computation'); - tag[$REVISION] = ++CURRENT_REVISION; + tag[$REVISION] = ++currentRevision; notifyWatchers(); } function consumeTag(tag: Tag): void { - if (CONSUME_TAGS) CURRENT_COMPUTATION?.add(tag); + if (consumeTags) currentComputation?.add(tag); } function notifyWatchers(): void { @@ -41,11 +42,17 @@ function getMax(tags: Tag[]): Revision { class State implements Signal { private tag = new Tag(); + private lastRevision: Revision; private equals = (a: T, b: T): boolean => a === b; private [$WATCHED] = (): void => {}; private [$UNWATCHED] = (): void => {}; + get isDirty() { + return this.lastRevision < this.tag[$REVISION]; + } + constructor(private value: T, options: SignalOptions = {}) { + this.lastRevision = this.tag[$REVISION]; this.equals = options.equals ?? this.equals; this[$WATCHED] = options[$WATCHED] ?? this[$WATCHED]; this[$UNWATCHED] = options[$UNWATCHED] ?? this[$UNWATCHED]; @@ -53,6 +60,7 @@ class State implements Signal { get(): T { consumeTag(this.tag); + this.lastRevision = this.tag[$REVISION]; return this.value; } @@ -71,6 +79,10 @@ class Computed implements Signal { private [$WATCHED] = (): void => {}; private [$UNWATCHED] = (): void => {}; + get isDirty() { + return !(this.lastTags && getMax(this.lastTags) === this.lastRevision); + } + constructor(private cb: (this: Computed) => T, options: SignalOptions = {}) { this.equals = options.equals ?? this.equals; this[$WATCHED] = options[$WATCHED] ?? this[$WATCHED]; @@ -78,26 +90,27 @@ class Computed implements Signal { } get(): T { - if (this.lastTags && getMax(this.lastTags) === this.lastRevision) { - if (CURRENT_COMPUTATION && this.lastTags.length > 0) - for (let tag of this.lastTags) CURRENT_COMPUTATION.add(tag); + if (this.lastTags && !this.isDirty) { + if (currentComputation && this.lastTags.length > 0) + for (let tag of this.lastTags) currentComputation.add(tag); return this.lastValue; } - let previousComputation = CURRENT_COMPUTATION; + let previousComputation = currentComputation; + currentComputation = new Set(); try { this.lastValue = this.cb.call(this); } finally { - let tags = Array.from(CURRENT_COMPUTATION ?? []); + let tags = Array.from(currentComputation ?? []); this.lastTags = tags; this.lastRevision = getMax(tags); if (previousComputation && tags.length > 0) for (let tag of tags) previousComputation.add(tag); - CURRENT_COMPUTATION = previousComputation; - CURRENT_COMPUTED = null; + currentComputation = previousComputation; + // currentComputed = null; } return this.lastValue; @@ -109,17 +122,17 @@ class Computed implements Signal { // Analogous to `crypto.subtle` function untrack(cb: () => T): T { try { - CONSUME_TAGS = false; + consumeTags = false; return cb(); } finally { - CONSUME_TAGS = true; + consumeTags = true; } } // Get the current computed signal which is tracking any signal reads, if any -function currentComputed(): Computed | null { - return CURRENT_COMPUTED; -} +// function currentComputed(): Computed | null { +// return currentComputed; +// } // Returns ordered list of all signals which this one referenced // during the last time it was evaluated. @@ -170,7 +183,11 @@ class Watcher { // Returns the set of sources in the Watcher's set which are still dirty, or is a computed signal // with a source which is dirty or pending and hasn't yet been re-evaluated getPending(): Signal[] { - return Array.from(this.signals); + return Array.from(this.pending()); + } + + *pending(): Generator> { + for (let signal of this.signals) if (signal.isDirty) yield signal; } [$WATCHER_NOTIFY](): void { @@ -187,7 +204,7 @@ export const Signal = { Computed, subtle: { Watcher, - currentComputed, + // currentComputed, untrack, watched, unwatched, From d83dcd28fcd44a0e4ee7d418b8a8094b0bb6cddd Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Tue, 21 May 2024 20:26:21 +0300 Subject: [PATCH 5/8] new Signals implementation --- src/signal-polyfill.ts | 271 ++++++++++++----------------------------- 1 file changed, 78 insertions(+), 193 deletions(-) diff --git a/src/signal-polyfill.ts b/src/signal-polyfill.ts index 03535f47..bd3fae90 100644 --- a/src/signal-polyfill.ts +++ b/src/signal-polyfill.ts @@ -1,224 +1,109 @@ -type Revision = number; +//gist.github.com/lifeart/b6fc9ec2e111a12bb78a0558ef5afa11 -interface Signal { - get(): T; - isDirty: boolean; -} - -const $WATCHED = Symbol('watched'); -const $UNWATCHED = Symbol('unwatched'); -const $REVISION = Symbol('revision'); -const $WATCHER_NOTIFY = Symbol('watcher notify'); - -const WATCHERS = new Set(); - -let consumeTags: boolean = true; -let currentRevision: Revision = 0; -let currentComputation: Set | null = null; -// let currentComputed: Computed | null = null; - -class Tag { - [$REVISION]: Revision = currentRevision; -} - -function dirtyTag(tag: Tag): void { - if (currentComputation?.has(tag)) - throw new Error('cannot dirty tag that has been used during a computation'); - tag[$REVISION] = ++currentRevision; - notifyWatchers(); -} - -function consumeTag(tag: Tag): void { - if (consumeTags) currentComputation?.add(tag); -} - -function notifyWatchers(): void { - for (let watcher of WATCHERS) watcher[$WATCHER_NOTIFY](); -} - -function getMax(tags: Tag[]): Revision { - return Math.max(...tags.map((t) => t[$REVISION])); -} +// one of possible signals implementations -class State implements Signal { - private tag = new Tag(); - private lastRevision: Revision; - private equals = (a: T, b: T): boolean => a === b; - private [$WATCHED] = (): void => {}; - private [$UNWATCHED] = (): void => {}; +let USED_SIGNALS: Set<$Signal> | null = null; +const RELATED_WATCHERS: WeakMap> = new WeakMap(); +const COMPUTED_SIGNALS: WeakMap<$Signal, Set> = new WeakMap(); - get isDirty() { - return this.lastRevision < this.tag[$REVISION]; - } - - constructor(private value: T, options: SignalOptions = {}) { - this.lastRevision = this.tag[$REVISION]; - this.equals = options.equals ?? this.equals; - this[$WATCHED] = options[$WATCHED] ?? this[$WATCHED]; - this[$UNWATCHED] = options[$UNWATCHED] ?? this[$UNWATCHED]; +class $Signal { + value: any; + constructor(value: any) { + this.value = value; } - - get(): T { - consumeTag(this.tag); - this.lastRevision = this.tag[$REVISION]; + get() { + USED_SIGNALS?.add(this); return this.value; } - - set(value: T): void { - if (this.equals(this.value, value)) return; + set(value: any) { this.value = value; - dirtyTag(this.tag); + const Watchers: Set = new Set(); + COMPUTED_SIGNALS.get(this)?.forEach((computed) => { + computed.isValid = false; + computed.relatedSignals.forEach((signal) => { + COMPUTED_SIGNALS.get(signal)!.delete(computed); + }); + computed.relatedSignals = new Set(); + RELATED_WATCHERS.get(computed)!.forEach((watcher) => { + if (watcher.isWatching) { + watcher.pending.add(computed); + Watchers.add(watcher); + } + }); + }); + Watchers.forEach((watcher) => { + watcher.callback(); + }); } } -class Computed implements Signal { - private lastTags: Tag[] | undefined; - private lastRevision: Revision | undefined; - private declare lastValue: T; - private equals = (a: T, b: T): boolean => a === b; - private [$WATCHED] = (): void => {}; - private [$UNWATCHED] = (): void => {}; - - get isDirty() { - return !(this.lastTags && getMax(this.lastTags) === this.lastRevision); +class Computed { + fn: Function; + relatedSignals: Set<$Signal> = new Set(); + isValid = false; + result: any; + constructor(fn = () => {}) { + this.fn = fn; } - - constructor(private cb: (this: Computed) => T, options: SignalOptions = {}) { - this.equals = options.equals ?? this.equals; - this[$WATCHED] = options[$WATCHED] ?? this[$WATCHED]; - this[$UNWATCHED] = options[$UNWATCHED] ?? this[$UNWATCHED]; - } - - get(): T { - if (this.lastTags && !this.isDirty) { - if (currentComputation && this.lastTags.length > 0) - for (let tag of this.lastTags) currentComputation.add(tag); - return this.lastValue; + get() { + if (this.isValid) { + return this.result; } - - let previousComputation = currentComputation; - currentComputation = new Set(); - + const oldSignals = USED_SIGNALS; + USED_SIGNALS = new Set(); try { - this.lastValue = this.cb.call(this); + this.result = this.fn(); + this.isValid = true; + return this.result; } finally { - let tags = Array.from(currentComputation ?? []); - this.lastTags = tags; - this.lastRevision = getMax(tags); - - if (previousComputation && tags.length > 0) - for (let tag of tags) previousComputation.add(tag); - - currentComputation = previousComputation; - // currentComputed = null; + this.relatedSignals = new Set(USED_SIGNALS); + USED_SIGNALS = oldSignals; + this.relatedSignals.forEach((signal) => { + if (!COMPUTED_SIGNALS.has(signal)) { + COMPUTED_SIGNALS.set(signal, new Set()); + } + COMPUTED_SIGNALS.get(signal)!.add(this); + }); } - - return this.lastValue; - } -} - -// This namespace includes "advanced" features that are better to -// leave for framework authors rather than application developers. -// Analogous to `crypto.subtle` -function untrack(cb: () => T): T { - try { - consumeTags = false; - return cb(); - } finally { - consumeTags = true; } } - -// Get the current computed signal which is tracking any signal reads, if any -// function currentComputed(): Computed | null { -// return currentComputed; -// } - -// Returns ordered list of all signals which this one referenced -// during the last time it was evaluated. -// For a Watcher, lists the set of signals which it is watching. -// function introspectSources(s: Computed | Watcher): (State | Computed)[]; - -// Returns the Watchers that this signal is contained in, plus any -// Computed signals which read this signal last time they were evaluated, -// if that computed signal is (recursively) watched. -// function introspectSinks(s: State | Computed): (Computed | Watcher)[]; - -// True if this signal is "live", in that it is watched by a Watcher, -// or it is read by a Computed signal which is (recursively) live. -// function hasSinks(s: State | Computed): boolean; - -// True if this element is "reactive", in that it depends -// on some other signal. A Computed where hasSources is false -// will always return the same constant. -// function hasSources(s: Computed | Watcher): boolean; - class Watcher { - private signals = new Set>(); - - // When a (recursive) source of Watcher is written to, call this callback, - // if it hasn't already been called since the last `watch` call. - // No signals may be read or written during the notify. - constructor(readonly notify: (this: Watcher) => void) {} - - // Add these signals to the Watcher's set, and set the watcher to run its - // notify callback next time any signal in the set (or one of its dependencies) changes. - // Can be called with no arguments just to reset the "notified" state, so that - // the notify callback will be invoked again. - watch(...signals: Signal[]): void { - for (let signal of signals) { - this.signals.add(signal); - } - if (this.signals.size > 0) WATCHERS.add(this); + constructor(callback: Function) { + this.callback = callback; } - - // Remove these signals from the watched set (e.g., for an effect which is disposed) - unwatch(...signals: Signal[]): void { - for (let signal of signals) { - this.signals.delete(signal); + watched: Set = new Set(); + pending: Set = new Set(); + callback: Function; + isWatching = true; + watch(computed?: Computed) { + if (!computed) { + this.isWatching = true; + return; } - if (this.signals.size === 0) WATCHERS.delete(this); - } - - // Returns the set of sources in the Watcher's set which are still dirty, or is a computed signal - // with a source which is dirty or pending and hasn't yet been re-evaluated - getPending(): Signal[] { - return Array.from(this.pending()); + if (!RELATED_WATCHERS.has(computed)) { + RELATED_WATCHERS.set(computed, new Set()); + } + RELATED_WATCHERS.get(computed)!.add(this); + this.watched.add(computed); } - - *pending(): Generator> { - for (let signal of this.signals) if (signal.isDirty) yield signal; + unwatch(computed: Computed) { + this.watched.delete(computed); + RELATED_WATCHERS.get(computed)!.delete(this); } - - [$WATCHER_NOTIFY](): void { - this.notify(); + getPending() { + try { + return Array.from(this.pending); + } finally { + this.pending.clear(); + this.isWatching = false; + } } } -// Hooks to observe being watched or no longer watched -const watched = $WATCHED; -const unwatched = $UNWATCHED; - export const Signal = { - State, + State: $Signal, Computed, subtle: { Watcher, - // currentComputed, - untrack, - watched, - unwatched, }, }; - -interface SignalOptions { - // Custom comparison function between old and new value. Default: Object.is. - // The signal is passed in as the this value for context. - equals?: (this: Signal, t: T, t2: T) => boolean; - - // Callback called when isWatched becomes true, if it was previously false - [$WATCHED]?: (this: Signal) => void; - - // Callback called whenever isWatched becomes false, if it was previously true - [$UNWATCHED]?: (this: Signal) => void; -} From 7aa55e81b4c0b15786d6cb92ca5f5559e5551c18 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Tue, 21 May 2024 21:08:06 +0300 Subject: [PATCH 6/8] remove related signals --- src/signal-polyfill.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/signal-polyfill.ts b/src/signal-polyfill.ts index bd3fae90..d2f37e93 100644 --- a/src/signal-polyfill.ts +++ b/src/signal-polyfill.ts @@ -20,10 +20,6 @@ class $Signal { const Watchers: Set = new Set(); COMPUTED_SIGNALS.get(this)?.forEach((computed) => { computed.isValid = false; - computed.relatedSignals.forEach((signal) => { - COMPUTED_SIGNALS.get(signal)!.delete(computed); - }); - computed.relatedSignals = new Set(); RELATED_WATCHERS.get(computed)!.forEach((watcher) => { if (watcher.isWatching) { watcher.pending.add(computed); @@ -39,7 +35,6 @@ class $Signal { class Computed { fn: Function; - relatedSignals: Set<$Signal> = new Set(); isValid = false; result: any; constructor(fn = () => {}) { @@ -56,14 +51,13 @@ class Computed { this.isValid = true; return this.result; } finally { - this.relatedSignals = new Set(USED_SIGNALS); - USED_SIGNALS = oldSignals; - this.relatedSignals.forEach((signal) => { + USED_SIGNALS.forEach((signal) => { if (!COMPUTED_SIGNALS.has(signal)) { COMPUTED_SIGNALS.set(signal, new Set()); } COMPUTED_SIGNALS.get(signal)!.add(this); }); + USED_SIGNALS = oldSignals; } } } From dbc5084a17d192bcbe849a8097e6e25e958b65d8 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Tue, 21 May 2024 21:18:08 +0300 Subject: [PATCH 7/8] simplify pending --- src/signal-polyfill.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/signal-polyfill.ts b/src/signal-polyfill.ts index d2f37e93..05a5188e 100644 --- a/src/signal-polyfill.ts +++ b/src/signal-polyfill.ts @@ -3,6 +3,7 @@ // one of possible signals implementations let USED_SIGNALS: Set<$Signal> | null = null; +let totalWatchers = 0; const RELATED_WATCHERS: WeakMap> = new WeakMap(); const COMPUTED_SIGNALS: WeakMap<$Signal, Set> = new WeakMap(); @@ -20,12 +21,15 @@ class $Signal { const Watchers: Set = new Set(); COMPUTED_SIGNALS.get(this)?.forEach((computed) => { computed.isValid = false; - RELATED_WATCHERS.get(computed)!.forEach((watcher) => { - if (watcher.isWatching) { - watcher.pending.add(computed); - Watchers.add(watcher); + if (Watchers.size === totalWatchers) { + return; + } + for (const watcher of RELATED_WATCHERS.get(computed)!) { + Watchers.add(watcher); + if (Watchers.size === totalWatchers) { + return; } - }); + } }); Watchers.forEach((watcher) => { watcher.callback(); @@ -64,9 +68,9 @@ class Computed { class Watcher { constructor(callback: Function) { this.callback = callback; + totalWatchers++; } watched: Set = new Set(); - pending: Set = new Set(); callback: Function; isWatching = true; watch(computed?: Computed) { @@ -85,10 +89,12 @@ class Watcher { RELATED_WATCHERS.get(computed)!.delete(this); } getPending() { + if (!this.isWatching) { + return []; + } try { - return Array.from(this.pending); + return Array.from(this.watched).filter((computed) => !computed.isValid); } finally { - this.pending.clear(); this.isWatching = false; } } From 2e200a456796c577922663292ac1d2b8332ce35d Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Fri, 14 Jun 2024 00:04:35 +0400 Subject: [PATCH 8/8] untrack --- src/signal-polyfill.ts | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/signal-polyfill.ts b/src/signal-polyfill.ts index 05a5188e..536867d8 100644 --- a/src/signal-polyfill.ts +++ b/src/signal-polyfill.ts @@ -3,7 +3,7 @@ // one of possible signals implementations let USED_SIGNALS: Set<$Signal> | null = null; -let totalWatchers = 0; +let inUntrackCall = false; const RELATED_WATCHERS: WeakMap> = new WeakMap(); const COMPUTED_SIGNALS: WeakMap<$Signal, Set> = new WeakMap(); @@ -21,14 +21,8 @@ class $Signal { const Watchers: Set = new Set(); COMPUTED_SIGNALS.get(this)?.forEach((computed) => { computed.isValid = false; - if (Watchers.size === totalWatchers) { - return; - } for (const watcher of RELATED_WATCHERS.get(computed)!) { Watchers.add(watcher); - if (Watchers.size === totalWatchers) { - return; - } } }); Watchers.forEach((watcher) => { @@ -37,6 +31,17 @@ class $Signal { } } +function untrack(fn = () => {}) { + inUntrackCall = true; + try { + fn(); + } catch(e) { + // EOL + } finally { + inUntrackCall = false; + } +}; + class Computed { fn: Function; isValid = false; @@ -49,26 +54,27 @@ class Computed { return this.result; } const oldSignals = USED_SIGNALS; - USED_SIGNALS = new Set(); + USED_SIGNALS = inUntrackCall ? USED_SIGNALS : new Set(); try { this.result = this.fn(); this.isValid = true; return this.result; } finally { - USED_SIGNALS.forEach((signal) => { - if (!COMPUTED_SIGNALS.has(signal)) { - COMPUTED_SIGNALS.set(signal, new Set()); - } - COMPUTED_SIGNALS.get(signal)!.add(this); - }); - USED_SIGNALS = oldSignals; + if (!inUntrackCall) { + USED_SIGNALS?.forEach((signal) => { + if (!COMPUTED_SIGNALS.has(signal)) { + COMPUTED_SIGNALS.set(signal, new Set()); + } + COMPUTED_SIGNALS.get(signal)!.add(this); + }); + USED_SIGNALS = oldSignals; + } } } } class Watcher { constructor(callback: Function) { this.callback = callback; - totalWatchers++; } watched: Set = new Set(); callback: Function; @@ -103,6 +109,7 @@ class Watcher { export const Signal = { State: $Signal, Computed, + untrack, subtle: { Watcher, },