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>
77 lines
3 KiB
TypeScript
77 lines
3 KiB
TypeScript
import { useCallback } from "react";
|
|
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
|
import type { ManagedPromptsState } from "../mcp/state/managedPromptsState.js";
|
|
import type { Prompt } from "@modelcontextprotocol/client";
|
|
import { useListError } from "./useListError.js";
|
|
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
|
|
|
/**
|
|
* Shared stable empty list for the no-server 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_PROMPTS: Prompt[] = [];
|
|
|
|
const readPrompts = (state: ManagedPromptsState): Prompt[] =>
|
|
state.getPrompts();
|
|
const readListChanged = (state: ManagedPromptsState): boolean =>
|
|
state.getListChanged();
|
|
|
|
export interface UseManagedPromptsResult {
|
|
/**
|
|
* The last fetch's failure (transport error, or a result the SDK codec
|
|
* rejected), or `null` when it succeeded. Includes the connect-time load,
|
|
* whose failure has no caller to surface it (#1953).
|
|
*/
|
|
error: Error | null;
|
|
prompts: Prompt[];
|
|
/** True when a `prompts/list_changed` arrived since the last user refresh. */
|
|
listChanged: boolean;
|
|
refresh: () => Promise<Prompt[]>;
|
|
/** Acknowledge the list-changed indicator without fetching (#1721). */
|
|
clearListChanged: () => void;
|
|
}
|
|
|
|
/**
|
|
* React hook that subscribes to ManagedPromptsState and returns prompts + refresh.
|
|
*/
|
|
export function useManagedPrompts(
|
|
client: InspectorClientProtocol | null,
|
|
managedPromptsState: ManagedPromptsState | null,
|
|
): UseManagedPromptsResult {
|
|
const prompts = useStoreSnapshot(
|
|
managedPromptsState,
|
|
"promptsChange",
|
|
readPrompts,
|
|
NO_PROMPTS,
|
|
);
|
|
const listChanged = useStoreSnapshot(
|
|
managedPromptsState,
|
|
"listChangedChange",
|
|
readListChanged,
|
|
false,
|
|
);
|
|
|
|
const error = useListError(managedPromptsState);
|
|
|
|
const refresh = useCallback(async (): Promise<Prompt[]> => {
|
|
if (!managedPromptsState || !client) return NO_PROMPTS;
|
|
// A user-initiated refresh acknowledges the change — clear the indicator
|
|
// BEFORE awaiting the fetch, not after. If a `prompts/list_changed` arrives
|
|
// mid-fetch, the state re-sets the flag (and auto-refreshes); clearing
|
|
// afterward would wipe that genuinely-new signal and the user would miss
|
|
// it. Clearing up front acknowledges only the change in hand.
|
|
managedPromptsState.clearListChanged();
|
|
// A user-initiated refresh forces a cache-bypassing round trip
|
|
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
|
|
// return stale — and re-stores the fresh aggregate. The store dispatches
|
|
// `promptsChange` as it commits, so the snapshot above updates on its own.
|
|
return managedPromptsState.refresh(undefined, "refresh");
|
|
}, [client, managedPromptsState]);
|
|
|
|
const clearListChanged = useCallback(() => {
|
|
managedPromptsState?.clearListChanged();
|
|
}, [managedPromptsState]);
|
|
|
|
return { prompts, error, listChanged, refresh, clearListChanged };
|
|
}
|