Files
WRNexusJS/packages/react/test/snapshot-cache.test.ts
ClintchizandClaude Opus 5 468f63f378 feat(react): add referentially-stable snapshot and selector caches
useSyncExternalStore requires getSnapshot to return an identical
reference when unchanged; @wrnexus/store's readonlySnapshot returns a
fresh clone per call. The cache lives here rather than in the store
package so existing consumers are untouched.

createSelectorCache takes an optional equality function: the Object.is
default can never stabilize a selector that allocates, which is the
usual source of infinite re-renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:11:22 +05:30

87 lines
2.7 KiB
TypeScript

import { expect, test } from "bun:test";
import { createSelectorCache, createSnapshotCache } from "../src/snapshot-cache.ts";
function fakeSource(initial: { count: number }) {
let state = { ...initial };
const listeners = new Set<() => void>();
return {
snapshot: () => Object.freeze({ ...state }),
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
mutate(next: { count: number }) {
state = next;
for (const listener of [...listeners]) listener();
},
};
}
test("returns a referentially identical snapshot until a mutation occurs", () => {
const source = fakeSource({ count: 0 });
const cache = createSnapshotCache(source);
const first = cache.getSnapshot();
const second = cache.getSnapshot();
expect(first).toBe(second);
source.mutate({ count: 1 });
const third = cache.getSnapshot();
expect(third).not.toBe(first);
expect(third.count).toBe(1);
});
test("dispose unsubscribes from the source", () => {
const source = fakeSource({ count: 0 });
const cache = createSnapshotCache(source);
cache.getSnapshot();
cache.dispose();
source.mutate({ count: 5 });
expect(cache.getSnapshot().count).toBe(0);
});
test("selector cache keeps a stable result when the selected value is unchanged", () => {
const source = fakeSource({ count: 0 });
const cache = createSnapshotCache(source);
const select = createSelectorCache(cache.getSnapshot, (state) => state.count);
const first = select();
expect(select()).toBe(first);
// A new snapshot object, but the selected value is unchanged.
source.mutate({ count: 0 });
expect(select()).toBe(first);
source.mutate({ count: 2 });
expect(select()).toBe(2);
});
test("an object-returning selector needs a custom equality function to stay stable", () => {
const source = fakeSource({ count: 0 });
const cache = createSnapshotCache(source);
// Default Object.is can never stabilize a selector that allocates: each call
// produces a distinct reference. This is the classic useSyncExternalStore
// infinite-render footgun, so island authors get an explicit escape hatch.
const unstable = createSelectorCache(cache.getSnapshot, (state) => ({
label: `n=${state.count}`,
}));
const firstUnstable = unstable();
source.mutate({ count: 0 });
expect(unstable()).not.toBe(firstUnstable);
const stable = createSelectorCache(
cache.getSnapshot,
(state) => ({ label: `n=${state.count}` }),
(a, b) => a.label === b.label,
);
const firstStable = stable();
source.mutate({ count: 0 });
expect(stable()).toBe(firstStable);
source.mutate({ count: 2 });
expect(stable()).not.toBe(firstStable);
expect(stable().label).toBe("n=2");
});