Copilot review round 2. The contract claimed an unstable `read`/`whenAbsent` trips React's "The result of getSnapshot should be cached" error. It does not. Measured on React 19: an inline `read` closure throws nothing, logs nothing, and forces no extra render pass. React's dev check calls getSnapshot twice within a single render, where the closure is unchanged, so the cache answers both calls identically and the warning never fires. The real cost is quiet — a fresh value every render, defeating downstream useMemo/React.memo/effect deps — which is a better reason to state the contract than an error that never arrives. Also stop requiring module scope for every fallback. A primitive `whenAbsent` (`false`, `undefined`, `"disconnected"`) is stable under Object.is and is passed inline throughout these hooks, so the rule contradicted the code it was describing. Only object/array fallbacks need a constant, which is what NO_TOOLS and NO_PAGINATION are. Signed-off-by: cliffhall <cliff@futurescale.com>
138 lines
6.6 KiB
TypeScript
138 lines
6.6 KiB
TypeScript
import { useCallback, useRef, useSyncExternalStore } from "react";
|
|
|
|
/**
|
|
* The slice of a state store `useStoreSnapshot` needs, declared structurally
|
|
* rather than as a concrete class so every list/log hook can share it without
|
|
* threading its item type through. `TypedEventTarget` satisfies it, and the
|
|
* `E` parameter is what makes a mistyped event name a compile error: the
|
|
* concrete stores declare `getEventRevision(type: keyof EventMap & string)`,
|
|
* so a store is assignable to `SnapshotStore<"toolsChange">` only when that
|
|
* name is really in its event map.
|
|
*/
|
|
export interface SnapshotStore<E extends string> {
|
|
getEventRevision(type: E): number;
|
|
addEventListener(type: E, listener: (event: Event) => void): void;
|
|
removeEventListener(type: E, listener: (event: Event) => void): void;
|
|
}
|
|
|
|
/**
|
|
* Subscribe to one event on one state store and read a value from it (#1955).
|
|
*
|
|
* This replaces the `useState(read(prop)) + useEffect(re-read; subscribe)`
|
|
* shape every list/log hook in this directory used to carry. That shape has
|
|
* two defects, both invisible in the common case and both fixed here:
|
|
*
|
|
* 1. **A stale frame when the store prop changes.** Switching servers hands
|
|
* the hook a different store, but the effect that re-reads it runs *after*
|
|
* the render commits — so React paints one frame of the previous server's
|
|
* tools, then corrects itself. `useSyncExternalStore` reads during render,
|
|
* so the swap lands in the same frame.
|
|
* 2. **A missed update between render and subscribe.** An event dispatched in
|
|
* that gap was lost, because the snapshot was taken before the listener
|
|
* was attached. React re-checks the snapshot immediately after subscribing,
|
|
* which closes the window — provided the snapshot is read from the live
|
|
* store rather than from something the (not-yet-attached) listener would
|
|
* have had to invalidate. That is why the cache below keys off the store's
|
|
* own dispatch counter and not off a dirty flag set by our listener.
|
|
*
|
|
* It is the same reasoning `useListError` documents; that hook needs no cache
|
|
* because its snapshot is the stored `Error` instance itself.
|
|
*
|
|
* ## Why a revision counter
|
|
*
|
|
* `useSyncExternalStore` requires `getSnapshot` to return a referentially
|
|
* stable value across reads that mean "no change" — otherwise every read looks
|
|
* like a new value and React re-renders forever. These stores return a
|
|
* defensive copy (`getTools()` is `[...this.items]`), so the raw getter cannot
|
|
* be used directly. Caching against `getEventRevision(event)` gives an O(1)
|
|
* check that is exact in both directions: unchanged revision returns the
|
|
* identical value, and any dispatch produces a fresh one — including a
|
|
* dispatch that mutated an entry *inside* the list, which a contents
|
|
* comparison could not detect (see `TypedEventTarget.getEventRevision`).
|
|
*
|
|
* ## Caller contract
|
|
*
|
|
* `read` and `whenAbsent` must be **referentially stable across renders**, and
|
|
* they are part of the cache key below (along with `event`) so that a changed
|
|
* argument is never answered with a value computed from the previous one.
|
|
*
|
|
* What "stable" requires depends on the value. A `read` is always a function,
|
|
* so declare it at module scope — every caller in this directory does. A
|
|
* `whenAbsent` that is a **primitive** (`false`, `undefined`, `"disconnected"`)
|
|
* is stable under `Object.is` already and is passed inline throughout these
|
|
* hooks; only an object or array fallback needs a module-scope constant, which
|
|
* is what `NO_TOOLS` and `NO_PAGINATION` are for.
|
|
*
|
|
* Getting that wrong is a quiet cost rather than a failure, so it will not
|
|
* announce itself. Measured (React 19, #1955): an inline `read` closure throws
|
|
* nothing, logs nothing — not even React's "The result of getSnapshot should be
|
|
* cached" dev warning, since that check calls `getSnapshot` twice within one
|
|
* render and the closure is the same both times, so the cache answers both
|
|
* identically — and forces no extra render pass. What it does is hand back a
|
|
* fresh value on every render, which defeats every downstream `useMemo`,
|
|
* `React.memo` and effect dependency that keys on it. That is the whole reason
|
|
* the contract is stated rather than merely implied.
|
|
*
|
|
* @param store the state store, or `null` when no server is active
|
|
* @param event the store event that signals this value changed
|
|
* @param read reads the value out of a non-null store
|
|
* @param whenAbsent the value to report while `store` is `null`
|
|
*/
|
|
export function useStoreSnapshot<
|
|
E extends string,
|
|
S extends SnapshotStore<E>,
|
|
V,
|
|
>(store: S | null, event: E, read: (store: S) => V, whenAbsent: V): V {
|
|
// One cache cell per hook instance. Written from `getSnapshot`, which React
|
|
// calls during render — that is the documented shape for a cached
|
|
// `getSnapshot`, and it is safe here because the cell is a pure memo of
|
|
// (store, revision) that any render can recompute identically.
|
|
const cache = useRef<{
|
|
store: S | null;
|
|
event: E;
|
|
read: (store: S) => V;
|
|
whenAbsent: V;
|
|
revision: number;
|
|
value: V;
|
|
} | null>(null);
|
|
|
|
const subscribe = useCallback(
|
|
(onStoreChange: () => void) => {
|
|
if (!store) return () => {};
|
|
store.addEventListener(event, onStoreChange);
|
|
return () => {
|
|
store.removeEventListener(event, onStoreChange);
|
|
};
|
|
},
|
|
[store, event],
|
|
);
|
|
|
|
const getSnapshot = useCallback((): V => {
|
|
// Read the revision from the live store on every call, so an event that
|
|
// arrived while nothing was listening is still visible here.
|
|
const revision = store ? store.getEventRevision(event) : 0;
|
|
const cached = cache.current;
|
|
// Every input the value was derived from is part of the key, not just the
|
|
// store and its revision: a changed `read` (or `event`, or — with a null
|
|
// store, where the revision is pinned at 0 — a changed `whenAbsent`) means
|
|
// the cached value answers a question nobody asked any more.
|
|
if (
|
|
cached &&
|
|
Object.is(cached.store, store) &&
|
|
cached.event === event &&
|
|
cached.read === read &&
|
|
Object.is(cached.whenAbsent, whenAbsent) &&
|
|
cached.revision === revision
|
|
) {
|
|
return cached.value;
|
|
}
|
|
const value = store ? read(store) : whenAbsent;
|
|
cache.current = { store, event, read, whenAbsent, revision, value };
|
|
return value;
|
|
}, [store, event, read, whenAbsent]);
|
|
|
|
// Server snapshot: the same read. These stores are browser/Node runtime
|
|
// objects with no SSR path, and passing the same getter keeps hydration
|
|
// consistent rather than throwing on a server render.
|
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
}
|