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>
This commit is contained in:
parent
8c91ae8820
commit
50502269f4
1 changed files with 987 additions and 772 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type { AppRendererClient } from "../mcp/inspectorClientProtocol.js";
|
||||
import type { TypedEvent } from "../mcp/inspectorClientEventTarget.js";
|
||||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
} from "@modelcontextprotocol/client";
|
||||
import type { ExcludedTool } from "../mcp/types.js";
|
||||
import type { MalformedListItem } from "../mcp/listSalvage.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
|
||||
// Module-scope frozen object so the `?? EMPTY_CLIENT_CAPABILITIES`
|
||||
// fallback below doesn't return a fresh literal on every render —
|
||||
|
|
@ -19,6 +20,40 @@ import type { MalformedListItem } from "../mcp/listSalvage.js";
|
|||
// would otherwise invalidate every tick when no client is attached.
|
||||
const EMPTY_CLIENT_CAPABILITIES: ClientCapabilities = Object.freeze({});
|
||||
|
||||
/**
|
||||
* Stable fallbacks for the no-client case, module scope for the same reason as
|
||||
* the constant above — a `useStoreSnapshot` fallback built in the component
|
||||
* body would look like a new value on every read.
|
||||
*/
|
||||
const NO_EXCLUDED_TOOLS: ExcludedTool[] = [];
|
||||
const NO_MALFORMED_LIST_ITEMS: MalformedListItem[] = [];
|
||||
|
||||
const readStatus = (client: InspectorClientProtocol): ConnectionStatus =>
|
||||
client.getStatus();
|
||||
const readCapabilities = (
|
||||
client: InspectorClientProtocol,
|
||||
): ServerCapabilities | undefined => client.getCapabilities();
|
||||
const readServerInfo = (
|
||||
client: InspectorClientProtocol,
|
||||
): Implementation | undefined => client.getServerInfo();
|
||||
const readInstructions = (
|
||||
client: InspectorClientProtocol,
|
||||
): string | undefined => client.getInstructions();
|
||||
const readProtocolVersion = (
|
||||
client: InspectorClientProtocol,
|
||||
): string | undefined => client.getProtocolVersion();
|
||||
const readProtocolEra = (
|
||||
client: InspectorClientProtocol,
|
||||
): ProtocolEra | undefined => client.getProtocolEra();
|
||||
const readDiscoverResult = (
|
||||
client: InspectorClientProtocol,
|
||||
): DiscoverResult | undefined => client.getDiscoverResult();
|
||||
const readExcludedTools = (client: InspectorClientProtocol): ExcludedTool[] =>
|
||||
client.getExcludedTools();
|
||||
const readMalformedListItems = (
|
||||
client: InspectorClientProtocol,
|
||||
): MalformedListItem[] => client.getMalformedListItems();
|
||||
|
||||
export interface UseInspectorClientResult {
|
||||
status: ConnectionStatus;
|
||||
capabilities?: ServerCapabilities;
|
||||
|
|
@ -69,6 +104,11 @@ export interface UseInspectorClientResult {
|
|||
* connection state. Log lists (message / stderr / fetch) live in dedicated
|
||||
* state managers consumed via useMessageLog / useStderrLog / useFetchRequestLog.
|
||||
*
|
||||
* Every value the client can be *asked* for is read through `useStoreSnapshot`
|
||||
* rather than mirrored into local state by an effect (#1955): the effect ran
|
||||
* after the commit, so swapping servers painted one frame of the previous
|
||||
* client's status and capabilities before correcting itself.
|
||||
*
|
||||
* Note: `appRendererClient` is read lazily from the client on every render
|
||||
* and is NOT subscribed. It changes once at connect time and is not expected
|
||||
* to change again during a session, so callers will see the current value
|
||||
|
|
@ -80,169 +120,99 @@ export interface UseInspectorClientResult {
|
|||
export function useInspectorClient(
|
||||
inspectorClient: InspectorClientProtocol | null,
|
||||
): UseInspectorClientResult {
|
||||
const [status, setStatus] = useState<ConnectionStatus>(
|
||||
inspectorClient?.getStatus() ?? "disconnected",
|
||||
const status = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"statusChange",
|
||||
readStatus,
|
||||
"disconnected" as ConnectionStatus,
|
||||
);
|
||||
const [capabilities, setCapabilities] = useState<
|
||||
ServerCapabilities | undefined
|
||||
>(inspectorClient?.getCapabilities());
|
||||
const [serverInfo, setServerInfo] = useState<Implementation | undefined>(
|
||||
inspectorClient?.getServerInfo(),
|
||||
const capabilities = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"capabilitiesChange",
|
||||
readCapabilities,
|
||||
undefined,
|
||||
);
|
||||
const [instructions, setInstructions] = useState<string | undefined>(
|
||||
inspectorClient?.getInstructions(),
|
||||
const serverInfo = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"serverInfoChange",
|
||||
readServerInfo,
|
||||
undefined,
|
||||
);
|
||||
const [protocolVersion, setProtocolVersion] = useState<string | undefined>(
|
||||
inspectorClient?.getProtocolVersion(),
|
||||
const instructions = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"instructionsChange",
|
||||
readInstructions,
|
||||
undefined,
|
||||
);
|
||||
const [protocolEra, setProtocolEra] = useState<ProtocolEra | undefined>(
|
||||
inspectorClient?.getProtocolEra(),
|
||||
const protocolVersion = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"protocolVersionChange",
|
||||
readProtocolVersion,
|
||||
undefined,
|
||||
);
|
||||
const [discoverResult, setDiscoverResult] = useState<
|
||||
DiscoverResult | undefined
|
||||
>(inspectorClient?.getDiscoverResult());
|
||||
const [excludedTools, setExcludedTools] = useState<ExcludedTool[]>(
|
||||
inspectorClient?.getExcludedTools() ?? [],
|
||||
const protocolEra = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"protocolEraChange",
|
||||
readProtocolEra,
|
||||
undefined,
|
||||
);
|
||||
const [malformedListItems, setMalformedListItems] = useState<
|
||||
MalformedListItem[]
|
||||
>(inspectorClient?.getMalformedListItems() ?? []);
|
||||
const [lastError, setLastError] = useState<string | undefined>(undefined);
|
||||
const discoverResult = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"discoverResultChange",
|
||||
readDiscoverResult,
|
||||
undefined,
|
||||
);
|
||||
const excludedTools = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"excludedToolsChange",
|
||||
readExcludedTools,
|
||||
NO_EXCLUDED_TOOLS,
|
||||
);
|
||||
const malformedListItems = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"malformedListItemsChange",
|
||||
readMalformedListItems,
|
||||
NO_MALFORMED_LIST_ITEMS,
|
||||
);
|
||||
|
||||
// `lastError` is the one value here that is NOT a snapshot of something the
|
||||
// client stores: there is no `getLastError()`, because the client emits the
|
||||
// failure and moves on. It is accumulated from the event stream instead, so
|
||||
// it stays local state — but it is still tied to one client, and must reset
|
||||
// when a different one is passed in.
|
||||
//
|
||||
// That reset is done during render (React's documented "adjusting state
|
||||
// when a prop changes" pattern, the same one `useValueChange` implements for
|
||||
// the web client) rather than in an effect. An effect would paint one frame
|
||||
// carrying the previous client's error, which is precisely the defect this
|
||||
// hook was converted to fix.
|
||||
const [errorState, setErrorState] = useState<{
|
||||
client: InspectorClientProtocol | null;
|
||||
message?: string;
|
||||
}>({ client: inspectorClient });
|
||||
if (errorState.client !== inspectorClient) {
|
||||
setErrorState({ client: inspectorClient });
|
||||
}
|
||||
const lastError =
|
||||
errorState.client === inspectorClient ? errorState.message : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!inspectorClient) {
|
||||
setStatus("disconnected");
|
||||
setCapabilities(undefined);
|
||||
setServerInfo(undefined);
|
||||
setInstructions(undefined);
|
||||
setProtocolVersion(undefined);
|
||||
setProtocolEra(undefined);
|
||||
setDiscoverResult(undefined);
|
||||
setExcludedTools([]);
|
||||
setMalformedListItems([]);
|
||||
setLastError(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(inspectorClient.getStatus());
|
||||
setCapabilities(inspectorClient.getCapabilities());
|
||||
setServerInfo(inspectorClient.getServerInfo());
|
||||
setInstructions(inspectorClient.getInstructions());
|
||||
setProtocolVersion(inspectorClient.getProtocolVersion());
|
||||
setProtocolEra(inspectorClient.getProtocolEra());
|
||||
setDiscoverResult(inspectorClient.getDiscoverResult());
|
||||
setExcludedTools(inspectorClient.getExcludedTools());
|
||||
setMalformedListItems(inspectorClient.getMalformedListItems());
|
||||
setLastError(undefined);
|
||||
|
||||
if (!inspectorClient) return;
|
||||
const onStatusChange = (event: TypedEvent<"statusChange">) => {
|
||||
setStatus(event.detail);
|
||||
// A fresh connection attempt clears any stale error from the prior
|
||||
// session so the UI doesn't keep showing why the last transport died.
|
||||
if (event.detail === "connecting") {
|
||||
setLastError(undefined);
|
||||
setErrorState({ client: inspectorClient });
|
||||
}
|
||||
};
|
||||
const onError = (event: TypedEvent<"error">) => {
|
||||
setLastError(event.detail.message);
|
||||
setErrorState({ client: inspectorClient, message: event.detail.message });
|
||||
};
|
||||
const onCapabilitiesChange = (event: TypedEvent<"capabilitiesChange">) => {
|
||||
setCapabilities(event.detail);
|
||||
};
|
||||
const onServerInfoChange = (event: TypedEvent<"serverInfoChange">) => {
|
||||
setServerInfo(event.detail);
|
||||
};
|
||||
const onInstructionsChange = (event: TypedEvent<"instructionsChange">) => {
|
||||
setInstructions(event.detail);
|
||||
};
|
||||
const onProtocolVersionChange = (
|
||||
event: TypedEvent<"protocolVersionChange">,
|
||||
) => {
|
||||
setProtocolVersion(event.detail);
|
||||
};
|
||||
const onProtocolEraChange = (event: TypedEvent<"protocolEraChange">) => {
|
||||
setProtocolEra(event.detail);
|
||||
};
|
||||
const onDiscoverResultChange = (
|
||||
event: TypedEvent<"discoverResultChange">,
|
||||
) => {
|
||||
setDiscoverResult(event.detail);
|
||||
};
|
||||
const onMalformedListItemsChange = (
|
||||
event: TypedEvent<"malformedListItemsChange">,
|
||||
) => {
|
||||
setMalformedListItems(event.detail);
|
||||
};
|
||||
const onExcludedToolsChange = (
|
||||
event: TypedEvent<"excludedToolsChange">,
|
||||
) => {
|
||||
setExcludedTools(event.detail);
|
||||
};
|
||||
|
||||
inspectorClient.addEventListener("statusChange", onStatusChange);
|
||||
inspectorClient.addEventListener("error", onError);
|
||||
inspectorClient.addEventListener(
|
||||
"capabilitiesChange",
|
||||
onCapabilitiesChange,
|
||||
);
|
||||
inspectorClient.addEventListener("serverInfoChange", onServerInfoChange);
|
||||
inspectorClient.addEventListener(
|
||||
"instructionsChange",
|
||||
onInstructionsChange,
|
||||
);
|
||||
inspectorClient.addEventListener(
|
||||
"protocolVersionChange",
|
||||
onProtocolVersionChange,
|
||||
);
|
||||
inspectorClient.addEventListener("protocolEraChange", onProtocolEraChange);
|
||||
inspectorClient.addEventListener(
|
||||
"discoverResultChange",
|
||||
onDiscoverResultChange,
|
||||
);
|
||||
inspectorClient.addEventListener(
|
||||
"excludedToolsChange",
|
||||
onExcludedToolsChange,
|
||||
);
|
||||
inspectorClient.addEventListener(
|
||||
"malformedListItemsChange",
|
||||
onMalformedListItemsChange,
|
||||
);
|
||||
|
||||
return () => {
|
||||
inspectorClient.removeEventListener("statusChange", onStatusChange);
|
||||
inspectorClient.removeEventListener("error", onError);
|
||||
inspectorClient.removeEventListener(
|
||||
"capabilitiesChange",
|
||||
onCapabilitiesChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"serverInfoChange",
|
||||
onServerInfoChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"instructionsChange",
|
||||
onInstructionsChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"protocolVersionChange",
|
||||
onProtocolVersionChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"protocolEraChange",
|
||||
onProtocolEraChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"discoverResultChange",
|
||||
onDiscoverResultChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"excludedToolsChange",
|
||||
onExcludedToolsChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"malformedListItemsChange",
|
||||
onMalformedListItemsChange,
|
||||
);
|
||||
};
|
||||
}, [inspectorClient]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue