inspector/core/react/usePendingClientRequests.ts
cliffhall 50502269f4 fix(core): read state stores during render instead of re-syncing in an effect
Every state-subscription hook in core/react/ seeded local React state from
its store prop and re-synced it inside a useEffect. That shape paints one
frame of the previous store's data when the prop changes (switching
servers), and loses any event dispatched between the render that took the
snapshot and the effect that attached the listener.

Convert all 16 to useSyncExternalStore through a new shared
useStoreSnapshot, following useManagedListError (#1954).

useSyncExternalStore needs a referentially stable snapshot, and these
getters hand back a defensive copy, so TypedEventTarget now keeps a
per-event-type dispatch counter (getEventRevision) that the snapshot is
cached against. A contents comparison would not do: MessageLogState folds
a response into its request entry by mutating that entry in place, which a
shallow compare reports as unchanged. The bump lives on the dispatchEvent
override so no dispatch — including a hand-rolled CustomEvent — can bypass
it.

useInspectorClient is included though the issue did not list it; its nine
subscribed slices made the stale frame most visible. Its lastError stays
useState (accumulated from events, no getter to read back) but resets on
client swap during render rather than in an effect.

The settings-draft and async fetch hooks are deliberately untouched: the
former hold genuinely local editable state, the latter are I/O rather than
store subscriptions.

Also answers the issue's open question. react-hooks/set-state-in-effect
never fired here because eslint-plugin-react-hooks has never linted
core/react/ at all — it is a clients/web devDependency and the root config
loads no React plugin. Corrected the config comment that gave "no JSX" as
the reason; the gap itself is #2192, measured at 15 findings, none of them
in the hooks converted here.

Closes #1955

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

55 lines
2.2 KiB
TypeScript

import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type { SamplingCreateMessage } from "../mcp/samplingCreateMessage.js";
import type { ElicitationCreateMessage } from "../mcp/elicitationCreateMessage.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
/**
* Shared stable empty lists for the no-client case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_SAMPLES: SamplingCreateMessage[] = [];
const NO_ELICITATIONS: ElicitationCreateMessage[] = [];
// Both getters already hand back a defensive copy of a queue the client
// mutates in place (`push`/`splice`), which is what makes them safe to read as
// a snapshot: the returned array can't change underneath React afterwards.
const readPendingSamples = (
client: InspectorClientProtocol,
): SamplingCreateMessage[] => client.getPendingSamples();
const readPendingElicitations = (
client: InspectorClientProtocol,
): ElicitationCreateMessage[] => client.getPendingElicitations();
export interface UsePendingClientRequestsResult {
pendingSamples: SamplingCreateMessage[];
pendingElicitations: ElicitationCreateMessage[];
}
/**
* React hook that subscribes to the InspectorClient's server-initiated request
* queues and returns the live pending sampling / elicitation arrays.
*
* Each entry exposes `respond()` / `reject()`, which resolve the handler Promise
* the client returned for the originating call (e.g. a tool execution that
* triggered the request). Rendering these and wiring those callbacks is what
* lets a tool call that spawned a sampling/elicitation request complete.
*/
export function usePendingClientRequests(
inspectorClient: InspectorClientProtocol | null,
): UsePendingClientRequestsResult {
const pendingSamples = useStoreSnapshot(
inspectorClient,
"pendingSamplesChange",
readPendingSamples,
NO_SAMPLES,
);
const pendingElicitations = useStoreSnapshot(
inspectorClient,
"pendingElicitationsChange",
readPendingElicitations,
NO_ELICITATIONS,
);
return { pendingSamples, pendingElicitations };
}