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>
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@wrnexus/react",
|
||||||
|
"version": "0.8.8",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./runtime": "./src/runtime-source.ts"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": { "optional": true },
|
||||||
|
"react-dom": { "optional": true }
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@wrnexus/store": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export interface SnapshotSource<S extends object> {
|
||||||
|
snapshot(): Readonly<S>;
|
||||||
|
subscribe(listener: () => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapshotCache<S extends object> {
|
||||||
|
getSnapshot(): Readonly<S>;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a store instance so repeated `getSnapshot()` calls return the same
|
||||||
|
* reference until the store notifies a change. `useSyncExternalStore` throws
|
||||||
|
* and spins if given a fresh object each call, which `@wrnexus/store`'s
|
||||||
|
* `readonlySnapshot` does by design.
|
||||||
|
*/
|
||||||
|
export function createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S> {
|
||||||
|
let cached: Readonly<S> | undefined;
|
||||||
|
let dirty = true;
|
||||||
|
|
||||||
|
const unsubscribe = source.subscribe(() => {
|
||||||
|
dirty = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getSnapshot() {
|
||||||
|
if (dirty || cached === undefined) {
|
||||||
|
cached = source.snapshot();
|
||||||
|
dirty = false;
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
unsubscribe();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoizes a selector over a cached snapshot. Without this, any mutation
|
||||||
|
* re-renders every island bound to the store, because snapshots are whole-state.
|
||||||
|
*/
|
||||||
|
export function createSelectorCache<S extends object, R>(
|
||||||
|
getSnapshot: () => Readonly<S>,
|
||||||
|
selector: (state: Readonly<S>) => R,
|
||||||
|
isEqual: (a: R, b: R) => boolean = Object.is,
|
||||||
|
): () => R {
|
||||||
|
let lastSnapshot: Readonly<S> | undefined;
|
||||||
|
let lastResult: R;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const snapshot = getSnapshot();
|
||||||
|
if (!initialized || snapshot !== lastSnapshot) {
|
||||||
|
const next = selector(snapshot);
|
||||||
|
if (!initialized || !isEqual(next, lastResult)) lastResult = next;
|
||||||
|
lastSnapshot = snapshot;
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
return lastResult;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user