diff --git a/.changeset/fix-atom-batch-dependencies.md b/.changeset/fix-atom-batch-dependencies.md new file mode 100644 index 00000000000..5c1ad70711f --- /dev/null +++ b/.changeset/fix-atom-batch-dependencies.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix Atom dependency tracking and re-entrant invalidation during batch rebuilds. diff --git a/packages/effect/src/unstable/reactivity/AtomRegistry.ts b/packages/effect/src/unstable/reactivity/AtomRegistry.ts index 5f4fb8b9e1b..4c54893f2a7 100644 --- a/packages/effect/src/unstable/reactivity/AtomRegistry.ts +++ b/packages/effect/src/unstable/reactivity/AtomRegistry.ts @@ -606,6 +606,8 @@ class NodeImpl { children = new Set>() listeners = new Set<() => void>() skipInvalidation = false + building = false + invalidatedDuringBuild = false currentState() { switch (this.state) { @@ -628,7 +630,9 @@ class NodeImpl { value(): A { if ((this.state & NodeFlags.waitingForValue) !== 0) { this.lifetime = makeLifetime(this) + this.building = true const value = this.atom.read(this.lifetime) + this.building = false if ((this.state & NodeFlags.waitingForValue) !== 0) { if (this.preserveInitialValueOnBuild) { this.preserveInitialValueOnBuild = false @@ -735,6 +739,9 @@ class NodeImpl { } invalidate(): void { + if (this.building && batchState.phase === BatchPhase.collect) { + this.invalidatedDuringBuild = true + } if (this.state === NodeState.valid) { this.state = NodeState.stale this.disposeLifetime() @@ -860,8 +867,9 @@ const LifetimeProto: Omit, "node" | "finalizers" | "disposed" | "i return this.node.registry.get(atom) } const parent = this.node.registry.ensureNode(atom) + const value = parent.value() this.node.addParent(parent) - return parent.value() + return value }, result(this: Lifetime, atom: Atom.Atom>, options?: { @@ -1110,7 +1118,12 @@ export function batch(f: () => void): void { function batchRebuildNode(node: NodeImpl) { if (node.state === NodeState.valid) { - return + if (!node.invalidatedDuringBuild) { + return + } + node.invalidatedDuringBuild = false + node.state = NodeState.stale + node.disposeLifetime() } for (const parent of node.parents) { diff --git a/packages/effect/test/reactivity/Atom.test.ts b/packages/effect/test/reactivity/Atom.test.ts index ef85db0fd5b..7273e126bbe 100644 --- a/packages/effect/test/reactivity/Atom.test.ts +++ b/packages/effect/test/reactivity/Atom.test.ts @@ -861,6 +861,64 @@ describe.sequential("Atom", () => { expect(r.get(derived)).toEqual("2b") }) + it.effect("retains method-form dependencies added during a batch rebuild", () => + Effect.gen(function*() { + const registry = AtomRegistry.make() + const source = Atom.make(Option.none()) + const gate = yield* Latch.make() + const asyncAtom = Atom.make((get) => + Effect.gen(function*() { + const value = get(source) + if (Option.isNone(value)) { + return yield* Effect.fail("SourceIsNone" as const) + } + yield* gate.await + return `computed-${value.value}` + }) + ) + const derived = Atom.make((get): unknown => { + const value = get.get(source) + if (Option.isNone(value)) { + return "empty" + } + return get.get(asyncAtom) + }) + + registry.subscribe(derived, () => {}, { immediate: true }) + registry.subscribe(asyncAtom, () => {}, { immediate: true }) + + Atom.batch(() => registry.set(source, Option.some("a"))) + + yield* gate.open + yield* Effect.yieldNow + + const result = registry.get(derived) as AsyncResult.AsyncResult + assert(AsyncResult.isSuccess(result)) + assert.strictEqual(result.value, "computed-a") + })) + + it("rebuilds an atom invalidated during its own batch rebuild", () => { + const registry = AtomRegistry.make() + const source = Atom.make(0) + const enabled = Atom.make(false) + const updateSource = Atom.make((get) => { + get.set(source, 1) + }) + const derived = Atom.make((get) => { + const value = get(source) + if (get(enabled)) { + get(updateSource) + } + return value + }) + + registry.subscribe(derived, () => {}, { immediate: true }) + + Atom.batch(() => registry.set(enabled, true)) + + assert.strictEqual(registry.get(derived), 1) + }) + it("nested batch", async () => { const r = AtomRegistry.make() const state = Atom.make(1).pipe(Atom.keepAlive)