inspector/core/react/useStoreSnapshot.ts
cliffhall f6105773f0 fix(core): key the snapshot cache on every input it derives from
Copilot review round 1.

The cache was keyed on the store and its revision alone, so a changed
`read` was answered with the previous reader's output, and with a null
store — where the revision is pinned at 0 — a changed `whenAbsent` was
ignored outright. Key on `event`, `read` and `whenAbsent` too.

This makes an unstable argument defeat the cache rather than silently
serve a value derived from the previous one, so React fails it with "The
result of getSnapshot should be cached" at first render. That is the
intended failure mode and is now documented as such: a loud error beats a
wrong list nobody can account for.

Also correct the docs, which described the helper as universal. That
misread useListError as a violation: its snapshot is the stored Error
instance, already referentially stable, so it subscribes with
useSyncExternalStore directly and needs no cache. The rule is
read-during-render; the helper is for getters that return a fresh value
per read.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 02:26:42 -04:00

131 lines
6.1 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 module-scope constants, not values built in
* the component body — every caller in this directory declares them beside the
* hook.
*
* They are part of the cache key below, along with `event`, so that a changed
* argument can never be answered with a value computed from the previous one.
* The consequence is that passing a fresh closure (or a fresh `[]`) per render
* defeats the cache entirely: each read returns a new defensive copy, and
* React fails it outright with "The result of getSnapshot should be cached".
* That is the intended failure mode. The alternative — keying only on the
* store and its revision — would tolerate an unstable argument by silently
* serving a stale value, and a loud error at first render beats a wrong list
* nobody can account for.
*
* @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);
}