diff --git a/package.json b/package.json index db33e79..0417cac 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "cicheck": "pnpm -s test && pnpm -s typecheck && pnpm -s build && pnpm -s smoke && pnpm -s format" }, "dependencies": { - "alien-signals": "^3.1.1" + "alien-signals": "^3.2.1" }, "devDependencies": { "@biomejs/biome": "1.9.4", diff --git a/packages/core/__tests__/reactivity.test.ts b/packages/core/__tests__/reactivity.test.ts new file mode 100644 index 0000000..1af79d5 --- /dev/null +++ b/packages/core/__tests__/reactivity.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { computed } from "../computed"; +import { effect } from "../reactivity"; +import { signal } from "../signal"; +import { watchEffect } from "../watchEffect"; + +describe("reactivity runtime", () => { + it("continues propagation after inner writes through a computed chain", () => { + const source = signal(0); + const first = computed(() => source.value); + const second = computed(() => first.value); + let runs = 0; + + const stop = watchEffect( + () => { + runs += 1; + if (second.value > 0) { + source.value = 0; + } + }, + { flush: "sync" }, + ); + + expect(runs).toBe(1); + + source.value = 1; + expect(source.value).toBe(0); + expect(runs).toBe(2); + + source.value = 2; + expect(source.value).toBe(0); + expect(runs).toBe(3); + + source.value = 3; + expect(source.value).toBe(0); + expect(runs).toBe(4); + + stop(); + }); + + it("does not throw when a computed update disposes its subscriber", () => { + const shouldDispose = signal(false); + const subscriber = { + current: undefined as ReturnType | undefined, + }; + + const indirectlyDisposes = computed(() => { + if (shouldDispose.value) { + subscriber.current?.stop(); + } + return 0; + }); + + const selfDisposing = computed(() => { + indirectlyDisposes.value; + return 0; + }); + + subscriber.current = effect(() => { + selfDisposing.value; + }); + + expect(() => { + shouldDispose.value = true; + }).not.toThrow(); + + subscriber.current.stop(); + }); +}); diff --git a/packages/core/reactivity.ts b/packages/core/reactivity.ts index f11ef33..f52f540 100644 --- a/packages/core/reactivity.ts +++ b/packages/core/reactivity.ts @@ -1,16 +1,28 @@ -import { - type Link, - ReactiveFlags, - type ReactiveNode, - createReactiveSystem, -} from "alien-signals/system"; +import { ReactiveFlags, createReactiveSystem } from "alien-signals/system"; import type { Computed } from "./computed"; import type { Signal } from "./signal"; import type { SignalNode } from "./signal"; export { ReactiveFlags }; -export type { Link, ReactiveNode }; + +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; +} export enum SignalFlags { IS_SIGNAL = "__v_isSignal", @@ -232,21 +244,37 @@ export function trigger( triggerEffects(depsToTrigger); } -const { link, unlink, propagate, checkDirty, shallowPropagate } = - createReactiveSystem({ - update(node: ReactiveNode & { update(): boolean }) { - return node.update(); - }, - notify(effect: Effect) { - queue.push(effect); - }, - unwatched() {}, - }); +const system = createReactiveSystem({ + update(node) { + return (node as ReactiveNode & { update(): boolean }).update(); + }, + notify(effect) { + queue.push(effect as Effect); + }, + unwatched() {}, +}); + +const link = system.link as ( + dep: ReactiveNode, + sub: ReactiveNode, + version: number, +) => void; +const unlink = system.unlink as ( + link: Link, + sub?: ReactiveNode, +) => Link | undefined; +const propagate = system.propagate as (link: Link, innerWrite: boolean) => void; +const checkDirty = system.checkDirty as ( + link: Link, + sub: ReactiveNode, +) => boolean; +const shallowPropagate = system.shallowPropagate as (link: Link) => void; export { link, unlink, propagate, shallowPropagate }; let cycle = 0; let batchDepth = 0; +let reactiveExecutionDepth = 0; let activeSub: ReactiveNode | undefined; const queue: Effect[] = []; @@ -287,6 +315,18 @@ export function setActiveSubscriber(node: ReactiveNode | undefined): void { activeSub = node; } +export function enterReactiveExecution(): void { + reactiveExecutionDepth += 1; +} + +export function exitReactiveExecution(): void { + reactiveExecutionDepth -= 1; +} + +export function isRunningReactiveExecution(): boolean { + return reactiveExecutionDepth > 0; +} + export function startBatch(): void { batchDepth += 1; } @@ -354,9 +394,11 @@ export class Effect implements ReactiveNode { this.flags = ReactiveFlags.Watching | ReactiveFlags.RecursedCheck; const previous = getActiveSubscriber(); setActiveSubscriber(this); + enterReactiveExecution(); try { return this.fn(); } finally { + exitReactiveExecution(); setActiveSubscriber(previous); this.flags &= ~ReactiveFlags.RecursedCheck; let toRemove = diff --git a/packages/core/signal.ts b/packages/core/signal.ts index b71380f..f24eaec 100644 --- a/packages/core/signal.ts +++ b/packages/core/signal.ts @@ -8,6 +8,7 @@ import { getCurrentCycle, hasChanged, isBatching, + isRunningReactiveExecution, link, propagate, setDepFactory, @@ -50,7 +51,7 @@ export class SignalNode implements ReactiveNode { this.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty; const subs = this.subs; if (subs !== undefined) { - propagate(subs); + propagate(subs, isRunningReactiveExecution()); if (!isBatching()) { flushSchedulerQueue(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeed47d..733faff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: alien-signals: - specifier: ^3.1.1 - version: 3.1.1 + specifier: ^3.2.1 + version: 3.2.1 devDependencies: '@biomejs/biome': specifier: 1.9.4 @@ -527,8 +527,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - alien-signals@3.1.1: - resolution: {integrity: sha512-ogkIWbVrLwKtHY6oOAXaYkAxP+cTH7V5FZ5+Tm4NZFd8VDZ6uNMDrfzqctTZ42eTMCSR3ne3otpcxmqSnFfPYA==} + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -1922,7 +1922,7 @@ snapshots: acorn@8.15.0: {} - alien-signals@3.1.1: {} + alien-signals@3.2.1: {} ansi-regex@5.0.1: {}