feat(react): add useWrnStore bridge over useSyncExternalStore
Resolves WRNexus stores from inside islands, reading through the cached snapshot so React sees a stable reference. Unknown store names throw with the list of registered stores rather than failing opaquely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+8
-4
@@ -71,12 +71,16 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^10.8.0",
|
"@types/react": "^19.2.18",
|
||||||
"happy-dom": "^20.11.1",
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"eslint": "^10.8.1",
|
||||||
|
"happy-dom": "^20.11.2",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
|
"react": "^19",
|
||||||
|
"react-dom": "^19",
|
||||||
"tsup": "^8.5.1",
|
"tsup": "^8.5.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"bun": ">=1.3.0"
|
"bun": ">=1.3.0"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { createSelectorCache, createSnapshotCache } from "./snapshot-cache.ts";
|
||||||
|
export type { SnapshotCache, SnapshotSource } from "./snapshot-cache.ts";
|
||||||
|
export { setStoreResolver, useWrnActions, useWrnStore } from "./store-bridge.ts";
|
||||||
|
export type { BoundStore, IslandStore, StoreResolver } from "./store-bridge.ts";
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useDebugValue, useMemo, useSyncExternalStore } from "react";
|
||||||
|
import { createSelectorCache, createSnapshotCache, type SnapshotCache } from "./snapshot-cache.ts";
|
||||||
|
|
||||||
|
export interface IslandStore<S extends object> {
|
||||||
|
snapshot(): Readonly<S>;
|
||||||
|
subscribe(listener: () => void): () => void;
|
||||||
|
actions: Record<string, (...args: any[]) => unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoreResolver = (name: string) => IslandStore<any> | undefined;
|
||||||
|
|
||||||
|
let resolver: StoreResolver | null = null;
|
||||||
|
let knownNames: string[] = [];
|
||||||
|
|
||||||
|
/** Registers how island stores are looked up. Set by the island runtime at mount. */
|
||||||
|
export function setStoreResolver(next: StoreResolver | null, names: string[] = []): void {
|
||||||
|
resolver = next;
|
||||||
|
knownNames = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoundStore<S extends object> {
|
||||||
|
store: IslandStore<S>;
|
||||||
|
getSnapshot: () => Readonly<S>;
|
||||||
|
cache: SnapshotCache<S>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStore<S extends object>(name: string): BoundStore<S> {
|
||||||
|
if (!resolver) {
|
||||||
|
throw new Error(
|
||||||
|
`useWrnStore("${name}") was called before the island runtime registered any stores.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const store = resolver(name) as IslandStore<S> | undefined;
|
||||||
|
if (!store) {
|
||||||
|
const available = knownNames.length > 0 ? knownNames.join(", ") : "(none registered)";
|
||||||
|
throw new Error(`Unknown WRNexus store "${name}". Available stores: ${available}`);
|
||||||
|
}
|
||||||
|
const cache = createSnapshotCache<S>(store);
|
||||||
|
return { store, cache, getSnapshot: cache.getSnapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam — exercises resolution and caching without rendering React. */
|
||||||
|
export function getStoreForTest<S extends object>(name: string): BoundStore<S> {
|
||||||
|
return resolveStore<S>(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a WRNexus store from inside a React island.
|
||||||
|
*
|
||||||
|
* Writes must go through `useWrnActions` from an event handler or effect —
|
||||||
|
* never during render, which would loop.
|
||||||
|
*/
|
||||||
|
export function useWrnStore<S extends object, R = Readonly<S>>(
|
||||||
|
name: string,
|
||||||
|
selector?: (state: Readonly<S>) => R,
|
||||||
|
isEqual?: (a: R, b: R) => boolean,
|
||||||
|
): R {
|
||||||
|
const bound = useMemo(() => resolveStore<S>(name), [name]);
|
||||||
|
const read = useMemo(
|
||||||
|
() =>
|
||||||
|
selector
|
||||||
|
? createSelectorCache<S, R>(bound.getSnapshot, selector, isEqual)
|
||||||
|
: (bound.getSnapshot as unknown as () => R),
|
||||||
|
[bound, selector, isEqual],
|
||||||
|
);
|
||||||
|
const value = useSyncExternalStore(bound.store.subscribe, read, read);
|
||||||
|
useDebugValue(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the action map for a store, for writes from handlers and effects. */
|
||||||
|
export function useWrnActions(name: string): Record<string, (...args: any[]) => unknown> {
|
||||||
|
return useMemo(() => resolveStore(name).store.actions, [name]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { getStoreForTest, setStoreResolver } from "../src/store-bridge.ts";
|
||||||
|
|
||||||
|
function fakeStore(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);
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
increment: () => {
|
||||||
|
state = { count: state.count + 1 };
|
||||||
|
for (const listener of [...listeners]) listener();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("resolves a registered store and caches its snapshot", () => {
|
||||||
|
const store = fakeStore({ count: 0 });
|
||||||
|
setStoreResolver((name) => (name === "counter" ? store : undefined), ["counter"]);
|
||||||
|
|
||||||
|
const bound = getStoreForTest<{ count: number }>("counter");
|
||||||
|
expect(bound.getSnapshot()).toBe(bound.getSnapshot());
|
||||||
|
|
||||||
|
bound.store.actions.increment!();
|
||||||
|
expect(bound.getSnapshot().count).toBe(1);
|
||||||
|
|
||||||
|
setStoreResolver(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws a helpful error for an unknown store name", () => {
|
||||||
|
setStoreResolver(
|
||||||
|
(name) => (name === "counter" ? fakeStore({ count: 0 }) : undefined),
|
||||||
|
["counter"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => getStoreForTest("typo")).toThrow(/Unknown WRNexus store "typo"/);
|
||||||
|
expect(() => getStoreForTest("typo")).toThrow(/counter/);
|
||||||
|
|
||||||
|
setStoreResolver(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when no resolver has been registered yet", () => {
|
||||||
|
setStoreResolver(null);
|
||||||
|
expect(() => getStoreForTest("counter")).toThrow(/before the island runtime registered/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user