From ff8b25bf7f93b4f2459e87df1134bda6fdf4c730 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 10:46:34 +0000 Subject: [PATCH 1/2] fix(store): run compare when the previous atom value is undefined `_update` treated an `undefined` previous snapshot as "nothing to compare against yet" so a custom `compare` never saw a computed atom's uncomputed snapshot. `undefined` is also a valid snapshot, though, so a computed atom that keeps returning `undefined` notified its subscribers on every recompute, and `compare` never ran while an atom held `undefined`. Track whether a computed atom has computed yet instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BvSDyHMgrdgjFz8EAk5VKo --- .changeset/atom-compare-undefined.md | 5 +++ packages/store/src/atom.ts | 6 ++- .../store/tests/undefined-snapshot.test.ts | 42 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 .changeset/atom-compare-undefined.md create mode 100644 packages/store/tests/undefined-snapshot.test.ts diff --git a/.changeset/atom-compare-undefined.md b/.changeset/atom-compare-undefined.md new file mode 100644 index 00000000..6f8969f5 --- /dev/null +++ b/.changeset/atom-compare-undefined.md @@ -0,0 +1,5 @@ +--- +'@tanstack/store': patch +--- + +Fix atoms skipping `compare` whenever their previous value was `undefined`. A computed atom that keeps returning `undefined` no longer notifies its subscribers on every recompute, and a custom `compare` now decides updates from an `undefined` value too. A computed atom's first computation still skips `compare`. diff --git a/packages/store/src/atom.ts b/packages/store/src/atom.ts index 1a29e607..7ff2da7a 100644 --- a/packages/store/src/atom.ts +++ b/packages/store/src/atom.ts @@ -157,6 +157,9 @@ export function createAtom( ): Atom | ReadonlyAtom { const isComputed = typeof valueOrFn === 'function' const getter = valueOrFn as (prev?: T) => T + // `undefined` is a valid snapshot, so it cannot also mean that a computed atom + // has nothing to compare against yet. + let hasSnapshot = !isComputed // Create plain object atom const atom: InternalAtom = { @@ -219,7 +222,8 @@ export function createAtom( : getValue === undefined && isComputed ? getter(oldValue) : getValue! - if (oldValue === undefined || !compare(oldValue, newValue)) { + if (!hasSnapshot || !compare(oldValue, newValue)) { + hasSnapshot = true atom._snapshot = newValue return true } diff --git a/packages/store/tests/undefined-snapshot.test.ts b/packages/store/tests/undefined-snapshot.test.ts new file mode 100644 index 00000000..7f2f0bb8 --- /dev/null +++ b/packages/store/tests/undefined-snapshot.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test, vi } from 'vitest' +import { createAtom } from '../src' + +describe('undefined snapshots', () => { + test('a computed atom that stays undefined does not notify', () => { + const source = createAtom(0) + const derived = createAtom(() => + source.get() > 10 ? source.get() : undefined, + ) + const fn = vi.fn() + derived.subscribe(fn) + + source.set(1) + source.set(2) + + expect(fn).not.toHaveBeenCalled() + }) + + test('compare still decides when the previous value is undefined', () => { + const atom = createAtom(undefined, { + compare: () => true, + }) + const fn = vi.fn() + atom.subscribe(fn) + + atom.set(1) + + expect(atom.get()).toBeUndefined() + expect(fn).not.toHaveBeenCalled() + }) + + test('compare is not called before a computed atom first computes', () => { + const source = createAtom(0) + const compare = vi.fn( + (prev: Array, next: Array) => prev.length === next.length, + ) + const derived = createAtom(() => [source.get()], { compare }) + + expect(derived.get()).toEqual([0]) + expect(compare).not.toHaveBeenCalled() + }) +}) From f72cae0b71e66c1805c9e715b160146de6fb7596 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:09:05 +0000 Subject: [PATCH 2/2] refactor(store): name the compare guard after the first computation `isFirstComputation` says what the guard is for, so the flag needs no comment to explain it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BvSDyHMgrdgjFz8EAk5VKo --- packages/store/src/atom.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/store/src/atom.ts b/packages/store/src/atom.ts index 7ff2da7a..c8198bd2 100644 --- a/packages/store/src/atom.ts +++ b/packages/store/src/atom.ts @@ -157,9 +157,7 @@ export function createAtom( ): Atom | ReadonlyAtom { const isComputed = typeof valueOrFn === 'function' const getter = valueOrFn as (prev?: T) => T - // `undefined` is a valid snapshot, so it cannot also mean that a computed atom - // has nothing to compare against yet. - let hasSnapshot = !isComputed + let isFirstComputation = isComputed // Create plain object atom const atom: InternalAtom = { @@ -222,8 +220,8 @@ export function createAtom( : getValue === undefined && isComputed ? getter(oldValue) : getValue! - if (!hasSnapshot || !compare(oldValue, newValue)) { - hasSnapshot = true + if (isFirstComputation || !compare(oldValue, newValue)) { + isFirstComputation = false atom._snapshot = newValue return true }