From 2b3e03842fb2fe093ee0f80a6d94a811a2b901f6 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 27 Sep 2026 12:23:20 +0300 Subject: [PATCH 1/6] Fix atom writes dropped by batch commit listeners --- .changeset/fix-atom-batch-listener-writes.md | 5 ++ .../effect/src/reactivity/AtomRegistry.ts | 25 +++++---- packages/effect/test/reactivity/Atom.test.ts | 56 +++++++++++++++++++ 3 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 .changeset/fix-atom-batch-listener-writes.md diff --git a/.changeset/fix-atom-batch-listener-writes.md b/.changeset/fix-atom-batch-listener-writes.md new file mode 100644 index 00000000000..abfa43983b7 --- /dev/null +++ b/.changeset/fix-atom-batch-listener-writes.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Run atom rebuilds queued by batch commit listeners so nested `Atom.fn` writes and dependent updates are not dropped. diff --git a/packages/effect/src/reactivity/AtomRegistry.ts b/packages/effect/src/reactivity/AtomRegistry.ts index 7269cb96f72..37cd86a556b 100644 --- a/packages/effect/src/reactivity/AtomRegistry.ts +++ b/packages/effect/src/reactivity/AtomRegistry.ts @@ -784,11 +784,11 @@ class NodeImpl { } notify(): void { - this.listeners.forEach(notifyListener) - if (batchState.phase === BatchPhase.commit) { batchState.notify.delete(this) } + + this.listeners.forEach(notifyListener) } disposeLifetime(): void { @@ -1113,24 +1113,29 @@ export const batchState = { * @internal */ export function batch(f: () => void): void { + const previousPhase = batchState.phase batchState.phase = BatchPhase.collect batchState.depth++ try { f() if (batchState.depth === 1) { - for (let i = 0; i < batchState.stale.length; i++) { - batchRebuildNode(batchState.stale[i]) - } - batchState.phase = BatchPhase.commit - for (const node of batchState.notify) { - node.notify() - } + let i = 0 + do { + batchState.phase = BatchPhase.collect + for (; i < batchState.stale.length; i++) { + batchRebuildNode(batchState.stale[i]) + } + batchState.phase = BatchPhase.commit + for (const node of batchState.notify) { + node.notify() + } + } while (i < batchState.stale.length) batchState.notify.clear() } } finally { batchState.depth-- + batchState.phase = previousPhase if (batchState.depth === 0) { - batchState.phase = BatchPhase.disabled batchState.stale = [] } } diff --git a/packages/effect/test/reactivity/Atom.test.ts b/packages/effect/test/reactivity/Atom.test.ts index 4502f19775f..3e50e6b918f 100644 --- a/packages/effect/test/reactivity/Atom.test.ts +++ b/packages/effect/test/reactivity/Atom.test.ts @@ -1083,6 +1083,62 @@ describe("Atom", { concurrent: false }, () => { expect(r.get(derived)).toEqual("2b") }) + it("runs Atom.fn writes from batch commit listeners", () => { + const registry = AtomRegistry.make() + const source = Atom.make(0) + const write = Atom.fn((value: number, get) => Effect.sync(() => get.registry.set(source, value))) + const seen: Array = [] + registry.mount(write) + registry.subscribe(source, (value) => seen.push(value)) + registry.subscribe(source, (value) => { + if (value < 3) registry.set(write, value + 1) + }) + + Atom.batch(() => registry.set(source, 1)) + + assert.strictEqual(registry.get(source), 3) + assert.deepStrictEqual(seen, [1, 2, 3]) + registry.dispose() + }) + + it("batches dependent updates made by commit listeners", () => { + const registry = AtomRegistry.make() + const source = Atom.make(0) + const left = Atom.make(0) + const right = Atom.make(0) + const pair = Atom.make((get) => [get(left), get(right)]) + const seen: Array> = [] + registry.subscribe(pair, (value) => seen.push(value), { immediate: true }) + registry.subscribe(source, (value) => { + Atom.batch(() => { + registry.set(left, value) + registry.set(right, value) + }) + }) + + Atom.batch(() => registry.set(source, 1)) + + assert.deepStrictEqual(seen, [[0, 0], [1, 1]]) + assert.deepStrictEqual(registry.get(pair), [1, 1]) + registry.dispose() + }) + + it("notifies an atom updated again by a nested batch in its listener", () => { + const registry = AtomRegistry.make() + const source = Atom.make(0) + const seen: Array = [] + registry.subscribe(source, (value) => seen.push(value)) + registry.subscribe(source, (value) => { + if (value < 3) Atom.batch(() => registry.set(source, value + 1)) + }) + + Atom.batch(() => registry.set(source, 1)) + + assert.strictEqual(registry.get(source), 3) + assert.deepStrictEqual(seen, [1, 2, 3]) + registry.dispose() + }) + it("initialValues", async () => { const state = Atom.make(0) const r = AtomRegistry.make({ From 9579fe39351284a879c811154fb6183f2beb39c4 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 27 Sep 2026 20:53:48 +0000 Subject: [PATCH 2/6] Trim batch commit listener regression tests --- packages/effect/test/reactivity/Atom.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/effect/test/reactivity/Atom.test.ts b/packages/effect/test/reactivity/Atom.test.ts index 3e50e6b918f..cca1d0c953d 100644 --- a/packages/effect/test/reactivity/Atom.test.ts +++ b/packages/effect/test/reactivity/Atom.test.ts @@ -1096,7 +1096,6 @@ describe("Atom", { concurrent: false }, () => { Atom.batch(() => registry.set(source, 1)) - assert.strictEqual(registry.get(source), 3) assert.deepStrictEqual(seen, [1, 2, 3]) registry.dispose() }) @@ -1119,23 +1118,6 @@ describe("Atom", { concurrent: false }, () => { Atom.batch(() => registry.set(source, 1)) assert.deepStrictEqual(seen, [[0, 0], [1, 1]]) - assert.deepStrictEqual(registry.get(pair), [1, 1]) - registry.dispose() - }) - - it("notifies an atom updated again by a nested batch in its listener", () => { - const registry = AtomRegistry.make() - const source = Atom.make(0) - const seen: Array = [] - registry.subscribe(source, (value) => seen.push(value)) - registry.subscribe(source, (value) => { - if (value < 3) Atom.batch(() => registry.set(source, value + 1)) - }) - - Atom.batch(() => registry.set(source, 1)) - - assert.strictEqual(registry.get(source), 3) - assert.deepStrictEqual(seen, [1, 2, 3]) registry.dispose() }) From 0b52f0c2c3c71cdd1f99bf7ad2b4b61d8e17cf45 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 27 Sep 2026 20:56:37 +0000 Subject: [PATCH 3/6] Clear batch notifications when commit fails --- packages/effect/src/reactivity/AtomRegistry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/effect/src/reactivity/AtomRegistry.ts b/packages/effect/src/reactivity/AtomRegistry.ts index 37cd86a556b..dcda89f6dcb 100644 --- a/packages/effect/src/reactivity/AtomRegistry.ts +++ b/packages/effect/src/reactivity/AtomRegistry.ts @@ -1130,13 +1130,13 @@ export function batch(f: () => void): void { node.notify() } } while (i < batchState.stale.length) - batchState.notify.clear() } } finally { batchState.depth-- batchState.phase = previousPhase if (batchState.depth === 0) { batchState.stale = [] + batchState.notify.clear() } } } From 397645c8284651a7b751fc671913b8e361b4e82f Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 27 Sep 2026 21:01:32 +0000 Subject: [PATCH 4/6] Cover failed atom batch notification cleanup --- packages/effect/test/reactivity/Atom.test.ts | 61 ++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/effect/test/reactivity/Atom.test.ts b/packages/effect/test/reactivity/Atom.test.ts index cca1d0c953d..897e79b9951 100644 --- a/packages/effect/test/reactivity/Atom.test.ts +++ b/packages/effect/test/reactivity/Atom.test.ts @@ -1121,6 +1121,67 @@ describe("Atom", { concurrent: false }, () => { registry.dispose() }) + it("discards notifications when a batch callback throws", () => { + const registry = AtomRegistry.make() + const source = Atom.make(0) + const seen: Array = [] + registry.subscribe(source, (value) => seen.push(value)) + const error = new Error("batch failed") + + let caught: unknown + try { + Atom.batch(() => { + registry.set(source, 1) + throw error + }) + } catch (cause) { + caught = cause + } + assert.strictEqual(caught, error) + Atom.batch(() => {}) + assert.deepStrictEqual(seen, []) + + Atom.batch(() => registry.set(source, 2)) + assert.deepStrictEqual(seen, [2]) + registry.dispose() + }) + + it("discards remaining notifications when a commit listener throws", () => { + const registry = AtomRegistry.make() + const first = Atom.make(0) + const second = Atom.make(0) + const seen: Array = [] + const error = new Error("listener failed") + let shouldThrow = true + registry.subscribe(first, () => { + if (shouldThrow) { + shouldThrow = false + throw error + } + }) + registry.subscribe(second, (value) => seen.push(value)) + + let caught: unknown + try { + Atom.batch(() => { + registry.set(first, 1) + registry.set(second, 1) + }) + } catch (cause) { + caught = cause + } + assert.strictEqual(caught, error) + Atom.batch(() => {}) + assert.deepStrictEqual(seen, []) + + Atom.batch(() => { + registry.set(first, 2) + registry.set(second, 2) + }) + assert.deepStrictEqual(seen, [2]) + registry.dispose() + }) + it("initialValues", async () => { const state = Atom.make(0) const r = AtomRegistry.make({ From d5bb954030667361d40d0a2300ca554ccd4d7ac4 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 27 Sep 2026 21:04:48 +0000 Subject: [PATCH 5/6] Clarify atom batch changeset --- .changeset/fix-atom-batch-listener-writes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-atom-batch-listener-writes.md b/.changeset/fix-atom-batch-listener-writes.md index abfa43983b7..f4330ed6049 100644 --- a/.changeset/fix-atom-batch-listener-writes.md +++ b/.changeset/fix-atom-batch-listener-writes.md @@ -2,4 +2,4 @@ "effect": patch --- -Run atom rebuilds queued by batch commit listeners so nested `Atom.fn` writes and dependent updates are not dropped. +Process atom writes queued by batch commit listeners instead of dropping them. From 97370aaa9e3dc73122da05184b4bd339c3bd1b30 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Sun, 27 Sep 2026 21:08:37 +0000 Subject: [PATCH 6/6] Trim atom batch listener regression coverage --- packages/effect/test/reactivity/Atom.test.ts | 82 -------------------- 1 file changed, 82 deletions(-) diff --git a/packages/effect/test/reactivity/Atom.test.ts b/packages/effect/test/reactivity/Atom.test.ts index 897e79b9951..a4f9cf87158 100644 --- a/packages/effect/test/reactivity/Atom.test.ts +++ b/packages/effect/test/reactivity/Atom.test.ts @@ -1100,88 +1100,6 @@ describe("Atom", { concurrent: false }, () => { registry.dispose() }) - it("batches dependent updates made by commit listeners", () => { - const registry = AtomRegistry.make() - const source = Atom.make(0) - const left = Atom.make(0) - const right = Atom.make(0) - const pair = Atom.make((get) => [get(left), get(right)]) - const seen: Array> = [] - registry.subscribe(pair, (value) => seen.push(value), { immediate: true }) - registry.subscribe(source, (value) => { - Atom.batch(() => { - registry.set(left, value) - registry.set(right, value) - }) - }) - - Atom.batch(() => registry.set(source, 1)) - - assert.deepStrictEqual(seen, [[0, 0], [1, 1]]) - registry.dispose() - }) - - it("discards notifications when a batch callback throws", () => { - const registry = AtomRegistry.make() - const source = Atom.make(0) - const seen: Array = [] - registry.subscribe(source, (value) => seen.push(value)) - const error = new Error("batch failed") - - let caught: unknown - try { - Atom.batch(() => { - registry.set(source, 1) - throw error - }) - } catch (cause) { - caught = cause - } - assert.strictEqual(caught, error) - Atom.batch(() => {}) - assert.deepStrictEqual(seen, []) - - Atom.batch(() => registry.set(source, 2)) - assert.deepStrictEqual(seen, [2]) - registry.dispose() - }) - - it("discards remaining notifications when a commit listener throws", () => { - const registry = AtomRegistry.make() - const first = Atom.make(0) - const second = Atom.make(0) - const seen: Array = [] - const error = new Error("listener failed") - let shouldThrow = true - registry.subscribe(first, () => { - if (shouldThrow) { - shouldThrow = false - throw error - } - }) - registry.subscribe(second, (value) => seen.push(value)) - - let caught: unknown - try { - Atom.batch(() => { - registry.set(first, 1) - registry.set(second, 1) - }) - } catch (cause) { - caught = cause - } - assert.strictEqual(caught, error) - Atom.batch(() => {}) - assert.deepStrictEqual(seen, []) - - Atom.batch(() => { - registry.set(first, 2) - registry.set(second, 2) - }) - assert.deepStrictEqual(seen, [2]) - registry.dispose() - }) - it("initialValues", async () => { const state = Atom.make(0) const r = AtomRegistry.make({