fix(core): key the snapshot cache on every input it derives from

Copilot review round 1.

The cache was keyed on the store and its revision alone, so a changed
`read` was answered with the previous reader's output, and with a null
store — where the revision is pinned at 0 — a changed `whenAbsent` was
ignored outright. Key on `event`, `read` and `whenAbsent` too.

This makes an unstable argument defeat the cache rather than silently
serve a value derived from the previous one, so React fails it with "The
result of getSnapshot should be cached" at first render. That is the
intended failure mode and is now documented as such: a loud error beats a
wrong list nobody can account for.

Also correct the docs, which described the helper as universal. That
misread useListError as a violation: its snapshot is the stored Error
instance, already referentially stable, so it subscribes with
useSyncExternalStore directly and needs no cache. The rule is
read-during-render; the helper is for getters that return a fresh value
per read.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-29 02:26:42 -04:00
commit f6105773f0

View file

@ -196,6 +196,66 @@ describe("useStoreSnapshot", () => {
expect(result.current).toBe(NO_ITEMS);
});
it("re-reads when the reader changes under an unchanged store", () => {
// The cache key covers every input the value was derived from, not just
// the store and its revision — otherwise a new reader is answered with the
// previous reader's output.
store.add("a");
const readLabels = (s: FakeStore): string[] =>
s.getItems().map((i) => i.label);
const readCount = (s: FakeStore): string[] => [String(s.getItems().length)];
const { result, rerender } = renderHook(
({ read }: { read: (s: FakeStore) => string[] }) =>
useStoreSnapshot(store, "itemsChange", read, []),
{ initialProps: { read: readLabels } },
);
expect(result.current).toEqual(["a"]);
rerender({ read: readCount });
expect(result.current).toEqual(["1"]);
});
it("re-reads when the fallback changes while the store is absent", () => {
// With no store the revision is pinned at 0, so the revision alone can
// never notice this one.
const first: Item[] = [{ label: "first-fallback" }];
const second: Item[] = [{ label: "second-fallback" }];
const { result, rerender } = renderHook(
({ absent }: { absent: Item[] }) =>
useStoreSnapshot(null, "itemsChange", readItems, absent),
{ initialProps: { absent: first } },
);
expect(result.current).toBe(first);
rerender({ absent: second });
expect(result.current).toBe(second);
});
it("re-reads when the watched event changes", () => {
store.add("a");
// Widen the prop up front rather than at each `rerender` — `initialProps`
// is what fixes the type, so a bare literal there pins it to that one
// event and the swap below stops compiling.
const initialProps: { event: keyof FakeStoreEventMap } = {
event: "itemsChange",
};
const { result, rerender } = renderHook(
({ event }: { event: keyof FakeStoreEventMap }) =>
useStoreSnapshot(store, event, readItems, NO_ITEMS),
{ initialProps },
);
const first = result.current;
// Same store and same reader, so only the event distinguishes the two —
// and each event carries its own revision, so the cached entry must not
// be reused across them.
rerender({ event: "otherChange" });
expect(result.current).not.toBe(first);
expect(result.current).toEqual([{ label: "a" }]);
});
it("stops listening on unmount", () => {
const { result, unmount } = renderHook(() =>
useStoreSnapshot(store, "itemsChange", readItems, NO_ITEMS),