From 93f3bab380923e8c4db8f78f3e969526e8ba5a65 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 9 Jan 2025 02:08:16 +0800 Subject: [PATCH 01/53] Experiment with building a proposal API on top of alien-signals --- package.json | 3 + pnpm-lock.yaml | 9 + src/alien.ts | 244 +++++++++++++++++++ src/index.ts | 1 + tests/behaviors/custom-equality.test.ts | 2 +- tests/behaviors/dynamic-dependencies.test.ts | 2 +- tests/behaviors/errors.test.ts | 2 +- tests/behaviors/liveness.test.ts | 2 +- tests/behaviors/pruning.test.ts | 2 +- tests/behaviors/receivers.test.ts | 2 +- 10 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 src/alien.ts diff --git a/package.json b/package.json index 8ef2b77..d6265ca 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,9 @@ "vitest": "^1.4.0", "webdriverio": "^8.36.1" }, + "dependencies": { + "alien-signals": "^0.4.14" + }, "volta": { "node": "22.0.0", "pnpm": "9.0.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a1fed8..8c48025 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + alien-signals: + specifier: ^0.4.14 + version: 0.4.14 devDependencies: '@types/node': specifier: ^20.11.25 @@ -640,6 +644,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + alien-signals@0.4.14: + resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -3125,6 +3132,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + alien-signals@0.4.14: {} + ansi-regex@5.0.1: {} ansi-regex@6.0.1: {} diff --git a/src/alien.ts b/src/alien.ts new file mode 100644 index 0000000..fdedd7a --- /dev/null +++ b/src/alien.ts @@ -0,0 +1,244 @@ +import * as alien from 'alien-signals'; + +export namespace Signal { + export function batch(fn: () => T): T { + alien.startBatch(); + try { + return fn(); + } finally { + alien.endBatch(); + } + } + + export class State extends alien.Signal { + watchCount = 0; + + constructor( + value: T, + private options?: Options + ) { + super(value); + if (options && options.equals) { + this.equals = options.equals; + } + } + + equals(t: T, t2: T): boolean { + return Object.is(t, t2); + } + + onWatched() { + if (this.watchCount++ === 0) { + this.options?.[subtle.watched]?.call(this); + } + } + + onUnwatched() { + if (--this.watchCount === 0) { + this.options?.[subtle.unwatched]?.call(this); + } + } + + get() { + const lastSub = this.subsTail; + const value = super.get(); + const newSub = this.subsTail; + if (lastSub !== newSub && newSub instanceof Computed && newSub.watchCount) { + this.onWatched(); + } + return value; + } + + set(value: T): void { + if (!this.equals(this.currentValue, value)) { + this.currentValue = value; + const subs = this.subs; + if (subs !== undefined) { + alien.propagate(subs); + } + } + } + } + + export class Computed extends alien.Computed { + isError = true; + watchCount = 0; + + constructor( + getter: () => T, + private options?: Options + ) { + super(getter); + if (options && options.equals) { + this.equals = options.equals; + } + } + + equals(t: T, t2: T): boolean { + return Object.is(t, t2); + } + + onWatched() { + if (this.watchCount++ === 0) { + this.options?.[subtle.watched]?.call(this); + let link = this.deps; + while (link) { + const dep = link.dep as AnySignal; + dep.onWatched(); + link = link.nextDep; + } + } + } + + onUnwatched() { + if (--this.watchCount === 0) { + this.options?.[subtle.unwatched]?.call(this); + let link = this.deps; + while (link) { + const dep = link.dep as AnySignal; + dep.onUnwatched(); + link = link.nextDep; + } + } + } + + get() { + const lastSub = this.subsTail; + const value = super.get(); + const newSub = this.subsTail; + if (lastSub !== newSub && newSub instanceof Computed && newSub.watchCount) { + this.onWatched(); + } + if (this.isError) { + throw value; + } + return value; + } + + update(): boolean { + const prevSub = alien.activeSub; + const prevTrackId = alien.activeTrackId; + alien.setActiveSub(this, alien.nextTrackId()); + alien.startTrack(this); + try { + const oldValue = this.currentValue; + const newValue = this.getter(oldValue); + if (this.isError || !this.equals(oldValue!, newValue)) { + this.isError = false; + this.currentValue = newValue; + return true; + } + return false; + } catch (err) { + this.isError = true; + this.currentValue = err as any; + return true; + } finally { + let removeDeps!: AnySignal[]; + if (this.watchCount) { + removeDeps = []; + let link = this.depsTail ? this.depsTail.nextDep : this.deps; + while (link) { + const dep = link.dep as AnySignal; + removeDeps.push(dep); + link = link.nextDep; + } + } + alien.setActiveSub(prevSub, prevTrackId); + alien.endTrack(this); + if (this.watchCount) { + for (const dep of removeDeps) { + dep.onUnwatched(); + } + let link = this.deps; + while (link) { + const dep = link.dep as AnySignal; + dep.onWatched(); + link = link.nextDep; + } + } + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type AnySignal = State | Computed; + + export namespace subtle { + export class Watcher extends alien.Effect { + constructor(fn: () => void) { + super(fn); + } + + run() { + this.flags = alien.SubscriberFlags.None; + const prevSub = alien.activeSub; + const prevTrackId = alien.activeTrackId; + alien.setActiveSub(undefined, 0); + try { + this.fn(); + } finally { + alien.setActiveSub(prevSub, prevTrackId); + } + } + + watch(signal?: AnySignal): void { + if (!signal) { + console.log('No effect on this call'); + return; + } + alien.link(signal, this); + signal.onWatched(); + this.flags = alien.SubscriberFlags.None; + } + + unwatch(signal: AnySignal): void { + alien.startTrack(this); + let dep = this.deps; + while (dep) { + if (dep.dep !== signal) { + alien.link(signal, this); + } else { + signal.onUnwatched(); + } + dep = dep.nextDep; + } + alien.endTrack(this); + } + + getPending() { + return introspectSources(this) + .filter((source) => + source instanceof Computed + && source.flags & (alien.SubscriberFlags.ToCheckDirty | alien.SubscriberFlags.Dirty) + ); + } + } + + export function introspectSources(signal: alien.Subscriber) { + const arr: AnySignal[] = []; + let dep = signal.deps; + while (dep) { + arr.push(dep.dep as AnySignal); + dep = dep.nextDep; + } + return arr; + } + + // Hooks to observe being watched or no longer watched + export const watched = Symbol('watched'); + export const unwatched = Symbol('unwatched'); + } + + export interface Options { + // Custom comparison function between old and new value. Default: Object.is. + // The signal is passed in as an optionally-used third parameter for context. + equals?: (this: AnySignal, t: T, t2: T) => boolean; + + // Callback called when hasSinks becomes true, if it was previously false + [subtle.watched]?: (this: AnySignal) => void; + + // Callback called whenever hasSinks becomes false, if it was previously true + [subtle.unwatched]?: (this: AnySignal) => void; + } +} diff --git a/src/index.ts b/src/index.ts index ecab651..7b38257 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ +export {Signal as AlienSignal} from './alien.js'; export {Signal} from './wrapper.js'; diff --git a/tests/behaviors/custom-equality.test.ts b/tests/behaviors/custom-equality.test.ts index 3fc5416..e74d622 100644 --- a/tests/behaviors/custom-equality.test.ts +++ b/tests/behaviors/custom-equality.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src/alien.js'; describe('Custom equality', () => { it('works for State', () => { diff --git a/tests/behaviors/dynamic-dependencies.test.ts b/tests/behaviors/dynamic-dependencies.test.ts index fbe404e..05bbd78 100644 --- a/tests/behaviors/dynamic-dependencies.test.ts +++ b/tests/behaviors/dynamic-dependencies.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src/alien.js'; describe('Dynamic dependencies', () => { function run(live) { diff --git a/tests/behaviors/errors.test.ts b/tests/behaviors/errors.test.ts index bf366a2..412ab4d 100644 --- a/tests/behaviors/errors.test.ts +++ b/tests/behaviors/errors.test.ts @@ -1,5 +1,5 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src/alien.js'; describe('Errors', () => { it('are cached by computed signals', () => { diff --git a/tests/behaviors/liveness.test.ts b/tests/behaviors/liveness.test.ts index fce1773..9ad32a4 100644 --- a/tests/behaviors/liveness.test.ts +++ b/tests/behaviors/liveness.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src/alien.js'; describe('liveness', () => { it('only changes on first and last descendant', () => { diff --git a/tests/behaviors/pruning.test.ts b/tests/behaviors/pruning.test.ts index bec8175..8d92dd1 100644 --- a/tests/behaviors/pruning.test.ts +++ b/tests/behaviors/pruning.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src/alien.js'; describe('Pruning', () => { it('only recalculates until things are equal', () => { diff --git a/tests/behaviors/receivers.test.ts b/tests/behaviors/receivers.test.ts index abd6683..9d182f6 100644 --- a/tests/behaviors/receivers.test.ts +++ b/tests/behaviors/receivers.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src/alien.js'; describe('Receivers', () => { it('is this for computed', () => { From cf5b0e1a3e34c692f566a3abb0f4679d0028d29c Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 9 Jan 2025 02:11:09 +0800 Subject: [PATCH 02/53] Format --- src/alien.ts | 426 +++++++++++++++++++++++++-------------------------- 1 file changed, 213 insertions(+), 213 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index fdedd7a..74517ce 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -1,244 +1,244 @@ import * as alien from 'alien-signals'; export namespace Signal { - export function batch(fn: () => T): T { - alien.startBatch(); - try { - return fn(); - } finally { - alien.endBatch(); - } + export function batch(fn: () => T): T { + alien.startBatch(); + try { + return fn(); + } finally { + alien.endBatch(); + } + } + + export class State extends alien.Signal { + watchCount = 0; + + constructor( + value: T, + private options?: Options, + ) { + super(value); + if (options && options.equals) { + this.equals = options.equals; + } } - export class State extends alien.Signal { - watchCount = 0; - - constructor( - value: T, - private options?: Options - ) { - super(value); - if (options && options.equals) { - this.equals = options.equals; - } - } + equals(t: T, t2: T): boolean { + return Object.is(t, t2); + } - equals(t: T, t2: T): boolean { - return Object.is(t, t2); - } + onWatched() { + if (this.watchCount++ === 0) { + this.options?.[subtle.watched]?.call(this); + } + } - onWatched() { - if (this.watchCount++ === 0) { - this.options?.[subtle.watched]?.call(this); - } - } + onUnwatched() { + if (--this.watchCount === 0) { + this.options?.[subtle.unwatched]?.call(this); + } + } - onUnwatched() { - if (--this.watchCount === 0) { - this.options?.[subtle.unwatched]?.call(this); - } - } + get() { + const lastSub = this.subsTail; + const value = super.get(); + const newSub = this.subsTail; + if (lastSub !== newSub && newSub instanceof Computed && newSub.watchCount) { + this.onWatched(); + } + return value; + } - get() { - const lastSub = this.subsTail; - const value = super.get(); - const newSub = this.subsTail; - if (lastSub !== newSub && newSub instanceof Computed && newSub.watchCount) { - this.onWatched(); - } - return value; + set(value: T): void { + if (!this.equals(this.currentValue, value)) { + this.currentValue = value; + const subs = this.subs; + if (subs !== undefined) { + alien.propagate(subs); } + } + } + } + + export class Computed extends alien.Computed { + isError = true; + watchCount = 0; + + constructor( + getter: () => T, + private options?: Options, + ) { + super(getter); + if (options && options.equals) { + this.equals = options.equals; + } + } - set(value: T): void { - if (!this.equals(this.currentValue, value)) { - this.currentValue = value; - const subs = this.subs; - if (subs !== undefined) { - alien.propagate(subs); - } - } - } + equals(t: T, t2: T): boolean { + return Object.is(t, t2); } - export class Computed extends alien.Computed { - isError = true; - watchCount = 0; - - constructor( - getter: () => T, - private options?: Options - ) { - super(getter); - if (options && options.equals) { - this.equals = options.equals; - } + onWatched() { + if (this.watchCount++ === 0) { + this.options?.[subtle.watched]?.call(this); + let link = this.deps; + while (link) { + const dep = link.dep as AnySignal; + dep.onWatched(); + link = link.nextDep; } + } + } - equals(t: T, t2: T): boolean { - return Object.is(t, t2); + onUnwatched() { + if (--this.watchCount === 0) { + this.options?.[subtle.unwatched]?.call(this); + let link = this.deps; + while (link) { + const dep = link.dep as AnySignal; + dep.onUnwatched(); + link = link.nextDep; } + } + } - onWatched() { - if (this.watchCount++ === 0) { - this.options?.[subtle.watched]?.call(this); - let link = this.deps; - while (link) { - const dep = link.dep as AnySignal; - dep.onWatched(); - link = link.nextDep; - } - } - } + get() { + const lastSub = this.subsTail; + const value = super.get(); + const newSub = this.subsTail; + if (lastSub !== newSub && newSub instanceof Computed && newSub.watchCount) { + this.onWatched(); + } + if (this.isError) { + throw value; + } + return value; + } - onUnwatched() { - if (--this.watchCount === 0) { - this.options?.[subtle.unwatched]?.call(this); - let link = this.deps; - while (link) { - const dep = link.dep as AnySignal; - dep.onUnwatched(); - link = link.nextDep; - } - } + update(): boolean { + const prevSub = alien.activeSub; + const prevTrackId = alien.activeTrackId; + alien.setActiveSub(this, alien.nextTrackId()); + alien.startTrack(this); + try { + const oldValue = this.currentValue; + const newValue = this.getter(oldValue); + if (this.isError || !this.equals(oldValue!, newValue)) { + this.isError = false; + this.currentValue = newValue; + return true; } - - get() { - const lastSub = this.subsTail; - const value = super.get(); - const newSub = this.subsTail; - if (lastSub !== newSub && newSub instanceof Computed && newSub.watchCount) { - this.onWatched(); - } - if (this.isError) { - throw value; - } - return value; + return false; + } catch (err) { + this.isError = true; + this.currentValue = err as any; + return true; + } finally { + let removeDeps!: AnySignal[]; + if (this.watchCount) { + removeDeps = []; + let link = this.depsTail ? this.depsTail.nextDep : this.deps; + while (link) { + const dep = link.dep as AnySignal; + removeDeps.push(dep); + link = link.nextDep; + } } - - update(): boolean { - const prevSub = alien.activeSub; - const prevTrackId = alien.activeTrackId; - alien.setActiveSub(this, alien.nextTrackId()); - alien.startTrack(this); - try { - const oldValue = this.currentValue; - const newValue = this.getter(oldValue); - if (this.isError || !this.equals(oldValue!, newValue)) { - this.isError = false; - this.currentValue = newValue; - return true; - } - return false; - } catch (err) { - this.isError = true; - this.currentValue = err as any; - return true; - } finally { - let removeDeps!: AnySignal[]; - if (this.watchCount) { - removeDeps = []; - let link = this.depsTail ? this.depsTail.nextDep : this.deps; - while (link) { - const dep = link.dep as AnySignal; - removeDeps.push(dep); - link = link.nextDep; - } - } - alien.setActiveSub(prevSub, prevTrackId); - alien.endTrack(this); - if (this.watchCount) { - for (const dep of removeDeps) { - dep.onUnwatched(); - } - let link = this.deps; - while (link) { - const dep = link.dep as AnySignal; - dep.onWatched(); - link = link.nextDep; - } - } - } + alien.setActiveSub(prevSub, prevTrackId); + alien.endTrack(this); + if (this.watchCount) { + for (const dep of removeDeps) { + dep.onUnwatched(); + } + let link = this.deps; + while (link) { + const dep = link.dep as AnySignal; + dep.onWatched(); + link = link.nextDep; + } } + } } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - type AnySignal = State | Computed; - - export namespace subtle { - export class Watcher extends alien.Effect { - constructor(fn: () => void) { - super(fn); - } - - run() { - this.flags = alien.SubscriberFlags.None; - const prevSub = alien.activeSub; - const prevTrackId = alien.activeTrackId; - alien.setActiveSub(undefined, 0); - try { - this.fn(); - } finally { - alien.setActiveSub(prevSub, prevTrackId); - } - } - - watch(signal?: AnySignal): void { - if (!signal) { - console.log('No effect on this call'); - return; - } - alien.link(signal, this); - signal.onWatched(); - this.flags = alien.SubscriberFlags.None; - } - - unwatch(signal: AnySignal): void { - alien.startTrack(this); - let dep = this.deps; - while (dep) { - if (dep.dep !== signal) { - alien.link(signal, this); - } else { - signal.onUnwatched(); - } - dep = dep.nextDep; - } - alien.endTrack(this); - } - - getPending() { - return introspectSources(this) - .filter((source) => - source instanceof Computed - && source.flags & (alien.SubscriberFlags.ToCheckDirty | alien.SubscriberFlags.Dirty) - ); - } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type AnySignal = State | Computed; + + export namespace subtle { + export class Watcher extends alien.Effect { + constructor(fn: () => void) { + super(fn); + } + + run() { + this.flags = alien.SubscriberFlags.None; + const prevSub = alien.activeSub; + const prevTrackId = alien.activeTrackId; + alien.setActiveSub(undefined, 0); + try { + this.fn(); + } finally { + alien.setActiveSub(prevSub, prevTrackId); } + } - export function introspectSources(signal: alien.Subscriber) { - const arr: AnySignal[] = []; - let dep = signal.deps; - while (dep) { - arr.push(dep.dep as AnySignal); - dep = dep.nextDep; - } - return arr; + watch(signal?: AnySignal): void { + if (!signal) { + console.log('No effect on this call'); + return; + } + alien.link(signal, this); + signal.onWatched(); + this.flags = alien.SubscriberFlags.None; + } + + unwatch(signal: AnySignal): void { + alien.startTrack(this); + let dep = this.deps; + while (dep) { + if (dep.dep !== signal) { + alien.link(signal, this); + } else { + signal.onUnwatched(); + } + dep = dep.nextDep; } + alien.endTrack(this); + } + + getPending() { + return introspectSources(this).filter( + (source) => + source instanceof Computed && + source.flags & (alien.SubscriberFlags.ToCheckDirty | alien.SubscriberFlags.Dirty), + ); + } + } - // Hooks to observe being watched or no longer watched - export const watched = Symbol('watched'); - export const unwatched = Symbol('unwatched'); + export function introspectSources(signal: alien.Subscriber) { + const arr: AnySignal[] = []; + let dep = signal.deps; + while (dep) { + arr.push(dep.dep as AnySignal); + dep = dep.nextDep; + } + return arr; } - export interface Options { - // Custom comparison function between old and new value. Default: Object.is. - // The signal is passed in as an optionally-used third parameter for context. - equals?: (this: AnySignal, t: T, t2: T) => boolean; + // Hooks to observe being watched or no longer watched + export const watched = Symbol('watched'); + export const unwatched = Symbol('unwatched'); + } - // Callback called when hasSinks becomes true, if it was previously false - [subtle.watched]?: (this: AnySignal) => void; + export interface Options { + // Custom comparison function between old and new value. Default: Object.is. + // The signal is passed in as an optionally-used third parameter for context. + equals?: (this: AnySignal, t: T, t2: T) => boolean; - // Callback called whenever hasSinks becomes false, if it was previously true - [subtle.unwatched]?: (this: AnySignal) => void; - } + // Callback called when hasSinks becomes true, if it was previously false + [subtle.watched]?: (this: AnySignal) => void; + + // Callback called whenever hasSinks becomes false, if it was previously true + [subtle.unwatched]?: (this: AnySignal) => void; + } } From 0b9100158e632ca7836591cad276aaeaf7755f9d Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 9 Jan 2025 02:46:46 +0800 Subject: [PATCH 03/53] Enable cycles tests --- src/alien.ts | 3 +++ tests/behaviors/cycles.test.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/alien.ts b/src/alien.ts index 74517ce..1418f7a 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -103,6 +103,9 @@ export namespace Signal { } get() { + if (this.flags & alien.SubscriberFlags.Tracking) { + throw new Error('Cycles detected'); + } const lastSub = this.subsTail; const value = super.get(); const newSub = this.subsTail; diff --git a/tests/behaviors/cycles.test.ts b/tests/behaviors/cycles.test.ts index 5ebb7a4..63aff17 100644 --- a/tests/behaviors/cycles.test.ts +++ b/tests/behaviors/cycles.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src/alien.js'; describe('Cycles', () => { it('detects trivial cycles', () => { From 1a8275392e25e6cd6945bc7982080b58dd19421b Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 9 Jan 2025 02:49:52 +0800 Subject: [PATCH 04/53] Enable /prohibited-contexts.test --- tests/behaviors/prohibited-contexts.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/behaviors/prohibited-contexts.test.ts b/tests/behaviors/prohibited-contexts.test.ts index 2136a1b..239a163 100644 --- a/tests/behaviors/prohibited-contexts.test.ts +++ b/tests/behaviors/prohibited-contexts.test.ts @@ -1,5 +1,7 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src/alien.js'; + +const isAlien = true; describe('Prohibited contexts', () => { it('allows writes during computed', () => { @@ -8,10 +10,16 @@ describe('Prohibited contexts', () => { expect(c.get()).toBe(2); expect(s.get()).toBe(2); - // Note: c is marked clean in this case, even though re-evaluating it - // would cause it to change value (due to the set inside of it). - expect(c.get()).toBe(2); - expect(s.get()).toBe(2); + if (isAlien) { + expect(c.get()).toBe(3); + expect(s.get()).toBe(3); + } + else { + // Note: c is marked clean in this case, even though re-evaluating it + // would cause it to change value (due to the set inside of it). + expect(c.get()).toBe(2); + expect(s.get()).toBe(2); + } s.set(3); From 3c749d2e57c843421ae6bd81e86c046c361e47cb Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 9 Jan 2025 03:17:17 +0800 Subject: [PATCH 05/53] Fix more tests --- src/alien.ts | 36 ++++++++++++++------- tests/behaviors/prohibited-contexts.test.ts | 3 +- tests/behaviors/pruning.test.ts | 13 +++++--- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index 1418f7a..df3d277 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -1,6 +1,10 @@ import * as alien from 'alien-signals'; +const WATCHER_TRACK_ID = -1; + export namespace Signal { + export const untrack = alien.untrack; + export function batch(fn: () => T): T { alien.startBatch(); try { @@ -40,6 +44,9 @@ export namespace Signal { } get() { + if (alien.activeTrackId === WATCHER_TRACK_ID) { + throw new Error('Cannot read from state inside watcher'); + } const lastSub = this.subsTail; const value = super.get(); const newSub = this.subsTail; @@ -50,6 +57,9 @@ export namespace Signal { } set(value: T): void { + if (alien.activeTrackId === WATCHER_TRACK_ID) { + throw new Error('Cannot write to state inside watcher'); + } if (!this.equals(this.currentValue, value)) { this.currentValue = value; const subs = this.subs; @@ -106,6 +116,9 @@ export namespace Signal { if (this.flags & alien.SubscriberFlags.Tracking) { throw new Error('Cycles detected'); } + if (alien.activeTrackId === WATCHER_TRACK_ID) { + throw new Error('Cannot read from computed inside watcher'); + } const lastSub = this.subsTail; const value = super.get(); const newSub = this.subsTail; @@ -177,7 +190,7 @@ export namespace Signal { this.flags = alien.SubscriberFlags.None; const prevSub = alien.activeSub; const prevTrackId = alien.activeTrackId; - alien.setActiveSub(undefined, 0); + alien.setActiveSub(undefined, WATCHER_TRACK_ID); try { this.fn(); } finally { @@ -185,27 +198,26 @@ export namespace Signal { } } - watch(signal?: AnySignal): void { - if (!signal) { - console.log('No effect on this call'); - return; + watch(...signals: AnySignal[]): void { + for (const signal of signals) { + alien.link(signal, this); + signal.onWatched(); } - alien.link(signal, this); - signal.onWatched(); this.flags = alien.SubscriberFlags.None; } - unwatch(signal: AnySignal): void { + unwatch(...signals: AnySignal[]): void { alien.startTrack(this); let dep = this.deps; while (dep) { - if (dep.dep !== signal) { - alien.link(signal, this); - } else { - signal.onUnwatched(); + if (!signals.includes(dep.dep as AnySignal)) { + alien.link(dep.dep, this); } dep = dep.nextDep; } + for (const signal of signals) { + signal.onUnwatched(); + } alien.endTrack(this); } diff --git a/tests/behaviors/prohibited-contexts.test.ts b/tests/behaviors/prohibited-contexts.test.ts index 239a163..5838942 100644 --- a/tests/behaviors/prohibited-contexts.test.ts +++ b/tests/behaviors/prohibited-contexts.test.ts @@ -13,8 +13,7 @@ describe('Prohibited contexts', () => { if (isAlien) { expect(c.get()).toBe(3); expect(s.get()).toBe(3); - } - else { + } else { // Note: c is marked clean in this case, even though re-evaluating it // would cause it to change value (due to the set inside of it). expect(c.get()).toBe(2); diff --git a/tests/behaviors/pruning.test.ts b/tests/behaviors/pruning.test.ts index 8d92dd1..c45cb76 100644 --- a/tests/behaviors/pruning.test.ts +++ b/tests/behaviors/pruning.test.ts @@ -1,6 +1,8 @@ import {describe, expect, it} from 'vitest'; import {Signal} from '../../src/alien.js'; +const isAlien = true; + describe('Pruning', () => { it('only recalculates until things are equal', () => { const s = new Signal.State(0); @@ -51,11 +53,14 @@ describe('Pruning', () => { expect(n3).toBe(1); s.set(1); - expect(n).toBe(1); - expect(n2).toBe(1); - expect(n3).toBe(1); - expect(w.getPending().length).toBe(1); + if (!isAlien) { + expect(n).toBe(1); + expect(n2).toBe(1); + expect(n3).toBe(1); + + expect(w.getPending().length).toBe(1); + } expect(c3.get()).toBe(5); expect(n).toBe(2); From 3831315b9919440773e9cda7906f5d980bcdf501 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 9 Jan 2025 03:22:20 +0800 Subject: [PATCH 06/53] Add hasSinks, introspectSinks --- src/alien.ts | 14 ++++++++++++++ tests/behaviors/type-checking.test.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/alien.ts b/src/alien.ts index df3d277..0246a79 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -230,6 +230,20 @@ export namespace Signal { } } + export function hasSinks(signal: AnySignal) { + return signal.watchCount > 0; + } + + export function introspectSinks(signal: AnySignal) { + const arr: (Computed | subtle.Watcher)[] = []; + let sub = signal.subs; + while (sub) { + arr.push(sub.sub as Computed | subtle.Watcher); + sub = sub.nextSub; + } + return arr; + } + export function introspectSources(signal: alien.Subscriber) { const arr: AnySignal[] = []; let dep = signal.deps; diff --git a/tests/behaviors/type-checking.test.ts b/tests/behaviors/type-checking.test.ts index b66fefd..1cb4a25 100644 --- a/tests/behaviors/type-checking.test.ts +++ b/tests/behaviors/type-checking.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src/alien.js'; describe('Expected class shape', () => { it('should be on the prototype', () => { From fc9a5b5e0edcac10aac35e8278e1fb614b083cc1 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 9 Jan 2025 03:25:38 +0800 Subject: [PATCH 07/53] Update type-checking.test.ts --- tests/behaviors/type-checking.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/behaviors/type-checking.test.ts b/tests/behaviors/type-checking.test.ts index 1cb4a25..c8962f3 100644 --- a/tests/behaviors/type-checking.test.ts +++ b/tests/behaviors/type-checking.test.ts @@ -1,6 +1,8 @@ import {describe, expect, it} from 'vitest'; import {Signal} from '../../src/alien.js'; +const isAlien = true; + describe('Expected class shape', () => { it('should be on the prototype', () => { expect(typeof Signal.State.prototype.get).toBe('function'); @@ -12,7 +14,7 @@ describe('Expected class shape', () => { }); }); -describe('type checks', () => { +describe.skipIf(isAlien)('type checks', () => { it('checks types in methods', () => { let x = {}; let s = new Signal.State(1); From d8b49bbeb069a8b0d4254e16e937a71557773893 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 9 Jan 2025 03:52:57 +0800 Subject: [PATCH 08/53] Add pending flag --- src/alien.ts | 11 +++++++++++ tests/behaviors/pruning.test.ts | 13 ++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index 0246a79..24f4c5b 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -180,12 +180,23 @@ export namespace Signal { // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnySignal = State | Computed; + const PENDING = 1 << 5; + export namespace subtle { export class Watcher extends alien.Effect { constructor(fn: () => void) { super(fn); } + notify() { + let flags = this.flags; + if (flags & alien.SubscriberFlags.Dirty) { + this.run(); + } else if (flags & alien.SubscriberFlags.ToCheckDirty) { + this.flags = PENDING; + } + } + run() { this.flags = alien.SubscriberFlags.None; const prevSub = alien.activeSub; diff --git a/tests/behaviors/pruning.test.ts b/tests/behaviors/pruning.test.ts index c45cb76..8d92dd1 100644 --- a/tests/behaviors/pruning.test.ts +++ b/tests/behaviors/pruning.test.ts @@ -1,8 +1,6 @@ import {describe, expect, it} from 'vitest'; import {Signal} from '../../src/alien.js'; -const isAlien = true; - describe('Pruning', () => { it('only recalculates until things are equal', () => { const s = new Signal.State(0); @@ -53,14 +51,11 @@ describe('Pruning', () => { expect(n3).toBe(1); s.set(1); + expect(n).toBe(1); + expect(n2).toBe(1); + expect(n3).toBe(1); - if (!isAlien) { - expect(n).toBe(1); - expect(n2).toBe(1); - expect(n3).toBe(1); - - expect(w.getPending().length).toBe(1); - } + expect(w.getPending().length).toBe(1); expect(c3.get()).toBe(5); expect(n).toBe(2); From e6f28d643a05ecb66d5104a2405575a59a1faf25 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 11 Jan 2025 21:17:01 +0800 Subject: [PATCH 09/53] Rewrite in alien-signals 1.0.0-alpha.1 --- package.json | 2 +- pnpm-lock.yaml | 10 +-- src/alien.ts | 175 +++++++++++++++++++++++++++++-------------------- 3 files changed, 109 insertions(+), 78 deletions(-) diff --git a/package.json b/package.json index d6265ca..e0dcdfe 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "webdriverio": "^8.36.1" }, "dependencies": { - "alien-signals": "^0.4.14" + "alien-signals": "1.0.0-alpha.1" }, "volta": { "node": "22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c48025..7a880d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: alien-signals: - specifier: ^0.4.14 - version: 0.4.14 + specifier: 1.0.0-alpha.1 + version: 1.0.0-alpha.1 devDependencies: '@types/node': specifier: ^20.11.25 @@ -644,8 +644,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - alien-signals@0.4.14: - resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} + alien-signals@1.0.0-alpha.1: + resolution: {integrity: sha512-mgqYHniWu3xhHZWXB9zDaMP0ze1mFofqXXH2CXo4mGbNBSSm+HcraGbvY8tAvIsMOviH6F6vgcYD6sBy3qf3vg==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3132,7 +3132,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - alien-signals@0.4.14: {} + alien-signals@1.0.0-alpha.1: {} ansi-regex@5.0.1: {} diff --git a/src/alien.ts b/src/alien.ts index 24f4c5b..4c5e2f4 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -1,27 +1,45 @@ import * as alien from 'alien-signals'; -const WATCHER_TRACK_ID = -1; +const WATCHER_PLACEHOLDER = Symbol('watcher') as any; export namespace Signal { - export const untrack = alien.untrack; - - export function batch(fn: () => T): T { - alien.startBatch(); + const {drainQueuedEffects, endTrack, isDirty, link, propagate, shallowPropagate, startTrack} = + alien.createSystem({ + isComputed(sub): sub is Computed { + return sub instanceof Computed; + }, + isEffect(sub): sub is subtle.Watcher { + return sub instanceof subtle.Watcher; + }, + notifyEffect(watcher) { + watcher.notify(); + }, + updateComputed(computed) { + return computed.update(); + }, + }); + + let activeSub: alien.Subscriber | undefined; + + export function untrack(fn: () => T) { + const prevSub = activeSub; + activeSub = undefined; try { return fn(); } finally { - alien.endBatch(); + activeSub = prevSub; } } - export class State extends alien.Signal { + export class State implements alien.Dependency { + subs: alien.Link | undefined = undefined; + subsTail: alien.Link | undefined = undefined; watchCount = 0; constructor( - value: T, + private currentValue: T, private options?: Options, ) { - super(value); if (options && options.equals) { this.equals = options.equals; } @@ -44,41 +62,47 @@ export namespace Signal { } get() { - if (alien.activeTrackId === WATCHER_TRACK_ID) { + if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from state inside watcher'); } - const lastSub = this.subsTail; - const value = super.get(); - const newSub = this.subsTail; - if (lastSub !== newSub && newSub instanceof Computed && newSub.watchCount) { - this.onWatched(); + if (activeSub !== undefined && link(this, activeSub)) { + const newSub = this.subsTail!; + if (newSub instanceof Computed && newSub.watchCount) { + this.onWatched(); + } } - return value; + return this.currentValue; } set(value: T): void { - if (alien.activeTrackId === WATCHER_TRACK_ID) { + if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot write to state inside watcher'); } if (!this.equals(this.currentValue, value)) { this.currentValue = value; const subs = this.subs; if (subs !== undefined) { - alien.propagate(subs); + propagate(subs); + drainQueuedEffects(); } } } } - export class Computed extends alien.Computed { + export class Computed implements alien.Dependency, alien.Subscriber { + subs: alien.Link | undefined = undefined; + subsTail: alien.Link | undefined = undefined; + deps: alien.Link | undefined = undefined; + depsTail: alien.Link | undefined = undefined; + flags = alien.SubscriberFlags.Dirty; isError = true; watchCount = 0; + currentValue: T | undefined = undefined; constructor( - getter: () => T, + private getter: () => T, private options?: Options, ) { - super(getter); if (options && options.equals) { this.equals = options.equals; } @@ -116,29 +140,39 @@ export namespace Signal { if (this.flags & alien.SubscriberFlags.Tracking) { throw new Error('Cycles detected'); } - if (alien.activeTrackId === WATCHER_TRACK_ID) { + if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from computed inside watcher'); } - const lastSub = this.subsTail; - const value = super.get(); - const newSub = this.subsTail; - if (lastSub !== newSub && newSub instanceof Computed && newSub.watchCount) { - this.onWatched(); + + if (isDirty(this, this.flags)) { + if (this.update()) { + const subs = this.subs; + if (subs !== undefined) { + shallowPropagate(subs); + } + } } + if (activeSub !== undefined && link(this, activeSub)) { + const newSub = this.subsTail!; + if (newSub instanceof Computed && newSub.watchCount) { + this.onWatched(); + } + } + if (this.isError) { - throw value; + throw this.currentValue; } - return value; + + return this.currentValue; } update(): boolean { - const prevSub = alien.activeSub; - const prevTrackId = alien.activeTrackId; - alien.setActiveSub(this, alien.nextTrackId()); - alien.startTrack(this); + const prevSub = activeSub; + activeSub = this; + startTrack(this); + const oldValue = this.currentValue; try { - const oldValue = this.currentValue; - const newValue = this.getter(oldValue); + const newValue = this.getter(); if (this.isError || !this.equals(oldValue!, newValue)) { this.isError = false; this.currentValue = newValue; @@ -146,31 +180,31 @@ export namespace Signal { } return false; } catch (err) { - this.isError = true; - this.currentValue = err as any; - return true; + if (!this.isError || !this.equals(oldValue!, err as any)) { + this.isError = true; + this.currentValue = err as any; + return true; + } + return false; } finally { - let removeDeps!: AnySignal[]; if (this.watchCount) { - removeDeps = []; - let link = this.depsTail ? this.depsTail.nextDep : this.deps; - while (link) { + for ( + let link = this.depsTail !== undefined ? this.depsTail.nextDep : this.deps; + link !== undefined; + link = link.nextDep + ) { const dep = link.dep as AnySignal; - removeDeps.push(dep); - link = link.nextDep; + dep.onUnwatched(); } } - alien.setActiveSub(prevSub, prevTrackId); - alien.endTrack(this); + + activeSub = prevSub; + endTrack(this); + if (this.watchCount) { - for (const dep of removeDeps) { - dep.onUnwatched(); - } - let link = this.deps; - while (link) { + for (let link = this.deps; link !== undefined; link = link.nextDep) { const dep = link.dep as AnySignal; dep.onWatched(); - link = link.nextDep; } } } @@ -180,56 +214,53 @@ export namespace Signal { // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnySignal = State | Computed; - const PENDING = 1 << 5; - export namespace subtle { - export class Watcher extends alien.Effect { - constructor(fn: () => void) { - super(fn); - } + export class Watcher implements alien.Subscriber { + deps: alien.Link | undefined = undefined; + depsTail: alien.Link | undefined = undefined; + flags = alien.SubscriberFlags.None; + + constructor(private fn: () => void) {} notify() { - let flags = this.flags; - if (flags & alien.SubscriberFlags.Dirty) { + if (this.flags & alien.SubscriberFlags.Dirty) { this.run(); - } else if (flags & alien.SubscriberFlags.ToCheckDirty) { - this.flags = PENDING; } } run() { this.flags = alien.SubscriberFlags.None; - const prevSub = alien.activeSub; - const prevTrackId = alien.activeTrackId; - alien.setActiveSub(undefined, WATCHER_TRACK_ID); + const prevSub = activeSub; + activeSub = WATCHER_PLACEHOLDER; try { this.fn(); } finally { - alien.setActiveSub(prevSub, prevTrackId); + activeSub = prevSub; } } watch(...signals: AnySignal[]): void { for (const signal of signals) { - alien.link(signal, this); - signal.onWatched(); + if (link(signal, this)) { + signal.onWatched(); + } } this.flags = alien.SubscriberFlags.None; } unwatch(...signals: AnySignal[]): void { - alien.startTrack(this); + startTrack(this); let dep = this.deps; while (dep) { if (!signals.includes(dep.dep as AnySignal)) { - alien.link(dep.dep, this); + link(dep.dep, this); } dep = dep.nextDep; } for (const signal of signals) { - signal.onUnwatched(); + signal.onUnwatched(); // TODO: check if it's watched } - alien.endTrack(this); + endTrack(this); } getPending() { From 5a6a9e1d3c3de73c98d873cafcf40b8789a84e43 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 11 Jan 2025 22:34:07 +0800 Subject: [PATCH 10/53] Reliable watch, unwatch --- src/alien.ts | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index 4c5e2f4..b97cc8b 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -163,7 +163,7 @@ export namespace Signal { throw this.currentValue; } - return this.currentValue; + return this.currentValue!; } update(): boolean { @@ -219,6 +219,7 @@ export namespace Signal { deps: alien.Link | undefined = undefined; depsTail: alien.Link | undefined = undefined; flags = alien.SubscriberFlags.None; + watchList = new Set(); constructor(private fn: () => void) {} @@ -241,24 +242,29 @@ export namespace Signal { watch(...signals: AnySignal[]): void { for (const signal of signals) { - if (link(signal, this)) { - signal.onWatched(); + if (this.watchList.has(signal)) { + continue; } + this.watchList.add(signal); + link(signal, this); + signal.onWatched(); } this.flags = alien.SubscriberFlags.None; } unwatch(...signals: AnySignal[]): void { + for (const signal of signals) { + if (!this.watchList.has(signal)) { + continue; + } + this.watchList.delete(signal); + signal.onUnwatched(); + } startTrack(this); - let dep = this.deps; - while (dep) { - if (!signals.includes(dep.dep as AnySignal)) { + for (let dep = this.deps; dep !== undefined; dep = dep.nextDep) { + if (this.watchList.has(dep.dep as AnySignal)) { link(dep.dep, this); } - dep = dep.nextDep; - } - for (const signal of signals) { - signal.onUnwatched(); // TODO: check if it's watched } endTrack(this); } @@ -278,20 +284,16 @@ export namespace Signal { export function introspectSinks(signal: AnySignal) { const arr: (Computed | subtle.Watcher)[] = []; - let sub = signal.subs; - while (sub) { + for (let sub = signal.subs; sub !== undefined; sub = sub.nextSub) { arr.push(sub.sub as Computed | subtle.Watcher); - sub = sub.nextSub; } return arr; } export function introspectSources(signal: alien.Subscriber) { const arr: AnySignal[] = []; - let dep = signal.deps; - while (dep) { + for (let dep = signal.deps; dep !== undefined; dep = dep.nextDep) { arr.push(dep.dep as AnySignal); - dep = dep.nextDep; } return arr; } From 03d17f1098f207985244a69b0b8eb6a1c94f55e2 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 11 Jan 2025 23:00:03 +0800 Subject: [PATCH 11/53] wip --- src/alien.ts | 71 +++++++++++++++++++++------------------------------- 1 file changed, 28 insertions(+), 43 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index b97cc8b..0a76337 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -11,8 +11,18 @@ export namespace Signal { isEffect(sub): sub is subtle.Watcher { return sub instanceof subtle.Watcher; }, - notifyEffect(watcher) { - watcher.notify(); + notifyEffect(watcher: subtle.Watcher) { + const flags = watcher.flags; + watcher.flags = alien.SubscriberFlags.None; + if (flags & alien.SubscriberFlags.Dirty) { + const prevSub = activeSub; + activeSub = WATCHER_PLACEHOLDER; + try { + watcher.fn(); + } finally { + activeSub = prevSub; + } + } }, updateComputed(computed) { return computed.update(); @@ -115,11 +125,9 @@ export namespace Signal { onWatched() { if (this.watchCount++ === 0) { this.options?.[subtle.watched]?.call(this); - let link = this.deps; - while (link) { + for (let link = this.deps; link !== undefined; link = link.nextDep) { const dep = link.dep as AnySignal; dep.onWatched(); - link = link.nextDep; } } } @@ -127,11 +135,9 @@ export namespace Signal { onUnwatched() { if (--this.watchCount === 0) { this.options?.[subtle.unwatched]?.call(this); - let link = this.deps; - while (link) { + for (let link = this.deps; link !== undefined; link = link.nextDep) { const dep = link.dep as AnySignal; dep.onUnwatched(); - link = link.nextDep; } } } @@ -143,7 +149,6 @@ export namespace Signal { if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from computed inside watcher'); } - if (isDirty(this, this.flags)) { if (this.update()) { const subs = this.subs; @@ -158,11 +163,9 @@ export namespace Signal { this.onWatched(); } } - if (this.isError) { throw this.currentValue; } - return this.currentValue!; } @@ -196,16 +199,15 @@ export namespace Signal { const dep = link.dep as AnySignal; dep.onUnwatched(); } - } - - activeSub = prevSub; - endTrack(this); - - if (this.watchCount) { + activeSub = prevSub; + endTrack(this); for (let link = this.deps; link !== undefined; link = link.nextDep) { const dep = link.dep as AnySignal; dep.onWatched(); } + } else { + activeSub = prevSub; + endTrack(this); } } } @@ -221,24 +223,7 @@ export namespace Signal { flags = alien.SubscriberFlags.None; watchList = new Set(); - constructor(private fn: () => void) {} - - notify() { - if (this.flags & alien.SubscriberFlags.Dirty) { - this.run(); - } - } - - run() { - this.flags = alien.SubscriberFlags.None; - const prevSub = activeSub; - activeSub = WATCHER_PLACEHOLDER; - try { - this.fn(); - } finally { - activeSub = prevSub; - } - } + constructor(public fn: () => void) {} watch(...signals: AnySignal[]): void { for (const signal of signals) { @@ -249,7 +234,6 @@ export namespace Signal { link(signal, this); signal.onWatched(); } - this.flags = alien.SubscriberFlags.None; } unwatch(...signals: AnySignal[]): void { @@ -261,9 +245,10 @@ export namespace Signal { signal.onUnwatched(); } startTrack(this); - for (let dep = this.deps; dep !== undefined; dep = dep.nextDep) { - if (this.watchList.has(dep.dep as AnySignal)) { - link(dep.dep, this); + for (let _link = this.deps; _link !== undefined; _link = _link.nextDep) { + const dep = _link.dep as AnySignal; + if (this.watchList.has(dep)) { + link(dep, this); } } endTrack(this); @@ -284,16 +269,16 @@ export namespace Signal { export function introspectSinks(signal: AnySignal) { const arr: (Computed | subtle.Watcher)[] = []; - for (let sub = signal.subs; sub !== undefined; sub = sub.nextSub) { - arr.push(sub.sub as Computed | subtle.Watcher); + for (let link = signal.subs; link !== undefined; link = link.nextSub) { + arr.push(link.sub as Computed | subtle.Watcher); } return arr; } export function introspectSources(signal: alien.Subscriber) { const arr: AnySignal[] = []; - for (let dep = signal.deps; dep !== undefined; dep = dep.nextDep) { - arr.push(dep.dep as AnySignal); + for (let link = signal.deps; link !== undefined; link = link.nextDep) { + arr.push(link.dep as AnySignal); } return arr; } From cb894348f0f38f92e4012281a16c4e0ed5bd7f9d Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sun, 12 Jan 2025 20:05:33 +0800 Subject: [PATCH 12/53] Update alien to alpha.2 --- package.json | 2 +- pnpm-lock.yaml | 10 +++---- src/alien.ts | 72 ++++++++++++++++++++++++++------------------------ 3 files changed, 43 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index e0dcdfe..d87f0bb 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "webdriverio": "^8.36.1" }, "dependencies": { - "alien-signals": "1.0.0-alpha.1" + "alien-signals": "1.0.0-alpha.2" }, "volta": { "node": "22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a880d3..09ae9d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: alien-signals: - specifier: 1.0.0-alpha.1 - version: 1.0.0-alpha.1 + specifier: 1.0.0-alpha.2 + version: 1.0.0-alpha.2 devDependencies: '@types/node': specifier: ^20.11.25 @@ -644,8 +644,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - alien-signals@1.0.0-alpha.1: - resolution: {integrity: sha512-mgqYHniWu3xhHZWXB9zDaMP0ze1mFofqXXH2CXo4mGbNBSSm+HcraGbvY8tAvIsMOviH6F6vgcYD6sBy3qf3vg==} + alien-signals@1.0.0-alpha.2: + resolution: {integrity: sha512-mmtAUgjxHfSfOpCqy5GBWpKMJzvtD8ghhHk+vkBfBNijN17h4vlmhJz9mL+oIF1f6xrkmUfokLrir/msw/iT9w==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3132,7 +3132,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - alien-signals@1.0.0-alpha.1: {} + alien-signals@1.0.0-alpha.2: {} ansi-regex@5.0.1: {} diff --git a/src/alien.ts b/src/alien.ts index 0a76337..be613fa 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -3,29 +3,33 @@ import * as alien from 'alien-signals'; const WATCHER_PLACEHOLDER = Symbol('watcher') as any; export namespace Signal { - const {drainQueuedEffects, endTrack, isDirty, link, propagate, shallowPropagate, startTrack} = + const {endTrack, link, propagate, startTrack, processQueuedEffects, processComputedUpdate} = alien.createSystem({ - isComputed(sub): sub is Computed { - return sub instanceof Computed; + computed: { + is(sub): sub is Computed { + return sub instanceof Computed; + }, + update(computed) { + return computed.update(); + }, }, - isEffect(sub): sub is subtle.Watcher { - return sub instanceof subtle.Watcher; - }, - notifyEffect(watcher: subtle.Watcher) { - const flags = watcher.flags; - watcher.flags = alien.SubscriberFlags.None; - if (flags & alien.SubscriberFlags.Dirty) { - const prevSub = activeSub; - activeSub = WATCHER_PLACEHOLDER; - try { - watcher.fn(); - } finally { - activeSub = prevSub; + effect: { + is(sub): sub is subtle.Watcher { + return sub instanceof subtle.Watcher; + }, + notify(watcher: subtle.Watcher) { + if (watcher.flags & alien.SubscriberFlags.Dirty) { + const prevSub = activeSub; + activeSub = WATCHER_PLACEHOLDER; + try { + watcher.fn(); + } finally { + activeSub = prevSub; + } + return true; } - } - }, - updateComputed(computed) { - return computed.update(); + return false; + }, }, }); @@ -50,7 +54,7 @@ export namespace Signal { private currentValue: T, private options?: Options, ) { - if (options && options.equals) { + if (options?.equals !== undefined) { this.equals = options.equals; } } @@ -93,7 +97,7 @@ export namespace Signal { const subs = this.subs; if (subs !== undefined) { propagate(subs); - drainQueuedEffects(); + processQueuedEffects(); } } } @@ -113,7 +117,7 @@ export namespace Signal { private getter: () => T, private options?: Options, ) { - if (options && options.equals) { + if (options?.equals !== undefined) { this.equals = options.equals; } } @@ -149,18 +153,16 @@ export namespace Signal { if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from computed inside watcher'); } - if (isDirty(this, this.flags)) { - if (this.update()) { - const subs = this.subs; - if (subs !== undefined) { - shallowPropagate(subs); - } - } + const flags = this.flags; + if (flags) { + processComputedUpdate(this, flags); } - if (activeSub !== undefined && link(this, activeSub)) { - const newSub = this.subsTail!; - if (newSub instanceof Computed && newSub.watchCount) { - this.onWatched(); + if (activeSub !== undefined) { + if (link(this, activeSub)) { + const newSub = this.subsTail!; + if (newSub instanceof Computed && newSub.watchCount) { + this.onWatched(); + } } } if (this.isError) { @@ -258,7 +260,7 @@ export namespace Signal { return introspectSources(this).filter( (source) => source instanceof Computed && - source.flags & (alien.SubscriberFlags.ToCheckDirty | alien.SubscriberFlags.Dirty), + source.flags & (alien.SubscriberFlags.CheckRequired | alien.SubscriberFlags.Dirty), ); } } From 55177cf23de0f151dfa56211325fd6e1658984c6 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sun, 12 Jan 2025 20:37:51 +0800 Subject: [PATCH 13/53] fix: don't call onWatched repeatedly for existing deps --- src/alien.ts | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index be613fa..7e0b0b1 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -19,13 +19,7 @@ export namespace Signal { }, notify(watcher: subtle.Watcher) { if (watcher.flags & alien.SubscriberFlags.Dirty) { - const prevSub = activeSub; - activeSub = WATCHER_PLACEHOLDER; - try { - watcher.fn(); - } finally { - activeSub = prevSub; - } + watcher.run(); return true; } return false; @@ -201,16 +195,9 @@ export namespace Signal { const dep = link.dep as AnySignal; dep.onUnwatched(); } - activeSub = prevSub; - endTrack(this); - for (let link = this.deps; link !== undefined; link = link.nextDep) { - const dep = link.dep as AnySignal; - dep.onWatched(); - } - } else { - activeSub = prevSub; - endTrack(this); } + activeSub = prevSub; + endTrack(this); } } } @@ -225,7 +212,17 @@ export namespace Signal { flags = alien.SubscriberFlags.None; watchList = new Set(); - constructor(public fn: () => void) {} + constructor(private fn: () => void) {} + + run() { + const prevSub = activeSub; + activeSub = WATCHER_PLACEHOLDER; + try { + this.fn(); + } finally { + activeSub = prevSub; + } + } watch(...signals: AnySignal[]): void { for (const signal of signals) { From b905a53fb7012ebb1fb9666fd2e3a11afba42454 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sun, 12 Jan 2025 20:38:47 +0800 Subject: [PATCH 14/53] refactor: move WATCHER_PLACEHOLDER declaration inside Signal namespace --- src/alien.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index 7e0b0b1..4887de9 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -1,8 +1,8 @@ import * as alien from 'alien-signals'; -const WATCHER_PLACEHOLDER = Symbol('watcher') as any; - export namespace Signal { + const WATCHER_PLACEHOLDER = Symbol('watcher') as any; + const {endTrack, link, propagate, startTrack, processQueuedEffects, processComputedUpdate} = alien.createSystem({ computed: { From 65f3ed606d88fff4550159a9812d0a1a078d075d Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sun, 12 Jan 2025 20:39:39 +0800 Subject: [PATCH 15/53] Remove unneeded eslint comment --- src/alien.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/alien.ts b/src/alien.ts index 4887de9..2af8f49 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -202,7 +202,6 @@ export namespace Signal { } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnySignal = State | Computed; export namespace subtle { From 5eace40ef2a9125325bfe5a5c70c5ca4e901c8bd Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sun, 12 Jan 2025 20:41:38 +0800 Subject: [PATCH 16/53] Format --- src/alien.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index 2af8f49..c47c012 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -73,10 +73,12 @@ export namespace Signal { if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from state inside watcher'); } - if (activeSub !== undefined && link(this, activeSub)) { - const newSub = this.subsTail!; - if (newSub instanceof Computed && newSub.watchCount) { - this.onWatched(); + if (activeSub !== undefined) { + if (link(this, activeSub)) { + const newSub = this.subsTail!; + if (newSub instanceof Computed && newSub.watchCount) { + this.onWatched(); + } } } return this.currentValue; From c4c39651c529267637ca8c5f87de5e0e1c3e14aa Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sun, 12 Jan 2025 21:03:25 +0800 Subject: [PATCH 17/53] Bump --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index d87f0bb..6e1bcac 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "webdriverio": "^8.36.1" }, "dependencies": { - "alien-signals": "1.0.0-alpha.2" + "alien-signals": "1.0.0-alpha.3" }, "volta": { "node": "22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09ae9d4..2f1a0f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: alien-signals: - specifier: 1.0.0-alpha.2 - version: 1.0.0-alpha.2 + specifier: 1.0.0-alpha.3 + version: 1.0.0-alpha.3 devDependencies: '@types/node': specifier: ^20.11.25 @@ -644,8 +644,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - alien-signals@1.0.0-alpha.2: - resolution: {integrity: sha512-mmtAUgjxHfSfOpCqy5GBWpKMJzvtD8ghhHk+vkBfBNijN17h4vlmhJz9mL+oIF1f6xrkmUfokLrir/msw/iT9w==} + alien-signals@1.0.0-alpha.3: + resolution: {integrity: sha512-MV3sebtKXuEjd+Bf71ETUGH8k7j1WtMVqM6BQ1VpTnQK8QMRtT4tOnT4/F1j8OBEmD+a63ikLovG5jb+1fX6jw==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3132,7 +3132,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - alien-signals@1.0.0-alpha.2: {} + alien-signals@1.0.0-alpha.3: {} ansi-regex@5.0.1: {} From 0fc7b9c0b988b9b6854cb2b5dba3fc51b7ff589b Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sun, 12 Jan 2025 22:00:32 +0800 Subject: [PATCH 18/53] Update alien.ts --- src/alien.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index c47c012..98648b1 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -143,13 +143,13 @@ export namespace Signal { } get() { - if (this.flags & alien.SubscriberFlags.Tracking) { - throw new Error('Cycles detected'); - } if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from computed inside watcher'); } const flags = this.flags; + if (flags & alien.SubscriberFlags.Tracking) { + throw new Error('Cycles detected'); + } if (flags) { processComputedUpdate(this, flags); } From 189e7c8b47c667df4160a265bd293572f1ebe66b Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Mon, 13 Jan 2025 21:56:09 +0800 Subject: [PATCH 19/53] fix: correct subsTail reference in Signal namespace --- src/alien.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index 98648b1..626d492 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -75,7 +75,7 @@ export namespace Signal { } if (activeSub !== undefined) { if (link(this, activeSub)) { - const newSub = this.subsTail!; + const newSub = this.subsTail!.sub; if (newSub instanceof Computed && newSub.watchCount) { this.onWatched(); } @@ -155,7 +155,7 @@ export namespace Signal { } if (activeSub !== undefined) { if (link(this, activeSub)) { - const newSub = this.subsTail!; + const newSub = this.subsTail!.sub; if (newSub instanceof Computed && newSub.watchCount) { this.onWatched(); } From 4b87b378aac21b8ce9e665ecd4d90b14dbf331eb Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Wed, 15 Jan 2025 02:16:19 +0800 Subject: [PATCH 20/53] Update alien-signals to 1.0.0 --- package.json | 2 +- pnpm-lock.yaml | 10 ++++---- src/alien.ts | 68 +++++++++++++++++++++++--------------------------- 3 files changed, 37 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index 6e1bcac..2b449a2 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "webdriverio": "^8.36.1" }, "dependencies": { - "alien-signals": "1.0.0-alpha.3" + "alien-signals": "1.0.0" }, "volta": { "node": "22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f1a0f3..d779a32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: alien-signals: - specifier: 1.0.0-alpha.3 - version: 1.0.0-alpha.3 + specifier: 1.0.0 + version: 1.0.0 devDependencies: '@types/node': specifier: ^20.11.25 @@ -644,8 +644,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - alien-signals@1.0.0-alpha.3: - resolution: {integrity: sha512-MV3sebtKXuEjd+Bf71ETUGH8k7j1WtMVqM6BQ1VpTnQK8QMRtT4tOnT4/F1j8OBEmD+a63ikLovG5jb+1fX6jw==} + alien-signals@1.0.0: + resolution: {integrity: sha512-Fd2sYMdyjWD6VKxeewCYHXsIYAiELGMtQzGJ6vyxpxtQ1exXYiNTynSqGllkk+mOqhtBFYcC1Qvb49FbCSvsQw==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3132,7 +3132,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - alien-signals@1.0.0-alpha.3: {} + alien-signals@1.0.0: {} ansi-regex@5.0.1: {} diff --git a/src/alien.ts b/src/alien.ts index 626d492..318f8e2 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -3,29 +3,25 @@ import * as alien from 'alien-signals'; export namespace Signal { const WATCHER_PLACEHOLDER = Symbol('watcher') as any; - const {endTrack, link, propagate, startTrack, processQueuedEffects, processComputedUpdate} = - alien.createSystem({ - computed: { - is(sub): sub is Computed { - return sub instanceof Computed; - }, - update(computed) { - return computed.update(); - }, - }, - effect: { - is(sub): sub is subtle.Watcher { - return sub instanceof subtle.Watcher; - }, - notify(watcher: subtle.Watcher) { - if (watcher.flags & alien.SubscriberFlags.Dirty) { - watcher.run(); - return true; - } - return false; - }, - }, - }); + const { + endTracking, + link, + propagate, + startTracking, + processComputedUpdate, + processEffectNotifications, + } = alien.createReactiveSystem({ + updateComputed(computed: Computed) { + return computed.update(); + }, + notifyEffect(watcher: subtle.Watcher) { + if (watcher.flags & alien.SubscriberFlags.Dirty) { + watcher.run(); + return true; + } + return false; + }, + }); let activeSub: alien.Subscriber | undefined; @@ -93,7 +89,7 @@ export namespace Signal { const subs = this.subs; if (subs !== undefined) { propagate(subs); - processQueuedEffects(); + processEffectNotifications(); } } } @@ -104,7 +100,7 @@ export namespace Signal { subsTail: alien.Link | undefined = undefined; deps: alien.Link | undefined = undefined; depsTail: alien.Link | undefined = undefined; - flags = alien.SubscriberFlags.Dirty; + flags = alien.SubscriberFlags.Computed | alien.SubscriberFlags.Dirty; isError = true; watchCount = 0; currentValue: T | undefined = undefined; @@ -150,15 +146,13 @@ export namespace Signal { if (flags & alien.SubscriberFlags.Tracking) { throw new Error('Cycles detected'); } - if (flags) { + if (flags & (alien.SubscriberFlags.Dirty | alien.SubscriberFlags.PendingComputed)) { processComputedUpdate(this, flags); } if (activeSub !== undefined) { - if (link(this, activeSub)) { - const newSub = this.subsTail!.sub; - if (newSub instanceof Computed && newSub.watchCount) { - this.onWatched(); - } + const newSub = link(this, activeSub)?.sub; + if (newSub instanceof Computed && newSub.watchCount) { + this.onWatched(); } } if (this.isError) { @@ -170,7 +164,7 @@ export namespace Signal { update(): boolean { const prevSub = activeSub; activeSub = this; - startTrack(this); + startTracking(this); const oldValue = this.currentValue; try { const newValue = this.getter(); @@ -199,7 +193,7 @@ export namespace Signal { } } activeSub = prevSub; - endTrack(this); + endTracking(this); } } } @@ -210,7 +204,7 @@ export namespace Signal { export class Watcher implements alien.Subscriber { deps: alien.Link | undefined = undefined; depsTail: alien.Link | undefined = undefined; - flags = alien.SubscriberFlags.None; + flags = alien.SubscriberFlags.Effect; watchList = new Set(); constructor(private fn: () => void) {} @@ -244,21 +238,21 @@ export namespace Signal { this.watchList.delete(signal); signal.onUnwatched(); } - startTrack(this); + startTracking(this); for (let _link = this.deps; _link !== undefined; _link = _link.nextDep) { const dep = _link.dep as AnySignal; if (this.watchList.has(dep)) { link(dep, this); } } - endTrack(this); + endTracking(this); } getPending() { return introspectSources(this).filter( (source) => source instanceof Computed && - source.flags & (alien.SubscriberFlags.CheckRequired | alien.SubscriberFlags.Dirty), + source.flags & (alien.SubscriberFlags.PendingComputed | alien.SubscriberFlags.Dirty), ); } } From a06f62afa47b927da97408bbcde98e1b266bd0d9 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 16 Jan 2025 14:51:00 +0800 Subject: [PATCH 21/53] test: add computed gc test --- tests/behaviors/gc.test.ts | 24 ++++++++++++++++++++++++ vite.config.ts | 9 +++++++++ 2 files changed, 33 insertions(+) create mode 100644 tests/behaviors/gc.test.ts diff --git a/tests/behaviors/gc.test.ts b/tests/behaviors/gc.test.ts new file mode 100644 index 0000000..78498f0 --- /dev/null +++ b/tests/behaviors/gc.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { Signal } from '../../src/alien.js'; + +describe('GC', () => { + it('clears subscription after computed is unref and GC is invoked', async () => { + const s1 = new Signal.State(10); + let c1: Signal.Computed | undefined = new Signal.Computed(() => s1.get()); + const c1Ref = new WeakRef(c1); + c1.get(); + c1 = undefined; + + await gc(); + expect(c1Ref.deref()).toBe(undefined); + }); +}); + +function gc() { + return new Promise((resolve) => { + setImmediate(() => { + globalThis.gc!(); + resolve(); + }); + }) +} diff --git a/vite.config.ts b/vite.config.ts index fea5e8c..54246ca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,15 @@ const entry = join(dirname(fileURLToPath(import.meta.url)), './src/index.ts'); export default defineConfig({ plugins: [dts()], + test: { + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + execArgv: ['--expose-gc'], + }, + }, + }, build: { minify: false, lib: { From b99822017d074056c552b747518cdec20f923173 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 16 Jan 2025 17:06:01 +0800 Subject: [PATCH 22/53] Update gc.test.ts --- tests/behaviors/gc.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/behaviors/gc.test.ts b/tests/behaviors/gc.test.ts index 78498f0..de114ac 100644 --- a/tests/behaviors/gc.test.ts +++ b/tests/behaviors/gc.test.ts @@ -11,6 +11,9 @@ describe('GC', () => { await gc(); expect(c1Ref.deref()).toBe(undefined); + + await new Promise(resolve => setTimeout(resolve, 100)); + expect(s1.subs).toBe(undefined); }); }); From 62b25b4e79cf0fce6e84996cb83d4e548af8811a Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Mon, 20 Jan 2025 19:41:45 +0800 Subject: [PATCH 23/53] Update alien-signals to 1.1.0-alpha.2 and change to pull model system --- package.json | 2 +- pnpm-lock.yaml | 10 ++-- src/alien.ts | 132 +++++++++++++++++++------------------------------ 3 files changed, 56 insertions(+), 88 deletions(-) diff --git a/package.json b/package.json index 2b449a2..54e917c 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "webdriverio": "^8.36.1" }, "dependencies": { - "alien-signals": "1.0.0" + "alien-signals": "1.1.0-alpha.2" }, "volta": { "node": "22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d779a32..d28910e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: alien-signals: - specifier: 1.0.0 - version: 1.0.0 + specifier: 1.1.0-alpha.2 + version: 1.1.0-alpha.2 devDependencies: '@types/node': specifier: ^20.11.25 @@ -644,8 +644,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - alien-signals@1.0.0: - resolution: {integrity: sha512-Fd2sYMdyjWD6VKxeewCYHXsIYAiELGMtQzGJ6vyxpxtQ1exXYiNTynSqGllkk+mOqhtBFYcC1Qvb49FbCSvsQw==} + alien-signals@1.1.0-alpha.2: + resolution: {integrity: sha512-CG7oNBPrr2jLfo5OGBBtSuA5dfKtWwkSIF9EMxIwNBamtoJZleGtBucwtzQeoNfqpMu9joagds520IJ//REnsA==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3132,7 +3132,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - alien-signals@1.0.0: {} + alien-signals@1.1.0-alpha.2: {} ansi-regex@5.0.1: {} diff --git a/src/alien.ts b/src/alien.ts index 318f8e2..9828a61 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -4,13 +4,14 @@ export namespace Signal { const WATCHER_PLACEHOLDER = Symbol('watcher') as any; const { - endTracking, link, propagate, + checkDirty, + endTracking, startTracking, processComputedUpdate, processEffectNotifications, - } = alien.createReactiveSystem({ + } = alien.pullmodel.createReactiveSystem({ updateComputed(computed: Computed) { return computed.update(); }, @@ -21,8 +22,22 @@ export namespace Signal { } return false; }, + shouldCheckDirty(computed: Computed) { + if (computed.globalVersion !== globalVersion) { + computed.globalVersion = globalVersion; + return true; + } + return false; + }, + onWatched(dep: AnySignal) { + dep.options?.[subtle.watched]?.call(dep); + }, + onUnwatched(dep: AnySignal) { + dep.options?.[subtle.unwatched]?.call(dep); + }, }); + let globalVersion = 0; let activeSub: alien.Subscriber | undefined; export function untrack(fn: () => T) { @@ -36,13 +51,13 @@ export namespace Signal { } export class State implements alien.Dependency { + version = 0; subs: alien.Link | undefined = undefined; subsTail: alien.Link | undefined = undefined; - watchCount = 0; constructor( private currentValue: T, - private options?: Options, + public options?: Options, ) { if (options?.equals !== undefined) { this.equals = options.equals; @@ -53,29 +68,12 @@ export namespace Signal { return Object.is(t, t2); } - onWatched() { - if (this.watchCount++ === 0) { - this.options?.[subtle.watched]?.call(this); - } - } - - onUnwatched() { - if (--this.watchCount === 0) { - this.options?.[subtle.unwatched]?.call(this); - } - } - get() { if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from state inside watcher'); } if (activeSub !== undefined) { - if (link(this, activeSub)) { - const newSub = this.subsTail!.sub; - if (newSub instanceof Computed && newSub.watchCount) { - this.onWatched(); - } - } + link(this, activeSub); } return this.currentValue; } @@ -85,6 +83,8 @@ export namespace Signal { throw new Error('Cannot write to state inside watcher'); } if (!this.equals(this.currentValue, value)) { + globalVersion++; + this.version++; this.currentValue = value; const subs = this.subs; if (subs !== undefined) { @@ -95,19 +95,21 @@ export namespace Signal { } } + const ErrorFlag = 1 << 8; + export class Computed implements alien.Dependency, alien.Subscriber { subs: alien.Link | undefined = undefined; subsTail: alien.Link | undefined = undefined; deps: alien.Link | undefined = undefined; depsTail: alien.Link | undefined = undefined; - flags = alien.SubscriberFlags.Computed | alien.SubscriberFlags.Dirty; - isError = true; - watchCount = 0; + flags = alien.SubscriberFlags.Computed | alien.SubscriberFlags.Dirty | ErrorFlag; currentValue: T | undefined = undefined; + version = 0; + globalVersion = globalVersion; constructor( private getter: () => T, - private options?: Options, + public options?: Options, ) { if (options?.equals !== undefined) { this.equals = options.equals; @@ -118,26 +120,6 @@ export namespace Signal { return Object.is(t, t2); } - onWatched() { - if (this.watchCount++ === 0) { - this.options?.[subtle.watched]?.call(this); - for (let link = this.deps; link !== undefined; link = link.nextDep) { - const dep = link.dep as AnySignal; - dep.onWatched(); - } - } - } - - onUnwatched() { - if (--this.watchCount === 0) { - this.options?.[subtle.unwatched]?.call(this); - for (let link = this.deps; link !== undefined; link = link.nextDep) { - const dep = link.dep as AnySignal; - dep.onUnwatched(); - } - } - } - get() { if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from computed inside watcher'); @@ -146,16 +128,23 @@ export namespace Signal { if (flags & alien.SubscriberFlags.Tracking) { throw new Error('Cycles detected'); } - if (flags & (alien.SubscriberFlags.Dirty | alien.SubscriberFlags.PendingComputed)) { + if (flags & alien.SubscriberFlags.Dirty) { + processComputedUpdate(this, flags); + } else if (this.subs === undefined) { + if (this.globalVersion !== globalVersion) { + this.globalVersion = globalVersion; + const deps = this.deps; + if (deps !== undefined && checkDirty(deps)) { + this.update(); + } + } + } else if (flags & alien.SubscriberFlags.PendingComputed) { processComputedUpdate(this, flags); } if (activeSub !== undefined) { - const newSub = link(this, activeSub)?.sub; - if (newSub instanceof Computed && newSub.watchCount) { - this.onWatched(); - } + link(this, activeSub); } - if (this.isError) { + if (this.flags & ErrorFlag) { throw this.currentValue; } return this.currentValue!; @@ -168,30 +157,22 @@ export namespace Signal { const oldValue = this.currentValue; try { const newValue = this.getter(); - if (this.isError || !this.equals(oldValue!, newValue)) { - this.isError = false; + if (this.flags & ErrorFlag || !this.equals(oldValue!, newValue)) { + this.version++; + this.flags &= ~ErrorFlag; this.currentValue = newValue; return true; } return false; } catch (err) { - if (!this.isError || !this.equals(oldValue!, err as any)) { - this.isError = true; + if (!(this.flags & ErrorFlag) || !this.equals(oldValue!, err as any)) { + this.version++; + this.flags |= ErrorFlag; this.currentValue = err as any; return true; } return false; } finally { - if (this.watchCount) { - for ( - let link = this.depsTail !== undefined ? this.depsTail.nextDep : this.deps; - link !== undefined; - link = link.nextDep - ) { - const dep = link.dep as AnySignal; - dep.onUnwatched(); - } - } activeSub = prevSub; endTracking(this); } @@ -205,9 +186,8 @@ export namespace Signal { deps: alien.Link | undefined = undefined; depsTail: alien.Link | undefined = undefined; flags = alien.SubscriberFlags.Effect; - watchList = new Set(); - constructor(private fn: () => void) {} + constructor(private fn: () => void) { } run() { const prevSub = activeSub; @@ -221,27 +201,15 @@ export namespace Signal { watch(...signals: AnySignal[]): void { for (const signal of signals) { - if (this.watchList.has(signal)) { - continue; - } - this.watchList.add(signal); link(signal, this); - signal.onWatched(); } } unwatch(...signals: AnySignal[]): void { - for (const signal of signals) { - if (!this.watchList.has(signal)) { - continue; - } - this.watchList.delete(signal); - signal.onUnwatched(); - } startTracking(this); for (let _link = this.deps; _link !== undefined; _link = _link.nextDep) { const dep = _link.dep as AnySignal; - if (this.watchList.has(dep)) { + if (!signals.includes(dep)) { link(dep, this); } } @@ -258,7 +226,7 @@ export namespace Signal { } export function hasSinks(signal: AnySignal) { - return signal.watchCount > 0; + return signal.subs !== undefined; } export function introspectSinks(signal: AnySignal) { From fecba84f92d9634c46fd96a7dbc52461fd7988f6 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Mon, 20 Jan 2025 20:00:03 +0800 Subject: [PATCH 24/53] refactor: optimize getPending method --- src/alien.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index 9828a61..452ef0f 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -217,11 +217,17 @@ export namespace Signal { } getPending() { - return introspectSources(this).filter( - (source) => + const arr: AnySignal[] = []; + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const source = link.dep; + if ( source instanceof Computed && - source.flags & (alien.SubscriberFlags.PendingComputed | alien.SubscriberFlags.Dirty), - ); + source.flags & (alien.SubscriberFlags.PendingComputed | alien.SubscriberFlags.Dirty) + ) { + arr.push(link.dep as AnySignal); + } + } + return arr; } } From aa178d3fb24a8ec16f13c3fcb02b17059a4fbb4c Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Mon, 20 Jan 2025 20:00:19 +0800 Subject: [PATCH 25/53] Format --- src/alien.ts | 2 +- tests/behaviors/gc.test.ts | 38 +++++++++++++++++++------------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index 452ef0f..f3e13a8 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -187,7 +187,7 @@ export namespace Signal { depsTail: alien.Link | undefined = undefined; flags = alien.SubscriberFlags.Effect; - constructor(private fn: () => void) { } + constructor(private fn: () => void) {} run() { const prevSub = activeSub; diff --git a/tests/behaviors/gc.test.ts b/tests/behaviors/gc.test.ts index de114ac..c3c899d 100644 --- a/tests/behaviors/gc.test.ts +++ b/tests/behaviors/gc.test.ts @@ -1,27 +1,27 @@ -import { describe, expect, it } from 'vitest'; -import { Signal } from '../../src/alien.js'; +import {describe, expect, it} from 'vitest'; +import {Signal} from '../../src/alien.js'; describe('GC', () => { - it('clears subscription after computed is unref and GC is invoked', async () => { - const s1 = new Signal.State(10); - let c1: Signal.Computed | undefined = new Signal.Computed(() => s1.get()); - const c1Ref = new WeakRef(c1); - c1.get(); - c1 = undefined; + it('clears subscription after computed is unref and GC is invoked', async () => { + const s1 = new Signal.State(10); + let c1: Signal.Computed | undefined = new Signal.Computed(() => s1.get()); + const c1Ref = new WeakRef(c1); + c1.get(); + c1 = undefined; - await gc(); - expect(c1Ref.deref()).toBe(undefined); + await gc(); + expect(c1Ref.deref()).toBe(undefined); - await new Promise(resolve => setTimeout(resolve, 100)); - expect(s1.subs).toBe(undefined); - }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(s1.subs).toBe(undefined); + }); }); function gc() { - return new Promise((resolve) => { - setImmediate(() => { - globalThis.gc!(); - resolve(); - }); - }) + return new Promise((resolve) => { + setImmediate(() => { + globalThis.gc!(); + resolve(); + }); + }); } From 3be861bc3163996de1b8975d6857b5e9fbdb0564 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Fri, 24 Jan 2025 13:21:01 +0800 Subject: [PATCH 26/53] Revert "Update alien-signals to 1.1.0-alpha.2 and change to pull model system" This reverts commit 62b25b4e79cf0fce6e84996cb83d4e548af8811a. --- package.json | 2 +- pnpm-lock.yaml | 10 ++-- src/alien.ts | 130 ++++++++++++++++++++++++++++++------------------- 3 files changed, 87 insertions(+), 55 deletions(-) diff --git a/package.json b/package.json index 54e917c..2b449a2 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "webdriverio": "^8.36.1" }, "dependencies": { - "alien-signals": "1.1.0-alpha.2" + "alien-signals": "1.0.0" }, "volta": { "node": "22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d28910e..d779a32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: alien-signals: - specifier: 1.1.0-alpha.2 - version: 1.1.0-alpha.2 + specifier: 1.0.0 + version: 1.0.0 devDependencies: '@types/node': specifier: ^20.11.25 @@ -644,8 +644,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - alien-signals@1.1.0-alpha.2: - resolution: {integrity: sha512-CG7oNBPrr2jLfo5OGBBtSuA5dfKtWwkSIF9EMxIwNBamtoJZleGtBucwtzQeoNfqpMu9joagds520IJ//REnsA==} + alien-signals@1.0.0: + resolution: {integrity: sha512-Fd2sYMdyjWD6VKxeewCYHXsIYAiELGMtQzGJ6vyxpxtQ1exXYiNTynSqGllkk+mOqhtBFYcC1Qvb49FbCSvsQw==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3132,7 +3132,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - alien-signals@1.1.0-alpha.2: {} + alien-signals@1.0.0: {} ansi-regex@5.0.1: {} diff --git a/src/alien.ts b/src/alien.ts index f3e13a8..fe138f8 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -4,14 +4,13 @@ export namespace Signal { const WATCHER_PLACEHOLDER = Symbol('watcher') as any; const { + endTracking, link, propagate, - checkDirty, - endTracking, startTracking, processComputedUpdate, processEffectNotifications, - } = alien.pullmodel.createReactiveSystem({ + } = alien.createReactiveSystem({ updateComputed(computed: Computed) { return computed.update(); }, @@ -22,22 +21,8 @@ export namespace Signal { } return false; }, - shouldCheckDirty(computed: Computed) { - if (computed.globalVersion !== globalVersion) { - computed.globalVersion = globalVersion; - return true; - } - return false; - }, - onWatched(dep: AnySignal) { - dep.options?.[subtle.watched]?.call(dep); - }, - onUnwatched(dep: AnySignal) { - dep.options?.[subtle.unwatched]?.call(dep); - }, }); - let globalVersion = 0; let activeSub: alien.Subscriber | undefined; export function untrack(fn: () => T) { @@ -51,13 +36,13 @@ export namespace Signal { } export class State implements alien.Dependency { - version = 0; subs: alien.Link | undefined = undefined; subsTail: alien.Link | undefined = undefined; + watchCount = 0; constructor( private currentValue: T, - public options?: Options, + private options?: Options, ) { if (options?.equals !== undefined) { this.equals = options.equals; @@ -68,12 +53,29 @@ export namespace Signal { return Object.is(t, t2); } + onWatched() { + if (this.watchCount++ === 0) { + this.options?.[subtle.watched]?.call(this); + } + } + + onUnwatched() { + if (--this.watchCount === 0) { + this.options?.[subtle.unwatched]?.call(this); + } + } + get() { if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from state inside watcher'); } if (activeSub !== undefined) { - link(this, activeSub); + if (link(this, activeSub)) { + const newSub = this.subsTail!.sub; + if (newSub instanceof Computed && newSub.watchCount) { + this.onWatched(); + } + } } return this.currentValue; } @@ -83,8 +85,6 @@ export namespace Signal { throw new Error('Cannot write to state inside watcher'); } if (!this.equals(this.currentValue, value)) { - globalVersion++; - this.version++; this.currentValue = value; const subs = this.subs; if (subs !== undefined) { @@ -95,21 +95,19 @@ export namespace Signal { } } - const ErrorFlag = 1 << 8; - export class Computed implements alien.Dependency, alien.Subscriber { subs: alien.Link | undefined = undefined; subsTail: alien.Link | undefined = undefined; deps: alien.Link | undefined = undefined; depsTail: alien.Link | undefined = undefined; - flags = alien.SubscriberFlags.Computed | alien.SubscriberFlags.Dirty | ErrorFlag; + flags = alien.SubscriberFlags.Computed | alien.SubscriberFlags.Dirty; + isError = true; + watchCount = 0; currentValue: T | undefined = undefined; - version = 0; - globalVersion = globalVersion; constructor( private getter: () => T, - public options?: Options, + private options?: Options, ) { if (options?.equals !== undefined) { this.equals = options.equals; @@ -120,6 +118,26 @@ export namespace Signal { return Object.is(t, t2); } + onWatched() { + if (this.watchCount++ === 0) { + this.options?.[subtle.watched]?.call(this); + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const dep = link.dep as AnySignal; + dep.onWatched(); + } + } + } + + onUnwatched() { + if (--this.watchCount === 0) { + this.options?.[subtle.unwatched]?.call(this); + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const dep = link.dep as AnySignal; + dep.onUnwatched(); + } + } + } + get() { if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from computed inside watcher'); @@ -128,23 +146,16 @@ export namespace Signal { if (flags & alien.SubscriberFlags.Tracking) { throw new Error('Cycles detected'); } - if (flags & alien.SubscriberFlags.Dirty) { - processComputedUpdate(this, flags); - } else if (this.subs === undefined) { - if (this.globalVersion !== globalVersion) { - this.globalVersion = globalVersion; - const deps = this.deps; - if (deps !== undefined && checkDirty(deps)) { - this.update(); - } - } - } else if (flags & alien.SubscriberFlags.PendingComputed) { + if (flags & (alien.SubscriberFlags.Dirty | alien.SubscriberFlags.PendingComputed)) { processComputedUpdate(this, flags); } if (activeSub !== undefined) { - link(this, activeSub); + const newSub = link(this, activeSub)?.sub; + if (newSub instanceof Computed && newSub.watchCount) { + this.onWatched(); + } } - if (this.flags & ErrorFlag) { + if (this.isError) { throw this.currentValue; } return this.currentValue!; @@ -157,22 +168,30 @@ export namespace Signal { const oldValue = this.currentValue; try { const newValue = this.getter(); - if (this.flags & ErrorFlag || !this.equals(oldValue!, newValue)) { - this.version++; - this.flags &= ~ErrorFlag; + if (this.isError || !this.equals(oldValue!, newValue)) { + this.isError = false; this.currentValue = newValue; return true; } return false; } catch (err) { - if (!(this.flags & ErrorFlag) || !this.equals(oldValue!, err as any)) { - this.version++; - this.flags |= ErrorFlag; + if (!this.isError || !this.equals(oldValue!, err as any)) { + this.isError = true; this.currentValue = err as any; return true; } return false; } finally { + if (this.watchCount) { + for ( + let link = this.depsTail !== undefined ? this.depsTail.nextDep : this.deps; + link !== undefined; + link = link.nextDep + ) { + const dep = link.dep as AnySignal; + dep.onUnwatched(); + } + } activeSub = prevSub; endTracking(this); } @@ -186,6 +205,7 @@ export namespace Signal { deps: alien.Link | undefined = undefined; depsTail: alien.Link | undefined = undefined; flags = alien.SubscriberFlags.Effect; + watchList = new Set(); constructor(private fn: () => void) {} @@ -201,15 +221,27 @@ export namespace Signal { watch(...signals: AnySignal[]): void { for (const signal of signals) { + if (this.watchList.has(signal)) { + continue; + } + this.watchList.add(signal); link(signal, this); + signal.onWatched(); } } unwatch(...signals: AnySignal[]): void { + for (const signal of signals) { + if (!this.watchList.has(signal)) { + continue; + } + this.watchList.delete(signal); + signal.onUnwatched(); + } startTracking(this); for (let _link = this.deps; _link !== undefined; _link = _link.nextDep) { const dep = _link.dep as AnySignal; - if (!signals.includes(dep)) { + if (this.watchList.has(dep)) { link(dep, this); } } @@ -232,7 +264,7 @@ export namespace Signal { } export function hasSinks(signal: AnySignal) { - return signal.subs !== undefined; + return signal.watchCount > 0; } export function introspectSinks(signal: AnySignal) { From f978b96d918e99021d722bfbea36897e3a49974c Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Fri, 24 Jan 2025 13:51:50 +0800 Subject: [PATCH 27/53] Adapting upstream cooling mechanisms --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/alien.ts | 24 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 2b449a2..905f56e 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "webdriverio": "^8.36.1" }, "dependencies": { - "alien-signals": "1.0.0" + "alien-signals": "1.1.0-alpha.3" }, "volta": { "node": "22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d779a32..db870ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: alien-signals: - specifier: 1.0.0 - version: 1.0.0 + specifier: 1.1.0-alpha.3 + version: 1.1.0-alpha.3 devDependencies: '@types/node': specifier: ^20.11.25 @@ -644,8 +644,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - alien-signals@1.0.0: - resolution: {integrity: sha512-Fd2sYMdyjWD6VKxeewCYHXsIYAiELGMtQzGJ6vyxpxtQ1exXYiNTynSqGllkk+mOqhtBFYcC1Qvb49FbCSvsQw==} + alien-signals@1.1.0-alpha.3: + resolution: {integrity: sha512-hHUnt+KYyPD4ygpyuplsOTMfQQlcZou8ZoBnbUWd5u+0TlfFEf6SyZn4U7OILWLypR2C+nb9Vmzdsb6lmZc0Rg==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3132,7 +3132,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - alien-signals@1.0.0: {} + alien-signals@1.1.0-alpha.3: {} ansi-regex@5.0.1: {} diff --git a/src/alien.ts b/src/alien.ts index fe138f8..e99916f 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -22,7 +22,13 @@ export namespace Signal { return false; }, }); + const nursery: alien.Subscriber = { + flags: alien.SubscriberFlags.None, + deps: undefined, + depsTail: undefined, + }; + let triggingCooling = false; let activeSub: alien.Subscriber | undefined; export function untrack(fn: () => T) { @@ -38,6 +44,7 @@ export namespace Signal { export class State implements alien.Dependency { subs: alien.Link | undefined = undefined; subsTail: alien.Link | undefined = undefined; + version = 0; watchCount = 0; constructor( @@ -85,6 +92,7 @@ export namespace Signal { throw new Error('Cannot write to state inside watcher'); } if (!this.equals(this.currentValue, value)) { + this.version++; this.currentValue = value; const subs = this.subs; if (subs !== undefined) { @@ -102,6 +110,7 @@ export namespace Signal { depsTail: alien.Link | undefined = undefined; flags = alien.SubscriberFlags.Computed | alien.SubscriberFlags.Dirty; isError = true; + version = 0; watchCount = 0; currentValue: T | undefined = undefined; @@ -154,6 +163,12 @@ export namespace Signal { if (newSub instanceof Computed && newSub.watchCount) { this.onWatched(); } + } else if (this.subs === undefined) { + link(this, nursery); + if (!triggingCooling) { + triggingCooling = true; + triggerCooling(); + } } if (this.isError) { throw this.currentValue; @@ -170,6 +185,7 @@ export namespace Signal { const newValue = this.getter(); if (this.isError || !this.equals(oldValue!, newValue)) { this.isError = false; + this.version++; this.currentValue = newValue; return true; } @@ -177,6 +193,7 @@ export namespace Signal { } catch (err) { if (!this.isError || !this.equals(oldValue!, err as any)) { this.isError = true; + this.version++; this.currentValue = err as any; return true; } @@ -198,6 +215,13 @@ export namespace Signal { } } + async function triggerCooling() { + await Promise.resolve(); // TODO: Confirm the scheduling logic + triggingCooling = false; + startTracking(nursery); + endTracking(nursery); + } + type AnySignal = State | Computed; export namespace subtle { From bb1988c180db6ad30e79eed4ea2bf80e81582c93 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Fri, 24 Jan 2025 23:17:34 +0800 Subject: [PATCH 28/53] Update to 2.0 alpha --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/alien.ts | 47 ++++++++++++++++++++++++++++++++--------------- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 905f56e..cf158aa 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "webdriverio": "^8.36.1" }, "dependencies": { - "alien-signals": "1.1.0-alpha.3" + "alien-signals": "2.0.0-alpha.0" }, "volta": { "node": "22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db870ef..89fe757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: alien-signals: - specifier: 1.1.0-alpha.3 - version: 1.1.0-alpha.3 + specifier: 2.0.0-alpha.0 + version: 2.0.0-alpha.0 devDependencies: '@types/node': specifier: ^20.11.25 @@ -644,8 +644,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - alien-signals@1.1.0-alpha.3: - resolution: {integrity: sha512-hHUnt+KYyPD4ygpyuplsOTMfQQlcZou8ZoBnbUWd5u+0TlfFEf6SyZn4U7OILWLypR2C+nb9Vmzdsb6lmZc0Rg==} + alien-signals@2.0.0-alpha.0: + resolution: {integrity: sha512-KQHrDmWKtnAVQ8Y82ZR3JUgT1MXX74cX/GjAWFkKb2FAeuujuuwTN6KUNt0BIDn9B65WxrPHoyJmx8GQiCvS4w==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3132,7 +3132,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - alien-signals@1.1.0-alpha.3: {} + alien-signals@2.0.0-alpha.0: {} ansi-regex@5.0.1: {} diff --git a/src/alien.ts b/src/alien.ts index e99916f..e1b9c66 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -4,22 +4,29 @@ export namespace Signal { const WATCHER_PLACEHOLDER = Symbol('watcher') as any; const { - endTracking, link, + warming, + cooling, propagate, + checkDirty, + endTracking, startTracking, - processComputedUpdate, processEffectNotifications, } = alien.createReactiveSystem({ - updateComputed(computed: Computed) { - return computed.update(); + computed: { + update(computed: Computed) { + return computed.update(); + }, + onUnwatched(computed) { + cooling(computed); + }, }, - notifyEffect(watcher: subtle.Watcher) { - if (watcher.flags & alien.SubscriberFlags.Dirty) { - watcher.run(); - return true; - } - return false; + effect: { + notify(watcher: subtle.Watcher) { + if (watcher.flags & alien.SubscriberFlags.Dirty) { + watcher.run(); + } + }, }, }); const nursery: alien.Subscriber = { @@ -151,12 +158,22 @@ export namespace Signal { if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from computed inside watcher'); } - const flags = this.flags; + let flags = this.flags; if (flags & alien.SubscriberFlags.Tracking) { throw new Error('Cycles detected'); } - if (flags & (alien.SubscriberFlags.Dirty | alien.SubscriberFlags.PendingComputed)) { - processComputedUpdate(this, flags); + if (flags & alien.SubscriberFlags.Cold) { + warming(this); + flags = this.flags | alien.SubscriberFlags.Pending; + } + if (flags & alien.SubscriberFlags.Dirty) { + this.update(); + } else if (flags & alien.SubscriberFlags.Pending) { + if (checkDirty(this.deps!)) { + this.update(); + } else { + this.flags &= ~alien.SubscriberFlags.Pending; + } } if (activeSub !== undefined) { const newSub = link(this, activeSub)?.sub; @@ -231,7 +248,7 @@ export namespace Signal { flags = alien.SubscriberFlags.Effect; watchList = new Set(); - constructor(private fn: () => void) {} + constructor(private fn: () => void) { } run() { const prevSub = activeSub; @@ -278,7 +295,7 @@ export namespace Signal { const source = link.dep; if ( source instanceof Computed && - source.flags & (alien.SubscriberFlags.PendingComputed | alien.SubscriberFlags.Dirty) + source.flags & (alien.SubscriberFlags.Dirty | alien.SubscriberFlags.Pending) ) { arr.push(link.dep as AnySignal); } From 0a25b09fab541b16dd4803217a1fd7f23a169229 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 25 Jan 2025 00:27:01 +0800 Subject: [PATCH 29/53] Update alien.ts --- src/alien.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/alien.ts b/src/alien.ts index e1b9c66..f31f1cd 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -164,7 +164,7 @@ export namespace Signal { } if (flags & alien.SubscriberFlags.Cold) { warming(this); - flags = this.flags | alien.SubscriberFlags.Pending; + flags |= alien.SubscriberFlags.Pending; } if (flags & alien.SubscriberFlags.Dirty) { this.update(); From 710e60c6ad8b1814af6d678a71d257bb42864fd9 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 1 May 2025 17:14:25 +0800 Subject: [PATCH 30/53] refactor: update alien-signals to 2.0.1 --- package.json | 2 +- pnpm-lock.yaml | 10 +-- src/alien.ts | 177 ++++++++++++++++++++++++++++--------------------- 3 files changed, 106 insertions(+), 83 deletions(-) diff --git a/package.json b/package.json index cf158aa..d6f9f2f 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "webdriverio": "^8.36.1" }, "dependencies": { - "alien-signals": "2.0.0-alpha.0" + "alien-signals": "^2.0.1" }, "volta": { "node": "22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89fe757..7091bd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: alien-signals: - specifier: 2.0.0-alpha.0 - version: 2.0.0-alpha.0 + specifier: ^2.0.1 + version: 2.0.1 devDependencies: '@types/node': specifier: ^20.11.25 @@ -644,8 +644,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - alien-signals@2.0.0-alpha.0: - resolution: {integrity: sha512-KQHrDmWKtnAVQ8Y82ZR3JUgT1MXX74cX/GjAWFkKb2FAeuujuuwTN6KUNt0BIDn9B65WxrPHoyJmx8GQiCvS4w==} + alien-signals@2.0.1: + resolution: {integrity: sha512-fvRLEA4BoO78crQ9otbxMfN/uCAgQsscaNLeAd7S3gzKjDJdsXuzAI1Cl3TFqFxbp4BdcyUJsH15NHjuvB/nvQ==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3132,7 +3132,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - alien-signals@2.0.0-alpha.0: {} + alien-signals@2.0.1: {} ansi-regex@5.0.1: {} diff --git a/src/alien.ts b/src/alien.ts index f31f1cd..63df751 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -3,40 +3,56 @@ import * as alien from 'alien-signals'; export namespace Signal { const WATCHER_PLACEHOLDER = Symbol('watcher') as any; + const enum EffectFlags { + Queued = 1 << 6, + } + const { link, - warming, - cooling, + unlink, propagate, checkDirty, endTracking, startTracking, - processEffectNotifications, + shallowPropagate, } = alien.createReactiveSystem({ - computed: { - update(computed: Computed) { - return computed.update(); - }, - onUnwatched(computed) { - cooling(computed); - }, + update(node: Computed | State) { + return node.update(); }, - effect: { - notify(watcher: subtle.Watcher) { - if (watcher.flags & alien.SubscriberFlags.Dirty) { - watcher.run(); - } - }, + notify(node: subtle.Watcher) { + const flags = node.flags; + if (!(flags & EffectFlags.Queued)) { + node.flags = flags | EffectFlags.Queued; + queuedEffects[queuedEffectsLength++] = node; + } + }, + unwatched(node) { + let toRemove = node.deps; + if (toRemove !== undefined) { + do { + toRemove = unlink(toRemove, node); + } while (toRemove !== undefined); + node.flags |= alien.ReactiveFlags.Dirty; + } }, }); - const nursery: alien.Subscriber = { - flags: alien.SubscriberFlags.None, - deps: undefined, - depsTail: undefined, - }; - - let triggingCooling = false; - let activeSub: alien.Subscriber | undefined; + const queuedEffects: subtle.Watcher[] = []; + + let notifyIndex = 0; + let queuedEffectsLength = 0; + let activeSub: alien.ReactiveNode | undefined; + + function flush(): void { + while (notifyIndex < queuedEffectsLength) { + const effect = queuedEffects[notifyIndex]; + // @ts-expect-error + queuedEffects[notifyIndex++] = undefined; + effect.flags &= ~EffectFlags.Queued; + effect.run(); + } + notifyIndex = 0; + queuedEffectsLength = 0; + } export function untrack(fn: () => T) { const prevSub = activeSub; @@ -48,16 +64,18 @@ export namespace Signal { } } - export class State implements alien.Dependency { + export class State implements alien.ReactiveNode { subs: alien.Link | undefined = undefined; subsTail: alien.Link | undefined = undefined; - version = 0; + flags: alien.ReactiveFlags = alien.ReactiveFlags.Mutable; watchCount = 0; + previousValue: T; constructor( - private currentValue: T, + private value: T, private options?: Options, ) { + this.previousValue = value; if (options?.equals !== undefined) { this.equals = options.equals; } @@ -79,47 +97,62 @@ export namespace Signal { } } + update() { + this.flags &= ~alien.ReactiveFlags.Dirty; + return !this.equals(this.previousValue, this.previousValue = this.value); + } + get() { if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from state inside watcher'); } + if (this.flags & alien.ReactiveFlags.Dirty) { + if (this.update()) { + const subs = this.subs; + if (subs !== undefined) { + shallowPropagate(subs); + } + } + } if (activeSub !== undefined) { - if (link(this, activeSub)) { - const newSub = this.subsTail!.sub; + const lastLink = this.subsTail; + link(this, activeSub); + const newLink = this.subsTail!; + if (newLink !== lastLink) { + const newSub = newLink.sub; if (newSub instanceof Computed && newSub.watchCount) { this.onWatched(); } } } - return this.currentValue; + return this.value; } set(value: T): void { if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot write to state inside watcher'); } - if (!this.equals(this.currentValue, value)) { - this.version++; - this.currentValue = value; + if (!this.equals(this.value, value)) { + this.value = value; + this.flags = alien.ReactiveFlags.Mutable | alien.ReactiveFlags.Dirty; const subs = this.subs; if (subs !== undefined) { propagate(subs); - processEffectNotifications(); + flush(); } } } } - export class Computed implements alien.Dependency, alien.Subscriber { + export class Computed implements alien.ReactiveNode { subs: alien.Link | undefined = undefined; subsTail: alien.Link | undefined = undefined; deps: alien.Link | undefined = undefined; depsTail: alien.Link | undefined = undefined; - flags = alien.SubscriberFlags.Computed | alien.SubscriberFlags.Dirty; + flags = alien.ReactiveFlags.Mutable | alien.ReactiveFlags.Dirty; isError = true; - version = 0; watchCount = 0; - currentValue: T | undefined = undefined; + value: T | undefined = undefined; constructor( private getter: () => T, @@ -159,59 +192,56 @@ export namespace Signal { throw new Error('Cannot read from computed inside watcher'); } let flags = this.flags; - if (flags & alien.SubscriberFlags.Tracking) { + if (flags & alien.ReactiveFlags.RecursedCheck) { throw new Error('Cycles detected'); } - if (flags & alien.SubscriberFlags.Cold) { - warming(this); - flags |= alien.SubscriberFlags.Pending; - } - if (flags & alien.SubscriberFlags.Dirty) { - this.update(); - } else if (flags & alien.SubscriberFlags.Pending) { - if (checkDirty(this.deps!)) { - this.update(); - } else { - this.flags &= ~alien.SubscriberFlags.Pending; + if ( + flags & alien.ReactiveFlags.Dirty + || (flags & alien.ReactiveFlags.Pending && checkDirty(this.deps!, this)) + ) { + if (this.update()) { + const subs = this.subs; + if (subs !== undefined) { + shallowPropagate(subs); + } } + } else if (flags & alien.ReactiveFlags.Pending) { + this.flags = flags & ~alien.ReactiveFlags.Pending; } if (activeSub !== undefined) { - const newSub = link(this, activeSub)?.sub; - if (newSub instanceof Computed && newSub.watchCount) { - this.onWatched(); - } - } else if (this.subs === undefined) { - link(this, nursery); - if (!triggingCooling) { - triggingCooling = true; - triggerCooling(); + const lastLink = this.subsTail; + link(this, activeSub); + const newLink = this.subsTail!; + if (newLink !== lastLink) { + const newSub = newLink.sub; + if (newSub instanceof Computed && newSub.watchCount) { + this.onWatched(); + } } } if (this.isError) { - throw this.currentValue; + throw this.value; } - return this.currentValue!; + return this.value!; } update(): boolean { const prevSub = activeSub; activeSub = this; startTracking(this); - const oldValue = this.currentValue; + const oldValue = this.value; try { const newValue = this.getter(); if (this.isError || !this.equals(oldValue!, newValue)) { this.isError = false; - this.version++; - this.currentValue = newValue; + this.value = newValue; return true; } return false; } catch (err) { if (!this.isError || !this.equals(oldValue!, err as any)) { this.isError = true; - this.version++; - this.currentValue = err as any; + this.value = err as any; return true; } return false; @@ -232,20 +262,13 @@ export namespace Signal { } } - async function triggerCooling() { - await Promise.resolve(); // TODO: Confirm the scheduling logic - triggingCooling = false; - startTracking(nursery); - endTracking(nursery); - } - type AnySignal = State | Computed; export namespace subtle { - export class Watcher implements alien.Subscriber { + export class Watcher implements alien.ReactiveNode { deps: alien.Link | undefined = undefined; depsTail: alien.Link | undefined = undefined; - flags = alien.SubscriberFlags.Effect; + flags = alien.ReactiveFlags.Watching; watchList = new Set(); constructor(private fn: () => void) { } @@ -295,7 +318,7 @@ export namespace Signal { const source = link.dep; if ( source instanceof Computed && - source.flags & (alien.SubscriberFlags.Dirty | alien.SubscriberFlags.Pending) + source.flags & (alien.ReactiveFlags.Dirty | alien.ReactiveFlags.Pending) ) { arr.push(link.dep as AnySignal); } @@ -316,7 +339,7 @@ export namespace Signal { return arr; } - export function introspectSources(signal: alien.Subscriber) { + export function introspectSources(signal: alien.ReactiveNode) { const arr: AnySignal[] = []; for (let link = signal.deps; link !== undefined; link = link.nextDep) { arr.push(link.dep as AnySignal); From 75d8c303747e2a5746488476fc8a23dc80c1fc0d Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 1 May 2025 17:32:03 +0800 Subject: [PATCH 31/53] simplify --- src/alien.ts | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index 63df751..f05c565 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -16,7 +16,7 @@ export namespace Signal { startTracking, shallowPropagate, } = alien.createReactiveSystem({ - update(node: Computed | State) { + update(node: State | Computed) { return node.update(); }, notify(node: subtle.Watcher) { @@ -26,15 +26,7 @@ export namespace Signal { queuedEffects[queuedEffectsLength++] = node; } }, - unwatched(node) { - let toRemove = node.deps; - if (toRemove !== undefined) { - do { - toRemove = unlink(toRemove, node); - } while (toRemove !== undefined); - node.flags |= alien.ReactiveFlags.Dirty; - } - }, + unwatched() { }, }); const queuedEffects: subtle.Watcher[] = []; @@ -269,7 +261,7 @@ export namespace Signal { deps: alien.Link | undefined = undefined; depsTail: alien.Link | undefined = undefined; flags = alien.ReactiveFlags.Watching; - watchList = new Set(); + watchList = new Map(); constructor(private fn: () => void) { } @@ -288,28 +280,22 @@ export namespace Signal { if (this.watchList.has(signal)) { continue; } - this.watchList.add(signal); link(signal, this); + this.watchList.set(signal, this.depsTail!); signal.onWatched(); } } unwatch(...signals: AnySignal[]): void { for (const signal of signals) { - if (!this.watchList.has(signal)) { + const link = this.watchList.get(signal); + if (link === undefined) { continue; } + unlink(link, this); this.watchList.delete(signal); signal.onUnwatched(); } - startTracking(this); - for (let _link = this.deps; _link !== undefined; _link = _link.nextDep) { - const dep = _link.dep as AnySignal; - if (this.watchList.has(dep)) { - link(dep, this); - } - } - endTracking(this); } getPending() { From 4b00503469b8c30ef7740a2d13c2fcd6a4743943 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 1 May 2025 17:57:53 +0800 Subject: [PATCH 32/53] sort condition --- src/alien.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index f05c565..5ed5fee 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -303,8 +303,8 @@ export namespace Signal { for (let link = this.deps; link !== undefined; link = link.nextDep) { const source = link.dep; if ( - source instanceof Computed && - source.flags & (alien.ReactiveFlags.Dirty | alien.ReactiveFlags.Pending) + source.flags & (alien.ReactiveFlags.Dirty | alien.ReactiveFlags.Pending) && + source instanceof Computed ) { arr.push(link.dep as AnySignal); } From fcad6f2f96dd934609fe90125691ccda53343510 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 1 May 2025 18:22:56 +0800 Subject: [PATCH 33/53] Delete gc.test.ts --- tests/behaviors/gc.test.ts | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 tests/behaviors/gc.test.ts diff --git a/tests/behaviors/gc.test.ts b/tests/behaviors/gc.test.ts deleted file mode 100644 index c3c899d..0000000 --- a/tests/behaviors/gc.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/alien.js'; - -describe('GC', () => { - it('clears subscription after computed is unref and GC is invoked', async () => { - const s1 = new Signal.State(10); - let c1: Signal.Computed | undefined = new Signal.Computed(() => s1.get()); - const c1Ref = new WeakRef(c1); - c1.get(); - c1 = undefined; - - await gc(); - expect(c1Ref.deref()).toBe(undefined); - - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(s1.subs).toBe(undefined); - }); -}); - -function gc() { - return new Promise((resolve) => { - setImmediate(() => { - globalThis.gc!(); - resolve(); - }); - }); -} From 5c4d56ed2eaae3f1f87765d28ba7d92b9de7e379 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 1 May 2025 18:25:46 +0800 Subject: [PATCH 34/53] Update vite.config.ts --- vite.config.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 54246ca..fea5e8c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,15 +7,6 @@ const entry = join(dirname(fileURLToPath(import.meta.url)), './src/index.ts'); export default defineConfig({ plugins: [dts()], - test: { - pool: 'forks', - poolOptions: { - forks: { - singleFork: true, - execArgv: ['--expose-gc'], - }, - }, - }, build: { minify: false, lib: { From 5e46d9435382fbc4c58826812474c5287b1671bc Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 1 May 2025 18:55:06 +0800 Subject: [PATCH 35/53] unwatched --- src/alien.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index 5ed5fee..6000e80 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -26,7 +26,15 @@ export namespace Signal { queuedEffects[queuedEffectsLength++] = node; } }, - unwatched() { }, + unwatched(node) { + let toRemove = node.deps; + if (toRemove !== undefined) { + do { + toRemove = unlink(toRemove, node); + } while (toRemove !== undefined); + node.flags |= alien.ReactiveFlags.Dirty; + } + }, }); const queuedEffects: subtle.Watcher[] = []; @@ -280,9 +288,9 @@ export namespace Signal { if (this.watchList.has(signal)) { continue; } + signal.onWatched(); link(signal, this); this.watchList.set(signal, this.depsTail!); - signal.onWatched(); } } @@ -292,9 +300,9 @@ export namespace Signal { if (link === undefined) { continue; } + signal.onUnwatched(); unlink(link, this); this.watchList.delete(signal); - signal.onUnwatched(); } } From 9428ab584bbd8733a519e8d55c8487e087fc647c Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 12 Aug 2025 15:51:52 +0800 Subject: [PATCH 36/53] bump --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a3a3d86..4a66ed4 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "webdriverio": "^8.36.1" }, "dependencies": { - "alien-signals": "^2.0.1" + "alien-signals": "^2.0.6" }, "volta": { "node": "22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a59b259..e198aec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: alien-signals: - specifier: ^2.0.1 - version: 2.0.1 + specifier: ^2.0.6 + version: 2.0.6 devDependencies: '@types/node': specifier: ^20.11.25 @@ -800,8 +800,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - alien-signals@2.0.1: - resolution: {integrity: sha512-fvRLEA4BoO78crQ9otbxMfN/uCAgQsscaNLeAd7S3gzKjDJdsXuzAI1Cl3TFqFxbp4BdcyUJsH15NHjuvB/nvQ==} + alien-signals@2.0.6: + resolution: {integrity: sha512-P3TxJSe31bUHBiblg59oU1PpaWPtmxF9GhJ/cB7OkgJ0qN/ifFSKUI25/v8ZhsT+lIG6ac8DpTOplXxORX6F3Q==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3372,7 +3372,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - alien-signals@2.0.1: {} + alien-signals@2.0.6: {} ansi-regex@5.0.1: {} From e61395de3f3a1e9411a8899629bc90c834c57233 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 12 Aug 2025 16:28:43 +0800 Subject: [PATCH 37/53] Use `alien-signals/system` --- src/alien.ts | 63 +++++++++++++++++++++++++++------------------------ tsconfig.json | 2 +- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index 6000e80..c5fb2ae 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -1,4 +1,9 @@ -import * as alien from 'alien-signals'; +import { + ReactiveFlags, + createReactiveSystem, + type ReactiveNode, + type Link, +} from 'alien-signals/system'; export namespace Signal { const WATCHER_PLACEHOLDER = Symbol('watcher') as any; @@ -15,7 +20,7 @@ export namespace Signal { endTracking, startTracking, shallowPropagate, - } = alien.createReactiveSystem({ + } = createReactiveSystem({ update(node: State | Computed) { return node.update(); }, @@ -32,7 +37,7 @@ export namespace Signal { do { toRemove = unlink(toRemove, node); } while (toRemove !== undefined); - node.flags |= alien.ReactiveFlags.Dirty; + node.flags |= ReactiveFlags.Dirty; } }, }); @@ -40,7 +45,7 @@ export namespace Signal { let notifyIndex = 0; let queuedEffectsLength = 0; - let activeSub: alien.ReactiveNode | undefined; + let activeSub: ReactiveNode | undefined; function flush(): void { while (notifyIndex < queuedEffectsLength) { @@ -64,10 +69,10 @@ export namespace Signal { } } - export class State implements alien.ReactiveNode { - subs: alien.Link | undefined = undefined; - subsTail: alien.Link | undefined = undefined; - flags: alien.ReactiveFlags = alien.ReactiveFlags.Mutable; + export class State implements ReactiveNode { + subs: Link | undefined = undefined; + subsTail: Link | undefined = undefined; + flags: ReactiveFlags = ReactiveFlags.Mutable; watchCount = 0; previousValue: T; @@ -98,7 +103,7 @@ export namespace Signal { } update() { - this.flags &= ~alien.ReactiveFlags.Dirty; + this.flags &= ~ReactiveFlags.Dirty; return !this.equals(this.previousValue, this.previousValue = this.value); } @@ -106,7 +111,7 @@ export namespace Signal { if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from state inside watcher'); } - if (this.flags & alien.ReactiveFlags.Dirty) { + if (this.flags & ReactiveFlags.Dirty) { if (this.update()) { const subs = this.subs; if (subs !== undefined) { @@ -134,7 +139,7 @@ export namespace Signal { } if (!this.equals(this.value, value)) { this.value = value; - this.flags = alien.ReactiveFlags.Mutable | alien.ReactiveFlags.Dirty; + this.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty; const subs = this.subs; if (subs !== undefined) { propagate(subs); @@ -144,12 +149,12 @@ export namespace Signal { } } - export class Computed implements alien.ReactiveNode { - subs: alien.Link | undefined = undefined; - subsTail: alien.Link | undefined = undefined; - deps: alien.Link | undefined = undefined; - depsTail: alien.Link | undefined = undefined; - flags = alien.ReactiveFlags.Mutable | alien.ReactiveFlags.Dirty; + export class Computed implements ReactiveNode { + subs: Link | undefined = undefined; + subsTail: Link | undefined = undefined; + deps: Link | undefined = undefined; + depsTail: Link | undefined = undefined; + flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty; isError = true; watchCount = 0; value: T | undefined = undefined; @@ -192,12 +197,12 @@ export namespace Signal { throw new Error('Cannot read from computed inside watcher'); } let flags = this.flags; - if (flags & alien.ReactiveFlags.RecursedCheck) { + if (flags & ReactiveFlags.RecursedCheck) { throw new Error('Cycles detected'); } if ( - flags & alien.ReactiveFlags.Dirty - || (flags & alien.ReactiveFlags.Pending && checkDirty(this.deps!, this)) + flags & ReactiveFlags.Dirty + || (flags & ReactiveFlags.Pending && checkDirty(this.deps!, this)) ) { if (this.update()) { const subs = this.subs; @@ -205,8 +210,8 @@ export namespace Signal { shallowPropagate(subs); } } - } else if (flags & alien.ReactiveFlags.Pending) { - this.flags = flags & ~alien.ReactiveFlags.Pending; + } else if (flags & ReactiveFlags.Pending) { + this.flags = flags & ~ReactiveFlags.Pending; } if (activeSub !== undefined) { const lastLink = this.subsTail; @@ -265,11 +270,11 @@ export namespace Signal { type AnySignal = State | Computed; export namespace subtle { - export class Watcher implements alien.ReactiveNode { - deps: alien.Link | undefined = undefined; - depsTail: alien.Link | undefined = undefined; - flags = alien.ReactiveFlags.Watching; - watchList = new Map(); + export class Watcher implements ReactiveNode { + deps: Link | undefined = undefined; + depsTail: Link | undefined = undefined; + flags = ReactiveFlags.Watching; + watchList = new Map(); constructor(private fn: () => void) { } @@ -311,7 +316,7 @@ export namespace Signal { for (let link = this.deps; link !== undefined; link = link.nextDep) { const source = link.dep; if ( - source.flags & (alien.ReactiveFlags.Dirty | alien.ReactiveFlags.Pending) && + source.flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending) && source instanceof Computed ) { arr.push(link.dep as AnySignal); @@ -333,7 +338,7 @@ export namespace Signal { return arr; } - export function introspectSources(signal: alien.ReactiveNode) { + export function introspectSources(signal: ReactiveNode) { const arr: AnySignal[] = []; for (let link = signal.deps; link !== undefined; link = link.nextDep) { arr.push(link.dep as AnySignal); diff --git a/tsconfig.json b/tsconfig.json index 6ead5fc..82a7188 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "outDir": "dist", "rootDir": "src", "pretty": true, - "moduleResolution": "node", + "moduleResolution": "nodenext", "module": "ESNext", "target": "ES2022", "sourceMap": true, From 82036bafeff5fbc5d497a52b6d36314fb92d869e Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 12 Aug 2025 17:19:55 +0800 Subject: [PATCH 38/53] Fix type-checking.test.ts --- src/alien.ts | 76 +++++++++++++++++++++++++-- tests/behaviors/type-checking.test.ts | 4 +- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index c5fb2ae..681a5c8 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -6,6 +6,10 @@ import { } from 'alien-signals/system'; export namespace Signal { + export let isState: (s: any) => boolean, + isComputed: (s: any) => boolean, + isWatcher: (s: any) => boolean; + const WATCHER_PLACEHOLDER = Symbol('watcher') as any; const enum EffectFlags { @@ -76,6 +80,11 @@ export namespace Signal { watchCount = 0; previousValue: T; + #brand() { } + static { + isState = (s) => typeof s === 'object' && #brand in s; + } + constructor( private value: T, private options?: Options, @@ -108,6 +117,9 @@ export namespace Signal { } get() { + if (!isState(this)) { + throw new TypeError('Wrong receiver type for Signal.State.prototype.get'); + } if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from state inside watcher'); } @@ -134,6 +146,9 @@ export namespace Signal { } set(value: T): void { + if (!isState(this)) { + throw new TypeError('Wrong receiver type for Signal.State.prototype.set'); + } if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot write to state inside watcher'); } @@ -159,6 +174,11 @@ export namespace Signal { watchCount = 0; value: T | undefined = undefined; + #brand() { } + static { + isComputed = (c: any) => typeof c === 'object' && #brand in c; + } + constructor( private getter: () => T, private options?: Options, @@ -193,6 +213,9 @@ export namespace Signal { } get() { + if (!isComputed(this)) { + throw new TypeError('Wrong receiver type for Signal.Computed.prototype.get'); + } if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from computed inside watcher'); } @@ -268,6 +291,7 @@ export namespace Signal { } type AnySignal = State | Computed; + type AnySink = Computed | subtle.Watcher; export namespace subtle { export class Watcher implements ReactiveNode { @@ -276,6 +300,11 @@ export namespace Signal { flags = ReactiveFlags.Watching; watchList = new Map(); + #brand() { } + static { + isWatcher = (w: any): w is Watcher => #brand in w; + } + constructor(private fn: () => void) { } run() { @@ -288,7 +317,20 @@ export namespace Signal { } } + #assertSignals(signals: AnySignal[]): void { + for (const signal of signals) { + if (!isComputed(signal) && !isState(signal)) { + throw new TypeError('Called watch/unwatch without a Computed or State argument'); + } + } + } + watch(...signals: AnySignal[]): void { + if (!isWatcher(this)) { + throw new TypeError('Called watch without Watcher receiver'); + } + this.#assertSignals(signals); + for (const signal of signals) { if (this.watchList.has(signal)) { continue; @@ -300,6 +342,11 @@ export namespace Signal { } unwatch(...signals: AnySignal[]): void { + if (!isWatcher(this)) { + throw new TypeError('Called unwatch without Watcher receiver'); + } + this.#assertSignals(signals); + for (const signal of signals) { const link = this.watchList.get(signal); if (link === undefined) { @@ -312,6 +359,9 @@ export namespace Signal { } getPending() { + if (!isWatcher(this)) { + throw new TypeError('Called getPending without Watcher receiver'); + } const arr: AnySignal[] = []; for (let link = this.deps; link !== undefined; link = link.nextDep) { const source = link.dep; @@ -327,20 +377,36 @@ export namespace Signal { } export function hasSinks(signal: AnySignal) { + if (!isComputed(signal) && !isState(signal)) { + throw new TypeError('Called hasSinks without a Signal argument'); + } return signal.watchCount > 0; } - export function introspectSinks(signal: AnySignal) { - const arr: (Computed | subtle.Watcher)[] = []; + export function hasSources(signal: AnySink) { + if (!isComputed(signal) && !isWatcher(signal)) { + throw new TypeError('Called hasSources without a Computed or Watcher argument'); + } + return signal.depsTail !== undefined; + } + + export function introspectSinks(signal: AnySignal): AnySink[] { + if (!isComputed(signal) && !isState(signal)) { + throw new TypeError('Called introspectSinks without a Signal argument'); + } + const arr: AnySink[] = []; for (let link = signal.subs; link !== undefined; link = link.nextSub) { - arr.push(link.sub as Computed | subtle.Watcher); + arr.push(link.sub as AnySink); } return arr; } - export function introspectSources(signal: ReactiveNode) { + export function introspectSources(sink: AnySink): AnySignal[] { + if (!isComputed(sink) && !isWatcher(sink)) { + throw new TypeError('Called introspectSources without a Computed or Watcher argument'); + } const arr: AnySignal[] = []; - for (let link = signal.deps; link !== undefined; link = link.nextDep) { + for (let link = sink.deps; link !== undefined; link = link.nextDep) { arr.push(link.dep as AnySignal); } return arr; diff --git a/tests/behaviors/type-checking.test.ts b/tests/behaviors/type-checking.test.ts index c8962f3..1cb4a25 100644 --- a/tests/behaviors/type-checking.test.ts +++ b/tests/behaviors/type-checking.test.ts @@ -1,8 +1,6 @@ import {describe, expect, it} from 'vitest'; import {Signal} from '../../src/alien.js'; -const isAlien = true; - describe('Expected class shape', () => { it('should be on the prototype', () => { expect(typeof Signal.State.prototype.get).toBe('function'); @@ -14,7 +12,7 @@ describe('Expected class shape', () => { }); }); -describe.skipIf(isAlien)('type checks', () => { +describe('type checks', () => { it('checks types in methods', () => { let x = {}; let s = new Signal.State(1); From 63e2a69ef3dec61d5aa7f2af4be5a84e80c630aa Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 12 Aug 2025 17:42:25 +0800 Subject: [PATCH 39/53] Remove isAlien --- tests/behaviors/prohibited-contexts.test.ts | 13 ++----------- tsconfig.json | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/tests/behaviors/prohibited-contexts.test.ts b/tests/behaviors/prohibited-contexts.test.ts index 5838942..530f4ba 100644 --- a/tests/behaviors/prohibited-contexts.test.ts +++ b/tests/behaviors/prohibited-contexts.test.ts @@ -1,8 +1,6 @@ import {describe, expect, it} from 'vitest'; import {Signal} from '../../src/alien.js'; -const isAlien = true; - describe('Prohibited contexts', () => { it('allows writes during computed', () => { const s = new Signal.State(1); @@ -10,15 +8,8 @@ describe('Prohibited contexts', () => { expect(c.get()).toBe(2); expect(s.get()).toBe(2); - if (isAlien) { - expect(c.get()).toBe(3); - expect(s.get()).toBe(3); - } else { - // Note: c is marked clean in this case, even though re-evaluating it - // would cause it to change value (due to the set inside of it). - expect(c.get()).toBe(2); - expect(s.get()).toBe(2); - } + expect(c.get()).toBe(3); + expect(s.get()).toBe(3); s.set(3); diff --git a/tsconfig.json b/tsconfig.json index 82a7188..61d57b4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "outDir": "dist", "rootDir": "src", "pretty": true, - "moduleResolution": "nodenext", + "moduleResolution": "bundler", "module": "ESNext", "target": "ES2022", "sourceMap": true, From 2da14164541e9445b1cf70c67acfb5ca1b64ce04 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 12 Aug 2025 17:52:29 +0800 Subject: [PATCH 40/53] Move untrack --- src/alien.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index 681a5c8..d6e6e8f 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -4,6 +4,7 @@ import { type ReactiveNode, type Link, } from 'alien-signals/system'; +import { defaultEquals } from './equality'; export namespace Signal { export let isState: (s: any) => boolean, @@ -63,16 +64,6 @@ export namespace Signal { queuedEffectsLength = 0; } - export function untrack(fn: () => T) { - const prevSub = activeSub; - activeSub = undefined; - try { - return fn(); - } finally { - activeSub = prevSub; - } - } - export class State implements ReactiveNode { subs: Link | undefined = undefined; subsTail: Link | undefined = undefined; @@ -96,7 +87,7 @@ export namespace Signal { } equals(t: T, t2: T): boolean { - return Object.is(t, t2); + return defaultEquals(t, t2); } onWatched() { @@ -189,7 +180,7 @@ export namespace Signal { } equals(t: T, t2: T): boolean { - return Object.is(t, t2); + return defaultEquals(t, t2); } onWatched() { @@ -294,6 +285,16 @@ export namespace Signal { type AnySink = Computed | subtle.Watcher; export namespace subtle { + export function untrack(fn: () => T) { + const prevSub = activeSub; + activeSub = undefined; + try { + return fn(); + } finally { + activeSub = prevSub; + } + } + export class Watcher implements ReactiveNode { deps: Link | undefined = undefined; depsTail: Link | undefined = undefined; From 6812d9453e6e76dfcf7e4c3e37feef564e6ee0da Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 12 Aug 2025 18:56:31 +0800 Subject: [PATCH 41/53] Fix public-api-types.ts --- src/alien.ts | 224 ++++++++++++-------- src/public-api-types.ts | 16 +- tests/Signal/subtle/currentComputed.test.ts | 2 +- 3 files changed, 142 insertions(+), 100 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index d6e6e8f..de48e8d 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -7,9 +7,9 @@ import { import { defaultEquals } from './equality'; export namespace Signal { - export let isState: (s: any) => boolean, - isComputed: (s: any) => boolean, - isWatcher: (s: any) => boolean; + export let isState: (s: any) => s is State, + isComputed: (s: any) => s is Computed, + isWatcher: (s: any) => s is subtle.Watcher; const WATCHER_PLACEHOLDER = Symbol('watcher') as any; @@ -26,10 +26,10 @@ export namespace Signal { startTracking, shallowPropagate, } = createReactiveSystem({ - update(node: State | Computed) { + update(node: _State | _Computed) { return node.update(); }, - notify(node: subtle.Watcher) { + notify(node: _Watcher) { const flags = node.flags; if (!(flags & EffectFlags.Queued)) { node.flags = flags | EffectFlags.Queued; @@ -46,7 +46,7 @@ export namespace Signal { } }, }); - const queuedEffects: subtle.Watcher[] = []; + const queuedEffects: _Watcher[] = []; let notifyIndex = 0; let queuedEffectsLength = 0; @@ -64,7 +64,7 @@ export namespace Signal { queuedEffectsLength = 0; } - export class State implements ReactiveNode { + class _State implements ReactiveNode { subs: Link | undefined = undefined; subsTail: Link | undefined = undefined; flags: ReactiveFlags = ReactiveFlags.Mutable; @@ -128,7 +128,7 @@ export namespace Signal { const newLink = this.subsTail!; if (newLink !== lastLink) { const newSub = newLink.sub; - if (newSub instanceof Computed && newSub.watchCount) { + if (newSub instanceof _Computed && newSub.watchCount) { this.onWatched(); } } @@ -155,7 +155,19 @@ export namespace Signal { } } - export class Computed implements ReactiveNode { + export interface State { + get(): T; + set(value: T): void; + } + + export const State = _State as { + new ( + value: T, + options?: Options, + ): State + }; + + class _Computed implements ReactiveNode { subs: Link | undefined = undefined; subsTail: Link | undefined = undefined; deps: Link | undefined = undefined; @@ -187,7 +199,7 @@ export namespace Signal { if (this.watchCount++ === 0) { this.options?.[subtle.watched]?.call(this); for (let link = this.deps; link !== undefined; link = link.nextDep) { - const dep = link.dep as AnySignal; + const dep = link.dep as _AnySignal; dep.onWatched(); } } @@ -197,7 +209,7 @@ export namespace Signal { if (--this.watchCount === 0) { this.options?.[subtle.unwatched]?.call(this); for (let link = this.deps; link !== undefined; link = link.nextDep) { - const dep = link.dep as AnySignal; + const dep = link.dep as _AnySignal; dep.onUnwatched(); } } @@ -233,7 +245,7 @@ export namespace Signal { const newLink = this.subsTail!; if (newLink !== lastLink) { const newSub = newLink.sub; - if (newSub instanceof Computed && newSub.watchCount) { + if (newSub instanceof _Computed && newSub.watchCount) { this.onWatched(); } } @@ -271,7 +283,7 @@ export namespace Signal { link !== undefined; link = link.nextDep ) { - const dep = link.dep as AnySignal; + const dep = link.dep as _AnySignal; dep.onUnwatched(); } } @@ -281,123 +293,147 @@ export namespace Signal { } } - type AnySignal = State | Computed; - type AnySink = Computed | subtle.Watcher; + export interface Computed { + get(): T; + } - export namespace subtle { - export function untrack(fn: () => T) { + export const Computed = _Computed as { + new ( + getter: () => T, + options?: Options, + ): Computed + }; + + class _Watcher implements ReactiveNode { + deps: Link | undefined = undefined; + depsTail: Link | undefined = undefined; + flags = ReactiveFlags.Watching; + watchList = new Map<_AnySignal, Link>(); + + #brand() { } + static { + isWatcher = (w: any): w is _Watcher => #brand in w; + } + + constructor(private fn: () => void) { } + + run() { const prevSub = activeSub; - activeSub = undefined; + activeSub = WATCHER_PLACEHOLDER; try { - return fn(); + this.fn(); } finally { activeSub = prevSub; } } - export class Watcher implements ReactiveNode { - deps: Link | undefined = undefined; - depsTail: Link | undefined = undefined; - flags = ReactiveFlags.Watching; - watchList = new Map(); - - #brand() { } - static { - isWatcher = (w: any): w is Watcher => #brand in w; + #assertSignals(signals: _AnySignal[]): void { + for (const signal of signals) { + if (!isComputed(signal) && !isState(signal)) { + throw new TypeError('Called watch/unwatch without a Computed or State argument'); + } } + } - constructor(private fn: () => void) { } + watch(...signals: _AnySignal[]): void { + if (!isWatcher(this)) { + throw new TypeError('Called watch without Watcher receiver'); + } + this.#assertSignals(signals); - run() { - const prevSub = activeSub; - activeSub = WATCHER_PLACEHOLDER; - try { - this.fn(); - } finally { - activeSub = prevSub; + for (const signal of signals) { + if (this.watchList.has(signal)) { + continue; } + signal.onWatched(); + link(signal, this); + this.watchList.set(signal, this.depsTail!); } + } - #assertSignals(signals: AnySignal[]): void { - for (const signal of signals) { - if (!isComputed(signal) && !isState(signal)) { - throw new TypeError('Called watch/unwatch without a Computed or State argument'); - } - } + unwatch(...signals: _AnySignal[]): void { + if (!isWatcher(this)) { + throw new TypeError('Called unwatch without Watcher receiver'); } + this.#assertSignals(signals); - watch(...signals: AnySignal[]): void { - if (!isWatcher(this)) { - throw new TypeError('Called watch without Watcher receiver'); + for (const signal of signals) { + const link = this.watchList.get(signal); + if (link === undefined) { + continue; } - this.#assertSignals(signals); + signal.onUnwatched(); + unlink(link, this); + this.watchList.delete(signal); + } + } - for (const signal of signals) { - if (this.watchList.has(signal)) { - continue; - } - signal.onWatched(); - link(signal, this); - this.watchList.set(signal, this.depsTail!); + getPending(): _AnySignal[] { + if (!isWatcher(this)) { + throw new TypeError('Called getPending without Watcher receiver'); + } + const arr: _AnySignal[] = []; + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const source = link.dep; + if ( + source.flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending) && + source instanceof _Computed + ) { + arr.push(link.dep as _AnySignal); } } + return arr; + } + } - unwatch(...signals: AnySignal[]): void { - if (!isWatcher(this)) { - throw new TypeError('Called unwatch without Watcher receiver'); - } - this.#assertSignals(signals); + type _AnySignal = _State | _Computed; + type _AnySink = _Computed | _Watcher; - for (const signal of signals) { - const link = this.watchList.get(signal); - if (link === undefined) { - continue; - } - signal.onUnwatched(); - unlink(link, this); - this.watchList.delete(signal); - } - } + type AnySignal = State | Computed; + type AnySink = Computed | subtle.Watcher; - getPending() { - if (!isWatcher(this)) { - throw new TypeError('Called getPending without Watcher receiver'); - } - const arr: AnySignal[] = []; - for (let link = this.deps; link !== undefined; link = link.nextDep) { - const source = link.dep; - if ( - source.flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending) && - source instanceof Computed - ) { - arr.push(link.dep as AnySignal); - } - } - return arr; + export namespace subtle { + export function untrack(fn: () => T) { + const prevSub = activeSub; + activeSub = undefined; + try { + return fn(); + } finally { + activeSub = prevSub; } } + export interface Watcher { + watch(...signals: AnySignal[]): void; + unwatch(...signals: AnySignal[]): void; + getPending(): AnySignal[] + } + + export const Watcher = _Watcher as { + new(fn: () => void): Watcher + }; + export function hasSinks(signal: AnySignal) { if (!isComputed(signal) && !isState(signal)) { throw new TypeError('Called hasSinks without a Signal argument'); } - return signal.watchCount > 0; + return (signal as _AnySignal).watchCount > 0; } export function hasSources(signal: AnySink) { if (!isComputed(signal) && !isWatcher(signal)) { throw new TypeError('Called hasSources without a Computed or Watcher argument'); } - return signal.depsTail !== undefined; + return (signal as _AnySink).depsTail !== undefined; } export function introspectSinks(signal: AnySignal): AnySink[] { if (!isComputed(signal) && !isState(signal)) { throw new TypeError('Called introspectSinks without a Signal argument'); } - const arr: AnySink[] = []; - for (let link = signal.subs; link !== undefined; link = link.nextSub) { - arr.push(link.sub as AnySink); + const arr: _AnySink[] = []; + for (let link = (signal as _AnySignal).subs; link !== undefined; link = link.nextSub) { + arr.push(link.sub as _AnySink); } return arr; } @@ -406,13 +442,19 @@ export namespace Signal { if (!isComputed(sink) && !isWatcher(sink)) { throw new TypeError('Called introspectSources without a Computed or Watcher argument'); } - const arr: AnySignal[] = []; - for (let link = sink.deps; link !== undefined; link = link.nextDep) { - arr.push(link.dep as AnySignal); + const arr: _AnySignal[] = []; + for (let link = (sink as _AnySink).deps; link !== undefined; link = link.nextDep) { + arr.push(link.dep as _AnySignal); } return arr; } + export function currentComputed(): Computed | undefined { + if (isComputed(activeSub)) { + return activeSub; + } + } + // Hooks to observe being watched or no longer watched export const watched = Symbol('watched'); export const unwatched = Symbol('unwatched'); diff --git a/src/public-api-types.ts b/src/public-api-types.ts index 762a8f6..86f97b7 100644 --- a/src/public-api-types.ts +++ b/src/public-api-types.ts @@ -2,8 +2,8 @@ * The purpose of these tests is to make sure the types are exposed how we expect, * and that we double-check what we're exposing as public API */ -import {expectTypeOf} from 'expect-type'; -import {Signal} from './wrapper.ts'; +import { expectTypeOf } from 'expect-type'; +import { Signal } from './alien.ts'; /** * Top-Level @@ -17,13 +17,13 @@ expectTypeOf().toEqualTypeOf< */ expectTypeOf(Signal.State).toBeConstructibleWith(1); expectTypeOf(Signal.State).toBeConstructibleWith(1, {}); -expectTypeOf(Signal.State).toBeConstructibleWith(1, {equals: (a, b) => true}); -expectTypeOf(Signal.State).toBeConstructibleWith(1, {[Signal.subtle.watched]: () => true}); +expectTypeOf(Signal.State).toBeConstructibleWith(1, { equals: (a, b) => true }); +expectTypeOf(Signal.State).toBeConstructibleWith(1, { [Signal.subtle.watched]: () => true }); expectTypeOf(Signal.State).toBeConstructibleWith(1, { [Signal.subtle.unwatched]: () => true, }); expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 2); -expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, {equals: (a, b) => true}); +expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, { equals: (a, b) => true }); expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, { [Signal.subtle.watched]: () => true, }); @@ -69,8 +69,8 @@ expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, { /** * Properties on each of the instances / namespaces */ -expectTypeOf & string>().toEqualTypeOf<'get' | 'set'>(); -expectTypeOf & string>().toEqualTypeOf<'get'>(); +expectTypeOf>().toEqualTypeOf<'get' | 'set'>(); +expectTypeOf>().toEqualTypeOf<'get'>(); expectTypeOf().toEqualTypeOf< | 'untrack' | 'currentComputed' @@ -83,7 +83,7 @@ expectTypeOf().toEqualTypeOf< | 'unwatched' >(); -expectTypeOf().toEqualTypeOf< +expectTypeOf().toEqualTypeOf< 'watch' | 'unwatch' | 'getPending' >(); diff --git a/tests/Signal/subtle/currentComputed.test.ts b/tests/Signal/subtle/currentComputed.test.ts index a21c26d..93531a7 100644 --- a/tests/Signal/subtle/currentComputed.test.ts +++ b/tests/Signal/subtle/currentComputed.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../../src/wrapper.js'; +import {Signal} from '../../../src/alien.js'; describe('currentComputed', () => { it('works', () => { From d9e8a97729400badd8283bfb1ce91e69444f76bf Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 12 Aug 2025 18:59:26 +0800 Subject: [PATCH 42/53] Update passed tests --- tests/Signal/computed.test.ts | 2 +- tests/Signal/subtle/untrack.test.ts | 2 +- tests/Signal/subtle/watcher.test.ts | 2 +- tests/behaviors/guards.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Signal/computed.test.ts b/tests/Signal/computed.test.ts index 6c02416..6d4d8be 100644 --- a/tests/Signal/computed.test.ts +++ b/tests/Signal/computed.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src/alien.js'; describe('Computed', () => { it('should work', () => { diff --git a/tests/Signal/subtle/untrack.test.ts b/tests/Signal/subtle/untrack.test.ts index 5e06be7..c8f16b3 100644 --- a/tests/Signal/subtle/untrack.test.ts +++ b/tests/Signal/subtle/untrack.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../../src/wrapper.js'; +import {Signal} from '../../../src/alien.js'; describe('Untrack', () => { it('works', () => { diff --git a/tests/Signal/subtle/watcher.test.ts b/tests/Signal/subtle/watcher.test.ts index 8eada4f..e2f84cf 100644 --- a/tests/Signal/subtle/watcher.test.ts +++ b/tests/Signal/subtle/watcher.test.ts @@ -1,5 +1,5 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../../src/wrapper.js'; +import {Signal} from '../../../src/alien.js'; describe('Watcher', () => { type Destructor = () => void; diff --git a/tests/behaviors/guards.test.ts b/tests/behaviors/guards.test.ts index d66a143..fc2186d 100644 --- a/tests/behaviors/guards.test.ts +++ b/tests/behaviors/guards.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src/alien.js'; describe('Guards', () => { it('should work with Signals', () => { From 7b27fbb584e62d84bad898a73fe0b52735087bef Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 12 Aug 2025 19:06:47 +0800 Subject: [PATCH 43/53] Fix state.test.ts --- src/alien.ts | 19 +++---------------- tests/Signal/state.test.ts | 2 +- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index de48e8d..f84ac0a 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -26,7 +26,7 @@ export namespace Signal { startTracking, shallowPropagate, } = createReactiveSystem({ - update(node: _State | _Computed) { + update(node: _Computed) { return node.update(); }, notify(node: _Watcher) { @@ -67,7 +67,7 @@ export namespace Signal { class _State implements ReactiveNode { subs: Link | undefined = undefined; subsTail: Link | undefined = undefined; - flags: ReactiveFlags = ReactiveFlags.Mutable; + flags: ReactiveFlags = ReactiveFlags.None; watchCount = 0; previousValue: T; @@ -102,11 +102,6 @@ export namespace Signal { } } - update() { - this.flags &= ~ReactiveFlags.Dirty; - return !this.equals(this.previousValue, this.previousValue = this.value); - } - get() { if (!isState(this)) { throw new TypeError('Wrong receiver type for Signal.State.prototype.get'); @@ -114,14 +109,6 @@ export namespace Signal { if (activeSub === WATCHER_PLACEHOLDER) { throw new Error('Cannot read from state inside watcher'); } - if (this.flags & ReactiveFlags.Dirty) { - if (this.update()) { - const subs = this.subs; - if (subs !== undefined) { - shallowPropagate(subs); - } - } - } if (activeSub !== undefined) { const lastLink = this.subsTail; link(this, activeSub); @@ -145,10 +132,10 @@ export namespace Signal { } if (!this.equals(this.value, value)) { this.value = value; - this.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty; const subs = this.subs; if (subs !== undefined) { propagate(subs); + shallowPropagate(subs); flush(); } } diff --git a/tests/Signal/state.test.ts b/tests/Signal/state.test.ts index 067830a..4a55deb 100644 --- a/tests/Signal/state.test.ts +++ b/tests/Signal/state.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src/alien.js'; describe('Signal.State', () => { it('should work', () => { From 392b254ff850b50ca2b7848c92916cb1a6514f02 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 12 Aug 2025 19:13:26 +0800 Subject: [PATCH 44/53] Fix watch-unwatch.test.ts --- src/alien.ts | 1 + tests/Signal/subtle/watch-unwatch.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/alien.ts b/src/alien.ts index f84ac0a..b3fe3e4 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -307,6 +307,7 @@ export namespace Signal { run() { const prevSub = activeSub; activeSub = WATCHER_PLACEHOLDER; + this.flags &= ~(ReactiveFlags.Dirty | ReactiveFlags.Pending); try { this.fn(); } finally { diff --git a/tests/Signal/subtle/watch-unwatch.test.ts b/tests/Signal/subtle/watch-unwatch.test.ts index e6e4e1d..6f9be74 100644 --- a/tests/Signal/subtle/watch-unwatch.test.ts +++ b/tests/Signal/subtle/watch-unwatch.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../../src/wrapper.js'; +import {Signal} from '../../../src/alien.js'; describe('watch and unwatch', () => { it('handles multiple watchers well', () => { From 8ec6814af5879566d9b97d33411d9f1f83485d1c Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 12 Aug 2025 19:16:33 +0800 Subject: [PATCH 45/53] Lint fix --- src/alien.ts | 83 +++++++++++++++++------------------------ src/public-api-types.ts | 14 +++---- 2 files changed, 41 insertions(+), 56 deletions(-) diff --git a/src/alien.ts b/src/alien.ts index b3fe3e4..db603ca 100644 --- a/src/alien.ts +++ b/src/alien.ts @@ -4,7 +4,7 @@ import { type ReactiveNode, type Link, } from 'alien-signals/system'; -import { defaultEquals } from './equality'; +import {defaultEquals} from './equality'; export namespace Signal { export let isState: (s: any) => s is State, @@ -17,35 +17,28 @@ export namespace Signal { Queued = 1 << 6, } - const { - link, - unlink, - propagate, - checkDirty, - endTracking, - startTracking, - shallowPropagate, - } = createReactiveSystem({ - update(node: _Computed) { - return node.update(); - }, - notify(node: _Watcher) { - const flags = node.flags; - if (!(flags & EffectFlags.Queued)) { - node.flags = flags | EffectFlags.Queued; - queuedEffects[queuedEffectsLength++] = node; - } - }, - unwatched(node) { - let toRemove = node.deps; - if (toRemove !== undefined) { - do { - toRemove = unlink(toRemove, node); - } while (toRemove !== undefined); - node.flags |= ReactiveFlags.Dirty; - } - }, - }); + const {link, unlink, propagate, checkDirty, endTracking, startTracking, shallowPropagate} = + createReactiveSystem({ + update(node: _Computed) { + return node.update(); + }, + notify(node: _Watcher) { + const flags = node.flags; + if (!(flags & EffectFlags.Queued)) { + node.flags = flags | EffectFlags.Queued; + queuedEffects[queuedEffectsLength++] = node; + } + }, + unwatched(node) { + let toRemove = node.deps; + if (toRemove !== undefined) { + do { + toRemove = unlink(toRemove, node); + } while (toRemove !== undefined); + node.flags |= ReactiveFlags.Dirty; + } + }, + }); const queuedEffects: _Watcher[] = []; let notifyIndex = 0; @@ -71,9 +64,9 @@ export namespace Signal { watchCount = 0; previousValue: T; - #brand() { } + #brand() {} static { - isState = (s) => typeof s === 'object' && #brand in s; + isState = ((s) => typeof s === 'object' && #brand in s) as typeof isState; } constructor( @@ -148,10 +141,7 @@ export namespace Signal { } export const State = _State as { - new ( - value: T, - options?: Options, - ): State + new (value: T, options?: Options): State; }; class _Computed implements ReactiveNode { @@ -164,9 +154,9 @@ export namespace Signal { watchCount = 0; value: T | undefined = undefined; - #brand() { } + #brand() {} static { - isComputed = (c: any) => typeof c === 'object' && #brand in c; + isComputed = ((c: any) => typeof c === 'object' && #brand in c) as typeof isComputed; } constructor( @@ -214,8 +204,8 @@ export namespace Signal { throw new Error('Cycles detected'); } if ( - flags & ReactiveFlags.Dirty - || (flags & ReactiveFlags.Pending && checkDirty(this.deps!, this)) + flags & ReactiveFlags.Dirty || + (flags & ReactiveFlags.Pending && checkDirty(this.deps!, this)) ) { if (this.update()) { const subs = this.subs; @@ -285,10 +275,7 @@ export namespace Signal { } export const Computed = _Computed as { - new ( - getter: () => T, - options?: Options, - ): Computed + new (getter: () => T, options?: Options): Computed; }; class _Watcher implements ReactiveNode { @@ -297,12 +284,12 @@ export namespace Signal { flags = ReactiveFlags.Watching; watchList = new Map<_AnySignal, Link>(); - #brand() { } + #brand() {} static { isWatcher = (w: any): w is _Watcher => #brand in w; } - constructor(private fn: () => void) { } + constructor(private fn: () => void) {} run() { const prevSub = activeSub; @@ -394,11 +381,11 @@ export namespace Signal { export interface Watcher { watch(...signals: AnySignal[]): void; unwatch(...signals: AnySignal[]): void; - getPending(): AnySignal[] + getPending(): AnySignal[]; } export const Watcher = _Watcher as { - new(fn: () => void): Watcher + new (fn: () => void): Watcher; }; export function hasSinks(signal: AnySignal) { diff --git a/src/public-api-types.ts b/src/public-api-types.ts index 86f97b7..4714915 100644 --- a/src/public-api-types.ts +++ b/src/public-api-types.ts @@ -2,8 +2,8 @@ * The purpose of these tests is to make sure the types are exposed how we expect, * and that we double-check what we're exposing as public API */ -import { expectTypeOf } from 'expect-type'; -import { Signal } from './alien.ts'; +import {expectTypeOf} from 'expect-type'; +import {Signal} from './alien.ts'; /** * Top-Level @@ -17,13 +17,13 @@ expectTypeOf().toEqualTypeOf< */ expectTypeOf(Signal.State).toBeConstructibleWith(1); expectTypeOf(Signal.State).toBeConstructibleWith(1, {}); -expectTypeOf(Signal.State).toBeConstructibleWith(1, { equals: (a, b) => true }); -expectTypeOf(Signal.State).toBeConstructibleWith(1, { [Signal.subtle.watched]: () => true }); +expectTypeOf(Signal.State).toBeConstructibleWith(1, {equals: (a, b) => true}); +expectTypeOf(Signal.State).toBeConstructibleWith(1, {[Signal.subtle.watched]: () => true}); expectTypeOf(Signal.State).toBeConstructibleWith(1, { [Signal.subtle.unwatched]: () => true, }); expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 2); -expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, { equals: (a, b) => true }); +expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, {equals: (a, b) => true}); expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, { [Signal.subtle.watched]: () => true, }); @@ -83,9 +83,7 @@ expectTypeOf().toEqualTypeOf< | 'unwatched' >(); -expectTypeOf().toEqualTypeOf< - 'watch' | 'unwatch' | 'getPending' ->(); +expectTypeOf().toEqualTypeOf<'watch' | 'unwatch' | 'getPending'>(); /** * Inference works From 2b9c3864bf6db00a73c87dd306c0d7348bfa5a42 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 12 Aug 2025 19:19:48 +0800 Subject: [PATCH 46/53] Delete old implement and update Vue test result --- src/alien.ts | 449 ---------------- src/computed.ts | 145 ------ src/equality.ts | 23 - src/errors.ts | 21 - src/graph.ts | 520 ------------------- src/index.ts | 450 +++++++++++++++- src/public-api-types.ts | 2 +- src/signal.ts | 105 ---- src/wrapper.ts | 286 ---------- tests/Signal/computed.test.ts | 2 +- tests/Signal/ported/vue.test.ts | 4 +- tests/Signal/state.test.ts | 2 +- tests/Signal/subtle/currentComputed.test.ts | 2 +- tests/Signal/subtle/untrack.test.ts | 2 +- tests/Signal/subtle/watch-unwatch.test.ts | 2 +- tests/Signal/subtle/watcher.test.ts | 2 +- tests/behaviors/custom-equality.test.ts | 2 +- tests/behaviors/cycles.test.ts | 2 +- tests/behaviors/dynamic-dependencies.test.ts | 2 +- tests/behaviors/errors.test.ts | 2 +- tests/behaviors/guards.test.ts | 2 +- tests/behaviors/liveness.test.ts | 2 +- tests/behaviors/prohibited-contexts.test.ts | 2 +- tests/behaviors/pruning.test.ts | 2 +- tests/behaviors/receivers.test.ts | 2 +- tests/behaviors/type-checking.test.ts | 2 +- 26 files changed, 467 insertions(+), 1570 deletions(-) delete mode 100644 src/alien.ts delete mode 100644 src/computed.ts delete mode 100644 src/equality.ts delete mode 100644 src/errors.ts delete mode 100644 src/graph.ts delete mode 100644 src/signal.ts delete mode 100644 src/wrapper.ts diff --git a/src/alien.ts b/src/alien.ts deleted file mode 100644 index db603ca..0000000 --- a/src/alien.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { - ReactiveFlags, - createReactiveSystem, - type ReactiveNode, - type Link, -} from 'alien-signals/system'; -import {defaultEquals} from './equality'; - -export namespace Signal { - export let isState: (s: any) => s is State, - isComputed: (s: any) => s is Computed, - isWatcher: (s: any) => s is subtle.Watcher; - - const WATCHER_PLACEHOLDER = Symbol('watcher') as any; - - const enum EffectFlags { - Queued = 1 << 6, - } - - const {link, unlink, propagate, checkDirty, endTracking, startTracking, shallowPropagate} = - createReactiveSystem({ - update(node: _Computed) { - return node.update(); - }, - notify(node: _Watcher) { - const flags = node.flags; - if (!(flags & EffectFlags.Queued)) { - node.flags = flags | EffectFlags.Queued; - queuedEffects[queuedEffectsLength++] = node; - } - }, - unwatched(node) { - let toRemove = node.deps; - if (toRemove !== undefined) { - do { - toRemove = unlink(toRemove, node); - } while (toRemove !== undefined); - node.flags |= ReactiveFlags.Dirty; - } - }, - }); - const queuedEffects: _Watcher[] = []; - - let notifyIndex = 0; - let queuedEffectsLength = 0; - let activeSub: ReactiveNode | undefined; - - function flush(): void { - while (notifyIndex < queuedEffectsLength) { - const effect = queuedEffects[notifyIndex]; - // @ts-expect-error - queuedEffects[notifyIndex++] = undefined; - effect.flags &= ~EffectFlags.Queued; - effect.run(); - } - notifyIndex = 0; - queuedEffectsLength = 0; - } - - class _State implements ReactiveNode { - subs: Link | undefined = undefined; - subsTail: Link | undefined = undefined; - flags: ReactiveFlags = ReactiveFlags.None; - watchCount = 0; - previousValue: T; - - #brand() {} - static { - isState = ((s) => typeof s === 'object' && #brand in s) as typeof isState; - } - - constructor( - private value: T, - private options?: Options, - ) { - this.previousValue = value; - if (options?.equals !== undefined) { - this.equals = options.equals; - } - } - - equals(t: T, t2: T): boolean { - return defaultEquals(t, t2); - } - - onWatched() { - if (this.watchCount++ === 0) { - this.options?.[subtle.watched]?.call(this); - } - } - - onUnwatched() { - if (--this.watchCount === 0) { - this.options?.[subtle.unwatched]?.call(this); - } - } - - get() { - if (!isState(this)) { - throw new TypeError('Wrong receiver type for Signal.State.prototype.get'); - } - if (activeSub === WATCHER_PLACEHOLDER) { - throw new Error('Cannot read from state inside watcher'); - } - if (activeSub !== undefined) { - const lastLink = this.subsTail; - link(this, activeSub); - const newLink = this.subsTail!; - if (newLink !== lastLink) { - const newSub = newLink.sub; - if (newSub instanceof _Computed && newSub.watchCount) { - this.onWatched(); - } - } - } - return this.value; - } - - set(value: T): void { - if (!isState(this)) { - throw new TypeError('Wrong receiver type for Signal.State.prototype.set'); - } - if (activeSub === WATCHER_PLACEHOLDER) { - throw new Error('Cannot write to state inside watcher'); - } - if (!this.equals(this.value, value)) { - this.value = value; - const subs = this.subs; - if (subs !== undefined) { - propagate(subs); - shallowPropagate(subs); - flush(); - } - } - } - } - - export interface State { - get(): T; - set(value: T): void; - } - - export const State = _State as { - new (value: T, options?: Options): State; - }; - - class _Computed implements ReactiveNode { - subs: Link | undefined = undefined; - subsTail: Link | undefined = undefined; - deps: Link | undefined = undefined; - depsTail: Link | undefined = undefined; - flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty; - isError = true; - watchCount = 0; - value: T | undefined = undefined; - - #brand() {} - static { - isComputed = ((c: any) => typeof c === 'object' && #brand in c) as typeof isComputed; - } - - constructor( - private getter: () => T, - private options?: Options, - ) { - if (options?.equals !== undefined) { - this.equals = options.equals; - } - } - - equals(t: T, t2: T): boolean { - return defaultEquals(t, t2); - } - - onWatched() { - if (this.watchCount++ === 0) { - this.options?.[subtle.watched]?.call(this); - for (let link = this.deps; link !== undefined; link = link.nextDep) { - const dep = link.dep as _AnySignal; - dep.onWatched(); - } - } - } - - onUnwatched() { - if (--this.watchCount === 0) { - this.options?.[subtle.unwatched]?.call(this); - for (let link = this.deps; link !== undefined; link = link.nextDep) { - const dep = link.dep as _AnySignal; - dep.onUnwatched(); - } - } - } - - get() { - if (!isComputed(this)) { - throw new TypeError('Wrong receiver type for Signal.Computed.prototype.get'); - } - if (activeSub === WATCHER_PLACEHOLDER) { - throw new Error('Cannot read from computed inside watcher'); - } - let flags = this.flags; - if (flags & ReactiveFlags.RecursedCheck) { - throw new Error('Cycles detected'); - } - if ( - flags & ReactiveFlags.Dirty || - (flags & ReactiveFlags.Pending && checkDirty(this.deps!, this)) - ) { - if (this.update()) { - const subs = this.subs; - if (subs !== undefined) { - shallowPropagate(subs); - } - } - } else if (flags & ReactiveFlags.Pending) { - this.flags = flags & ~ReactiveFlags.Pending; - } - if (activeSub !== undefined) { - const lastLink = this.subsTail; - link(this, activeSub); - const newLink = this.subsTail!; - if (newLink !== lastLink) { - const newSub = newLink.sub; - if (newSub instanceof _Computed && newSub.watchCount) { - this.onWatched(); - } - } - } - if (this.isError) { - throw this.value; - } - return this.value!; - } - - update(): boolean { - const prevSub = activeSub; - activeSub = this; - startTracking(this); - const oldValue = this.value; - try { - const newValue = this.getter(); - if (this.isError || !this.equals(oldValue!, newValue)) { - this.isError = false; - this.value = newValue; - return true; - } - return false; - } catch (err) { - if (!this.isError || !this.equals(oldValue!, err as any)) { - this.isError = true; - this.value = err as any; - return true; - } - return false; - } finally { - if (this.watchCount) { - for ( - let link = this.depsTail !== undefined ? this.depsTail.nextDep : this.deps; - link !== undefined; - link = link.nextDep - ) { - const dep = link.dep as _AnySignal; - dep.onUnwatched(); - } - } - activeSub = prevSub; - endTracking(this); - } - } - } - - export interface Computed { - get(): T; - } - - export const Computed = _Computed as { - new (getter: () => T, options?: Options): Computed; - }; - - class _Watcher implements ReactiveNode { - deps: Link | undefined = undefined; - depsTail: Link | undefined = undefined; - flags = ReactiveFlags.Watching; - watchList = new Map<_AnySignal, Link>(); - - #brand() {} - static { - isWatcher = (w: any): w is _Watcher => #brand in w; - } - - constructor(private fn: () => void) {} - - run() { - const prevSub = activeSub; - activeSub = WATCHER_PLACEHOLDER; - this.flags &= ~(ReactiveFlags.Dirty | ReactiveFlags.Pending); - try { - this.fn(); - } finally { - activeSub = prevSub; - } - } - - #assertSignals(signals: _AnySignal[]): void { - for (const signal of signals) { - if (!isComputed(signal) && !isState(signal)) { - throw new TypeError('Called watch/unwatch without a Computed or State argument'); - } - } - } - - watch(...signals: _AnySignal[]): void { - if (!isWatcher(this)) { - throw new TypeError('Called watch without Watcher receiver'); - } - this.#assertSignals(signals); - - for (const signal of signals) { - if (this.watchList.has(signal)) { - continue; - } - signal.onWatched(); - link(signal, this); - this.watchList.set(signal, this.depsTail!); - } - } - - unwatch(...signals: _AnySignal[]): void { - if (!isWatcher(this)) { - throw new TypeError('Called unwatch without Watcher receiver'); - } - this.#assertSignals(signals); - - for (const signal of signals) { - const link = this.watchList.get(signal); - if (link === undefined) { - continue; - } - signal.onUnwatched(); - unlink(link, this); - this.watchList.delete(signal); - } - } - - getPending(): _AnySignal[] { - if (!isWatcher(this)) { - throw new TypeError('Called getPending without Watcher receiver'); - } - const arr: _AnySignal[] = []; - for (let link = this.deps; link !== undefined; link = link.nextDep) { - const source = link.dep; - if ( - source.flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending) && - source instanceof _Computed - ) { - arr.push(link.dep as _AnySignal); - } - } - return arr; - } - } - - type _AnySignal = _State | _Computed; - type _AnySink = _Computed | _Watcher; - - type AnySignal = State | Computed; - type AnySink = Computed | subtle.Watcher; - - export namespace subtle { - export function untrack(fn: () => T) { - const prevSub = activeSub; - activeSub = undefined; - try { - return fn(); - } finally { - activeSub = prevSub; - } - } - - export interface Watcher { - watch(...signals: AnySignal[]): void; - unwatch(...signals: AnySignal[]): void; - getPending(): AnySignal[]; - } - - export const Watcher = _Watcher as { - new (fn: () => void): Watcher; - }; - - export function hasSinks(signal: AnySignal) { - if (!isComputed(signal) && !isState(signal)) { - throw new TypeError('Called hasSinks without a Signal argument'); - } - return (signal as _AnySignal).watchCount > 0; - } - - export function hasSources(signal: AnySink) { - if (!isComputed(signal) && !isWatcher(signal)) { - throw new TypeError('Called hasSources without a Computed or Watcher argument'); - } - return (signal as _AnySink).depsTail !== undefined; - } - - export function introspectSinks(signal: AnySignal): AnySink[] { - if (!isComputed(signal) && !isState(signal)) { - throw new TypeError('Called introspectSinks without a Signal argument'); - } - const arr: _AnySink[] = []; - for (let link = (signal as _AnySignal).subs; link !== undefined; link = link.nextSub) { - arr.push(link.sub as _AnySink); - } - return arr; - } - - export function introspectSources(sink: AnySink): AnySignal[] { - if (!isComputed(sink) && !isWatcher(sink)) { - throw new TypeError('Called introspectSources without a Computed or Watcher argument'); - } - const arr: _AnySignal[] = []; - for (let link = (sink as _AnySink).deps; link !== undefined; link = link.nextDep) { - arr.push(link.dep as _AnySignal); - } - return arr; - } - - export function currentComputed(): Computed | undefined { - if (isComputed(activeSub)) { - return activeSub; - } - } - - // Hooks to observe being watched or no longer watched - export const watched = Symbol('watched'); - export const unwatched = Symbol('unwatched'); - } - - export interface Options { - // Custom comparison function between old and new value. Default: Object.is. - // The signal is passed in as an optionally-used third parameter for context. - equals?: (this: AnySignal, t: T, t2: T) => boolean; - - // Callback called when hasSinks becomes true, if it was previously false - [subtle.watched]?: (this: AnySignal) => void; - - // Callback called whenever hasSinks becomes false, if it was previously true - [subtle.unwatched]?: (this: AnySignal) => void; - } -} diff --git a/src/computed.ts b/src/computed.ts deleted file mode 100644 index 555896a..0000000 --- a/src/computed.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import {defaultEquals, ValueEqualityComparer} from './equality.js'; -import { - consumerAfterComputation, - consumerBeforeComputation, - producerAccessed, - producerUpdateValueVersion, - REACTIVE_NODE, - ReactiveNode, - SIGNAL, -} from './graph.js'; - -/** - * A computation, which derives a value from a declarative reactive expression. - * - * `Computed`s are both producers and consumers of reactivity. - */ -export interface ComputedNode extends ReactiveNode, ValueEqualityComparer { - /** - * Current value of the computation, or one of the sentinel values above (`UNSET`, `COMPUTING`, - * `ERROR`). - */ - value: T; - - /** - * If `value` is `ERRORED`, the error caught from the last computation attempt which will - * be re-thrown. - */ - error: unknown; - - /** - * The computation function which will produce a new value. - */ - computation: () => T; -} - -export type ComputedGetter = (() => T) & { - [SIGNAL]: ComputedNode; -}; - -export function computedGet(node: ComputedNode) { - // Check if the value needs updating before returning it. - producerUpdateValueVersion(node); - - // Record that someone looked at this signal. - producerAccessed(node); - - if (node.value === ERRORED) { - throw node.error; - } - - return node.value; -} - -/** - * Create a computed signal which derives a reactive value from an expression. - */ -export function createComputed(computation: () => T): ComputedGetter { - const node: ComputedNode = Object.create(COMPUTED_NODE); - node.computation = computation; - - const computed = () => computedGet(node); - (computed as ComputedGetter)[SIGNAL] = node; - return computed as unknown as ComputedGetter; -} - -/** - * A dedicated symbol used before a computed value has been calculated for the first time. - * Explicitly typed as `any` so we can use it as signal's value. - */ -const UNSET: any = /* @__PURE__ */ Symbol('UNSET'); - -/** - * A dedicated symbol used in place of a computed signal value to indicate that a given computation - * is in progress. Used to detect cycles in computation chains. - * Explicitly typed as `any` so we can use it as signal's value. - */ -const COMPUTING: any = /* @__PURE__ */ Symbol('COMPUTING'); - -/** - * A dedicated symbol used in place of a computed signal value to indicate that a given computation - * failed. The thrown error is cached until the computation gets dirty again. - * Explicitly typed as `any` so we can use it as signal's value. - */ -const ERRORED: any = /* @__PURE__ */ Symbol('ERRORED'); - -// Note: Using an IIFE here to ensure that the spread assignment is not considered -// a side-effect, ending up preserving `COMPUTED_NODE` and `REACTIVE_NODE`. -// TODO: remove when https://github.com/evanw/esbuild/issues/3392 is resolved. -const COMPUTED_NODE = /* @__PURE__ */ (() => { - return { - ...REACTIVE_NODE, - value: UNSET, - dirty: true, - error: null, - equal: defaultEquals, - - producerMustRecompute(node: ComputedNode): boolean { - // Force a recomputation if there's no current value, or if the current value is in the - // process of being calculated (which should throw an error). - return node.value === UNSET || node.value === COMPUTING; - }, - - producerRecomputeValue(node: ComputedNode): void { - if (node.value === COMPUTING) { - // Our computation somehow led to a cyclic read of itself. - throw new Error('Detected cycle in computations.'); - } - - const oldValue = node.value; - node.value = COMPUTING; - - const prevConsumer = consumerBeforeComputation(node); - let newValue: unknown; - let wasEqual = false; - try { - newValue = node.computation.call(node.wrapper); - const oldOk = oldValue !== UNSET && oldValue !== ERRORED; - wasEqual = oldOk && node.equal.call(node.wrapper, oldValue, newValue); - } catch (err) { - newValue = ERRORED; - node.error = err; - } finally { - consumerAfterComputation(node, prevConsumer); - } - - if (wasEqual) { - // No change to `valueVersion` - old and new values are - // semantically equivalent. - node.value = oldValue; - return; - } - - node.value = newValue; - node.version++; - }, - }; -})(); diff --git a/src/equality.ts b/src/equality.ts deleted file mode 100644 index 5014620..0000000 --- a/src/equality.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -/** - * An interface representing a comparison strategy to determine if two values are equal. - * - * @template T The type of the values to be compared. - */ -export interface ValueEqualityComparer { - equal(a: T, b: T): boolean; -} - -/** - * The default equality function used for `signal` and `computed`, which uses referential equality. - */ -export function defaultEquals(a: T, b: T) { - return Object.is(a, b); -} diff --git a/src/errors.ts b/src/errors.ts deleted file mode 100644 index 0f58fe9..0000000 --- a/src/errors.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -function defaultThrowError(): never { - throw new Error(); -} - -let throwInvalidWriteToSignalErrorFn = defaultThrowError; - -export function throwInvalidWriteToSignalError() { - throwInvalidWriteToSignalErrorFn(); -} - -export function setThrowInvalidWriteToSignalError(fn: () => never): void { - throwInvalidWriteToSignalErrorFn = fn; -} diff --git a/src/graph.ts b/src/graph.ts deleted file mode 100644 index 24a93d9..0000000 --- a/src/graph.ts +++ /dev/null @@ -1,520 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -// Required as the signals library is in a separate package, so we need to explicitly ensure the -// global `ngDevMode` type is defined. -declare const ngDevMode: boolean | undefined; - -/** - * The currently active consumer `ReactiveNode`, if running code in a reactive context. - * - * Change this via `setActiveConsumer`. - */ -let activeConsumer: ReactiveNode | null = null; -let inNotificationPhase = false; - -type Version = number & {__brand: 'Version'}; - -/** - * Global epoch counter. Incremented whenever a source signal is set. - */ -let epoch: Version = 1 as Version; - -/** - * Symbol used to tell `Signal`s apart from other functions. - * - * This can be used to auto-unwrap signals in various cases, or to auto-wrap non-signal values. - */ -export const SIGNAL = /* @__PURE__ */ Symbol('SIGNAL'); - -export function setActiveConsumer(consumer: ReactiveNode | null): ReactiveNode | null { - const prev = activeConsumer; - activeConsumer = consumer; - return prev; -} - -export function getActiveConsumer(): ReactiveNode | null { - return activeConsumer; -} - -export function isInNotificationPhase(): boolean { - return inNotificationPhase; -} - -export interface Reactive { - [SIGNAL]: ReactiveNode; -} - -export function isReactive(value: unknown): value is Reactive { - return (value as Partial)[SIGNAL] !== undefined; -} - -export const REACTIVE_NODE: ReactiveNode = { - version: 0 as Version, - lastCleanEpoch: 0 as Version, - dirty: false, - producerNode: undefined, - producerLastReadVersion: undefined, - producerIndexOfThis: undefined, - nextProducerIndex: 0, - liveConsumerNode: undefined, - liveConsumerIndexOfThis: undefined, - consumerAllowSignalWrites: false, - consumerIsAlwaysLive: false, - producerMustRecompute: () => false, - producerRecomputeValue: () => {}, - consumerMarkedDirty: () => {}, - consumerOnSignalRead: () => {}, -}; - -/** - * A producer and/or consumer which participates in the reactive graph. - * - * Producer `ReactiveNode`s which are accessed when a consumer `ReactiveNode` is the - * `activeConsumer` are tracked as dependencies of that consumer. - * - * Certain consumers are also tracked as "live" consumers and create edges in the other direction, - * from producer to consumer. These edges are used to propagate change notifications when a - * producer's value is updated. - * - * A `ReactiveNode` may be both a producer and consumer. - */ -export interface ReactiveNode { - /** - * Version of the value that this node produces. - * - * This is incremented whenever a new value is produced by this node which is not equal to the - * previous value (by whatever definition of equality is in use). - */ - version: Version; - - /** - * Epoch at which this node is verified to be clean. - * - * This allows skipping of some polling operations in the case where no signals have been set - * since this node was last read. - */ - lastCleanEpoch: Version; - - /** - * Whether this node (in its consumer capacity) is dirty. - * - * Only live consumers become dirty, when receiving a change notification from a dependency - * producer. - */ - dirty: boolean; - - /** - * Producers which are dependencies of this consumer. - * - * Uses the same indices as the `producerLastReadVersion` and `producerIndexOfThis` arrays. - */ - producerNode: ReactiveNode[] | undefined; - - /** - * `Version` of the value last read by a given producer. - * - * Uses the same indices as the `producerNode` and `producerIndexOfThis` arrays. - */ - producerLastReadVersion: Version[] | undefined; - - /** - * Index of `this` (consumer) in each producer's `liveConsumers` array. - * - * This value is only meaningful if this node is live (`liveConsumers.length > 0`). Otherwise - * these indices are stale. - * - * Uses the same indices as the `producerNode` and `producerLastReadVersion` arrays. - */ - producerIndexOfThis: number[] | undefined; - - /** - * Index into the producer arrays that the next dependency of this node as a consumer will use. - * - * This index is zeroed before this node as a consumer begins executing. When a producer is read, - * it gets inserted into the producers arrays at this index. There may be an existing dependency - * in this location which may or may not match the incoming producer, depending on whether the - * same producers were read in the same order as the last computation. - */ - nextProducerIndex: number; - - /** - * Array of consumers of this producer that are "live" (they require push notifications). - * - * `liveConsumerNode.length` is effectively our reference count for this node. - */ - liveConsumerNode: ReactiveNode[] | undefined; - - /** - * Index of `this` (producer) in each consumer's `producerNode` array. - * - * Uses the same indices as the `liveConsumerNode` array. - */ - liveConsumerIndexOfThis: number[] | undefined; - - /** - * Whether writes to signals are allowed when this consumer is the `activeConsumer`. - * - * This is used to enforce guardrails such as preventing writes to writable signals in the - * computation function of computed signals, which is supposed to be pure. - */ - consumerAllowSignalWrites: boolean; - - readonly consumerIsAlwaysLive: boolean; - - /** - * Tracks whether producers need to recompute their value independently of the reactive graph (for - * example, if no initial value has been computed). - */ - producerMustRecompute(node: unknown): boolean; - producerRecomputeValue(node: unknown): void; - consumerMarkedDirty(this: unknown): void; - - /** - * Called when a signal is read within this consumer. - */ - consumerOnSignalRead(node: unknown): void; - - /** - * Called when the signal becomes "live" - */ - watched?(): void; - - /** - * Called when the signal stops being "live" - */ - unwatched?(): void; - - /** - * Optional extra data for embedder of this signal library. - * Sent to various callbacks as the this value. - */ - wrapper?: any; -} - -interface ConsumerNode extends ReactiveNode { - producerNode: NonNullable; - producerIndexOfThis: NonNullable; - producerLastReadVersion: NonNullable; -} - -interface ProducerNode extends ReactiveNode { - liveConsumerNode: NonNullable; - liveConsumerIndexOfThis: NonNullable; -} - -/** - * Called by implementations when a producer's signal is read. - */ -export function producerAccessed(node: ReactiveNode): void { - if (inNotificationPhase) { - throw new Error( - typeof ngDevMode !== 'undefined' && ngDevMode - ? `Assertion error: signal read during notification phase` - : '', - ); - } - - if (activeConsumer === null) { - // Accessed outside of a reactive context, so nothing to record. - return; - } - - activeConsumer.consumerOnSignalRead(node); - - // This producer is the `idx`th dependency of `activeConsumer`. - const idx = activeConsumer.nextProducerIndex++; - - assertConsumerNode(activeConsumer); - - if (idx < activeConsumer.producerNode.length && activeConsumer.producerNode[idx] !== node) { - // There's been a change in producers since the last execution of `activeConsumer`. - // `activeConsumer.producerNode[idx]` holds a stale dependency which will be be removed and - // replaced with `this`. - // - // If `activeConsumer` isn't live, then this is a no-op, since we can replace the producer in - // `activeConsumer.producerNode` directly. However, if `activeConsumer` is live, then we need - // to remove it from the stale producer's `liveConsumer`s. - if (consumerIsLive(activeConsumer)) { - const staleProducer = activeConsumer.producerNode[idx]; - producerRemoveLiveConsumerAtIndex(staleProducer, activeConsumer.producerIndexOfThis[idx]); - - // At this point, the only record of `staleProducer` is the reference at - // `activeConsumer.producerNode[idx]` which will be overwritten below. - } - } - - if (activeConsumer.producerNode[idx] !== node) { - // We're a new dependency of the consumer (at `idx`). - activeConsumer.producerNode[idx] = node; - - // If the active consumer is live, then add it as a live consumer. If not, then use 0 as a - // placeholder value. - activeConsumer.producerIndexOfThis[idx] = consumerIsLive(activeConsumer) - ? producerAddLiveConsumer(node, activeConsumer, idx) - : 0; - } - activeConsumer.producerLastReadVersion[idx] = node.version; -} - -/** - * Increment the global epoch counter. - * - * Called by source producers (that is, not computeds) whenever their values change. - */ -export function producerIncrementEpoch(): void { - epoch++; -} - -/** - * Ensure this producer's `version` is up-to-date. - */ -export function producerUpdateValueVersion(node: ReactiveNode): void { - if (!node.dirty && node.lastCleanEpoch === epoch) { - // Even non-live consumers can skip polling if they previously found themselves to be clean at - // the current epoch, since their dependencies could not possibly have changed (such a change - // would've increased the epoch). - return; - } - - if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) { - // None of our producers report a change since the last time they were read, so no - // recomputation of our value is necessary, and we can consider ourselves clean. - node.dirty = false; - node.lastCleanEpoch = epoch; - return; - } - - node.producerRecomputeValue(node); - - // After recomputing the value, we're no longer dirty. - node.dirty = false; - node.lastCleanEpoch = epoch; -} - -/** - * Propagate a dirty notification to live consumers of this producer. - */ -export function producerNotifyConsumers(node: ReactiveNode): void { - if (node.liveConsumerNode === undefined) { - return; - } - - // Prevent signal reads when we're updating the graph - const prev = inNotificationPhase; - inNotificationPhase = true; - try { - for (const consumer of node.liveConsumerNode) { - if (!consumer.dirty) { - consumerMarkDirty(consumer); - } - } - } finally { - inNotificationPhase = prev; - } -} - -/** - * Whether this `ReactiveNode` in its producer capacity is currently allowed to initiate updates, - * based on the current consumer context. - */ -export function producerUpdatesAllowed(): boolean { - return activeConsumer?.consumerAllowSignalWrites !== false; -} - -export function consumerMarkDirty(node: ReactiveNode): void { - node.dirty = true; - producerNotifyConsumers(node); - node.consumerMarkedDirty?.call(node.wrapper ?? node); -} - -/** - * Prepare this consumer to run a computation in its reactive context. - * - * Must be called by subclasses which represent reactive computations, before those computations - * begin. - */ -export function consumerBeforeComputation(node: ReactiveNode | null): ReactiveNode | null { - node && (node.nextProducerIndex = 0); - return setActiveConsumer(node); -} - -/** - * Finalize this consumer's state after a reactive computation has run. - * - * Must be called by subclasses which represent reactive computations, after those computations - * have finished. - */ -export function consumerAfterComputation( - node: ReactiveNode | null, - prevConsumer: ReactiveNode | null, -): void { - setActiveConsumer(prevConsumer); - - if ( - !node || - node.producerNode === undefined || - node.producerIndexOfThis === undefined || - node.producerLastReadVersion === undefined - ) { - return; - } - - if (consumerIsLive(node)) { - // For live consumers, we need to remove the producer -> consumer edge for any stale producers - // which weren't dependencies after the recomputation. - for (let i = node.nextProducerIndex; i < node.producerNode.length; i++) { - producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); - } - } - - // Truncate the producer tracking arrays. - // Perf note: this is essentially truncating the length to `node.nextProducerIndex`, but - // benchmarking has shown that individual pop operations are faster. - while (node.producerNode.length > node.nextProducerIndex) { - node.producerNode.pop(); - node.producerLastReadVersion.pop(); - node.producerIndexOfThis.pop(); - } -} - -/** - * Determine whether this consumer has any dependencies which have changed since the last time - * they were read. - */ -export function consumerPollProducersForChange(node: ReactiveNode): boolean { - assertConsumerNode(node); - - // Poll producers for change. - for (let i = 0; i < node.producerNode.length; i++) { - const producer = node.producerNode[i]; - const seenVersion = node.producerLastReadVersion[i]; - - // First check the versions. A mismatch means that the producer's value is known to have - // changed since the last time we read it. - if (seenVersion !== producer.version) { - return true; - } - - // The producer's version is the same as the last time we read it, but it might itself be - // stale. Force the producer to recompute its version (calculating a new value if necessary). - producerUpdateValueVersion(producer); - - // Now when we do this check, `producer.version` is guaranteed to be up to date, so if the - // versions still match then it has not changed since the last time we read it. - if (seenVersion !== producer.version) { - return true; - } - } - - return false; -} - -/** - * Disconnect this consumer from the graph. - */ -export function consumerDestroy(node: ReactiveNode): void { - assertConsumerNode(node); - if (consumerIsLive(node)) { - // Drop all connections from the graph to this node. - for (let i = 0; i < node.producerNode.length; i++) { - producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); - } - } - - // Truncate all the arrays to drop all connection from this node to the graph. - node.producerNode.length = - node.producerLastReadVersion.length = - node.producerIndexOfThis.length = - 0; - if (node.liveConsumerNode) { - node.liveConsumerNode.length = node.liveConsumerIndexOfThis!.length = 0; - } -} - -/** - * Add `consumer` as a live consumer of this node. - * - * Note that this operation is potentially transitive. If this node becomes live, then it becomes - * a live consumer of all of its current producers. - */ -function producerAddLiveConsumer( - node: ReactiveNode, - consumer: ReactiveNode, - indexOfThis: number, -): number { - assertProducerNode(node); - assertConsumerNode(node); - if (node.liveConsumerNode.length === 0) { - node.watched?.call(node.wrapper); - // When going from 0 to 1 live consumers, we become a live consumer to our producers. - for (let i = 0; i < node.producerNode.length; i++) { - node.producerIndexOfThis[i] = producerAddLiveConsumer(node.producerNode[i], node, i); - } - } - node.liveConsumerIndexOfThis.push(indexOfThis); - return node.liveConsumerNode.push(consumer) - 1; -} - -/** - * Remove the live consumer at `idx`. - */ -export function producerRemoveLiveConsumerAtIndex(node: ReactiveNode, idx: number): void { - assertProducerNode(node); - assertConsumerNode(node); - - if (typeof ngDevMode !== 'undefined' && ngDevMode && idx >= node.liveConsumerNode.length) { - throw new Error( - `Assertion error: active consumer index ${idx} is out of bounds of ${node.liveConsumerNode.length} consumers)`, - ); - } - - if (node.liveConsumerNode.length === 1) { - // When removing the last live consumer, we will no longer be live. We need to remove - // ourselves from our producers' tracking (which may cause consumer-producers to lose - // liveness as well). - node.unwatched?.call(node.wrapper); - for (let i = 0; i < node.producerNode.length; i++) { - producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); - } - } - - // Move the last value of `liveConsumers` into `idx`. Note that if there's only a single - // live consumer, this is a no-op. - const lastIdx = node.liveConsumerNode.length - 1; - node.liveConsumerNode[idx] = node.liveConsumerNode[lastIdx]; - node.liveConsumerIndexOfThis[idx] = node.liveConsumerIndexOfThis[lastIdx]; - - // Truncate the array. - node.liveConsumerNode.length--; - node.liveConsumerIndexOfThis.length--; - - // If the index is still valid, then we need to fix the index pointer from the producer to this - // consumer, and update it from `lastIdx` to `idx` (accounting for the move above). - if (idx < node.liveConsumerNode.length) { - const idxProducer = node.liveConsumerIndexOfThis[idx]; - const consumer = node.liveConsumerNode[idx]; - assertConsumerNode(consumer); - consumer.producerIndexOfThis[idxProducer] = idx; - } -} - -function consumerIsLive(node: ReactiveNode): boolean { - return node.consumerIsAlwaysLive || (node?.liveConsumerNode?.length ?? 0) > 0; -} - -export function assertConsumerNode(node: ReactiveNode): asserts node is ConsumerNode { - node.producerNode ??= []; - node.producerIndexOfThis ??= []; - node.producerLastReadVersion ??= []; -} - -export function assertProducerNode(node: ReactiveNode): asserts node is ProducerNode { - node.liveConsumerNode ??= []; - node.liveConsumerIndexOfThis ??= []; -} diff --git a/src/index.ts b/src/index.ts index 7b38257..952b2ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,448 @@ -export {Signal as AlienSignal} from './alien.js'; -export {Signal} from './wrapper.js'; +import { + ReactiveFlags, + createReactiveSystem, + type ReactiveNode, + type Link, +} from 'alien-signals/system'; + +export namespace Signal { + export let isState: (s: any) => s is State, + isComputed: (s: any) => s is Computed, + isWatcher: (s: any) => s is subtle.Watcher; + + const WATCHER_PLACEHOLDER = Symbol('watcher') as any; + + const enum EffectFlags { + Queued = 1 << 6, + } + + const {link, unlink, propagate, checkDirty, endTracking, startTracking, shallowPropagate} = + createReactiveSystem({ + update(node: _Computed) { + return node.update(); + }, + notify(node: _Watcher) { + const flags = node.flags; + if (!(flags & EffectFlags.Queued)) { + node.flags = flags | EffectFlags.Queued; + queuedEffects[queuedEffectsLength++] = node; + } + }, + unwatched(node) { + let toRemove = node.deps; + if (toRemove !== undefined) { + do { + toRemove = unlink(toRemove, node); + } while (toRemove !== undefined); + node.flags |= ReactiveFlags.Dirty; + } + }, + }); + const queuedEffects: _Watcher[] = []; + + let notifyIndex = 0; + let queuedEffectsLength = 0; + let activeSub: ReactiveNode | undefined; + + function flush(): void { + while (notifyIndex < queuedEffectsLength) { + const effect = queuedEffects[notifyIndex]; + // @ts-expect-error + queuedEffects[notifyIndex++] = undefined; + effect.flags &= ~EffectFlags.Queued; + effect.run(); + } + notifyIndex = 0; + queuedEffectsLength = 0; + } + + class _State implements ReactiveNode { + subs: Link | undefined = undefined; + subsTail: Link | undefined = undefined; + flags: ReactiveFlags = ReactiveFlags.None; + watchCount = 0; + previousValue: T; + + #brand() {} + static { + isState = ((s) => typeof s === 'object' && #brand in s) as typeof isState; + } + + constructor( + private value: T, + private options?: Options, + ) { + this.previousValue = value; + if (options?.equals !== undefined) { + this.equals = options.equals; + } + } + + equals(t: T, t2: T): boolean { + return Object.is(t, t2); + } + + onWatched() { + if (this.watchCount++ === 0) { + this.options?.[subtle.watched]?.call(this); + } + } + + onUnwatched() { + if (--this.watchCount === 0) { + this.options?.[subtle.unwatched]?.call(this); + } + } + + get() { + if (!isState(this)) { + throw new TypeError('Wrong receiver type for Signal.State.prototype.get'); + } + if (activeSub === WATCHER_PLACEHOLDER) { + throw new Error('Cannot read from state inside watcher'); + } + if (activeSub !== undefined) { + const lastLink = this.subsTail; + link(this, activeSub); + const newLink = this.subsTail!; + if (newLink !== lastLink) { + const newSub = newLink.sub; + if (newSub instanceof _Computed && newSub.watchCount) { + this.onWatched(); + } + } + } + return this.value; + } + + set(value: T): void { + if (!isState(this)) { + throw new TypeError('Wrong receiver type for Signal.State.prototype.set'); + } + if (activeSub === WATCHER_PLACEHOLDER) { + throw new Error('Cannot write to state inside watcher'); + } + if (!this.equals(this.value, value)) { + this.value = value; + const subs = this.subs; + if (subs !== undefined) { + propagate(subs); + shallowPropagate(subs); + flush(); + } + } + } + } + + export interface State { + get(): T; + set(value: T): void; + } + + export const State = _State as { + new (value: T, options?: Options): State; + }; + + class _Computed implements ReactiveNode { + subs: Link | undefined = undefined; + subsTail: Link | undefined = undefined; + deps: Link | undefined = undefined; + depsTail: Link | undefined = undefined; + flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty; + isError = true; + watchCount = 0; + value: T | undefined = undefined; + + #brand() {} + static { + isComputed = ((c: any) => typeof c === 'object' && #brand in c) as typeof isComputed; + } + + constructor( + private getter: () => T, + private options?: Options, + ) { + if (options?.equals !== undefined) { + this.equals = options.equals; + } + } + + equals(t: T, t2: T): boolean { + return Object.is(t, t2); + } + + onWatched() { + if (this.watchCount++ === 0) { + this.options?.[subtle.watched]?.call(this); + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const dep = link.dep as _AnySignal; + dep.onWatched(); + } + } + } + + onUnwatched() { + if (--this.watchCount === 0) { + this.options?.[subtle.unwatched]?.call(this); + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const dep = link.dep as _AnySignal; + dep.onUnwatched(); + } + } + } + + get() { + if (!isComputed(this)) { + throw new TypeError('Wrong receiver type for Signal.Computed.prototype.get'); + } + if (activeSub === WATCHER_PLACEHOLDER) { + throw new Error('Cannot read from computed inside watcher'); + } + let flags = this.flags; + if (flags & ReactiveFlags.RecursedCheck) { + throw new Error('Cycles detected'); + } + if ( + flags & ReactiveFlags.Dirty || + (flags & ReactiveFlags.Pending && checkDirty(this.deps!, this)) + ) { + if (this.update()) { + const subs = this.subs; + if (subs !== undefined) { + shallowPropagate(subs); + } + } + } else if (flags & ReactiveFlags.Pending) { + this.flags = flags & ~ReactiveFlags.Pending; + } + if (activeSub !== undefined) { + const lastLink = this.subsTail; + link(this, activeSub); + const newLink = this.subsTail!; + if (newLink !== lastLink) { + const newSub = newLink.sub; + if (newSub instanceof _Computed && newSub.watchCount) { + this.onWatched(); + } + } + } + if (this.isError) { + throw this.value; + } + return this.value!; + } + + update(): boolean { + const prevSub = activeSub; + activeSub = this; + startTracking(this); + const oldValue = this.value; + try { + const newValue = this.getter(); + if (this.isError || !this.equals(oldValue!, newValue)) { + this.isError = false; + this.value = newValue; + return true; + } + return false; + } catch (err) { + if (!this.isError || !this.equals(oldValue!, err as any)) { + this.isError = true; + this.value = err as any; + return true; + } + return false; + } finally { + if (this.watchCount) { + for ( + let link = this.depsTail !== undefined ? this.depsTail.nextDep : this.deps; + link !== undefined; + link = link.nextDep + ) { + const dep = link.dep as _AnySignal; + dep.onUnwatched(); + } + } + activeSub = prevSub; + endTracking(this); + } + } + } + + export interface Computed { + get(): T; + } + + export const Computed = _Computed as { + new (getter: () => T, options?: Options): Computed; + }; + + class _Watcher implements ReactiveNode { + deps: Link | undefined = undefined; + depsTail: Link | undefined = undefined; + flags = ReactiveFlags.Watching; + watchList = new Map<_AnySignal, Link>(); + + #brand() {} + static { + isWatcher = (w: any): w is _Watcher => #brand in w; + } + + constructor(private fn: () => void) {} + + run() { + const prevSub = activeSub; + activeSub = WATCHER_PLACEHOLDER; + this.flags &= ~(ReactiveFlags.Dirty | ReactiveFlags.Pending); + try { + this.fn(); + } finally { + activeSub = prevSub; + } + } + + #assertSignals(signals: _AnySignal[]): void { + for (const signal of signals) { + if (!isComputed(signal) && !isState(signal)) { + throw new TypeError('Called watch/unwatch without a Computed or State argument'); + } + } + } + + watch(...signals: _AnySignal[]): void { + if (!isWatcher(this)) { + throw new TypeError('Called watch without Watcher receiver'); + } + this.#assertSignals(signals); + + for (const signal of signals) { + if (this.watchList.has(signal)) { + continue; + } + signal.onWatched(); + link(signal, this); + this.watchList.set(signal, this.depsTail!); + } + } + + unwatch(...signals: _AnySignal[]): void { + if (!isWatcher(this)) { + throw new TypeError('Called unwatch without Watcher receiver'); + } + this.#assertSignals(signals); + + for (const signal of signals) { + const link = this.watchList.get(signal); + if (link === undefined) { + continue; + } + signal.onUnwatched(); + unlink(link, this); + this.watchList.delete(signal); + } + } + + getPending(): _AnySignal[] { + if (!isWatcher(this)) { + throw new TypeError('Called getPending without Watcher receiver'); + } + const arr: _AnySignal[] = []; + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const source = link.dep; + if ( + source.flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending) && + source instanceof _Computed + ) { + arr.push(link.dep as _AnySignal); + } + } + return arr; + } + } + + type _AnySignal = _State | _Computed; + type _AnySink = _Computed | _Watcher; + + type AnySignal = State | Computed; + type AnySink = Computed | subtle.Watcher; + + export namespace subtle { + export function untrack(fn: () => T) { + const prevSub = activeSub; + activeSub = undefined; + try { + return fn(); + } finally { + activeSub = prevSub; + } + } + + export interface Watcher { + watch(...signals: AnySignal[]): void; + unwatch(...signals: AnySignal[]): void; + getPending(): AnySignal[]; + } + + export const Watcher = _Watcher as { + new (fn: () => void): Watcher; + }; + + export function hasSinks(signal: AnySignal) { + if (!isComputed(signal) && !isState(signal)) { + throw new TypeError('Called hasSinks without a Signal argument'); + } + return (signal as _AnySignal).watchCount > 0; + } + + export function hasSources(signal: AnySink) { + if (!isComputed(signal) && !isWatcher(signal)) { + throw new TypeError('Called hasSources without a Computed or Watcher argument'); + } + return (signal as _AnySink).depsTail !== undefined; + } + + export function introspectSinks(signal: AnySignal): AnySink[] { + if (!isComputed(signal) && !isState(signal)) { + throw new TypeError('Called introspectSinks without a Signal argument'); + } + const arr: _AnySink[] = []; + for (let link = (signal as _AnySignal).subs; link !== undefined; link = link.nextSub) { + arr.push(link.sub as _AnySink); + } + return arr; + } + + export function introspectSources(sink: AnySink): AnySignal[] { + if (!isComputed(sink) && !isWatcher(sink)) { + throw new TypeError('Called introspectSources without a Computed or Watcher argument'); + } + const arr: _AnySignal[] = []; + for (let link = (sink as _AnySink).deps; link !== undefined; link = link.nextDep) { + arr.push(link.dep as _AnySignal); + } + return arr; + } + + export function currentComputed(): Computed | undefined { + if (isComputed(activeSub)) { + return activeSub; + } + } + + // Hooks to observe being watched or no longer watched + export const watched = Symbol('watched'); + export const unwatched = Symbol('unwatched'); + } + + export interface Options { + // Custom comparison function between old and new value. Default: Object.is. + // The signal is passed in as an optionally-used third parameter for context. + equals?: (this: AnySignal, t: T, t2: T) => boolean; + + // Callback called when hasSinks becomes true, if it was previously false + [subtle.watched]?: (this: AnySignal) => void; + + // Callback called whenever hasSinks becomes false, if it was previously true + [subtle.unwatched]?: (this: AnySignal) => void; + } +} diff --git a/src/public-api-types.ts b/src/public-api-types.ts index 4714915..f9945c9 100644 --- a/src/public-api-types.ts +++ b/src/public-api-types.ts @@ -3,7 +3,7 @@ * and that we double-check what we're exposing as public API */ import {expectTypeOf} from 'expect-type'; -import {Signal} from './alien.ts'; +import {Signal} from '.'; /** * Top-Level diff --git a/src/signal.ts b/src/signal.ts deleted file mode 100644 index 6d5b692..0000000 --- a/src/signal.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import {defaultEquals, ValueEqualityComparer} from './equality.js'; -import {throwInvalidWriteToSignalError} from './errors.js'; -import { - producerAccessed, - producerIncrementEpoch, - producerNotifyConsumers, - producerUpdatesAllowed, - REACTIVE_NODE, - ReactiveNode, - SIGNAL, -} from './graph.js'; - -// Required as the signals library is in a separate package, so we need to explicitly ensure the -// global `ngDevMode` type is defined. -declare const ngDevMode: boolean | undefined; - -/** - * If set, called after `WritableSignal`s are updated. - * - * This hook can be used to achieve various effects, such as running effects synchronously as part - * of setting a signal. - */ -let postSignalSetFn: (() => void) | null = null; - -export interface SignalNode extends ReactiveNode, ValueEqualityComparer { - value: T; -} - -export type SignalBaseGetter = (() => T) & {readonly [SIGNAL]: unknown}; - -// Note: Closure *requires* this to be an `interface` and not a type, which is why the -// `SignalBaseGetter` type exists to provide the correct shape. -export interface SignalGetter extends SignalBaseGetter { - readonly [SIGNAL]: SignalNode; -} - -/** - * Create a `Signal` that can be set or updated directly. - */ -export function createSignal(initialValue: T): SignalGetter { - const node: SignalNode = Object.create(SIGNAL_NODE); - node.value = initialValue; - const getter = (() => { - producerAccessed(node); - return node.value; - }) as SignalGetter; - (getter as any)[SIGNAL] = node; - return getter; -} - -export function setPostSignalSetFn(fn: (() => void) | null): (() => void) | null { - const prev = postSignalSetFn; - postSignalSetFn = fn; - return prev; -} - -export function signalGetFn(this: SignalNode): T { - producerAccessed(this); - return this.value; -} - -export function signalSetFn(node: SignalNode, newValue: T) { - if (!producerUpdatesAllowed()) { - throwInvalidWriteToSignalError(); - } - - if (!node.equal.call(node.wrapper, node.value, newValue)) { - node.value = newValue; - signalValueChanged(node); - } -} - -export function signalUpdateFn(node: SignalNode, updater: (value: T) => T): void { - if (!producerUpdatesAllowed()) { - throwInvalidWriteToSignalError(); - } - - signalSetFn(node, updater(node.value)); -} - -// Note: Using an IIFE here to ensure that the spread assignment is not considered -// a side-effect, ending up preserving `COMPUTED_NODE` and `REACTIVE_NODE`. -// TODO: remove when https://github.com/evanw/esbuild/issues/3392 is resolved. -export const SIGNAL_NODE: SignalNode = /* @__PURE__ */ (() => { - return { - ...REACTIVE_NODE, - equal: defaultEquals, - value: undefined, - }; -})(); - -function signalValueChanged(node: SignalNode): void { - node.version++; - producerIncrementEpoch(); - producerNotifyConsumers(node); - postSignalSetFn?.(); -} diff --git a/src/wrapper.ts b/src/wrapper.ts deleted file mode 100644 index e15af74..0000000 --- a/src/wrapper.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * @license - * Copyright 2024 Bloomberg Finance L.P. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {computedGet, createComputed, type ComputedNode} from './computed.js'; -import { - SIGNAL, - getActiveConsumer, - isInNotificationPhase, - producerAccessed, - assertConsumerNode, - setActiveConsumer, - REACTIVE_NODE, - type ReactiveNode, - assertProducerNode, - producerRemoveLiveConsumerAtIndex, -} from './graph.js'; -import {createSignal, signalGetFn, signalSetFn, type SignalNode} from './signal.js'; - -const NODE: unique symbol = Symbol('node'); - -// eslint-disable-next-line @typescript-eslint/no-namespace -export namespace Signal { - export let isState: (s: any) => boolean, - isComputed: (s: any) => boolean, - isWatcher: (s: any) => boolean; - - // A read-write Signal - export class State { - readonly [NODE]: SignalNode; - #brand() {} - - static { - isState = (s) => typeof s === 'object' && #brand in s; - } - - constructor(initialValue: T, options: Signal.Options = {}) { - const ref = createSignal(initialValue); - const node: SignalNode = ref[SIGNAL]; - this[NODE] = node; - node.wrapper = this; - if (options) { - const equals = options.equals; - if (equals) { - node.equal = equals; - } - node.watched = options[Signal.subtle.watched]; - node.unwatched = options[Signal.subtle.unwatched]; - } - } - - public get(): T { - if (!isState(this)) throw new TypeError('Wrong receiver type for Signal.State.prototype.get'); - return (signalGetFn).call(this[NODE]); - } - - public set(newValue: T): void { - if (!isState(this)) throw new TypeError('Wrong receiver type for Signal.State.prototype.set'); - if (isInNotificationPhase()) { - throw new Error('Writes to signals not permitted during Watcher callback'); - } - const ref = this[NODE]; - signalSetFn(ref, newValue); - } - } - - // A Signal which is a formula based on other Signals - export class Computed { - readonly [NODE]: ComputedNode; - - #brand() {} - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static { - isComputed = (c: any) => typeof c === 'object' && #brand in c; - } - - // Create a Signal which evaluates to the value returned by the callback. - // Callback is called with this signal as the parameter. - constructor(computation: () => T, options?: Signal.Options) { - const ref = createComputed(computation); - const node = ref[SIGNAL]; - node.consumerAllowSignalWrites = true; - this[NODE] = node; - node.wrapper = this; - if (options) { - const equals = options.equals; - if (equals) { - node.equal = equals; - } - node.watched = options[Signal.subtle.watched]; - node.unwatched = options[Signal.subtle.unwatched]; - } - } - - get(): T { - if (!isComputed(this)) - throw new TypeError('Wrong receiver type for Signal.Computed.prototype.get'); - return computedGet(this[NODE]); - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - type AnySignal = State | Computed; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - type AnySink = Computed | subtle.Watcher; - - // eslint-disable-next-line @typescript-eslint/no-namespace - export namespace subtle { - // Run a callback with all tracking disabled (even for nested computed). - export function untrack(cb: () => T): T { - let output: T; - let prevActiveConsumer = null; - try { - prevActiveConsumer = setActiveConsumer(null); - output = cb(); - } finally { - setActiveConsumer(prevActiveConsumer); - } - return output; - } - - // Returns ordered list of all signals which this one referenced - // during the last time it was evaluated - export function introspectSources(sink: AnySink): AnySignal[] { - if (!isComputed(sink) && !isWatcher(sink)) { - throw new TypeError('Called introspectSources without a Computed or Watcher argument'); - } - return sink[NODE].producerNode?.map((n) => n.wrapper) ?? []; - } - - // Returns the subset of signal sinks which recursively - // lead to an Effect which has not been disposed - // Note: Only watched Computed signals will be in this list. - export function introspectSinks(signal: AnySignal): AnySink[] { - if (!isComputed(signal) && !isState(signal)) { - throw new TypeError('Called introspectSinks without a Signal argument'); - } - return signal[NODE].liveConsumerNode?.map((n) => n.wrapper) ?? []; - } - - // True iff introspectSinks() is non-empty - export function hasSinks(signal: AnySignal): boolean { - if (!isComputed(signal) && !isState(signal)) { - throw new TypeError('Called hasSinks without a Signal argument'); - } - const liveConsumerNode = signal[NODE].liveConsumerNode; - if (!liveConsumerNode) return false; - return liveConsumerNode.length > 0; - } - - // True iff introspectSources() is non-empty - export function hasSources(signal: AnySink): boolean { - if (!isComputed(signal) && !isWatcher(signal)) { - throw new TypeError('Called hasSources without a Computed or Watcher argument'); - } - const producerNode = signal[NODE].producerNode; - if (!producerNode) return false; - return producerNode.length > 0; - } - - export class Watcher { - readonly [NODE]: ReactiveNode; - - #brand() {} - static { - isWatcher = (w: any): w is Watcher => #brand in w; - } - - // 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(notify: (this: Watcher) => void) { - let node = Object.create(REACTIVE_NODE); - node.wrapper = this; - node.consumerMarkedDirty = notify; - node.consumerIsAlwaysLive = true; - node.consumerAllowSignalWrites = false; - node.producerNode = []; - this[NODE] = node; - } - - #assertSignals(signals: AnySignal[]): void { - for (const signal of signals) { - if (!isComputed(signal) && !isState(signal)) { - throw new TypeError('Called watch/unwatch without a Computed or State argument'); - } - } - } - - // 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: AnySignal[]): void { - if (!isWatcher(this)) { - throw new TypeError('Called unwatch without Watcher receiver'); - } - this.#assertSignals(signals); - - const node = this[NODE]; - node.dirty = false; // Give the watcher a chance to trigger again - const prev = setActiveConsumer(node); - for (const signal of signals) { - producerAccessed(signal[NODE]); - } - setActiveConsumer(prev); - } - - // Remove these signals from the watched set (e.g., for an effect which is disposed) - unwatch(...signals: AnySignal[]): void { - if (!isWatcher(this)) { - throw new TypeError('Called unwatch without Watcher receiver'); - } - this.#assertSignals(signals); - - const node = this[NODE]; - assertConsumerNode(node); - - for (let i = node.producerNode.length - 1; i >= 0; i--) { - if (signals.includes(node.producerNode[i].wrapper)) { - producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); - - // Logic copied from producerRemoveLiveConsumerAtIndex, but reversed - const lastIdx = node.producerNode!.length - 1; - node.producerNode![i] = node.producerNode![lastIdx]; - node.producerIndexOfThis[i] = node.producerIndexOfThis[lastIdx]; - - node.producerNode.length--; - node.producerIndexOfThis.length--; - node.nextProducerIndex--; - - if (i < node.producerNode.length) { - const idxConsumer = node.producerIndexOfThis[i]; - const producer = node.producerNode[i]; - assertProducerNode(producer); - producer.liveConsumerIndexOfThis[idxConsumer] = i; - } - } - } - } - - // Returns the set of computeds in the Watcher's set which are still yet - // to be re-evaluated - getPending(): Computed[] { - if (!isWatcher(this)) { - throw new TypeError('Called getPending without Watcher receiver'); - } - const node = this[NODE]; - return node.producerNode!.filter((n) => n.dirty).map((n) => n.wrapper); - } - } - - export function currentComputed(): Computed | undefined { - return getActiveConsumer()?.wrapper; - } - - // Hooks to observe being watched or no longer watched - export const watched = Symbol('watched'); - export const unwatched = Symbol('unwatched'); - } - - export interface Options { - // Custom comparison function between old and new value. Default: Object.is. - // The signal is passed in as an optionally-used third parameter for context. - equals?: (this: AnySignal, t: T, t2: T) => boolean; - - // Callback called when hasSinks becomes true, if it was previously false - [Signal.subtle.watched]?: (this: AnySignal) => void; - - // Callback called whenever hasSinks becomes false, if it was previously true - [Signal.subtle.unwatched]?: (this: AnySignal) => void; - } -} diff --git a/tests/Signal/computed.test.ts b/tests/Signal/computed.test.ts index 6d4d8be..0003048 100644 --- a/tests/Signal/computed.test.ts +++ b/tests/Signal/computed.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/alien.js'; +import {Signal} from '../../src'; describe('Computed', () => { it('should work', () => { diff --git a/tests/Signal/ported/vue.test.ts b/tests/Signal/ported/vue.test.ts index 647d8a7..e09e128 100644 --- a/tests/Signal/ported/vue.test.ts +++ b/tests/Signal/ported/vue.test.ts @@ -52,7 +52,7 @@ describe('Ported - Vue', () => { }); const c2 = new Signal.Computed(() => v.get() + c1.get()); expect(c2.get()).toBe('0foo'); - expect(c2.get()).toBe('0foo'); // ! In vue it recomputes and becomes '1foo' + expect(c2.get()).toBe('1foo'); }); // https://github.com/vuejs/core/blob/main/packages/reactivity/__tests__/computed.spec.ts#L925 @@ -68,6 +68,6 @@ describe('Ported - Vue', () => { expect(c2.get()).toBe('0,0'); v.set(1); - expect(c2.get()).toBe('0,0'); // ! In vue it recomputes and becomes '1,0' + expect(c2.get()).toBe('1,0'); }); }); diff --git a/tests/Signal/state.test.ts b/tests/Signal/state.test.ts index 4a55deb..b8d2286 100644 --- a/tests/Signal/state.test.ts +++ b/tests/Signal/state.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/alien.js'; +import {Signal} from '../../src'; describe('Signal.State', () => { it('should work', () => { diff --git a/tests/Signal/subtle/currentComputed.test.ts b/tests/Signal/subtle/currentComputed.test.ts index 93531a7..98ec2d1 100644 --- a/tests/Signal/subtle/currentComputed.test.ts +++ b/tests/Signal/subtle/currentComputed.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../../src/alien.js'; +import {Signal} from '../../../src'; describe('currentComputed', () => { it('works', () => { diff --git a/tests/Signal/subtle/untrack.test.ts b/tests/Signal/subtle/untrack.test.ts index c8f16b3..0a72cbd 100644 --- a/tests/Signal/subtle/untrack.test.ts +++ b/tests/Signal/subtle/untrack.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../../src/alien.js'; +import {Signal} from '../../../src'; describe('Untrack', () => { it('works', () => { diff --git a/tests/Signal/subtle/watch-unwatch.test.ts b/tests/Signal/subtle/watch-unwatch.test.ts index 6f9be74..84a5165 100644 --- a/tests/Signal/subtle/watch-unwatch.test.ts +++ b/tests/Signal/subtle/watch-unwatch.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../../src/alien.js'; +import {Signal} from '../../../src'; describe('watch and unwatch', () => { it('handles multiple watchers well', () => { diff --git a/tests/Signal/subtle/watcher.test.ts b/tests/Signal/subtle/watcher.test.ts index e2f84cf..6483eb5 100644 --- a/tests/Signal/subtle/watcher.test.ts +++ b/tests/Signal/subtle/watcher.test.ts @@ -1,5 +1,5 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../../src/alien.js'; +import {Signal} from '../../../src'; describe('Watcher', () => { type Destructor = () => void; diff --git a/tests/behaviors/custom-equality.test.ts b/tests/behaviors/custom-equality.test.ts index e74d622..729b822 100644 --- a/tests/behaviors/custom-equality.test.ts +++ b/tests/behaviors/custom-equality.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../src/alien.js'; +import {Signal} from '../../src'; describe('Custom equality', () => { it('works for State', () => { diff --git a/tests/behaviors/cycles.test.ts b/tests/behaviors/cycles.test.ts index 63aff17..12ac647 100644 --- a/tests/behaviors/cycles.test.ts +++ b/tests/behaviors/cycles.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/alien.js'; +import {Signal} from '../../src'; describe('Cycles', () => { it('detects trivial cycles', () => { diff --git a/tests/behaviors/dynamic-dependencies.test.ts b/tests/behaviors/dynamic-dependencies.test.ts index 05bbd78..1ae02b0 100644 --- a/tests/behaviors/dynamic-dependencies.test.ts +++ b/tests/behaviors/dynamic-dependencies.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/alien.js'; +import {Signal} from '../../src'; describe('Dynamic dependencies', () => { function run(live) { diff --git a/tests/behaviors/errors.test.ts b/tests/behaviors/errors.test.ts index 412ab4d..72cff09 100644 --- a/tests/behaviors/errors.test.ts +++ b/tests/behaviors/errors.test.ts @@ -1,5 +1,5 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../src/alien.js'; +import {Signal} from '../../src'; describe('Errors', () => { it('are cached by computed signals', () => { diff --git a/tests/behaviors/guards.test.ts b/tests/behaviors/guards.test.ts index fc2186d..5efb1ed 100644 --- a/tests/behaviors/guards.test.ts +++ b/tests/behaviors/guards.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/alien.js'; +import {Signal} from '../../src'; describe('Guards', () => { it('should work with Signals', () => { diff --git a/tests/behaviors/liveness.test.ts b/tests/behaviors/liveness.test.ts index 9ad32a4..eee5b26 100644 --- a/tests/behaviors/liveness.test.ts +++ b/tests/behaviors/liveness.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../src/alien.js'; +import {Signal} from '../../src'; describe('liveness', () => { it('only changes on first and last descendant', () => { diff --git a/tests/behaviors/prohibited-contexts.test.ts b/tests/behaviors/prohibited-contexts.test.ts index 530f4ba..9048e46 100644 --- a/tests/behaviors/prohibited-contexts.test.ts +++ b/tests/behaviors/prohibited-contexts.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/alien.js'; +import {Signal} from '../../src'; describe('Prohibited contexts', () => { it('allows writes during computed', () => { diff --git a/tests/behaviors/pruning.test.ts b/tests/behaviors/pruning.test.ts index 8d92dd1..a7011c0 100644 --- a/tests/behaviors/pruning.test.ts +++ b/tests/behaviors/pruning.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/alien.js'; +import {Signal} from '../../src'; describe('Pruning', () => { it('only recalculates until things are equal', () => { diff --git a/tests/behaviors/receivers.test.ts b/tests/behaviors/receivers.test.ts index 9d182f6..09f2eee 100644 --- a/tests/behaviors/receivers.test.ts +++ b/tests/behaviors/receivers.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/alien.js'; +import {Signal} from '../../src'; describe('Receivers', () => { it('is this for computed', () => { diff --git a/tests/behaviors/type-checking.test.ts b/tests/behaviors/type-checking.test.ts index 1cb4a25..13650d1 100644 --- a/tests/behaviors/type-checking.test.ts +++ b/tests/behaviors/type-checking.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/alien.js'; +import {Signal} from '../../src'; describe('Expected class shape', () => { it('should be on the prototype', () => { From 0a1b38bd0417bb3dda9f91a3b742d00416faff8b Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 12 Aug 2025 19:25:34 +0800 Subject: [PATCH 47/53] Update queuedEffects type --- src/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 952b2ab..9b01ed5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,7 +38,7 @@ export namespace Signal { } }, }); - const queuedEffects: _Watcher[] = []; + const queuedEffects: (_Watcher | undefined)[] = []; let notifyIndex = 0; let queuedEffectsLength = 0; @@ -46,8 +46,7 @@ export namespace Signal { function flush(): void { while (notifyIndex < queuedEffectsLength) { - const effect = queuedEffects[notifyIndex]; - // @ts-expect-error + const effect = queuedEffects[notifyIndex]!; queuedEffects[notifyIndex++] = undefined; effect.flags &= ~EffectFlags.Queued; effect.run(); From c7935369ee70479363cd55d2bfaa8bd201f81993 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 12 Aug 2025 22:31:12 +0800 Subject: [PATCH 48/53] Remove instanceof usage --- src/index.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9b01ed5..1f29b54 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,7 +106,7 @@ export namespace Signal { const newLink = this.subsTail!; if (newLink !== lastLink) { const newSub = newLink.sub; - if (newSub instanceof _Computed && newSub.watchCount) { + if (isComputed(newSub) && (newSub as _Computed).watchCount) { this.onWatched(); } } @@ -220,7 +220,7 @@ export namespace Signal { const newLink = this.subsTail!; if (newLink !== lastLink) { const newSub = newLink.sub; - if (newSub instanceof _Computed && newSub.watchCount) { + if (isComputed(newSub) && (newSub as _Computed).watchCount) { this.onWatched(); } } @@ -348,10 +348,7 @@ export namespace Signal { const arr: _AnySignal[] = []; for (let link = this.deps; link !== undefined; link = link.nextDep) { const source = link.dep; - if ( - source.flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending) && - source instanceof _Computed - ) { + if (source.flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending) && isComputed(source)) { arr.push(link.dep as _AnySignal); } } From cb63bbeedd230090bccf66754c5370920f23aaf0 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Wed, 13 Aug 2025 09:27:35 +0800 Subject: [PATCH 49/53] Remove "as" type cast --- src/index.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1f29b54..e7fff95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,7 +55,7 @@ export namespace Signal { queuedEffectsLength = 0; } - class _State implements ReactiveNode { + class _State implements ReactiveNode, State { subs: Link | undefined = undefined; subsTail: Link | undefined = undefined; flags: ReactiveFlags = ReactiveFlags.None; @@ -138,11 +138,11 @@ export namespace Signal { set(value: T): void; } - export const State = _State as { + export const State: { new (value: T, options?: Options): State; - }; + } = _State; - class _Computed implements ReactiveNode { + class _Computed implements ReactiveNode, Computed { subs: Link | undefined = undefined; subsTail: Link | undefined = undefined; deps: Link | undefined = undefined; @@ -272,11 +272,11 @@ export namespace Signal { get(): T; } - export const Computed = _Computed as { + export const Computed: { new (getter: () => T, options?: Options): Computed; - }; + } = _Computed; - class _Watcher implements ReactiveNode { + class _Watcher implements ReactiveNode, subtle.Watcher { deps: Link | undefined = undefined; depsTail: Link | undefined = undefined; flags = ReactiveFlags.Watching; @@ -379,9 +379,9 @@ export namespace Signal { getPending(): AnySignal[]; } - export const Watcher = _Watcher as { + export const Watcher: { new (fn: () => void): Watcher; - }; + } = _Watcher; export function hasSinks(signal: AnySignal) { if (!isComputed(signal) && !isState(signal)) { From 7b34b6680ce084c4defbe814352501184e6e2918 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sun, 28 Sep 2025 15:34:13 +0800 Subject: [PATCH 50/53] Update alien-signals to 3.0.0 --- package.json | 2 +- pnpm-lock.yaml | 10 ++++---- src/index.ts | 67 +++++++++++++++++++++++++++++--------------------- 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 4a66ed4..2657f31 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "webdriverio": "^8.36.1" }, "dependencies": { - "alien-signals": "^2.0.6" + "alien-signals": "^3.0.0" }, "volta": { "node": "22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e198aec..1e6324f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: alien-signals: - specifier: ^2.0.6 - version: 2.0.6 + specifier: ^3.0.0 + version: 3.0.0 devDependencies: '@types/node': specifier: ^20.11.25 @@ -800,8 +800,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - alien-signals@2.0.6: - resolution: {integrity: sha512-P3TxJSe31bUHBiblg59oU1PpaWPtmxF9GhJ/cB7OkgJ0qN/ifFSKUI25/v8ZhsT+lIG6ac8DpTOplXxORX6F3Q==} + alien-signals@3.0.0: + resolution: {integrity: sha512-JHoRJf18Y6HN4/KZALr3iU+0vW9LKG+8FMThQlbn4+gv8utsLIkwpomjElGPccGeNwh0FI2HN6BLnyFLo6OyLQ==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3372,7 +3372,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - alien-signals@2.0.6: {} + alien-signals@3.0.0: {} ansi-regex@5.0.1: {} diff --git a/src/index.ts b/src/index.ts index e7fff95..9ac622c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,30 +16,30 @@ export namespace Signal { Queued = 1 << 6, } - const {link, unlink, propagate, checkDirty, endTracking, startTracking, shallowPropagate} = - createReactiveSystem({ - update(node: _Computed) { - return node.update(); - }, - notify(node: _Watcher) { - const flags = node.flags; - if (!(flags & EffectFlags.Queued)) { - node.flags = flags | EffectFlags.Queued; - queuedEffects[queuedEffectsLength++] = node; - } - }, - unwatched(node) { - let toRemove = node.deps; - if (toRemove !== undefined) { - do { - toRemove = unlink(toRemove, node); - } while (toRemove !== undefined); - node.flags |= ReactiveFlags.Dirty; - } - }, - }); + const {link, unlink, propagate, checkDirty, shallowPropagate} = createReactiveSystem({ + update(node: _Computed) { + return node.update(); + }, + notify(node: _Watcher) { + const flags = node.flags; + if (!(flags & EffectFlags.Queued)) { + node.flags = flags | EffectFlags.Queued; + queuedEffects[queuedEffectsLength++] = node; + } + }, + unwatched(node) { + let toRemove = node.deps; + if (toRemove !== undefined) { + do { + toRemove = unlink(toRemove, node); + } while (toRemove !== undefined); + node.flags |= ReactiveFlags.Dirty; + } + }, + }); const queuedEffects: (_Watcher | undefined)[] = []; + let cycle = 0; let notifyIndex = 0; let queuedEffectsLength = 0; let activeSub: ReactiveNode | undefined; @@ -55,6 +55,14 @@ export namespace Signal { queuedEffectsLength = 0; } + function purgeDeps(sub: ReactiveNode) { + const depsTail = sub.depsTail as Link | undefined; + let toRemove = depsTail !== undefined ? depsTail.nextDep : sub.deps; + while (toRemove !== undefined) { + toRemove = unlink(toRemove, sub); + } + } + class _State implements ReactiveNode, State { subs: Link | undefined = undefined; subsTail: Link | undefined = undefined; @@ -102,7 +110,7 @@ export namespace Signal { } if (activeSub !== undefined) { const lastLink = this.subsTail; - link(this, activeSub); + link(this, activeSub, cycle); const newLink = this.subsTail!; if (newLink !== lastLink) { const newSub = newLink.sub; @@ -216,7 +224,7 @@ export namespace Signal { } if (activeSub !== undefined) { const lastLink = this.subsTail; - link(this, activeSub); + link(this, activeSub, cycle); const newLink = this.subsTail!; if (newLink !== lastLink) { const newSub = newLink.sub; @@ -234,7 +242,9 @@ export namespace Signal { update(): boolean { const prevSub = activeSub; activeSub = this; - startTracking(this); + ++cycle; + this.depsTail = undefined; + this.flags = 5 as ReactiveFlags.Mutable | ReactiveFlags.RecursedCheck; const oldValue = this.value; try { const newValue = this.getter(); @@ -254,7 +264,7 @@ export namespace Signal { } finally { if (this.watchCount) { for ( - let link = this.depsTail !== undefined ? this.depsTail.nextDep : this.deps; + let link = this.depsTail !== undefined ? (this.depsTail as Link).nextDep : this.deps; link !== undefined; link = link.nextDep ) { @@ -263,7 +273,8 @@ export namespace Signal { } } activeSub = prevSub; - endTracking(this); + this.flags &= ~(4 satisfies ReactiveFlags.RecursedCheck); + purgeDeps(this); } } } @@ -319,7 +330,7 @@ export namespace Signal { continue; } signal.onWatched(); - link(signal, this); + link(signal, this, 0); this.watchList.set(signal, this.depsTail!); } } From 793c9084f9c2306b18e2f0225c8677b62d7bd882 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sun, 28 Sep 2025 15:45:26 +0800 Subject: [PATCH 51/53] Remove purgeDeps --- src/index.ts | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9ac622c..fe771a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,14 +55,6 @@ export namespace Signal { queuedEffectsLength = 0; } - function purgeDeps(sub: ReactiveNode) { - const depsTail = sub.depsTail as Link | undefined; - let toRemove = depsTail !== undefined ? depsTail.nextDep : sub.deps; - while (toRemove !== undefined) { - toRemove = unlink(toRemove, sub); - } - } - class _State implements ReactiveNode, State { subs: Link | undefined = undefined; subsTail: Link | undefined = undefined; @@ -262,19 +254,17 @@ export namespace Signal { } return false; } finally { - if (this.watchCount) { - for ( - let link = this.depsTail !== undefined ? (this.depsTail as Link).nextDep : this.deps; - link !== undefined; - link = link.nextDep - ) { - const dep = link.dep as _AnySignal; + const watched = !!this.watchCount; + let toRemove = this.depsTail !== undefined ? (this.depsTail as Link).nextDep : this.deps; + while (toRemove !== undefined) { + if (watched) { + const dep = toRemove.dep as _AnySignal; dep.onUnwatched(); } + toRemove = unlink(toRemove, this); } activeSub = prevSub; this.flags &= ~(4 satisfies ReactiveFlags.RecursedCheck); - purgeDeps(this); } } } From 15810e0fe202776948da6d4f41fbb5dfa13b1057 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Mon, 12 Jan 2026 20:18:44 +0800 Subject: [PATCH 52/53] Copy system.ts from alien-signals 3.1.2 --- src/index.ts | 50 ++++----- src/system.ts | 292 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+), 27 deletions(-) create mode 100644 src/system.ts diff --git a/src/index.ts b/src/index.ts index fe771a2..7812bec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,4 @@ -import { - ReactiveFlags, - createReactiveSystem, - type ReactiveNode, - type Link, -} from 'alien-signals/system'; +import {createReactiveSystem, type Link, ReactiveFlags, type ReactiveNode} from './system'; export namespace Signal { export let isState: (s: any) => s is State, @@ -12,20 +7,19 @@ export namespace Signal { const WATCHER_PLACEHOLDER = Symbol('watcher') as any; - const enum EffectFlags { - Queued = 1 << 6, - } + let cycle = 0; + let notifyIndex = 0; + let queuedLength = 0; + let activeSub: ReactiveNode | undefined; + const queued: (_Watcher | undefined)[] = []; const {link, unlink, propagate, checkDirty, shallowPropagate} = createReactiveSystem({ update(node: _Computed) { return node.update(); }, notify(node: _Watcher) { - const flags = node.flags; - if (!(flags & EffectFlags.Queued)) { - node.flags = flags | EffectFlags.Queued; - queuedEffects[queuedEffectsLength++] = node; - } + queued[queuedLength++] = node; + node.flags &= ~ReactiveFlags.Watching; }, unwatched(node) { let toRemove = node.deps; @@ -37,22 +31,24 @@ export namespace Signal { } }, }); - const queuedEffects: (_Watcher | undefined)[] = []; - - let cycle = 0; - let notifyIndex = 0; - let queuedEffectsLength = 0; - let activeSub: ReactiveNode | undefined; function flush(): void { - while (notifyIndex < queuedEffectsLength) { - const effect = queuedEffects[notifyIndex]!; - queuedEffects[notifyIndex++] = undefined; - effect.flags &= ~EffectFlags.Queued; - effect.run(); + try { + while (notifyIndex < queuedLength) { + const effect = queued[notifyIndex]!; + queued[notifyIndex++] = undefined; + effect.flags |= ReactiveFlags.Watching; + effect.run(); + } + } finally { + while (notifyIndex < queuedLength) { + const effect = queued[notifyIndex]!; + queued[notifyIndex++] = undefined; + effect.flags |= ReactiveFlags.Watching | ReactiveFlags.Recursed; + } + notifyIndex = 0; + queuedLength = 0; } - notifyIndex = 0; - queuedEffectsLength = 0; } class _State implements ReactiveNode, State { diff --git a/src/system.ts b/src/system.ts new file mode 100644 index 0000000..10ca55f --- /dev/null +++ b/src/system.ts @@ -0,0 +1,292 @@ +// copyed from https://github.com/stackblitz/alien-signals/blob/836130c4837f8184ae0079e6d550b0adba07b936/src/system.ts + +export interface ReactiveNode { + deps?: Link; + depsTail?: Link; + subs?: Link; + subsTail?: Link; + flags: ReactiveFlags; +} + +export interface Link { + version: number; + dep: ReactiveNode; + sub: ReactiveNode; + prevSub: Link | undefined; + nextSub: Link | undefined; + prevDep: Link | undefined; + nextDep: Link | undefined; +} + +interface Stack { + value: T; + prev: Stack | undefined; +} + +export const enum ReactiveFlags { + None = 0, + Mutable = 1, + Watching = 2, + RecursedCheck = 4, + Recursed = 8, + Dirty = 16, + Pending = 32, +} + +export function createReactiveSystem({ + update, + notify, + unwatched, +}: { + update(sub: ReactiveNode): boolean; + notify(sub: ReactiveNode): void; + unwatched(sub: ReactiveNode): void; +}) { + return { + link, + unlink, + propagate, + checkDirty, + shallowPropagate, + }; + + function link(dep: ReactiveNode, sub: ReactiveNode, version: number): void { + const prevDep = sub.depsTail; + if (prevDep !== undefined && prevDep.dep === dep) { + return; + } + const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps; + if (nextDep !== undefined && nextDep.dep === dep) { + nextDep.version = version; + sub.depsTail = nextDep; + return; + } + const prevSub = dep.subsTail; + if (prevSub !== undefined && prevSub.version === version && prevSub.sub === sub) { + return; + } + const newLink = + (sub.depsTail = + dep.subsTail = + { + version, + dep, + sub, + prevDep, + nextDep, + prevSub, + nextSub: undefined, + }); + if (nextDep !== undefined) { + nextDep.prevDep = newLink; + } + if (prevDep !== undefined) { + prevDep.nextDep = newLink; + } else { + sub.deps = newLink; + } + if (prevSub !== undefined) { + prevSub.nextSub = newLink; + } else { + dep.subs = newLink; + } + } + + function unlink(link: Link, sub = link.sub): Link | undefined { + const dep = link.dep; + const prevDep = link.prevDep; + const nextDep = link.nextDep; + const nextSub = link.nextSub; + const prevSub = link.prevSub; + if (nextDep !== undefined) { + nextDep.prevDep = prevDep; + } else { + sub.depsTail = prevDep; + } + if (prevDep !== undefined) { + prevDep.nextDep = nextDep; + } else { + sub.deps = nextDep; + } + if (nextSub !== undefined) { + nextSub.prevSub = prevSub; + } else { + dep.subsTail = prevSub; + } + if (prevSub !== undefined) { + prevSub.nextSub = nextSub; + } else if ((dep.subs = nextSub) === undefined) { + unwatched(dep); + } + return nextDep; + } + + function propagate(link: Link): void { + let next = link.nextSub; + let stack: Stack | undefined; + + top: do { + const sub = link.sub; + let flags = sub.flags; + + if ( + !( + flags & + (ReactiveFlags.RecursedCheck | + ReactiveFlags.Recursed | + ReactiveFlags.Dirty | + ReactiveFlags.Pending) + ) + ) { + sub.flags = flags | ReactiveFlags.Pending; + } else if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed))) { + flags = ReactiveFlags.None; + } else if (!(flags & ReactiveFlags.RecursedCheck)) { + sub.flags = (flags & ~ReactiveFlags.Recursed) | ReactiveFlags.Pending; + } else if ( + !(flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending)) && + isValidLink(link, sub) + ) { + sub.flags = flags | (ReactiveFlags.Recursed | ReactiveFlags.Pending); + flags &= ReactiveFlags.Mutable; + } else { + flags = ReactiveFlags.None; + } + + if (flags & ReactiveFlags.Watching) { + notify(sub); + } + + if (flags & ReactiveFlags.Mutable) { + const subSubs = sub.subs; + if (subSubs !== undefined) { + const nextSub = (link = subSubs).nextSub; + if (nextSub !== undefined) { + stack = {value: next, prev: stack}; + next = nextSub; + } + continue; + } + } + + if ((link = next!) !== undefined) { + next = link.nextSub; + continue; + } + + while (stack !== undefined) { + link = stack.value!; + stack = stack.prev; + if (link !== undefined) { + next = link.nextSub; + continue top; + } + } + + break; + } while (true); + } + + function checkDirty(link: Link, sub: ReactiveNode): boolean { + let stack: Stack | undefined; + let checkDepth = 0; + let dirty = false; + + top: do { + const dep = link.dep; + const flags = dep.flags; + + if (sub.flags & ReactiveFlags.Dirty) { + dirty = true; + } else if ( + (flags & (ReactiveFlags.Mutable | ReactiveFlags.Dirty)) === + (ReactiveFlags.Mutable | ReactiveFlags.Dirty) + ) { + if (update(dep)) { + const subs = dep.subs!; + if (subs.nextSub !== undefined) { + shallowPropagate(subs); + } + dirty = true; + } + } else if ( + (flags & (ReactiveFlags.Mutable | ReactiveFlags.Pending)) === + (ReactiveFlags.Mutable | ReactiveFlags.Pending) + ) { + if (link.nextSub !== undefined || link.prevSub !== undefined) { + stack = {value: link, prev: stack}; + } + link = dep.deps!; + sub = dep; + ++checkDepth; + continue; + } + + if (!dirty) { + const nextDep = link.nextDep; + if (nextDep !== undefined) { + link = nextDep; + continue; + } + } + + while (checkDepth--) { + const firstSub = sub.subs!; + const hasMultipleSubs = firstSub.nextSub !== undefined; + if (hasMultipleSubs) { + link = stack!.value; + stack = stack!.prev; + } else { + link = firstSub; + } + if (dirty) { + if (update(sub)) { + if (hasMultipleSubs) { + shallowPropagate(firstSub); + } + sub = link.sub; + continue; + } + dirty = false; + } else { + sub.flags &= ~ReactiveFlags.Pending; + } + sub = link.sub; + const nextDep = link.nextDep; + if (nextDep !== undefined) { + link = nextDep; + continue top; + } + } + + return dirty; + } while (true); + } + + function shallowPropagate(link: Link): void { + do { + const sub = link.sub; + const flags = sub.flags; + if ((flags & (ReactiveFlags.Pending | ReactiveFlags.Dirty)) === ReactiveFlags.Pending) { + sub.flags = flags | ReactiveFlags.Dirty; + if ( + (flags & (ReactiveFlags.Watching | ReactiveFlags.RecursedCheck)) === + ReactiveFlags.Watching + ) { + notify(sub); + } + } + } while ((link = link.nextSub!) !== undefined); + } + + function isValidLink(checkLink: Link, sub: ReactiveNode): boolean { + let link = sub.depsTail; + while (link !== undefined) { + if (link === checkLink) { + return true; + } + link = link.prevDep; + } + return false; + } +} From 296bbb375eec75559d5d8ab2b41fcdb576c03153 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Mon, 12 Jan 2026 20:20:19 +0800 Subject: [PATCH 53/53] Remove alien-signals --- package.json | 3 --- pnpm-lock.yaml | 9 --------- 2 files changed, 12 deletions(-) diff --git a/package.json b/package.json index 2657f31..1461055 100644 --- a/package.json +++ b/package.json @@ -43,9 +43,6 @@ "vitest": "^1.4.0", "webdriverio": "^8.36.1" }, - "dependencies": { - "alien-signals": "^3.0.0" - }, "volta": { "node": "22.0.0", "pnpm": "9.0.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e6324f..065aaf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,6 @@ settings: importers: .: - dependencies: - alien-signals: - specifier: ^3.0.0 - version: 3.0.0 devDependencies: '@types/node': specifier: ^20.11.25 @@ -800,9 +796,6 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - alien-signals@3.0.0: - resolution: {integrity: sha512-JHoRJf18Y6HN4/KZALr3iU+0vW9LKG+8FMThQlbn4+gv8utsLIkwpomjElGPccGeNwh0FI2HN6BLnyFLo6OyLQ==} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -3372,8 +3365,6 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - alien-signals@3.0.0: {} - ansi-regex@5.0.1: {} ansi-regex@6.0.1: {}