From 7a2e0872b622241951f5bad4ff1d5a88bee68b05 Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Thu, 16 Jul 2026 22:03:28 -0700 Subject: [PATCH 1/2] fix state get() reads in notify cbs after set() --- .../create-state/state-modifications.test.ts | 15 +++++++++++++++ src/create-state/state-core.ts | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/integration-tests/create-state/state-modifications.test.ts b/integration-tests/create-state/state-modifications.test.ts index 2b414d6..65665a9 100644 --- a/integration-tests/create-state/state-modifications.test.ts +++ b/integration-tests/create-state/state-modifications.test.ts @@ -171,6 +171,21 @@ describe("state modifications", () => { }); describe("glitch-free push-pull", () => { + test("allows source subscribers to synchronously read the latest derived value", () => { + const source = new StateCore(1); + const doubled = source.map((value) => value * 2); + + expect(doubled.get()).toBe(2); + + const reads: number[] = []; + source.on(() => reads.push(doubled.get() as number)); + + source.set(2); + + expect(reads).toEqual([4]); + expect(doubled.get()).toBe(4); + }); + test("does not emit an intermediate value in a deep, uneven diamond graph", () => { const source = new StateCore(1); const short = source.map((value) => value + 100); diff --git a/src/create-state/state-core.ts b/src/create-state/state-core.ts index 9d732e6..d5aa14e 100644 --- a/src/create-state/state-core.ts +++ b/src/create-state/state-core.ts @@ -67,13 +67,16 @@ class StateCore { const prevValue = this._value; this._prevValue = prevValue; this._value = newValue; - this.notifySubscribers(newValue, prevValue); this._children.forEach((child) => { child._dirty = true; }); this.flush(); + + // technically, we can notify subscribers earlier, but then synchronous reading + // would return stale state values. + this.notifySubscribers(newValue, prevValue); } update(fn: (currentValue: CoreValue) => T) { From 7e6414841010fc9617cb7c1cb098213a6a3da0e1 Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Thu, 16 Jul 2026 23:38:07 -0700 Subject: [PATCH 2/2] correctly notify derived listeners when all values are updated --- .../create-state/state-modifications.test.ts | 16 ++++++++++++++ src/create-state/state-core.ts | 22 ++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/integration-tests/create-state/state-modifications.test.ts b/integration-tests/create-state/state-modifications.test.ts index 65665a9..28d8693 100644 --- a/integration-tests/create-state/state-modifications.test.ts +++ b/integration-tests/create-state/state-modifications.test.ts @@ -186,6 +186,22 @@ describe("state modifications", () => { expect(doubled.get()).toBe(4); }); + test("allows derived subscribers to synchronously read the latest downstream value", () => { + const source = new StateCore(1); + const doubled = source.map((value) => value * 2); + const quadrupled = doubled.map((value) => value * 2); + + expect(quadrupled.get()).toBe(4); + + const reads: number[] = []; + doubled.on(() => reads.push(quadrupled.get() as number)); + + source.set(2); + + expect(reads).toEqual([8]); + expect(quadrupled.get()).toBe(8); + }); + test("does not emit an intermediate value in a deep, uneven diamond graph", () => { const source = new StateCore(1); const short = source.map((value) => value + 100); diff --git a/src/create-state/state-core.ts b/src/create-state/state-core.ts index d5aa14e..0b78797 100644 --- a/src/create-state/state-core.ts +++ b/src/create-state/state-core.ts @@ -22,6 +22,8 @@ type CoreOptions = { const defaultEquality: EqualityFn = (value1, value2) => value1 === value2; +type PendingNotification = { node: StateCore; value: any; prevValue: any }; + class StateCore { private _value: CoreValue; private _prevValue: CoreValue = emptyValue; @@ -72,11 +74,17 @@ class StateCore { child._dirty = true; }); - this.flush(); + const pendingNotifications = this.flush(); // technically, we can notify subscribers earlier, but then synchronous reading // would return stale state values. this.notifySubscribers(newValue, prevValue); + + // we intentionally process derived notifications after direct subscribers to + // the signal + pendingNotifications.forEach(({ node, value, prevValue }) => { + node.notifySubscribers(value, prevValue); + }); } update(fn: (currentValue: CoreValue) => T) { @@ -208,7 +216,9 @@ class StateCore { return { changed }; } - private flush() { + private flush(): PendingNotification[] { + const pendingNotifications: PendingNotification[] = []; + /** * We utilize a min-heap to ensure that we don't trigger notifications * before all the previous necessary work is done. Otherwise it can @@ -259,12 +269,18 @@ class StateCore { if (!changed) continue; - child.notifySubscribers(value, child._prevValue); + pendingNotifications.push({ + node: child, + prevValue: child._prevValue, + value, + }); child._children.forEach((grandchild) => { grandchild._dirty = true; enqueue(grandchild); }); } + + return pendingNotifications; } private notifySubscribers(value: CoreValue, prevValue: CoreValue) {