Merge branch 'v2/main' into v2/fix/2165-oauth-recovery-floating-promises
This commit is contained in:
commit
26fdc48bf1
19 changed files with 1088 additions and 772 deletions
|
|
@ -25,6 +25,55 @@ export class TypedEventGeneric<
|
|||
* implementation so the subclass remains assignable to EventTarget.
|
||||
*/
|
||||
export class TypedEventTarget<EventMap extends object> extends EventTarget {
|
||||
/**
|
||||
* Per-event-type dispatch counter. Read through `getEventRevision` by
|
||||
* `useStoreSnapshot` (core/react) to tell "nothing has happened on this
|
||||
* event since I last looked" from "something has", without comparing the
|
||||
* snapshot's contents (#1955).
|
||||
*/
|
||||
private readonly eventRevisions = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* How many times an event of this type has been dispatched from this
|
||||
* target. Monotonic per type, and per type only: an `errorChange` does not
|
||||
* advance `toolsChange`, so a subscriber to one is not re-read because of
|
||||
* the other.
|
||||
*
|
||||
* The counter — not the list's contents — is what a `useSyncExternalStore`
|
||||
* snapshot is cached against. That distinction is load-bearing rather than
|
||||
* an optimization: these stores hand out a defensive copy, so comparing
|
||||
* contents is the only alternative, and a contents comparison cannot see an
|
||||
* in-place mutation of an entry the list already holds. `MessageLogState`
|
||||
* does exactly that when a response is folded into its request entry
|
||||
* (filling in `response`/`duration`), and it dispatches — so the counter
|
||||
* moves and the fold reaches the UI, where a shallow compare would report
|
||||
* "unchanged" and never re-render it.
|
||||
*/
|
||||
getEventRevision(type: keyof EventMap & string): number {
|
||||
return this.eventRevisions.get(type) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overridden solely to advance the counter. It sits on `dispatchEvent`
|
||||
* rather than on `dispatchTypedEvent` because that is the one funnel every
|
||||
* dispatch passes through — the typed helper below delegates to it, and so
|
||||
* does a hand-rolled `dispatchEvent(new CustomEvent(...))`, which the tests
|
||||
* use extensively. A counter bumped only in the typed helper would be
|
||||
* silently stale for those, i.e. exactly where nobody would look for it.
|
||||
*
|
||||
* Bumped BEFORE dispatching: the state mutation always precedes the
|
||||
* dispatch, so by the time a listener runs the counter must already reflect
|
||||
* it, or a snapshot read from inside that listener would return the cached
|
||||
* pre-change value.
|
||||
*/
|
||||
override dispatchEvent(event: Event): boolean {
|
||||
this.eventRevisions.set(
|
||||
event.type,
|
||||
(this.eventRevisions.get(event.type) ?? 0) + 1,
|
||||
);
|
||||
return super.dispatchEvent(event);
|
||||
}
|
||||
|
||||
dispatchTypedEvent<K extends keyof EventMap>(
|
||||
type: K,
|
||||
...args: EventMap[K] extends void ? [] : [detail: EventMap[K]]
|
||||
|
|
|
|||
15
core/react/pagination.ts
Normal file
15
core/react/pagination.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { PagePaginationState } from "../mcp/state/pagedToolsState.js";
|
||||
|
||||
/**
|
||||
* The pagination snapshot reported while no paged store is attached (no
|
||||
* active server): nothing loaded, nowhere to go next.
|
||||
*
|
||||
* Module scope and frozen because it is a `useStoreSnapshot` fallback — a
|
||||
* fresh object literal per render would make the snapshot look different on
|
||||
* every read. Shared by the three `usePaged*` list hooks, which is safe
|
||||
* precisely because it is immutable.
|
||||
*/
|
||||
export const NO_PAGINATION: PagePaginationState = Object.freeze({
|
||||
nextCursor: undefined,
|
||||
pageCount: 0,
|
||||
});
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import type { FetchRequestEntry } from "../mcp/types.js";
|
||||
import type {
|
||||
FetchRequestLogState,
|
||||
FetchRequestLogStateEventMap,
|
||||
} from "../mcp/state/fetchRequestLogState.js";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import type { FetchRequestLogState } from "../mcp/state/fetchRequestLogState.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_FETCH_REQUESTS: FetchRequestEntry[] = [];
|
||||
|
||||
const readFetchRequests = (state: FetchRequestLogState): FetchRequestEntry[] =>
|
||||
state.getFetchRequests();
|
||||
|
||||
export interface UseFetchRequestLogResult {
|
||||
fetchRequests: FetchRequestEntry[];
|
||||
|
|
@ -17,35 +23,12 @@ export interface UseFetchRequestLogResult {
|
|||
export function useFetchRequestLog(
|
||||
fetchRequestLogState: FetchRequestLogState | null,
|
||||
): UseFetchRequestLogResult {
|
||||
const [fetchRequests, setFetchRequests] = useState<FetchRequestEntry[]>(
|
||||
fetchRequestLogState?.getFetchRequests() ?? [],
|
||||
const fetchRequests = useStoreSnapshot(
|
||||
fetchRequestLogState,
|
||||
"fetchRequestsChange",
|
||||
readFetchRequests,
|
||||
NO_FETCH_REQUESTS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchRequestLogState) {
|
||||
setFetchRequests([]);
|
||||
return;
|
||||
}
|
||||
setFetchRequests(fetchRequestLogState.getFetchRequests());
|
||||
const onFetchRequestsChange = (
|
||||
event: TypedEventGeneric<
|
||||
FetchRequestLogStateEventMap,
|
||||
"fetchRequestsChange"
|
||||
>,
|
||||
) => {
|
||||
setFetchRequests(event.detail);
|
||||
};
|
||||
fetchRequestLogState.addEventListener(
|
||||
"fetchRequestsChange",
|
||||
onFetchRequestsChange,
|
||||
);
|
||||
return () => {
|
||||
fetchRequestLogState.removeEventListener(
|
||||
"fetchRequestsChange",
|
||||
onFetchRequestsChange,
|
||||
);
|
||||
};
|
||||
}, [fetchRequestLogState]);
|
||||
|
||||
return { fetchRequests };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type {
|
||||
ManagedPromptsState,
|
||||
ManagedPromptsStateEventMap,
|
||||
} from "../mcp/state/managedPromptsState.js";
|
||||
import type { ManagedPromptsState } from "../mcp/state/managedPromptsState.js";
|
||||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
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 {
|
||||
/**
|
||||
|
|
@ -30,52 +39,23 @@ export function useManagedPrompts(
|
|||
client: InspectorClientProtocol | null,
|
||||
managedPromptsState: ManagedPromptsState | null,
|
||||
): UseManagedPromptsResult {
|
||||
const [prompts, setPrompts] = useState<Prompt[]>(
|
||||
managedPromptsState?.getPrompts() ?? [],
|
||||
const prompts = useStoreSnapshot(
|
||||
managedPromptsState,
|
||||
"promptsChange",
|
||||
readPrompts,
|
||||
NO_PROMPTS,
|
||||
);
|
||||
const [listChanged, setListChanged] = useState<boolean>(
|
||||
managedPromptsState?.getListChanged() ?? false,
|
||||
const listChanged = useStoreSnapshot(
|
||||
managedPromptsState,
|
||||
"listChangedChange",
|
||||
readListChanged,
|
||||
false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!managedPromptsState) {
|
||||
setPrompts([]);
|
||||
setListChanged(false);
|
||||
return;
|
||||
}
|
||||
setPrompts(managedPromptsState.getPrompts());
|
||||
setListChanged(managedPromptsState.getListChanged());
|
||||
const onPromptsChange = (
|
||||
event: TypedEventGeneric<ManagedPromptsStateEventMap, "promptsChange">,
|
||||
) => {
|
||||
setPrompts(event.detail);
|
||||
};
|
||||
const onListChangedChange = (
|
||||
event: TypedEventGeneric<
|
||||
ManagedPromptsStateEventMap,
|
||||
"listChangedChange"
|
||||
>,
|
||||
) => {
|
||||
setListChanged(event.detail);
|
||||
};
|
||||
managedPromptsState.addEventListener("promptsChange", onPromptsChange);
|
||||
managedPromptsState.addEventListener(
|
||||
"listChangedChange",
|
||||
onListChangedChange,
|
||||
);
|
||||
return () => {
|
||||
managedPromptsState.removeEventListener("promptsChange", onPromptsChange);
|
||||
managedPromptsState.removeEventListener(
|
||||
"listChangedChange",
|
||||
onListChangedChange,
|
||||
);
|
||||
};
|
||||
}, [managedPromptsState]);
|
||||
|
||||
const error = useListError(managedPromptsState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Prompt[]> => {
|
||||
if (!managedPromptsState || !client) return [];
|
||||
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
|
||||
|
|
@ -84,10 +64,9 @@ export function useManagedPrompts(
|
|||
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.
|
||||
const next = await managedPromptsState.refresh(undefined, "refresh");
|
||||
setPrompts(next);
|
||||
return next;
|
||||
// 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(() => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type {
|
||||
ManagedRequestorTasksState,
|
||||
ManagedRequestorTasksStateEventMap,
|
||||
} from "../mcp/state/managedRequestorTasksState.js";
|
||||
import type { ManagedRequestorTasksState } from "../mcp/state/managedRequestorTasksState.js";
|
||||
import type { Task } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.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_TASKS: Task[] = [];
|
||||
|
||||
const readTasks = (state: ManagedRequestorTasksState): Task[] =>
|
||||
state.getTasks();
|
||||
|
||||
export interface UseManagedRequestorTasksResult {
|
||||
tasks: Task[];
|
||||
|
|
@ -21,38 +28,18 @@ export function useManagedRequestorTasks(
|
|||
client: InspectorClientProtocol | null,
|
||||
managedRequestorTasksState: ManagedRequestorTasksState | null,
|
||||
): UseManagedRequestorTasksResult {
|
||||
const [tasks, setTasks] = useState<Task[]>(
|
||||
managedRequestorTasksState?.getTasks() ?? [],
|
||||
const tasks = useStoreSnapshot(
|
||||
managedRequestorTasksState,
|
||||
"tasksChange",
|
||||
readTasks,
|
||||
NO_TASKS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!managedRequestorTasksState) {
|
||||
setTasks([]);
|
||||
return;
|
||||
}
|
||||
setTasks(managedRequestorTasksState.getTasks());
|
||||
const onTasksChange = (
|
||||
event: TypedEventGeneric<
|
||||
ManagedRequestorTasksStateEventMap,
|
||||
"tasksChange"
|
||||
>,
|
||||
) => {
|
||||
setTasks(event.detail);
|
||||
};
|
||||
managedRequestorTasksState.addEventListener("tasksChange", onTasksChange);
|
||||
return () => {
|
||||
managedRequestorTasksState.removeEventListener(
|
||||
"tasksChange",
|
||||
onTasksChange,
|
||||
);
|
||||
};
|
||||
}, [managedRequestorTasksState]);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Task[]> => {
|
||||
if (!managedRequestorTasksState || !client) return [];
|
||||
const next = await managedRequestorTasksState.refresh();
|
||||
setTasks(next);
|
||||
return next;
|
||||
if (!managedRequestorTasksState || !client) return NO_TASKS;
|
||||
// The store dispatches `tasksChange` as it commits the new list, so the
|
||||
// snapshot above updates on its own — nothing local to push it into.
|
||||
return managedRequestorTasksState.refresh();
|
||||
}, [client, managedRequestorTasksState]);
|
||||
|
||||
const clearCompleted = useCallback((): void => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type {
|
||||
ManagedResourceTemplatesState,
|
||||
ManagedResourceTemplatesStateEventMap,
|
||||
} from "../mcp/state/managedResourceTemplatesState.js";
|
||||
import type { ManagedResourceTemplatesState } from "../mcp/state/managedResourceTemplatesState.js";
|
||||
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
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_RESOURCE_TEMPLATES: ResourceTemplate[] = [];
|
||||
|
||||
const readResourceTemplates = (
|
||||
state: ManagedResourceTemplatesState,
|
||||
): ResourceTemplate[] => state.getResourceTemplates();
|
||||
|
||||
export interface UseManagedResourceTemplatesResult {
|
||||
/**
|
||||
|
|
@ -27,49 +35,25 @@ export function useManagedResourceTemplates(
|
|||
client: InspectorClientProtocol | null,
|
||||
managedResourceTemplatesState: ManagedResourceTemplatesState | null,
|
||||
): UseManagedResourceTemplatesResult {
|
||||
const [resourceTemplates, setResourceTemplates] = useState<
|
||||
ResourceTemplate[]
|
||||
>(managedResourceTemplatesState?.getResourceTemplates() ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!managedResourceTemplatesState) {
|
||||
setResourceTemplates([]);
|
||||
return;
|
||||
}
|
||||
setResourceTemplates(managedResourceTemplatesState.getResourceTemplates());
|
||||
const onResourceTemplatesChange = (
|
||||
event: TypedEventGeneric<
|
||||
ManagedResourceTemplatesStateEventMap,
|
||||
"resourceTemplatesChange"
|
||||
>,
|
||||
) => {
|
||||
setResourceTemplates(event.detail);
|
||||
};
|
||||
managedResourceTemplatesState.addEventListener(
|
||||
"resourceTemplatesChange",
|
||||
onResourceTemplatesChange,
|
||||
);
|
||||
return () => {
|
||||
managedResourceTemplatesState.removeEventListener(
|
||||
"resourceTemplatesChange",
|
||||
onResourceTemplatesChange,
|
||||
);
|
||||
};
|
||||
}, [managedResourceTemplatesState]);
|
||||
const resourceTemplates = useStoreSnapshot(
|
||||
managedResourceTemplatesState,
|
||||
"resourceTemplatesChange",
|
||||
readResourceTemplates,
|
||||
NO_RESOURCE_TEMPLATES,
|
||||
);
|
||||
|
||||
const error = useListError(managedResourceTemplatesState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<ResourceTemplate[]> => {
|
||||
if (!managedResourceTemplatesState || !client) return [];
|
||||
if (!managedResourceTemplatesState || !client) {
|
||||
return NO_RESOURCE_TEMPLATES;
|
||||
}
|
||||
// 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.
|
||||
const next = await managedResourceTemplatesState.refresh(
|
||||
undefined,
|
||||
"refresh",
|
||||
);
|
||||
setResourceTemplates(next);
|
||||
return next;
|
||||
// return stale — and re-stores the fresh aggregate. The store dispatches
|
||||
// `resourceTemplatesChange` as it commits, so the snapshot updates on its
|
||||
// own.
|
||||
return managedResourceTemplatesState.refresh(undefined, "refresh");
|
||||
}, [client, managedResourceTemplatesState]);
|
||||
|
||||
return { resourceTemplates, error, refresh };
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type {
|
||||
ManagedResourcesState,
|
||||
ManagedResourcesStateEventMap,
|
||||
} from "../mcp/state/managedResourcesState.js";
|
||||
import type { ManagedResourcesState } from "../mcp/state/managedResourcesState.js";
|
||||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
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_RESOURCES: Resource[] = [];
|
||||
|
||||
const readResources = (state: ManagedResourcesState): Resource[] =>
|
||||
state.getResources();
|
||||
const readListChanged = (state: ManagedResourcesState): boolean =>
|
||||
state.getListChanged();
|
||||
|
||||
export interface UseManagedResourcesResult {
|
||||
/**
|
||||
|
|
@ -32,61 +41,23 @@ export function useManagedResources(
|
|||
client: InspectorClientProtocol | null,
|
||||
managedResourcesState: ManagedResourcesState | null,
|
||||
): UseManagedResourcesResult {
|
||||
const [resources, setResources] = useState<Resource[]>(
|
||||
managedResourcesState?.getResources() ?? [],
|
||||
const resources = useStoreSnapshot(
|
||||
managedResourcesState,
|
||||
"resourcesChange",
|
||||
readResources,
|
||||
NO_RESOURCES,
|
||||
);
|
||||
const [listChanged, setListChanged] = useState<boolean>(
|
||||
managedResourcesState?.getListChanged() ?? false,
|
||||
const listChanged = useStoreSnapshot(
|
||||
managedResourcesState,
|
||||
"listChangedChange",
|
||||
readListChanged,
|
||||
false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!managedResourcesState) {
|
||||
setResources([]);
|
||||
setListChanged(false);
|
||||
return;
|
||||
}
|
||||
setResources(managedResourcesState.getResources());
|
||||
setListChanged(managedResourcesState.getListChanged());
|
||||
const onResourcesChange = (
|
||||
event: TypedEventGeneric<
|
||||
ManagedResourcesStateEventMap,
|
||||
"resourcesChange"
|
||||
>,
|
||||
) => {
|
||||
setResources(event.detail);
|
||||
};
|
||||
const onListChangedChange = (
|
||||
event: TypedEventGeneric<
|
||||
ManagedResourcesStateEventMap,
|
||||
"listChangedChange"
|
||||
>,
|
||||
) => {
|
||||
setListChanged(event.detail);
|
||||
};
|
||||
managedResourcesState.addEventListener(
|
||||
"resourcesChange",
|
||||
onResourcesChange,
|
||||
);
|
||||
managedResourcesState.addEventListener(
|
||||
"listChangedChange",
|
||||
onListChangedChange,
|
||||
);
|
||||
return () => {
|
||||
managedResourcesState.removeEventListener(
|
||||
"resourcesChange",
|
||||
onResourcesChange,
|
||||
);
|
||||
managedResourcesState.removeEventListener(
|
||||
"listChangedChange",
|
||||
onListChangedChange,
|
||||
);
|
||||
};
|
||||
}, [managedResourcesState]);
|
||||
|
||||
const error = useListError(managedResourcesState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Resource[]> => {
|
||||
if (!managedResourcesState || !client) return [];
|
||||
if (!managedResourcesState || !client) return NO_RESOURCES;
|
||||
// A user-initiated refresh acknowledges the change — clear the indicator
|
||||
// BEFORE awaiting the fetch, not after. If a `resources/list_changed`
|
||||
// arrives mid-fetch, the state re-sets the flag (and auto-refreshes);
|
||||
|
|
@ -95,10 +66,9 @@ export function useManagedResources(
|
|||
managedResourcesState.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.
|
||||
const next = await managedResourcesState.refresh(undefined, "refresh");
|
||||
setResources(next);
|
||||
return next;
|
||||
// return stale — and re-stores the fresh aggregate. The store dispatches
|
||||
// `resourcesChange` as it commits, so the snapshot updates on its own.
|
||||
return managedResourcesState.refresh(undefined, "refresh");
|
||||
}, [client, managedResourcesState]);
|
||||
|
||||
const clearListChanged = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type { ManagedToolsState } from "../mcp/state/managedToolsState.js";
|
||||
import type { ManagedToolsStateEventMap } from "../mcp/state/managedToolsState.js";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
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_TOOLS: Tool[] = [];
|
||||
|
||||
const readTools = (state: ManagedToolsState): Tool[] => state.getTools();
|
||||
const readListChanged = (state: ManagedToolsState): boolean =>
|
||||
state.getListChanged();
|
||||
|
||||
export interface UseManagedToolsResult {
|
||||
/**
|
||||
|
|
@ -33,49 +43,23 @@ export function useManagedTools(
|
|||
client: InspectorClientProtocol | null,
|
||||
managedToolsState: ManagedToolsState | null,
|
||||
): UseManagedToolsResult {
|
||||
const [tools, setTools] = useState<Tool[]>(
|
||||
managedToolsState?.getTools() ?? [],
|
||||
const tools = useStoreSnapshot(
|
||||
managedToolsState,
|
||||
"toolsChange",
|
||||
readTools,
|
||||
NO_TOOLS,
|
||||
);
|
||||
const [listChanged, setListChanged] = useState<boolean>(
|
||||
managedToolsState?.getListChanged() ?? false,
|
||||
const listChanged = useStoreSnapshot(
|
||||
managedToolsState,
|
||||
"listChangedChange",
|
||||
readListChanged,
|
||||
false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!managedToolsState) {
|
||||
setTools([]);
|
||||
setListChanged(false);
|
||||
return;
|
||||
}
|
||||
setTools(managedToolsState.getTools());
|
||||
setListChanged(managedToolsState.getListChanged());
|
||||
const onToolsChange = (
|
||||
event: TypedEventGeneric<ManagedToolsStateEventMap, "toolsChange">,
|
||||
) => {
|
||||
setTools(event.detail);
|
||||
};
|
||||
const onListChangedChange = (
|
||||
event: TypedEventGeneric<ManagedToolsStateEventMap, "listChangedChange">,
|
||||
) => {
|
||||
setListChanged(event.detail);
|
||||
};
|
||||
managedToolsState.addEventListener("toolsChange", onToolsChange);
|
||||
managedToolsState.addEventListener(
|
||||
"listChangedChange",
|
||||
onListChangedChange,
|
||||
);
|
||||
return () => {
|
||||
managedToolsState.removeEventListener("toolsChange", onToolsChange);
|
||||
managedToolsState.removeEventListener(
|
||||
"listChangedChange",
|
||||
onListChangedChange,
|
||||
);
|
||||
};
|
||||
}, [managedToolsState]);
|
||||
|
||||
const error = useListError(managedToolsState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Tool[]> => {
|
||||
if (!managedToolsState || !client) return [];
|
||||
if (!managedToolsState || !client) return NO_TOOLS;
|
||||
// A user-initiated refresh acknowledges the change — clear the indicator
|
||||
// BEFORE awaiting the fetch, not after. If a `tools/list_changed` arrives
|
||||
// mid-fetch, the state re-sets the flag (and auto-refreshes); clearing
|
||||
|
|
@ -84,10 +68,10 @@ export function useManagedTools(
|
|||
managedToolsState.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.
|
||||
const next = await managedToolsState.refresh(undefined, "refresh");
|
||||
setTools(next);
|
||||
return next;
|
||||
// return stale — and re-stores the fresh aggregate. The store dispatches
|
||||
// `toolsChange` as it commits, so the snapshot above updates on its own;
|
||||
// there is no local state left to push the result into.
|
||||
return managedToolsState.refresh(undefined, "refresh");
|
||||
}, [client, managedToolsState]);
|
||||
|
||||
const clearListChanged = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import type { MessageEntry } from "../mcp/types.js";
|
||||
import type {
|
||||
MessageLogState,
|
||||
MessageLogStateEventMap,
|
||||
} from "../mcp/state/messageLogState.js";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import type { MessageLogState } from "../mcp/state/messageLogState.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_MESSAGES: MessageEntry[] = [];
|
||||
|
||||
const readMessages = (state: MessageLogState): MessageEntry[] =>
|
||||
state.getMessages();
|
||||
|
||||
export interface UseMessageLogResult {
|
||||
messages: MessageEntry[];
|
||||
|
|
@ -12,30 +18,22 @@ export interface UseMessageLogResult {
|
|||
|
||||
/**
|
||||
* React hook that subscribes to MessageLogState and returns the message list.
|
||||
*
|
||||
* Note this log's changes are not all appends: folding a response into its
|
||||
* request entry mutates that entry in place and dispatches. The snapshot is
|
||||
* cached against the store's dispatch count rather than the list's contents
|
||||
* precisely so that case still produces a new list identity and re-renders —
|
||||
* see `TypedEventTarget.getEventRevision`.
|
||||
*/
|
||||
export function useMessageLog(
|
||||
messageLogState: MessageLogState | null,
|
||||
): UseMessageLogResult {
|
||||
const [messages, setMessages] = useState<MessageEntry[]>(
|
||||
messageLogState?.getMessages() ?? [],
|
||||
const messages = useStoreSnapshot(
|
||||
messageLogState,
|
||||
"messagesChange",
|
||||
readMessages,
|
||||
NO_MESSAGES,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageLogState) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
setMessages(messageLogState.getMessages());
|
||||
const onMessagesChange = (
|
||||
event: TypedEventGeneric<MessageLogStateEventMap, "messagesChange">,
|
||||
) => {
|
||||
setMessages(event.detail);
|
||||
};
|
||||
messageLogState.addEventListener("messagesChange", onMessagesChange);
|
||||
return () => {
|
||||
messageLogState.removeEventListener("messagesChange", onMessagesChange);
|
||||
};
|
||||
}, [messageLogState]);
|
||||
|
||||
return { messages };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,26 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type { RequestMetadata } from "../mcp/types.js";
|
||||
import type {
|
||||
PagedPromptsState,
|
||||
PagedPromptsStateEventMap,
|
||||
LoadPageResult,
|
||||
} from "../mcp/state/pagedPromptsState.js";
|
||||
import type { PagePaginationState } from "../mcp/state/pagedToolsState.js";
|
||||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
import { NO_PAGINATION } from "./pagination.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: PagedPromptsState): Prompt[] => state.getPrompts();
|
||||
const readPagination = (state: PagedPromptsState): PagePaginationState =>
|
||||
state.getPagination();
|
||||
|
||||
export interface UsePagedPromptsResult {
|
||||
prompts: Prompt[];
|
||||
|
|
@ -38,47 +50,18 @@ export function usePagedPrompts(
|
|||
client: InspectorClientProtocol | null,
|
||||
pagedPromptsState: PagedPromptsState | null,
|
||||
): UsePagedPromptsResult {
|
||||
const [prompts, setPrompts] = useState<Prompt[]>(
|
||||
pagedPromptsState?.getPrompts() ?? [],
|
||||
const prompts = useStoreSnapshot(
|
||||
pagedPromptsState,
|
||||
"promptsChange",
|
||||
readPrompts,
|
||||
NO_PROMPTS,
|
||||
);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(
|
||||
pagedPromptsState?.getPagination().nextCursor,
|
||||
const { nextCursor, pageCount } = useStoreSnapshot(
|
||||
pagedPromptsState,
|
||||
"paginationChange",
|
||||
readPagination,
|
||||
NO_PAGINATION,
|
||||
);
|
||||
const [pageCount, setPageCount] = useState<number>(
|
||||
pagedPromptsState?.getPagination().pageCount ?? 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pagedPromptsState) {
|
||||
setPrompts([]);
|
||||
setNextCursor(undefined);
|
||||
setPageCount(0);
|
||||
return;
|
||||
}
|
||||
setPrompts(pagedPromptsState.getPrompts());
|
||||
setNextCursor(pagedPromptsState.getPagination().nextCursor);
|
||||
setPageCount(pagedPromptsState.getPagination().pageCount);
|
||||
const onPromptsChange = (
|
||||
event: TypedEventGeneric<PagedPromptsStateEventMap, "promptsChange">,
|
||||
) => {
|
||||
setPrompts(event.detail);
|
||||
};
|
||||
const onPaginationChange = (
|
||||
event: TypedEventGeneric<PagedPromptsStateEventMap, "paginationChange">,
|
||||
) => {
|
||||
setNextCursor(event.detail.nextCursor);
|
||||
setPageCount(event.detail.pageCount);
|
||||
};
|
||||
pagedPromptsState.addEventListener("promptsChange", onPromptsChange);
|
||||
pagedPromptsState.addEventListener("paginationChange", onPaginationChange);
|
||||
return () => {
|
||||
pagedPromptsState.removeEventListener("promptsChange", onPromptsChange);
|
||||
pagedPromptsState.removeEventListener(
|
||||
"paginationChange",
|
||||
onPaginationChange,
|
||||
);
|
||||
};
|
||||
}, [pagedPromptsState]);
|
||||
|
||||
const error = useListError(pagedPromptsState);
|
||||
|
||||
|
|
@ -88,7 +71,7 @@ export function usePagedPrompts(
|
|||
metadata?: RequestMetadata,
|
||||
): Promise<LoadPageResult> => {
|
||||
if (!pagedPromptsState || !client) {
|
||||
return { prompts: [], nextCursor: undefined };
|
||||
return { prompts: NO_PROMPTS, nextCursor: undefined };
|
||||
}
|
||||
return pagedPromptsState.loadPage(cursor, metadata);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type {
|
||||
PagedRequestorTasksState,
|
||||
PagedRequestorTasksStateEventMap,
|
||||
LoadPageResult,
|
||||
} from "../mcp/state/pagedRequestorTasksState.js";
|
||||
import type { Task } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.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_TASKS: Task[] = [];
|
||||
|
||||
const readTasks = (state: PagedRequestorTasksState): Task[] => state.getTasks();
|
||||
// This store has no dedicated pagination event — the cursor advances with the
|
||||
// page, so it rides `tasksChange` like the list does.
|
||||
const readNextCursor = (state: PagedRequestorTasksState): string | undefined =>
|
||||
state.getNextCursor() ?? undefined;
|
||||
|
||||
export interface UsePagedRequestorTasksResult {
|
||||
tasks: Task[];
|
||||
|
|
@ -23,45 +35,27 @@ export function usePagedRequestorTasks(
|
|||
client: InspectorClientProtocol | null,
|
||||
pagedRequestorTasksState: PagedRequestorTasksState | null,
|
||||
): UsePagedRequestorTasksResult {
|
||||
const [tasks, setTasks] = useState<Task[]>(
|
||||
pagedRequestorTasksState?.getTasks() ?? [],
|
||||
const tasks = useStoreSnapshot(
|
||||
pagedRequestorTasksState,
|
||||
"tasksChange",
|
||||
readTasks,
|
||||
NO_TASKS,
|
||||
);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(
|
||||
pagedRequestorTasksState?.getNextCursor() ?? undefined,
|
||||
const nextCursor = useStoreSnapshot(
|
||||
pagedRequestorTasksState,
|
||||
"tasksChange",
|
||||
readNextCursor,
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pagedRequestorTasksState) {
|
||||
setTasks([]);
|
||||
setNextCursor(undefined);
|
||||
return;
|
||||
}
|
||||
setTasks(pagedRequestorTasksState.getTasks());
|
||||
setNextCursor(pagedRequestorTasksState.getNextCursor() ?? undefined);
|
||||
const onTasksChange = (
|
||||
event: TypedEventGeneric<PagedRequestorTasksStateEventMap, "tasksChange">,
|
||||
) => {
|
||||
setTasks(event.detail);
|
||||
setNextCursor(pagedRequestorTasksState.getNextCursor() ?? undefined);
|
||||
};
|
||||
pagedRequestorTasksState.addEventListener("tasksChange", onTasksChange);
|
||||
return () => {
|
||||
pagedRequestorTasksState.removeEventListener(
|
||||
"tasksChange",
|
||||
onTasksChange,
|
||||
);
|
||||
};
|
||||
}, [pagedRequestorTasksState]);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (cursor?: string): Promise<LoadPageResult> => {
|
||||
if (!pagedRequestorTasksState || !client) {
|
||||
return { tasks: [], nextCursor: undefined };
|
||||
return { tasks: NO_TASKS, nextCursor: undefined };
|
||||
}
|
||||
const result = await pagedRequestorTasksState.loadPage(cursor);
|
||||
setTasks(pagedRequestorTasksState.getTasks());
|
||||
setNextCursor(pagedRequestorTasksState.getNextCursor() ?? undefined);
|
||||
return result;
|
||||
// The store dispatches `tasksChange` as it commits the page, so both
|
||||
// snapshots above update on their own.
|
||||
return pagedRequestorTasksState.loadPage(cursor);
|
||||
},
|
||||
[client, pagedRequestorTasksState],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type { RequestMetadata } from "../mcp/types.js";
|
||||
import type {
|
||||
PagedResourceTemplatesState,
|
||||
PagedResourceTemplatesStateEventMap,
|
||||
LoadPageResult,
|
||||
} from "../mcp/state/pagedResourceTemplatesState.js";
|
||||
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.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_RESOURCE_TEMPLATES: ResourceTemplate[] = [];
|
||||
|
||||
const readResourceTemplates = (
|
||||
state: PagedResourceTemplatesState,
|
||||
): ResourceTemplate[] => state.getResourceTemplates();
|
||||
|
||||
export interface UsePagedResourceTemplatesResult {
|
||||
resourceTemplates: ResourceTemplate[];
|
||||
|
|
@ -26,35 +36,12 @@ export function usePagedResourceTemplates(
|
|||
client: InspectorClientProtocol | null,
|
||||
pagedResourceTemplatesState: PagedResourceTemplatesState | null,
|
||||
): UsePagedResourceTemplatesResult {
|
||||
const [resourceTemplates, setResourceTemplates] = useState<
|
||||
ResourceTemplate[]
|
||||
>(pagedResourceTemplatesState?.getResourceTemplates() ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pagedResourceTemplatesState) {
|
||||
setResourceTemplates([]);
|
||||
return;
|
||||
}
|
||||
setResourceTemplates(pagedResourceTemplatesState.getResourceTemplates());
|
||||
const onResourceTemplatesChange = (
|
||||
event: TypedEventGeneric<
|
||||
PagedResourceTemplatesStateEventMap,
|
||||
"resourceTemplatesChange"
|
||||
>,
|
||||
) => {
|
||||
setResourceTemplates(event.detail);
|
||||
};
|
||||
pagedResourceTemplatesState.addEventListener(
|
||||
"resourceTemplatesChange",
|
||||
onResourceTemplatesChange,
|
||||
);
|
||||
return () => {
|
||||
pagedResourceTemplatesState.removeEventListener(
|
||||
"resourceTemplatesChange",
|
||||
onResourceTemplatesChange,
|
||||
);
|
||||
};
|
||||
}, [pagedResourceTemplatesState]);
|
||||
const resourceTemplates = useStoreSnapshot(
|
||||
pagedResourceTemplatesState,
|
||||
"resourceTemplatesChange",
|
||||
readResourceTemplates,
|
||||
NO_RESOURCE_TEMPLATES,
|
||||
);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (
|
||||
|
|
@ -62,14 +49,14 @@ export function usePagedResourceTemplates(
|
|||
metadata?: RequestMetadata,
|
||||
): Promise<LoadPageResult> => {
|
||||
if (!pagedResourceTemplatesState || !client) {
|
||||
return { resourceTemplates: [], nextCursor: undefined };
|
||||
return {
|
||||
resourceTemplates: NO_RESOURCE_TEMPLATES,
|
||||
nextCursor: undefined,
|
||||
};
|
||||
}
|
||||
const result = await pagedResourceTemplatesState.loadPage(
|
||||
cursor,
|
||||
metadata,
|
||||
);
|
||||
setResourceTemplates(pagedResourceTemplatesState.getResourceTemplates());
|
||||
return result;
|
||||
// The store dispatches `resourceTemplatesChange` as it commits the page,
|
||||
// so the snapshot above updates on its own.
|
||||
return pagedResourceTemplatesState.loadPage(cursor, metadata);
|
||||
},
|
||||
[client, pagedResourceTemplatesState],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type { RequestMetadata } from "../mcp/types.js";
|
||||
import type {
|
||||
PagedResourcesState,
|
||||
PagedResourcesStateEventMap,
|
||||
LoadPageResult,
|
||||
} from "../mcp/state/pagedResourcesState.js";
|
||||
import type { PagePaginationState } from "../mcp/state/pagedToolsState.js";
|
||||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
import { NO_PAGINATION } from "./pagination.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_RESOURCES: Resource[] = [];
|
||||
|
||||
const readResources = (state: PagedResourcesState): Resource[] =>
|
||||
state.getResources();
|
||||
const readPagination = (state: PagedResourcesState): PagePaginationState =>
|
||||
state.getPagination();
|
||||
|
||||
export interface UsePagedResourcesResult {
|
||||
resources: Resource[];
|
||||
|
|
@ -38,53 +51,18 @@ export function usePagedResources(
|
|||
client: InspectorClientProtocol | null,
|
||||
pagedResourcesState: PagedResourcesState | null,
|
||||
): UsePagedResourcesResult {
|
||||
const [resources, setResources] = useState<Resource[]>(
|
||||
pagedResourcesState?.getResources() ?? [],
|
||||
const resources = useStoreSnapshot(
|
||||
pagedResourcesState,
|
||||
"resourcesChange",
|
||||
readResources,
|
||||
NO_RESOURCES,
|
||||
);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(
|
||||
pagedResourcesState?.getPagination().nextCursor,
|
||||
const { nextCursor, pageCount } = useStoreSnapshot(
|
||||
pagedResourcesState,
|
||||
"paginationChange",
|
||||
readPagination,
|
||||
NO_PAGINATION,
|
||||
);
|
||||
const [pageCount, setPageCount] = useState<number>(
|
||||
pagedResourcesState?.getPagination().pageCount ?? 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pagedResourcesState) {
|
||||
setResources([]);
|
||||
setNextCursor(undefined);
|
||||
setPageCount(0);
|
||||
return;
|
||||
}
|
||||
setResources(pagedResourcesState.getResources());
|
||||
setNextCursor(pagedResourcesState.getPagination().nextCursor);
|
||||
setPageCount(pagedResourcesState.getPagination().pageCount);
|
||||
const onResourcesChange = (
|
||||
event: TypedEventGeneric<PagedResourcesStateEventMap, "resourcesChange">,
|
||||
) => {
|
||||
setResources(event.detail);
|
||||
};
|
||||
const onPaginationChange = (
|
||||
event: TypedEventGeneric<PagedResourcesStateEventMap, "paginationChange">,
|
||||
) => {
|
||||
setNextCursor(event.detail.nextCursor);
|
||||
setPageCount(event.detail.pageCount);
|
||||
};
|
||||
pagedResourcesState.addEventListener("resourcesChange", onResourcesChange);
|
||||
pagedResourcesState.addEventListener(
|
||||
"paginationChange",
|
||||
onPaginationChange,
|
||||
);
|
||||
return () => {
|
||||
pagedResourcesState.removeEventListener(
|
||||
"resourcesChange",
|
||||
onResourcesChange,
|
||||
);
|
||||
pagedResourcesState.removeEventListener(
|
||||
"paginationChange",
|
||||
onPaginationChange,
|
||||
);
|
||||
};
|
||||
}, [pagedResourcesState]);
|
||||
|
||||
const error = useListError(pagedResourcesState);
|
||||
|
||||
|
|
@ -94,7 +72,7 @@ export function usePagedResources(
|
|||
metadata?: RequestMetadata,
|
||||
): Promise<LoadPageResult> => {
|
||||
if (!pagedResourcesState || !client) {
|
||||
return { resources: [], nextCursor: undefined };
|
||||
return { resources: NO_RESOURCES, nextCursor: undefined };
|
||||
}
|
||||
return pagedResourcesState.loadPage(cursor, metadata);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type {
|
||||
PagedToolsState,
|
||||
PagedToolsStateEventMap,
|
||||
PagePaginationState,
|
||||
LoadPageResult,
|
||||
} from "../mcp/state/pagedToolsState.js";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
import { NO_PAGINATION } from "./pagination.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_TOOLS: Tool[] = [];
|
||||
|
||||
const readTools = (state: PagedToolsState): Tool[] => state.getTools();
|
||||
const readPagination = (state: PagedToolsState): PagePaginationState =>
|
||||
state.getPagination();
|
||||
|
||||
export interface UsePagedToolsResult {
|
||||
tools: Tool[];
|
||||
|
|
@ -35,52 +47,25 @@ export function usePagedTools(
|
|||
client: InspectorClientProtocol | null,
|
||||
pagedToolsState: PagedToolsState | null,
|
||||
): UsePagedToolsResult {
|
||||
const [tools, setTools] = useState<Tool[]>(pagedToolsState?.getTools() ?? []);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(
|
||||
pagedToolsState?.getPagination().nextCursor,
|
||||
const tools = useStoreSnapshot(
|
||||
pagedToolsState,
|
||||
"toolsChange",
|
||||
readTools,
|
||||
NO_TOOLS,
|
||||
);
|
||||
const [pageCount, setPageCount] = useState<number>(
|
||||
pagedToolsState?.getPagination().pageCount ?? 0,
|
||||
const { nextCursor, pageCount } = useStoreSnapshot(
|
||||
pagedToolsState,
|
||||
"paginationChange",
|
||||
readPagination,
|
||||
NO_PAGINATION,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pagedToolsState) {
|
||||
setTools([]);
|
||||
setNextCursor(undefined);
|
||||
setPageCount(0);
|
||||
return;
|
||||
}
|
||||
setTools(pagedToolsState.getTools());
|
||||
setNextCursor(pagedToolsState.getPagination().nextCursor);
|
||||
setPageCount(pagedToolsState.getPagination().pageCount);
|
||||
const onToolsChange = (
|
||||
event: TypedEventGeneric<PagedToolsStateEventMap, "toolsChange">,
|
||||
) => {
|
||||
setTools(event.detail);
|
||||
};
|
||||
const onPaginationChange = (
|
||||
event: TypedEventGeneric<PagedToolsStateEventMap, "paginationChange">,
|
||||
) => {
|
||||
setNextCursor(event.detail.nextCursor);
|
||||
setPageCount(event.detail.pageCount);
|
||||
};
|
||||
pagedToolsState.addEventListener("toolsChange", onToolsChange);
|
||||
pagedToolsState.addEventListener("paginationChange", onPaginationChange);
|
||||
return () => {
|
||||
pagedToolsState.removeEventListener("toolsChange", onToolsChange);
|
||||
pagedToolsState.removeEventListener(
|
||||
"paginationChange",
|
||||
onPaginationChange,
|
||||
);
|
||||
};
|
||||
}, [pagedToolsState]);
|
||||
|
||||
const error = useListError(pagedToolsState);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (cursor?: string): Promise<LoadPageResult> => {
|
||||
if (!pagedToolsState || !client) {
|
||||
return { tools: [], nextCursor: undefined };
|
||||
return { tools: NO_TOOLS, nextCursor: undefined };
|
||||
}
|
||||
return pagedToolsState.loadPage(cursor);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,25 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type { SamplingCreateMessage } from "../mcp/samplingCreateMessage.js";
|
||||
import type { ElicitationCreateMessage } from "../mcp/elicitationCreateMessage.js";
|
||||
import type { TypedEvent } from "../mcp/inspectorClientEventTarget.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[];
|
||||
|
|
@ -21,47 +38,18 @@ export interface UsePendingClientRequestsResult {
|
|||
export function usePendingClientRequests(
|
||||
inspectorClient: InspectorClientProtocol | null,
|
||||
): UsePendingClientRequestsResult {
|
||||
const [pendingSamples, setPendingSamples] = useState<SamplingCreateMessage[]>(
|
||||
inspectorClient?.getPendingSamples() ?? [],
|
||||
const pendingSamples = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"pendingSamplesChange",
|
||||
readPendingSamples,
|
||||
NO_SAMPLES,
|
||||
);
|
||||
const pendingElicitations = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"pendingElicitationsChange",
|
||||
readPendingElicitations,
|
||||
NO_ELICITATIONS,
|
||||
);
|
||||
const [pendingElicitations, setPendingElicitations] = useState<
|
||||
ElicitationCreateMessage[]
|
||||
>(inspectorClient?.getPendingElicitations() ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inspectorClient) {
|
||||
setPendingSamples([]);
|
||||
setPendingElicitations([]);
|
||||
return;
|
||||
}
|
||||
setPendingSamples(inspectorClient.getPendingSamples());
|
||||
setPendingElicitations(inspectorClient.getPendingElicitations());
|
||||
|
||||
const onSamplesChange = (event: TypedEvent<"pendingSamplesChange">) => {
|
||||
setPendingSamples([...event.detail]);
|
||||
};
|
||||
const onElicitationsChange = (
|
||||
event: TypedEvent<"pendingElicitationsChange">,
|
||||
) => {
|
||||
setPendingElicitations([...event.detail]);
|
||||
};
|
||||
|
||||
inspectorClient.addEventListener("pendingSamplesChange", onSamplesChange);
|
||||
inspectorClient.addEventListener(
|
||||
"pendingElicitationsChange",
|
||||
onElicitationsChange,
|
||||
);
|
||||
return () => {
|
||||
inspectorClient.removeEventListener(
|
||||
"pendingSamplesChange",
|
||||
onSamplesChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"pendingElicitationsChange",
|
||||
onElicitationsChange,
|
||||
);
|
||||
};
|
||||
}, [inspectorClient]);
|
||||
|
||||
return { pendingSamples, pendingElicitations };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import type {
|
||||
ResourceSubscriptionsState,
|
||||
ResourceSubscriptionsStateEventMap,
|
||||
} from "../mcp/state/resourceSubscriptionsState.js";
|
||||
import type { ResourceSubscriptionsState } from "../mcp/state/resourceSubscriptionsState.js";
|
||||
import type {
|
||||
InspectorResourceSubscription,
|
||||
ResourceSubscriptionStreamState,
|
||||
} from "../mcp/types.js";
|
||||
import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../mcp/types.js";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.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_SUBSCRIPTIONS: InspectorResourceSubscription[] = [];
|
||||
|
||||
const readSubscriptions = (
|
||||
state: ResourceSubscriptionsState,
|
||||
): InspectorResourceSubscription[] => state.getSubscriptions();
|
||||
const readStreamState = (
|
||||
state: ResourceSubscriptionsState,
|
||||
): ResourceSubscriptionStreamState => state.getStreamState();
|
||||
|
||||
export interface UseResourceSubscriptionsResult {
|
||||
subscriptions: InspectorResourceSubscription[];
|
||||
|
|
@ -29,45 +39,18 @@ export interface UseResourceSubscriptionsResult {
|
|||
export function useResourceSubscriptions(
|
||||
state: ResourceSubscriptionsState | null,
|
||||
): UseResourceSubscriptionsResult {
|
||||
const [subscriptions, setSubscriptions] = useState<
|
||||
InspectorResourceSubscription[]
|
||||
>(state?.getSubscriptions() ?? []);
|
||||
const [streamState, setStreamState] =
|
||||
useState<ResourceSubscriptionStreamState>(
|
||||
state?.getStreamState() ?? INACTIVE_SUBSCRIPTION_STREAM_STATE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) {
|
||||
setSubscriptions([]);
|
||||
setStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE);
|
||||
return;
|
||||
}
|
||||
setSubscriptions(state.getSubscriptions());
|
||||
setStreamState(state.getStreamState());
|
||||
const onSubscriptionsChange = (
|
||||
event: TypedEventGeneric<
|
||||
ResourceSubscriptionsStateEventMap,
|
||||
"subscriptionsChange"
|
||||
>,
|
||||
) => {
|
||||
setSubscriptions(event.detail);
|
||||
};
|
||||
const onStreamStateChange = (
|
||||
event: TypedEventGeneric<
|
||||
ResourceSubscriptionsStateEventMap,
|
||||
"streamStateChange"
|
||||
>,
|
||||
) => {
|
||||
setStreamState(event.detail);
|
||||
};
|
||||
state.addEventListener("subscriptionsChange", onSubscriptionsChange);
|
||||
state.addEventListener("streamStateChange", onStreamStateChange);
|
||||
return () => {
|
||||
state.removeEventListener("subscriptionsChange", onSubscriptionsChange);
|
||||
state.removeEventListener("streamStateChange", onStreamStateChange);
|
||||
};
|
||||
}, [state]);
|
||||
const subscriptions = useStoreSnapshot(
|
||||
state,
|
||||
"subscriptionsChange",
|
||||
readSubscriptions,
|
||||
NO_SUBSCRIPTIONS,
|
||||
);
|
||||
const streamState = useStoreSnapshot(
|
||||
state,
|
||||
"streamStateChange",
|
||||
readStreamState,
|
||||
INACTIVE_SUBSCRIPTION_STREAM_STATE,
|
||||
);
|
||||
|
||||
return { subscriptions, streamState };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import type { StderrLogEntry } from "../mcp/types.js";
|
||||
import type {
|
||||
StderrLogState,
|
||||
StderrLogStateEventMap,
|
||||
} from "../mcp/state/stderrLogState.js";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import type { StderrLogState } from "../mcp/state/stderrLogState.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_STDERR_LOGS: StderrLogEntry[] = [];
|
||||
|
||||
const readStderrLogs = (state: StderrLogState): StderrLogEntry[] =>
|
||||
state.getStderrLogs();
|
||||
|
||||
export interface UseStderrLogResult {
|
||||
stderrLogs: StderrLogEntry[];
|
||||
|
|
@ -16,29 +22,12 @@ export interface UseStderrLogResult {
|
|||
export function useStderrLog(
|
||||
stderrLogState: StderrLogState | null,
|
||||
): UseStderrLogResult {
|
||||
const [stderrLogs, setStderrLogs] = useState<StderrLogEntry[]>(
|
||||
stderrLogState?.getStderrLogs() ?? [],
|
||||
const stderrLogs = useStoreSnapshot(
|
||||
stderrLogState,
|
||||
"stderrLogsChange",
|
||||
readStderrLogs,
|
||||
NO_STDERR_LOGS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stderrLogState) {
|
||||
setStderrLogs([]);
|
||||
return;
|
||||
}
|
||||
setStderrLogs(stderrLogState.getStderrLogs());
|
||||
const onStderrLogsChange = (
|
||||
event: TypedEventGeneric<StderrLogStateEventMap, "stderrLogsChange">,
|
||||
) => {
|
||||
setStderrLogs(event.detail);
|
||||
};
|
||||
stderrLogState.addEventListener("stderrLogsChange", onStderrLogsChange);
|
||||
return () => {
|
||||
stderrLogState.removeEventListener(
|
||||
"stderrLogsChange",
|
||||
onStderrLogsChange,
|
||||
);
|
||||
};
|
||||
}, [stderrLogState]);
|
||||
|
||||
return { stderrLogs };
|
||||
}
|
||||
|
|
|
|||
138
core/react/useStoreSnapshot.ts
Normal file
138
core/react/useStoreSnapshot.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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 **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.
|
||||
*
|
||||
* 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
|
||||
* @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);
|
||||
}
|
||||
Loading…
Reference in a new issue