diff --git a/.changeset/fix-atom-batch-dependencies.md b/.changeset/fix-atom-batch-dependencies.md new file mode 100644 index 0000000..73d4a1e --- /dev/null +++ b/.changeset/fix-atom-batch-dependencies.md @@ -0,0 +1,5 @@ +--- +"@effect-atom/atom": patch +--- + +Fix Atom dependency tracking and re-entrant invalidation during batch rebuilds. diff --git a/packages/atom/src/internal/registry.ts b/packages/atom/src/internal/registry.ts index b00ada1..fcc3ff0 100644 --- a/packages/atom/src/internal/registry.ts +++ b/packages/atom/src/internal/registry.ts @@ -289,6 +289,8 @@ class Node { children = new Set>() listeners = new Set<() => void>() skipInvalidation = false + building = false + invalidatedDuringBuild = false get canBeRemoved(): boolean { return !this.atom.keepAlive && this.listeners.size === 0 && this.children.size === 0 && @@ -299,7 +301,9 @@ class Node { 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) { this.setValue(value) } @@ -383,6 +387,9 @@ class Node { } invalidate(): void { + if (this.building && batchState.phase === BatchPhase.collect) { + this.invalidatedDuringBuild = true + } if (this.state === NodeState.valid) { this.state = NodeState.stale this.disposeLifetime() @@ -512,8 +519,9 @@ const LifetimeProto: Omit, "node" | "finalizers" | "disposed" | "i throw disposedError(this.node.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?: { @@ -795,7 +803,12 @@ export function batch(f: () => void): void { function batchRebuildNode(node: Node) { 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/atom/test/Atom.test.ts b/packages/atom/test/Atom.test.ts index f725d82..1b48c94 100644 --- a/packages/atom/test/Atom.test.ts +++ b/packages/atom/test/Atom.test.ts @@ -697,6 +697,63 @@ describe("Atom", () => { expect(r.get(derived)).toEqual("2b") }) + it("retains method-form dependencies added during a batch rebuild", async () => { + const registry = Registry.make() + const source = Atom.make(Option.none()) + const gate = Effect.unsafeMakeLatch() + 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"))) + + gate.unsafeOpen() + await Effect.runPromise(Effect.yieldNow()) + + const result = registry.get(derived) as Result.Result + assert(Result.isSuccess(result)) + assert.strictEqual(result.value, "computed-a") + }) + + it("rebuilds an atom invalidated during its own batch rebuild", () => { + const registry = Registry.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 = Registry.make() const state = Atom.make(1).pipe(Atom.keepAlive)