docs(core): correct the useStoreSnapshot caller contract

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>
This commit is contained in:
cliffhall 2026-08-29 02:42:03 -04:00
commit 8f3619f618
3 changed files with 22 additions and 14 deletions

View file

@ -56,7 +56,7 @@ const CardContent = Group.withProps({
- The `onChange` runs **during render**, so it must be pure — `setState` and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks; a render can be replayed or abandoned.
- Effects remain correct for real external-system synchronization (DOM measurement, rAF, subscriptions, timers).
- **Subscribing to an `@inspector/core` state store is `useSyncExternalStore`, never `useState` + a subscribing `useEffect`** — that shape re-seeds state from the store prop, so it carries the same stale frame plus a window where an event dispatched between render and subscribe is lost. `useValueChange` is web-only and `core/react/` is shared with the CLI and TUI.
- Use **`useStoreSnapshot(store, event, read, whenAbsent)`** (`core/react/useStoreSnapshot.ts`), once per value, when the getter returns a **fresh value per read** (a defensive copy, or a freshly built object) — nearly all of them. When the snapshot is **already referentially stable**, subscribe directly and skip the helper, as `useListError` does with its `Error | null`. Pass `read`/`whenAbsent` as **module-scope constants**: they are part of the cache key, so an unstable one trips React's "getSnapshot should be cached" error.
- Use **`useStoreSnapshot(store, event, read, whenAbsent)`** (`core/react/useStoreSnapshot.ts`), once per value, when the getter returns a **fresh value per read** (a defensive copy, or a freshly built object) — nearly all of them. When the snapshot is **already referentially stable**, subscribe directly and skip the helper, as `useListError` does with its `Error | null`. `read`/`whenAbsent` must be **referentially stable** — they are part of the cache key. `read` is a function, so module scope; `whenAbsent` needs a module-scope constant only when it is an object or array, since a primitive fallback (`false`, `undefined`, a string) is stable already and is passed inline in these hooks. An unstable one fails **quietly** — no throw, no React warning, no extra render, just a fresh value each render that defeats downstream memoization.
- The snapshot is cached against the store's per-event dispatch counter (`TypedEventTarget.getEventRevision`), **not** its contents — a contents compare cannot see a dispatch that mutated an entry already in the list. Don't replace it with a shallow compare.
- The store is the source of truth; the event is only the signal. A test firing a store event must set the value on the store first, and a fake store must extend the real `TypedEventTarget` (a bare `EventTarget` lacks `getEventRevision`).

View file

@ -1219,7 +1219,8 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage `
- Reach for **`useStoreSnapshot(store, event, read, whenAbsent)`** (`core/react/useStoreSnapshot.ts`) when the getter returns a **fresh value per read** — a defensive copy (`getTools()` is `[...this.items]`) or a freshly built object (`getPagination()`). That is nearly all of them, and the caching it adds is what keeps `useSyncExternalStore` from looping. Call it once per value.
- When a getter's value is **already referentially stable**, subscribe with `useSyncExternalStore` directly and skip the helper — `useListError` does, because its snapshot is the stored `Error` instance itself (or `null`). The rule is read-during-render, not "always use the helper".
- Either way `useValueChange` is *not* the tool here — it lives in `clients/web/src`, and `core/react/` is consumed by the CLI and TUI too.
- `read` and `whenAbsent` must be **module-scope constants**, not built in the component body. They are part of the snapshot's cache key, so an unstable one defeats the cache and React fails it outright with "The result of getSnapshot should be cached" — deliberately, since the alternative is silently serving a value derived from the previous argument.
- `read` and `whenAbsent` must be **referentially stable across renders** — they are part of the snapshot's cache key. `read` is a function, so declare it at module scope. `whenAbsent` only needs a module-scope constant when it is an **object or array** (`NO_TOOLS`, `NO_PAGINATION`); a primitive fallback (`false`, `undefined`, `"disconnected"`) is already stable under `Object.is` and is passed inline throughout these hooks.
- An unstable one **fails quietly, so don't expect to be told**: measured on React 19, an inline `read` throws nothing, logs nothing — not even React's "getSnapshot should be cached" dev warning, whose double-call happens within a single render where the closure is unchanged — and forces no extra render. It simply returns a fresh value every render, defeating every downstream `useMemo` / `React.memo` / effect dep that keys on it.
- The snapshot is cached against the store's **per-event dispatch counter** (`TypedEventTarget.getEventRevision`), which every dispatch advances automatically — not against the snapshot's contents. That is deliberate and load-bearing: these getters return a defensive copy, so contents are the only alternative, and a contents comparison cannot see a dispatch that mutated an entry the list already holds (`MessageLogState` folding a response into its request entry does exactly that). Don't "optimize" it into a shallow compare.
- Consequently the **store is the source of truth and the event is only the signal** — the hook re-reads the store rather than taking the event's `detail`. A test that fires a store event must put the value on the store first; dispatching alone announces a change that isn't there. A hand-rolled fake must extend the real `TypedEventTarget` (a bare `EventTarget` has no `getEventRevision` and fails at runtime).
- Genuinely local state accumulated from an event stream, with no getter to read it back from, stays `useState` — `useInspectorClient`'s `lastError` is the one such case. Reset it on a store swap **during render**, not in an effect.

View file

@ -52,19 +52,26 @@ export interface SnapshotStore<E extends string> {
*
* ## 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.
* `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.
*
* 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.
* 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