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:
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user