diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 00000000..12243e1f --- /dev/null +++ b/packages/react/package.json @@ -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" + } +} diff --git a/packages/react/src/snapshot-cache.ts b/packages/react/src/snapshot-cache.ts new file mode 100644 index 00000000..98f39907 --- /dev/null +++ b/packages/react/src/snapshot-cache.ts @@ -0,0 +1,62 @@ +export interface SnapshotSource { + snapshot(): Readonly; + subscribe(listener: () => void): () => void; +} + +export interface SnapshotCache { + getSnapshot(): Readonly; + 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(source: SnapshotSource): SnapshotCache { + let cached: Readonly | 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( + getSnapshot: () => Readonly, + selector: (state: Readonly) => R, + isEqual: (a: R, b: R) => boolean = Object.is, +): () => R { + let lastSnapshot: Readonly | 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; + }; +} diff --git a/packages/react/test/snapshot-cache.test.ts b/packages/react/test/snapshot-cache.test.ts new file mode 100644 index 00000000..5f072b67 --- /dev/null +++ b/packages/react/test/snapshot-cache.test.ts @@ -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"); +});