From 8bacbf90afec2b1850075d641e2780939039d444 Mon Sep 17 00:00:00 2001 From: Tanner Scadden <44242867+Tanner-Scadden@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:47:24 -0700 Subject: [PATCH] test(Atom): family finalizer must not evict a newer atom for the same key Adds a failing regression test. When an atom created by Atom.family is collected, its FinalizationRegistry callback removes the cache entry by key without checking whether the entry still refers to the collected atom. If a newer atom for the same key was cached before the callback ran, it is evicted and the next lookup creates a third atom. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/atom/test/Atom.test.ts | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/atom/test/Atom.test.ts b/packages/atom/test/Atom.test.ts index d40389f..bbf7a50 100644 --- a/packages/atom/test/Atom.test.ts +++ b/packages/atom/test/Atom.test.ts @@ -688,6 +688,51 @@ describe("Atom", () => { } }) + it("family finalizer does not evict a newer atom for the same key", () => { + // Simulate GC deterministically: atoms in `collected` are treated as + // garbage collected, and finalizer callbacks only run when invoked manually. + const collected = new Set() + const registrations: Array<{ target: object; held: unknown; cleanup: (held: unknown) => void }> = [] + class TestWeakRef { + constructor(readonly target: T) {} + deref(): T | undefined { + return collected.has(this.target) ? undefined : this.target + } + } + class TestFinalizationRegistry { + constructor(readonly cleanup: (held: unknown) => void) {} + register(target: object, held: unknown) { + registrations.push({ target, held, cleanup: this.cleanup }) + } + unregister() { + return false + } + } + vitest.stubGlobal("WeakRef", TestWeakRef) + vitest.stubGlobal("FinalizationRegistry", TestFinalizationRegistry) + try { + const count = Atom.family((n: number) => Atom.make(n)) + + // atom A is created, released, and collected, but its finalizer is deferred + const a = count(1) + collected.add(a) + + // a new lookup creates and caches atom B for the same key + const b = count(1) + assert.notStrictEqual(a, b) + assert.strictEqual(count(1), b) + + // A's deferred finalizer runs after B has been cached + const stale = registrations.find((r) => r.target === a)! + stale.cleanup(stale.held) + + // B is still alive, so the family must keep returning it + assert.strictEqual(count(1), b) + } finally { + vitest.unstubAllGlobals() + } + }) + it("label", async () => { expect( Atom.make(0).pipe(Atom.withLabel("counter")).label![1]