Merge branch 'v2/main' into v2/chore/2163-agent-skills
Three conflicts, all in the files this PR refactored. The code from #1955 and #2161 (core/react's useSyncExternalStore conversion) merged cleanly. - `.github/copilot-instructions.md` — modify/delete. This PR deletes it, and the four lines v2/main added there are a distillation of AGENTS.md rules that arrive in AGENTS.md anyway. Kept deleted. - `AGENTS.md` — the incoming change expanded the `core/react/` entry in the project tree by eleven lines; this PR compacted that tree to one line per entry on purpose. Took the compact tree and carried the substance onto its one line. The other incoming hunk — the store-subscription rules under React instructions — merged cleanly, which is the split working: it is a RULE, so it lands in the section that stayed. - `README.md` — same shape, one tree line. This README no longer enumerates `core/`'s subdirectories at all, so the substance went where that content now lives: `docs/architecture.md` and the `project-structure` skill's core table. Nothing from v2/main was dropped; every incoming statement is now in exactly one place rather than the three it used to occupy. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
commit
67abdf96b4
27 changed files with 1126 additions and 803 deletions
|
|
@ -45,7 +45,7 @@ an MCP server, the request/response lifecycle, and a set of state stores.
|
|||
| `core/auth/node/` | Node OAuth storage + loopback callback server, **and** the `SecretStore` backends (keychain / file / memory) and their selection policy |
|
||||
| `core/client/` | Install-level client config (`client.json`): browser-safe parse plus Node load/save, remote backend, secrets, runner |
|
||||
| `core/json/` | JSON + parameter/argument conversion; the schema normalizations all three form builders share (nullable unions, root composition) and the tool-schema portability lint |
|
||||
| `core/react/` | React hooks over the state stores — consumed by both the web and TUI React trees |
|
||||
| `core/react/` | React hooks over the state stores — consumed by both the web and TUI React trees. Every subscription reads its snapshot **during render** via `useSyncExternalStore` (#1955); `useStoreSnapshot.ts` caches the fresh-value-per-read getters |
|
||||
| `core/node/` | Node-only helpers: version reader, host normalization/detection |
|
||||
| `core/storage/` | File I/O helpers used by the OAuth persist backends |
|
||||
| `core/logging/` | Silent pino logger singleton |
|
||||
|
|
|
|||
11
AGENTS.md
11
AGENTS.md
|
|
@ -49,7 +49,7 @@ inspector/
|
|||
│ ├── logging/ Silent pino logger singleton
|
||||
│ ├── mcp/ InspectorClient, transports, state stores, config import
|
||||
│ ├── node/ Node-only helpers (version reader, host normalization)
|
||||
│ ├── react/ React hooks over the state stores
|
||||
│ ├── react/ React hooks over the state stores (read during render — see React instructions)
|
||||
│ └── storage/ File I/O helpers for the OAuth persist backends
|
||||
├── test-servers/ Composable MCP test servers + JSON configs
|
||||
├── scripts/ Root build/verify tooling: install cascade, smokes, verify:* guards
|
||||
|
|
@ -383,6 +383,15 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage `
|
|||
- Use **`useValueChange(value, onChange)`** (`clients/web/src/hooks/useValueChange.ts`) instead. It is React's documented ["adjusting state during render"](https://react.dev/reference/react/useState#storing-information-from-previous-renders) pattern: it compares `value` against the previous render's with `Object.is` and calls `onChange(next)` during render, so React discards the in-progress output and re-runs the component before anything reaches the DOM. It does **not** fire on the first render — seed the dependent state with `useState` instead. Because the comparison is `Object.is`, the value you pass **must be referentially stable** across renders that mean "no change": prefer a primitive key derived from the data (an id, a name, a URI), and otherwise a memoized value. A fresh object/array literal would compare unequal every render and loop.
|
||||
- The `onChange` you pass runs **during render**, so it must be pure — `setState` calls and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks: a render can be replayed (StrictMode) or abandoned (concurrent React), so external work would run an unpredictable number of times.
|
||||
- An effect is still the right tool for genuine synchronization with an external system (DOM measurement, `requestAnimationFrame`, subscriptions, timers). The rule is about deriving React state from React props, not about effects in general. `NetworkEntry` shows the split: the reveal's force-open is a state update and uses `useValueChange`, while its `requestAnimationFrame` scroll stays a `useEffect`.
|
||||
- **Subscribing to an `@inspector/core` state store is `useSyncExternalStore`, never `useState` + a subscribing `useEffect`.** That second shape looks like the legitimate "synchronize with an external system" case above and is not, because it also *seeds and re-seeds local state from the store prop* — so it carries the same stale frame (switching servers paints the previous server's tools for one frame) plus a window where an event dispatched between the render and the effect is lost outright. Every hook in `core/react/` was converted away from it in #1955.
|
||||
- Reach for **`useStoreSnapshot(store, event, read, whenAbsent)`** (`core/react/useStoreSnapshot.ts`) when the getter returns a **fresh value per read** — a defensive copy (`getTools()` is `[...this.items]`) or a freshly built object (`getPagination()`). That is nearly all of them, and the caching it adds is what keeps `useSyncExternalStore` from looping. Call it once per value.
|
||||
- When a getter's value is **already referentially stable**, subscribe with `useSyncExternalStore` directly and skip the helper — `useListError` does, because its snapshot is the stored `Error` instance itself (or `null`). The rule is read-during-render, not "always use the helper".
|
||||
- Either way `useValueChange` is *not* the tool here — it lives in `clients/web/src`, and `core/react/` is consumed by the CLI and TUI too.
|
||||
- `read` and `whenAbsent` must be **referentially stable across renders** — they are part of the snapshot's cache key. `read` is a function, so declare it at module scope. `whenAbsent` only needs a module-scope constant when it is an **object or array** (`NO_TOOLS`, `NO_PAGINATION`); a primitive fallback (`false`, `undefined`, `"disconnected"`) is already stable under `Object.is` and is passed inline throughout these hooks.
|
||||
- An unstable one **fails quietly, so don't expect to be told**: measured on React 19, an inline `read` throws nothing, logs nothing — not even React's "getSnapshot should be cached" dev warning, whose double-call happens within a single render where the closure is unchanged — and forces no extra render. It simply returns a fresh value every render, defeating every downstream `useMemo` / `React.memo` / effect dep that keys on it.
|
||||
- The snapshot is cached against the store's **per-event dispatch counter** (`TypedEventTarget.getEventRevision`), which every dispatch advances automatically — not against the snapshot's contents. That is deliberate and load-bearing: these getters return a defensive copy, so contents are the only alternative, and a contents comparison cannot see a dispatch that mutated an entry the list already holds (`MessageLogState` folding a response into its request entry does exactly that). Don't "optimize" it into a shallow compare.
|
||||
- Consequently the **store is the source of truth and the event is only the signal** — the hook re-reads the store rather than taking the event's `detail`. A test that fires a store event must put the value on the store first; dispatching alone announces a change that isn't there. A hand-rolled fake must extend the real `TypedEventTarget` (a bare `EventTarget` has no `getEventRevision` and fails at runtime).
|
||||
- Genuinely local state accumulated from an event stream, with no getter to read it back from, stays `useState` — `useInspectorClient`'s `lastError` is the one such case. Reset it on a store swap **during render**, not in an effect.
|
||||
- Theme files vs. Storybook element components
|
||||
- **Theme files** (`src/theme/<Component>.ts`) and **element components** (`src/components/elements/`) serve different purposes and both are needed.
|
||||
- Theme files customize every instance of a Mantine component app-wide — defaults (size, radius), custom variants, and global style overrides. They are applied automatically by `MantineProvider`.
|
||||
|
|
|
|||
|
|
@ -44,12 +44,21 @@ const { messageLogClear } = vi.hoisted(() => ({ messageLogClear: vi.fn() }));
|
|||
// trigger the handlers). See #1368.
|
||||
|
||||
// --- Fake InspectorClient ---------------------------------------------------
|
||||
// Extends EventTarget so the App's `addEventListener("disconnect", …)` wiring
|
||||
// is real; the test fires `dispatchEvent(new Event("disconnect"))` to simulate
|
||||
// any of the three disconnect paths (toggle, header button, transport failure).
|
||||
// Extends the real TypedEventTarget so the App's `addEventListener("disconnect",
|
||||
// …)` wiring is real; the test fires `dispatchEvent(new Event("disconnect"))` to
|
||||
// simulate any of the three disconnect paths (toggle, header button, transport
|
||||
// failure).
|
||||
//
|
||||
// The real base rather than a bare EventTarget because the hooks this fake is
|
||||
// handed to read state through `useStoreSnapshot`, which caches its snapshot
|
||||
// against `getEventRevision` — a method the base owns and a bare EventTarget
|
||||
// does not have (#1955). Inheriting it also means the counter is advanced by
|
||||
// the `dispatchEvent` calls below, exactly as it would be in production.
|
||||
vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@inspector/core/mcp/index.js")>();
|
||||
const { TypedEventTarget } =
|
||||
await import("@inspector/core/mcp/typedEventTarget.js");
|
||||
// Each armed value makes one `connect()` reject, in FIFO order, so a test can
|
||||
// exercise the handshake-failure path — or a two-connect sequence such as the
|
||||
// auth-recovery retry, where the first call rejects with the recovery error
|
||||
|
|
@ -68,7 +77,9 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
|
|||
// control-flow decision from its resolving `false` (#2108): a throw is
|
||||
// surfaced as a failed attempt rather than falling through to the redirect.
|
||||
let nextChallengeCheckRejection: unknown = null;
|
||||
class FakeInspectorClient extends EventTarget {
|
||||
class FakeInspectorClient extends TypedEventTarget<
|
||||
import("@inspector/core/mcp/index.js").InspectorClientEventMap
|
||||
> {
|
||||
connect = vi.fn(() => {
|
||||
if (connectRejections.length > 0) {
|
||||
return Promise.reject(connectRejections.shift());
|
||||
|
|
@ -695,6 +706,50 @@ const clientInstances = (
|
|||
McpIndex as unknown as { __clientInstances: EventTarget[] }
|
||||
).__clientInstances;
|
||||
|
||||
/**
|
||||
* Mirror how the real client enqueues a server-initiated request: put it on the
|
||||
* queue, then announce the change.
|
||||
*
|
||||
* Both halves matter now that `usePendingClientRequests` reads the queue back
|
||||
* off the client (`useStoreSnapshot`, #1955) rather than taking the event's
|
||||
* `detail`. The store is the source of truth and the event is only the signal
|
||||
* that it moved, so dispatching alone would announce a change that isn't there
|
||||
* — and the modal under test would never open.
|
||||
*/
|
||||
function enqueuePendingRequests(
|
||||
client: EventTarget,
|
||||
queue: "getPendingSamples" | "getPendingElicitations",
|
||||
event: "pendingSamplesChange" | "pendingElicitationsChange",
|
||||
entries: readonly unknown[],
|
||||
): void {
|
||||
const withQueue = client as EventTarget &
|
||||
Record<typeof queue, ReturnType<typeof vi.fn>>;
|
||||
withQueue[queue].mockReturnValue(entries);
|
||||
client.dispatchEvent(new CustomEvent(event, { detail: entries }));
|
||||
}
|
||||
|
||||
const enqueuePendingSamples = (
|
||||
client: EventTarget,
|
||||
samples: readonly unknown[],
|
||||
): void =>
|
||||
enqueuePendingRequests(
|
||||
client,
|
||||
"getPendingSamples",
|
||||
"pendingSamplesChange",
|
||||
samples,
|
||||
);
|
||||
|
||||
const enqueuePendingElicitations = (
|
||||
client: EventTarget,
|
||||
elicitations: readonly unknown[],
|
||||
): void =>
|
||||
enqueuePendingRequests(
|
||||
client,
|
||||
"getPendingElicitations",
|
||||
"pendingElicitationsChange",
|
||||
elicitations,
|
||||
);
|
||||
|
||||
// The mock factory adds four test-only arming hooks to the module namespace.
|
||||
// Intersecting with `typeof McpIndex` keeps the real module's shape checked and
|
||||
// narrows this to a single cast — `as unknown as` would discard the former and
|
||||
|
|
@ -1417,9 +1472,7 @@ describe("App pending server-initiated request modal", () => {
|
|||
reject: vi.fn(),
|
||||
};
|
||||
act(() => {
|
||||
clientInstances[0].dispatchEvent(
|
||||
new CustomEvent("pendingSamplesChange", { detail: [sample] }),
|
||||
);
|
||||
enqueuePendingSamples(clientInstances[0], [sample]);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
|
|
@ -1433,9 +1486,7 @@ describe("App pending server-initiated request modal", () => {
|
|||
|
||||
// The client clearing its queue (empty event) closes the modal.
|
||||
act(() => {
|
||||
clientInstances[0].dispatchEvent(
|
||||
new CustomEvent("pendingSamplesChange", { detail: [] }),
|
||||
);
|
||||
enqueuePendingSamples(clientInstances[0], []);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Sampling Request")).not.toBeInTheDocument(),
|
||||
|
|
@ -1581,9 +1632,7 @@ describe("App task wiring", () => {
|
|||
respond,
|
||||
};
|
||||
act(() => {
|
||||
clientInstances[0].dispatchEvent(
|
||||
new CustomEvent("pendingElicitationsChange", { detail: [elicitation] }),
|
||||
);
|
||||
enqueuePendingElicitations(clientInstances[0], [elicitation]);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
|
|
|
|||
|
|
@ -245,18 +245,26 @@ export function useConnectionLifecycle({
|
|||
sessionReset,
|
||||
seedModernLogLevel,
|
||||
}: UseConnectionLifecycleOptions): ConnectionLifecycle {
|
||||
const [connectedServerId, setConnectedServerId] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
// Track the just-connected server so its card gets the green highlight +
|
||||
// scroll-into-view (#1682). It is a pure function of the two values it reads,
|
||||
// so it is derived rather than mirrored into state by an effect: a state copy
|
||||
// could only ever lag the status it copies by one committed render, which is
|
||||
// a frame of the wrong highlight for no gain.
|
||||
const connectedServerId =
|
||||
connectionStatus === "connected" ? activeServerId : undefined;
|
||||
|
||||
// `setupClientForServer` is synchronous and memoized, so a caller that
|
||||
// awaited the config would still resume with the `sandboxUrl` captured by the
|
||||
// render it STARTED in — undefined, on the very load this matters for. The
|
||||
// ref is written every render, so client construction reads the current value
|
||||
// whichever entry point (connect, deep link, OAuth callback) reached it.
|
||||
// ref carries the latest value instead, so client construction reads the
|
||||
// current one whichever entry point (connect, deep link, OAuth callback)
|
||||
// reached it. Published from a layout effect rather than written during
|
||||
// render: every reader is an event handler or a passive effect, both of which
|
||||
// run after the commit this writes in, so the value they see is unchanged.
|
||||
const sandboxUrlRef = useRef<string | undefined>(undefined);
|
||||
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
||||
sandboxUrlRef.current = sandboxUrl;
|
||||
useLayoutEffect(() => {
|
||||
sandboxUrlRef.current = sandboxUrl;
|
||||
}, [sandboxUrl]);
|
||||
|
||||
const {
|
||||
clearResultPanels,
|
||||
|
|
@ -270,18 +278,6 @@ export function useConnectionLifecycle({
|
|||
const deepLinkEnsureRef = useRef(false);
|
||||
const deepLinkUpdateRef = useRef(false);
|
||||
const deepLinkConnectRef = useRef(false);
|
||||
// Track the just-connected server so its card gets the green highlight +
|
||||
// scroll-into-view (#1682). Unlike `failedServerId` (which must survive the
|
||||
// `disconnect` event a failed connect fires), "connected" is a stable status,
|
||||
// so a status-driven effect can both set and clear it: set on connect, clear
|
||||
// whenever the session isn't connected (disconnect, a new attempt's
|
||||
// "connecting", or an error).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- pre-existing status-driven effect, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
||||
setConnectedServerId(
|
||||
connectionStatus === "connected" ? activeServerId : undefined,
|
||||
);
|
||||
}, [connectionStatus, activeServerId]);
|
||||
|
||||
// Disconnect the previous InspectorClient when it's replaced (server
|
||||
// switch) or when App unmounts (HMR, tests). Without this the prior
|
||||
|
|
@ -843,7 +839,13 @@ export function useConnectionLifecycle({
|
|||
const alreadyConnected =
|
||||
activeServerId === deepLink.serverId && connectionStatus === "connected";
|
||||
if (!alreadyConnected) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- pre-existing status-driven effect, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
||||
// `onToggleConnection` is a connect, not a state sync: it opens a
|
||||
// transport and drives the whole handshake, so the state it sets along
|
||||
// the way is a consequence of that I/O rather than something derivable
|
||||
// from props. An effect is the right tool here, and the one-shot
|
||||
// `deepLinkConnectRef` guard above is what keeps it from re-firing; the
|
||||
// rule sees only that an effect called something that reaches setState.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- justified directly above
|
||||
void onToggleConnection(deepLink.serverId).catch((err) => {
|
||||
// The toast fires from inside `onToggleConnection` for the common
|
||||
// cases; this catch covers the rest (surfaced on `data-error-message`).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import type { ElicitResult, Resource } from "@modelcontextprotocol/client";
|
||||
|
|
@ -88,9 +95,14 @@ export function useMcpApps({
|
|||
// hints (`connect-src 'none'`), exactly as before this wiring existed.
|
||||
// Walking the whole list to close that would issue the very requests the user
|
||||
// opted out of by turning pagination on, so the setting wins.
|
||||
//
|
||||
// Published from a layout effect rather than written during render: the only
|
||||
// reader is the async `sandboxready` handler below, which runs long after the
|
||||
// commit this writes in, so it sees the same listing either way.
|
||||
const listedResourcesRef = useRef<Resource[]>([]);
|
||||
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
||||
listedResourcesRef.current = resources;
|
||||
useLayoutEffect(() => {
|
||||
listedResourcesRef.current = resources;
|
||||
}, [resources]);
|
||||
// Best-effort by construction: this reads the list as it stands when the app
|
||||
// opens, and does not distinguish "no entry for this URI" from "the list
|
||||
// hasn't arrived yet". Both yield no hints, which is the same outcome as
|
||||
|
|
@ -117,9 +129,15 @@ export function useMcpApps({
|
|||
[configBaseUrl],
|
||||
);
|
||||
|
||||
// `react-hooks/refs` cannot see through `createAppBridgeFactory`, so it warns
|
||||
// that a ref-reading callback handed to it (`getListedResourceMeta`) *may* be
|
||||
// called during render. It is not: the factory only stores its deps, and the
|
||||
// sole call site is inside the async `sandboxready` handler, which runs once
|
||||
// an app frame has mounted and its sandbox has handshaked. Reading the
|
||||
// listing lazily there is the whole point — see `listedResourcesRef` above.
|
||||
const sandboxBridgeFactory = useMemo(
|
||||
() =>
|
||||
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
||||
// eslint-disable-next-line react-hooks/refs -- justified directly above
|
||||
createAppBridgeFactory({
|
||||
publishAppDocument: publishDocument,
|
||||
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
||||
|
|
@ -150,9 +168,12 @@ export function useMcpApps({
|
|||
// handed to every InspectorClient at construction — its `render` is what opts
|
||||
// this client into advertising the nested MCP Apps `elicitation` capability,
|
||||
// which is why only the web client (the one with a sandbox) claims it.
|
||||
const appElicitationControllerRef = useRef<AppElicitationController>(null);
|
||||
appElicitationControllerRef.current ??= new AppElicitationController();
|
||||
const appElicitationController = appElicitationControllerRef.current;
|
||||
// Held as lazily-initialized state rather than a ref seeded during render:
|
||||
// the instance is never reassigned, so `useState`'s initializer gives the
|
||||
// same create-once semantics without reading a ref while rendering.
|
||||
const [appElicitationController] = useState(
|
||||
() => new AppElicitationController(),
|
||||
);
|
||||
// The window onto the controller for the CURRENT client. Closing it when the
|
||||
// client is replaced both rejects that connection's queued requests and
|
||||
// refuses any it enqueues during its own (asynchronous) teardown — a late
|
||||
|
|
@ -160,9 +181,7 @@ export function useMcpApps({
|
|||
// client, i.e. read and answered through a different server.
|
||||
const appElicitationSessionRef = useRef<AppElicitationSession>(null);
|
||||
const appElicitations = useSyncExternalStore(
|
||||
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
||||
appElicitationController.subscribe,
|
||||
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
||||
appElicitationController.getEntries,
|
||||
);
|
||||
|
||||
|
|
@ -171,7 +190,7 @@ export function useMcpApps({
|
|||
// an elicitation, so telling those apps otherwise would be a false claim.
|
||||
const elicitationBridgeFactory = useMemo(
|
||||
() =>
|
||||
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
||||
// eslint-disable-next-line react-hooks/refs -- same dep, same reason as `sandboxBridgeFactory` above
|
||||
createAppBridgeFactory({
|
||||
advertiseElicitation: true,
|
||||
publishAppDocument: publishDocument,
|
||||
|
|
|
|||
287
clients/web/src/test/core/react/useStoreSnapshot.test.tsx
Normal file
287
clients/web/src/test/core/react/useStoreSnapshot.test.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { TypedEventTarget } from "@inspector/core/mcp/typedEventTarget";
|
||||
import { useStoreSnapshot } from "@inspector/core/react/useStoreSnapshot";
|
||||
|
||||
interface Item {
|
||||
label: string;
|
||||
seen?: boolean;
|
||||
}
|
||||
|
||||
interface FakeStoreEventMap {
|
||||
itemsChange: Item[];
|
||||
otherChange: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal stand-in for the real state stores, reproducing the two properties
|
||||
* that make them awkward to read from `useSyncExternalStore`: the getter hands
|
||||
* back a fresh defensive copy on every call, and the list is mutated in place
|
||||
* (both by appending and, in `markSeen`, by mutating an entry the list already
|
||||
* holds — the shape `MessageLogState` uses when it folds a response into its
|
||||
* request entry).
|
||||
*/
|
||||
class FakeStore extends TypedEventTarget<FakeStoreEventMap> {
|
||||
private items: Item[] = [];
|
||||
|
||||
getItems(): Item[] {
|
||||
return [...this.items];
|
||||
}
|
||||
|
||||
add(label: string): void {
|
||||
this.items.push({ label });
|
||||
this.dispatchTypedEvent("itemsChange", this.getItems());
|
||||
}
|
||||
|
||||
/** Mutate an existing entry in place, then announce it. */
|
||||
markSeen(label: string): void {
|
||||
const entry = this.items.find((i) => i.label === label);
|
||||
if (entry) entry.seen = true;
|
||||
this.dispatchTypedEvent("itemsChange", this.getItems());
|
||||
}
|
||||
|
||||
/** A dispatch on a different event, to prove per-event isolation. */
|
||||
bumpOther(): void {
|
||||
this.dispatchTypedEvent("otherChange", 1);
|
||||
}
|
||||
}
|
||||
|
||||
const NO_ITEMS: Item[] = [];
|
||||
const readItems = (store: FakeStore): Item[] => store.getItems();
|
||||
|
||||
/**
|
||||
* The shared subscription primitive behind every list/log hook in
|
||||
* `core/react` (#1955). Its job is to read a store during render — closing
|
||||
* both the stale-frame-on-store-swap and the missed-update windows the old
|
||||
* `useState` + `useEffect` shape carried — while still handing
|
||||
* `useSyncExternalStore` a referentially stable snapshot.
|
||||
*/
|
||||
describe("useStoreSnapshot", () => {
|
||||
let store: FakeStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new FakeStore();
|
||||
});
|
||||
|
||||
it("reports the fallback when there is no store", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useStoreSnapshot(null, "itemsChange", readItems, NO_ITEMS),
|
||||
);
|
||||
expect(result.current).toBe(NO_ITEMS);
|
||||
});
|
||||
|
||||
it("reads the store's current value on the first render", () => {
|
||||
store.add("a");
|
||||
const { result } = renderHook(() =>
|
||||
useStoreSnapshot(store, "itemsChange", readItems, NO_ITEMS),
|
||||
);
|
||||
expect(result.current).toEqual([{ label: "a" }]);
|
||||
});
|
||||
|
||||
it("updates when the store dispatches its event", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useStoreSnapshot(store, "itemsChange", readItems, NO_ITEMS),
|
||||
);
|
||||
expect(result.current).toEqual([]);
|
||||
|
||||
act(() => store.add("a"));
|
||||
expect(result.current).toEqual([{ label: "a" }]);
|
||||
});
|
||||
|
||||
it("returns the identical value across renders with no dispatch", () => {
|
||||
store.add("a");
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useStoreSnapshot(store, "itemsChange", readItems, NO_ITEMS),
|
||||
);
|
||||
const first = result.current;
|
||||
|
||||
rerender();
|
||||
rerender();
|
||||
|
||||
// Identity, not equality: a fresh array per read is what makes
|
||||
// `useSyncExternalStore` loop, and what defeats every downstream memo.
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
|
||||
it("reflects a store swap in the same render, with no stale frame", () => {
|
||||
const next = new FakeStore();
|
||||
store.add("from-first");
|
||||
next.add("from-second");
|
||||
|
||||
// Every render's value, captured during render rather than after commit —
|
||||
// the whole point is that no committed frame ever carries the previous
|
||||
// store's data.
|
||||
const rendered: Item[][] = [];
|
||||
const { rerender } = renderHook(
|
||||
({ s }: { s: FakeStore }) => {
|
||||
const value = useStoreSnapshot(s, "itemsChange", readItems, NO_ITEMS);
|
||||
rendered.push(value);
|
||||
return value;
|
||||
},
|
||||
{ initialProps: { s: store } },
|
||||
);
|
||||
expect(rendered.at(-1)).toEqual([{ label: "from-first" }]);
|
||||
|
||||
const rendersBeforeSwap = rendered.length;
|
||||
rerender({ s: next });
|
||||
|
||||
// Not one render after the swap carried the old store's list.
|
||||
expect(rendered.slice(rendersBeforeSwap)).not.toContainEqual([
|
||||
{ label: "from-first" },
|
||||
]);
|
||||
expect(rendered.at(-1)).toEqual([{ label: "from-second" }]);
|
||||
});
|
||||
|
||||
it("picks up a change dispatched between the render and the subscribe", () => {
|
||||
// The second defect the conversion fixes: the old shape took its snapshot
|
||||
// during render and attached its listener in an effect, so anything the
|
||||
// store did in between was lost. Reproduce that window exactly, by having
|
||||
// the store change on its way into `addEventListener`.
|
||||
const racing = new FakeStore();
|
||||
const realAdd = racing.addEventListener.bind(racing);
|
||||
racing.addEventListener = ((
|
||||
type: "itemsChange",
|
||||
listener: (event: Event) => void,
|
||||
) => {
|
||||
racing.add("slipped-in");
|
||||
realAdd(type, listener);
|
||||
}) as FakeStore["addEventListener"];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStoreSnapshot(racing, "itemsChange", readItems, NO_ITEMS),
|
||||
);
|
||||
|
||||
expect(result.current).toEqual([{ label: "slipped-in" }]);
|
||||
});
|
||||
|
||||
it("re-reads when a dispatch mutated an entry the list already held", () => {
|
||||
// The `MessageLogState` fold: same entries, same length, same references —
|
||||
// only a field inside one of them changed. Caching against the dispatch
|
||||
// count rather than the contents is what keeps this visible.
|
||||
store.add("a");
|
||||
const { result } = renderHook(() =>
|
||||
useStoreSnapshot(store, "itemsChange", readItems, NO_ITEMS),
|
||||
);
|
||||
const before = result.current;
|
||||
|
||||
act(() => store.markSeen("a"));
|
||||
|
||||
expect(result.current).not.toBe(before);
|
||||
expect(result.current).toEqual([{ label: "a", seen: true }]);
|
||||
});
|
||||
|
||||
it("ignores a dispatch of a different event on the same store", () => {
|
||||
store.add("a");
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useStoreSnapshot(store, "itemsChange", readItems, NO_ITEMS),
|
||||
);
|
||||
const before = result.current;
|
||||
|
||||
act(() => store.bumpOther());
|
||||
rerender();
|
||||
|
||||
expect(result.current).toBe(before);
|
||||
});
|
||||
|
||||
it("falls back when the store is taken away", () => {
|
||||
store.add("a");
|
||||
const { result, rerender } = renderHook(
|
||||
({ s }: { s: FakeStore | null }) =>
|
||||
useStoreSnapshot(s, "itemsChange", readItems, NO_ITEMS),
|
||||
{ initialProps: { s: store as FakeStore | null } },
|
||||
);
|
||||
expect(result.current).toEqual([{ label: "a" }]);
|
||||
|
||||
rerender({ s: null });
|
||||
expect(result.current).toBe(NO_ITEMS);
|
||||
});
|
||||
|
||||
it("re-reads when the reader changes under an unchanged store", () => {
|
||||
// The cache key covers every input the value was derived from, not just
|
||||
// the store and its revision — otherwise a new reader is answered with the
|
||||
// previous reader's output.
|
||||
store.add("a");
|
||||
const readLabels = (s: FakeStore): string[] =>
|
||||
s.getItems().map((i) => i.label);
|
||||
const readCount = (s: FakeStore): string[] => [String(s.getItems().length)];
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ read }: { read: (s: FakeStore) => string[] }) =>
|
||||
useStoreSnapshot(store, "itemsChange", read, []),
|
||||
{ initialProps: { read: readLabels } },
|
||||
);
|
||||
expect(result.current).toEqual(["a"]);
|
||||
|
||||
rerender({ read: readCount });
|
||||
expect(result.current).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("re-reads when the fallback changes while the store is absent", () => {
|
||||
// With no store the revision is pinned at 0, so the revision alone can
|
||||
// never notice this one.
|
||||
const first: Item[] = [{ label: "first-fallback" }];
|
||||
const second: Item[] = [{ label: "second-fallback" }];
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ absent }: { absent: Item[] }) =>
|
||||
useStoreSnapshot(null, "itemsChange", readItems, absent),
|
||||
{ initialProps: { absent: first } },
|
||||
);
|
||||
expect(result.current).toBe(first);
|
||||
|
||||
rerender({ absent: second });
|
||||
expect(result.current).toBe(second);
|
||||
});
|
||||
|
||||
it("re-reads when the watched event changes", () => {
|
||||
store.add("a");
|
||||
// Widen the prop up front rather than at each `rerender` — `initialProps`
|
||||
// is what fixes the type, so a bare literal there pins it to that one
|
||||
// event and the swap below stops compiling.
|
||||
const initialProps: { event: keyof FakeStoreEventMap } = {
|
||||
event: "itemsChange",
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ event }: { event: keyof FakeStoreEventMap }) =>
|
||||
useStoreSnapshot(store, event, readItems, NO_ITEMS),
|
||||
{ initialProps },
|
||||
);
|
||||
const first = result.current;
|
||||
|
||||
// Same store and same reader, so only the event distinguishes the two —
|
||||
// and each event carries its own revision, so the cached entry must not
|
||||
// be reused across them.
|
||||
rerender({ event: "otherChange" });
|
||||
expect(result.current).not.toBe(first);
|
||||
expect(result.current).toEqual([{ label: "a" }]);
|
||||
});
|
||||
|
||||
it("stops listening on unmount", () => {
|
||||
// Assert on the listener, not on the rendered value. Watching
|
||||
// `result.current` stay put after unmount proves nothing: React drops a
|
||||
// store notification for an unmounted component either way, so that test
|
||||
// passes with the listener still attached — which is the leak it was
|
||||
// supposed to catch.
|
||||
const addSpy = vi.spyOn(store, "addEventListener");
|
||||
const removeSpy = vi.spyOn(store, "removeEventListener");
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useStoreSnapshot(store, "itemsChange", readItems, NO_ITEMS),
|
||||
);
|
||||
expect(addSpy).toHaveBeenCalledWith("itemsChange", expect.any(Function));
|
||||
const subscribed = addSpy.mock.calls[0]?.[1];
|
||||
|
||||
unmount();
|
||||
|
||||
// The exact function that was attached is the one detached — a cleanup
|
||||
// that removed some other listener, or none, fails here.
|
||||
expect(removeSpy).toHaveBeenCalledWith("itemsChange", subscribed);
|
||||
});
|
||||
|
||||
it("unmounts cleanly with no store to unsubscribe from", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useStoreSnapshot(null, "itemsChange", readItems, NO_ITEMS),
|
||||
);
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue