Merge branch 'v2/main' into v2/chore/2161-react-hooks-suppressions
This commit is contained in:
commit
6af2edc2cc
25 changed files with 1088 additions and 772 deletions
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
|
|
@ -55,6 +55,10 @@ const CardContent = Group.withProps({
|
||||||
- Use **`useValueChange(value, onChange)`** (`src/hooks/useValueChange.ts`) — React's documented "adjusting state during render" pattern. It does not fire on the first render; seed the state with `useState`. The comparison is `Object.is`, so pass a **referentially stable** value — a primitive key (id/name/URI) or a memoized one, never a fresh object literal.
|
- Use **`useValueChange(value, onChange)`** (`src/hooks/useValueChange.ts`) — React's documented "adjusting state during render" pattern. It does not fire on the first render; seed the state with `useState`. The comparison is `Object.is`, so pass a **referentially stable** value — a primitive key (id/name/URI) or a memoized one, never a fresh object literal.
|
||||||
- The `onChange` runs **during render**, so it must be pure — `setState` and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks; a render can be replayed or abandoned.
|
- The `onChange` runs **during render**, so it must be pure — `setState` and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks; a render can be replayed or abandoned.
|
||||||
- Effects remain correct for real external-system synchronization (DOM measurement, rAF, subscriptions, timers).
|
- Effects remain correct for real external-system synchronization (DOM measurement, rAF, subscriptions, timers).
|
||||||
|
- **Subscribing to an `@inspector/core` state store is `useSyncExternalStore`, never `useState` + a subscribing `useEffect`** — that shape re-seeds state from the store prop, so it carries the same stale frame plus a window where an event dispatched between render and subscribe is lost. `useValueChange` is web-only and `core/react/` is shared with the CLI and TUI.
|
||||||
|
- Use **`useStoreSnapshot(store, event, read, whenAbsent)`** (`core/react/useStoreSnapshot.ts`), once per value, when the getter returns a **fresh value per read** (a defensive copy, or a freshly built object) — nearly all of them. When the snapshot is **already referentially stable**, subscribe directly and skip the helper, as `useListError` does with its `Error | null`. `read`/`whenAbsent` must be **referentially stable** — they are part of the cache key. `read` is a function, so module scope; `whenAbsent` needs a module-scope constant only when it is an object or array, since a primitive fallback (`false`, `undefined`, a string) is stable already and is passed inline in these hooks. An unstable one fails **quietly** — no throw, no React warning, no extra render, just a fresh value each render that defeats downstream memoization.
|
||||||
|
- The snapshot is cached against the store's per-event dispatch counter (`TypedEventTarget.getEventRevision`), **not** its contents — a contents compare cannot see a dispatch that mutated an entry already in the list. Don't replace it with a shallow compare.
|
||||||
|
- The store is the source of truth; the event is only the signal. A test firing a store event must set the value on the store first, and a fake store must extend the real `TypedEventTarget` (a bare `EventTarget` lacks `getEventRevision`).
|
||||||
|
|
||||||
### Theme files vs. element components
|
### Theme files vs. element components
|
||||||
|
|
||||||
|
|
|
||||||
21
AGENTS.md
21
AGENTS.md
|
|
@ -392,7 +392,17 @@ v2/main/
|
||||||
│ │ # predicate the guard is built on, isLoopbackHost gates the OAuth
|
│ │ # predicate the guard is built on, isLoopbackHost gates the OAuth
|
||||||
│ │ # callback listener; also stripBrackets. Used across
|
│ │ # callback listener; also stripBrackets. Used across
|
||||||
│ │ # clients/web/server, clients/cli, and core/auth/node — #1795)
|
│ │ # clients/web/server, clients/cli, and core/auth/node — #1795)
|
||||||
│ ├── react/ # React hooks over the state stores
|
│ ├── react/ # React hooks over the state stores. Every store
|
||||||
|
│ │ # subscription reads its snapshot DURING RENDER via
|
||||||
|
│ │ # useSyncExternalStore, so a store swap lands in the
|
||||||
|
│ │ # same frame and an event dispatched between render
|
||||||
|
│ │ # and subscribe is not lost (#1955). Getters that
|
||||||
|
│ │ # return a fresh value per read — a defensive copy,
|
||||||
|
│ │ # or a freshly built object — go through
|
||||||
|
│ │ # useStoreSnapshot.ts, which caches the snapshot
|
||||||
|
│ │ # against TypedEventTarget's per-event dispatch
|
||||||
|
│ │ # counter; useListError subscribes directly, its
|
||||||
|
│ │ # Error|null snapshot being stable already
|
||||||
│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends
|
│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends
|
||||||
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
|
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
|
||||||
│ ├── src/ # TypeScript sources. (modern-tasks.ts: SEP-2663 modern
|
│ ├── src/ # TypeScript sources. (modern-tasks.ts: SEP-2663 modern
|
||||||
|
|
@ -1205,6 +1215,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.
|
- 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.
|
- 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`.
|
- 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 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** (`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`.
|
- 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`.
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ inspector/
|
||||||
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import,
|
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import,
|
||||||
│ │ # and the RFC 6570 URI-template helpers the web form and TUI expand through
|
│ │ # and the RFC 6570 URI-template helpers the web form and TUI expand through
|
||||||
│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection)
|
│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection)
|
||||||
│ ├── react/ # React hooks over the state stores
|
│ ├── react/ # React hooks over the state stores, each reading its snapshot during render
|
||||||
│ └── storage/ # File I/O helpers for the OAuth persist backends
|
│ └── storage/ # File I/O helpers for the OAuth persist backends
|
||||||
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests
|
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests
|
||||||
├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, verify-dep-lockstep, pack:verify)
|
├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, verify-dep-lockstep, pack:verify)
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,21 @@ const { messageLogClear } = vi.hoisted(() => ({ messageLogClear: vi.fn() }));
|
||||||
// trigger the handlers). See #1368.
|
// trigger the handlers). See #1368.
|
||||||
|
|
||||||
// --- Fake InspectorClient ---------------------------------------------------
|
// --- Fake InspectorClient ---------------------------------------------------
|
||||||
// Extends EventTarget so the App's `addEventListener("disconnect", …)` wiring
|
// Extends the real TypedEventTarget so the App's `addEventListener("disconnect",
|
||||||
// is real; the test fires `dispatchEvent(new Event("disconnect"))` to simulate
|
// …)` wiring is real; the test fires `dispatchEvent(new Event("disconnect"))` to
|
||||||
// any of the three disconnect paths (toggle, header button, transport failure).
|
// 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) => {
|
vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
await importOriginal<typeof import("@inspector/core/mcp/index.js")>();
|
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
|
// 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
|
// 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
|
// 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
|
// control-flow decision from its resolving `false` (#2108): a throw is
|
||||||
// surfaced as a failed attempt rather than falling through to the redirect.
|
// surfaced as a failed attempt rather than falling through to the redirect.
|
||||||
let nextChallengeCheckRejection: unknown = null;
|
let nextChallengeCheckRejection: unknown = null;
|
||||||
class FakeInspectorClient extends EventTarget {
|
class FakeInspectorClient extends TypedEventTarget<
|
||||||
|
import("@inspector/core/mcp/index.js").InspectorClientEventMap
|
||||||
|
> {
|
||||||
connect = vi.fn(() => {
|
connect = vi.fn(() => {
|
||||||
if (connectRejections.length > 0) {
|
if (connectRejections.length > 0) {
|
||||||
return Promise.reject(connectRejections.shift());
|
return Promise.reject(connectRejections.shift());
|
||||||
|
|
@ -695,6 +706,50 @@ const clientInstances = (
|
||||||
McpIndex as unknown as { __clientInstances: EventTarget[] }
|
McpIndex as unknown as { __clientInstances: EventTarget[] }
|
||||||
).__clientInstances;
|
).__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.
|
// 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
|
// 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
|
// 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(),
|
reject: vi.fn(),
|
||||||
};
|
};
|
||||||
act(() => {
|
act(() => {
|
||||||
clientInstances[0].dispatchEvent(
|
enqueuePendingSamples(clientInstances[0], [sample]);
|
||||||
new CustomEvent("pendingSamplesChange", { detail: [sample] }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
|
@ -1433,9 +1486,7 @@ describe("App pending server-initiated request modal", () => {
|
||||||
|
|
||||||
// The client clearing its queue (empty event) closes the modal.
|
// The client clearing its queue (empty event) closes the modal.
|
||||||
act(() => {
|
act(() => {
|
||||||
clientInstances[0].dispatchEvent(
|
enqueuePendingSamples(clientInstances[0], []);
|
||||||
new CustomEvent("pendingSamplesChange", { detail: [] }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByText("Sampling Request")).not.toBeInTheDocument(),
|
expect(screen.queryByText("Sampling Request")).not.toBeInTheDocument(),
|
||||||
|
|
@ -1581,9 +1632,7 @@ describe("App task wiring", () => {
|
||||||
respond,
|
respond,
|
||||||
};
|
};
|
||||||
act(() => {
|
act(() => {
|
||||||
clientInstances[0].dispatchEvent(
|
enqueuePendingElicitations(clientInstances[0], [elicitation]);
|
||||||
new CustomEvent("pendingElicitationsChange", { detail: [elicitation] }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
|
|
||||||
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.
|
* implementation so the subclass remains assignable to EventTarget.
|
||||||
*/
|
*/
|
||||||
export class TypedEventTarget<EventMap extends object> extends 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>(
|
dispatchTypedEvent<K extends keyof EventMap>(
|
||||||
type: K,
|
type: K,
|
||||||
...args: EventMap[K] extends void ? [] : [detail: EventMap[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 { FetchRequestEntry } from "../mcp/types.js";
|
||||||
import type {
|
import type { FetchRequestLogState } from "../mcp/state/fetchRequestLogState.js";
|
||||||
FetchRequestLogState,
|
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||||
FetchRequestLogStateEventMap,
|
|
||||||
} from "../mcp/state/fetchRequestLogState.js";
|
/**
|
||||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.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 {
|
export interface UseFetchRequestLogResult {
|
||||||
fetchRequests: FetchRequestEntry[];
|
fetchRequests: FetchRequestEntry[];
|
||||||
|
|
@ -17,35 +23,12 @@ export interface UseFetchRequestLogResult {
|
||||||
export function useFetchRequestLog(
|
export function useFetchRequestLog(
|
||||||
fetchRequestLogState: FetchRequestLogState | null,
|
fetchRequestLogState: FetchRequestLogState | null,
|
||||||
): UseFetchRequestLogResult {
|
): UseFetchRequestLogResult {
|
||||||
const [fetchRequests, setFetchRequests] = useState<FetchRequestEntry[]>(
|
const fetchRequests = useStoreSnapshot(
|
||||||
fetchRequestLogState?.getFetchRequests() ?? [],
|
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 };
|
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 { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||||
import type { AppRendererClient } from "../mcp/inspectorClientProtocol.js";
|
import type { AppRendererClient } from "../mcp/inspectorClientProtocol.js";
|
||||||
import type { TypedEvent } from "../mcp/inspectorClientEventTarget.js";
|
import type { TypedEvent } from "../mcp/inspectorClientEventTarget.js";
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
} from "@modelcontextprotocol/client";
|
} from "@modelcontextprotocol/client";
|
||||||
import type { ExcludedTool } from "../mcp/types.js";
|
import type { ExcludedTool } from "../mcp/types.js";
|
||||||
import type { MalformedListItem } from "../mcp/listSalvage.js";
|
import type { MalformedListItem } from "../mcp/listSalvage.js";
|
||||||
|
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||||
|
|
||||||
// Module-scope frozen object so the `?? EMPTY_CLIENT_CAPABILITIES`
|
// Module-scope frozen object so the `?? EMPTY_CLIENT_CAPABILITIES`
|
||||||
// fallback below doesn't return a fresh literal on every render —
|
// 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.
|
// would otherwise invalidate every tick when no client is attached.
|
||||||
const EMPTY_CLIENT_CAPABILITIES: ClientCapabilities = Object.freeze({});
|
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 {
|
export interface UseInspectorClientResult {
|
||||||
status: ConnectionStatus;
|
status: ConnectionStatus;
|
||||||
capabilities?: ServerCapabilities;
|
capabilities?: ServerCapabilities;
|
||||||
|
|
@ -69,6 +104,11 @@ export interface UseInspectorClientResult {
|
||||||
* connection state. Log lists (message / stderr / fetch) live in dedicated
|
* connection state. Log lists (message / stderr / fetch) live in dedicated
|
||||||
* state managers consumed via useMessageLog / useStderrLog / useFetchRequestLog.
|
* 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
|
* 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
|
* 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
|
* to change again during a session, so callers will see the current value
|
||||||
|
|
@ -80,169 +120,99 @@ export interface UseInspectorClientResult {
|
||||||
export function useInspectorClient(
|
export function useInspectorClient(
|
||||||
inspectorClient: InspectorClientProtocol | null,
|
inspectorClient: InspectorClientProtocol | null,
|
||||||
): UseInspectorClientResult {
|
): UseInspectorClientResult {
|
||||||
const [status, setStatus] = useState<ConnectionStatus>(
|
const status = useStoreSnapshot(
|
||||||
inspectorClient?.getStatus() ?? "disconnected",
|
inspectorClient,
|
||||||
|
"statusChange",
|
||||||
|
readStatus,
|
||||||
|
"disconnected" as ConnectionStatus,
|
||||||
);
|
);
|
||||||
const [capabilities, setCapabilities] = useState<
|
const capabilities = useStoreSnapshot(
|
||||||
ServerCapabilities | undefined
|
inspectorClient,
|
||||||
>(inspectorClient?.getCapabilities());
|
"capabilitiesChange",
|
||||||
const [serverInfo, setServerInfo] = useState<Implementation | undefined>(
|
readCapabilities,
|
||||||
inspectorClient?.getServerInfo(),
|
undefined,
|
||||||
);
|
);
|
||||||
const [instructions, setInstructions] = useState<string | undefined>(
|
const serverInfo = useStoreSnapshot(
|
||||||
inspectorClient?.getInstructions(),
|
inspectorClient,
|
||||||
|
"serverInfoChange",
|
||||||
|
readServerInfo,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
const [protocolVersion, setProtocolVersion] = useState<string | undefined>(
|
const instructions = useStoreSnapshot(
|
||||||
inspectorClient?.getProtocolVersion(),
|
inspectorClient,
|
||||||
|
"instructionsChange",
|
||||||
|
readInstructions,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
const [protocolEra, setProtocolEra] = useState<ProtocolEra | undefined>(
|
const protocolVersion = useStoreSnapshot(
|
||||||
inspectorClient?.getProtocolEra(),
|
inspectorClient,
|
||||||
|
"protocolVersionChange",
|
||||||
|
readProtocolVersion,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
const [discoverResult, setDiscoverResult] = useState<
|
const protocolEra = useStoreSnapshot(
|
||||||
DiscoverResult | undefined
|
inspectorClient,
|
||||||
>(inspectorClient?.getDiscoverResult());
|
"protocolEraChange",
|
||||||
const [excludedTools, setExcludedTools] = useState<ExcludedTool[]>(
|
readProtocolEra,
|
||||||
inspectorClient?.getExcludedTools() ?? [],
|
undefined,
|
||||||
);
|
);
|
||||||
const [malformedListItems, setMalformedListItems] = useState<
|
const discoverResult = useStoreSnapshot(
|
||||||
MalformedListItem[]
|
inspectorClient,
|
||||||
>(inspectorClient?.getMalformedListItems() ?? []);
|
"discoverResultChange",
|
||||||
const [lastError, setLastError] = useState<string | undefined>(undefined);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!inspectorClient) {
|
if (!inspectorClient) return;
|
||||||
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);
|
|
||||||
|
|
||||||
const onStatusChange = (event: TypedEvent<"statusChange">) => {
|
const onStatusChange = (event: TypedEvent<"statusChange">) => {
|
||||||
setStatus(event.detail);
|
|
||||||
// A fresh connection attempt clears any stale error from the prior
|
// A fresh connection attempt clears any stale error from the prior
|
||||||
// session so the UI doesn't keep showing why the last transport died.
|
// session so the UI doesn't keep showing why the last transport died.
|
||||||
if (event.detail === "connecting") {
|
if (event.detail === "connecting") {
|
||||||
setLastError(undefined);
|
setErrorState({ client: inspectorClient });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onError = (event: TypedEvent<"error">) => {
|
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("statusChange", onStatusChange);
|
||||||
inspectorClient.addEventListener("error", onError);
|
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 () => {
|
return () => {
|
||||||
inspectorClient.removeEventListener("statusChange", onStatusChange);
|
inspectorClient.removeEventListener("statusChange", onStatusChange);
|
||||||
inspectorClient.removeEventListener("error", onError);
|
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]);
|
}, [inspectorClient]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||||
import type {
|
import type { ManagedPromptsState } from "../mcp/state/managedPromptsState.js";
|
||||||
ManagedPromptsState,
|
|
||||||
ManagedPromptsStateEventMap,
|
|
||||||
} from "../mcp/state/managedPromptsState.js";
|
|
||||||
import type { Prompt } from "@modelcontextprotocol/client";
|
import type { Prompt } from "@modelcontextprotocol/client";
|
||||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
|
||||||
import { useListError } from "./useListError.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 {
|
export interface UseManagedPromptsResult {
|
||||||
/**
|
/**
|
||||||
|
|
@ -30,52 +39,23 @@ export function useManagedPrompts(
|
||||||
client: InspectorClientProtocol | null,
|
client: InspectorClientProtocol | null,
|
||||||
managedPromptsState: ManagedPromptsState | null,
|
managedPromptsState: ManagedPromptsState | null,
|
||||||
): UseManagedPromptsResult {
|
): UseManagedPromptsResult {
|
||||||
const [prompts, setPrompts] = useState<Prompt[]>(
|
const prompts = useStoreSnapshot(
|
||||||
managedPromptsState?.getPrompts() ?? [],
|
managedPromptsState,
|
||||||
|
"promptsChange",
|
||||||
|
readPrompts,
|
||||||
|
NO_PROMPTS,
|
||||||
);
|
);
|
||||||
const [listChanged, setListChanged] = useState<boolean>(
|
const listChanged = useStoreSnapshot(
|
||||||
managedPromptsState?.getListChanged() ?? false,
|
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 error = useListError(managedPromptsState);
|
||||||
|
|
||||||
const refresh = useCallback(async (): Promise<Prompt[]> => {
|
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
|
// A user-initiated refresh acknowledges the change — clear the indicator
|
||||||
// BEFORE awaiting the fetch, not after. If a `prompts/list_changed` arrives
|
// BEFORE awaiting the fetch, not after. If a `prompts/list_changed` arrives
|
||||||
// mid-fetch, the state re-sets the flag (and auto-refreshes); clearing
|
// mid-fetch, the state re-sets the flag (and auto-refreshes); clearing
|
||||||
|
|
@ -84,10 +64,9 @@ export function useManagedPrompts(
|
||||||
managedPromptsState.clearListChanged();
|
managedPromptsState.clearListChanged();
|
||||||
// A user-initiated refresh forces a cache-bypassing round trip
|
// A user-initiated refresh forces a cache-bypassing round trip
|
||||||
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
|
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
|
||||||
// return stale — and re-stores the fresh aggregate.
|
// return stale — and re-stores the fresh aggregate. The store dispatches
|
||||||
const next = await managedPromptsState.refresh(undefined, "refresh");
|
// `promptsChange` as it commits, so the snapshot above updates on its own.
|
||||||
setPrompts(next);
|
return managedPromptsState.refresh(undefined, "refresh");
|
||||||
return next;
|
|
||||||
}, [client, managedPromptsState]);
|
}, [client, managedPromptsState]);
|
||||||
|
|
||||||
const clearListChanged = useCallback(() => {
|
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 { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||||
import type {
|
import type { ManagedRequestorTasksState } from "../mcp/state/managedRequestorTasksState.js";
|
||||||
ManagedRequestorTasksState,
|
|
||||||
ManagedRequestorTasksStateEventMap,
|
|
||||||
} from "../mcp/state/managedRequestorTasksState.js";
|
|
||||||
import type { Task } from "@modelcontextprotocol/client";
|
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 {
|
export interface UseManagedRequestorTasksResult {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
|
|
@ -21,38 +28,18 @@ export function useManagedRequestorTasks(
|
||||||
client: InspectorClientProtocol | null,
|
client: InspectorClientProtocol | null,
|
||||||
managedRequestorTasksState: ManagedRequestorTasksState | null,
|
managedRequestorTasksState: ManagedRequestorTasksState | null,
|
||||||
): UseManagedRequestorTasksResult {
|
): UseManagedRequestorTasksResult {
|
||||||
const [tasks, setTasks] = useState<Task[]>(
|
const tasks = useStoreSnapshot(
|
||||||
managedRequestorTasksState?.getTasks() ?? [],
|
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[]> => {
|
const refresh = useCallback(async (): Promise<Task[]> => {
|
||||||
if (!managedRequestorTasksState || !client) return [];
|
if (!managedRequestorTasksState || !client) return NO_TASKS;
|
||||||
const next = await managedRequestorTasksState.refresh();
|
// The store dispatches `tasksChange` as it commits the new list, so the
|
||||||
setTasks(next);
|
// snapshot above updates on its own — nothing local to push it into.
|
||||||
return next;
|
return managedRequestorTasksState.refresh();
|
||||||
}, [client, managedRequestorTasksState]);
|
}, [client, managedRequestorTasksState]);
|
||||||
|
|
||||||
const clearCompleted = useCallback((): void => {
|
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 { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||||
import type {
|
import type { ManagedResourceTemplatesState } from "../mcp/state/managedResourceTemplatesState.js";
|
||||||
ManagedResourceTemplatesState,
|
|
||||||
ManagedResourceTemplatesStateEventMap,
|
|
||||||
} from "../mcp/state/managedResourceTemplatesState.js";
|
|
||||||
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
|
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
|
||||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
|
||||||
import { useListError } from "./useListError.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 {
|
export interface UseManagedResourceTemplatesResult {
|
||||||
/**
|
/**
|
||||||
|
|
@ -27,49 +35,25 @@ export function useManagedResourceTemplates(
|
||||||
client: InspectorClientProtocol | null,
|
client: InspectorClientProtocol | null,
|
||||||
managedResourceTemplatesState: ManagedResourceTemplatesState | null,
|
managedResourceTemplatesState: ManagedResourceTemplatesState | null,
|
||||||
): UseManagedResourceTemplatesResult {
|
): UseManagedResourceTemplatesResult {
|
||||||
const [resourceTemplates, setResourceTemplates] = useState<
|
const resourceTemplates = useStoreSnapshot(
|
||||||
ResourceTemplate[]
|
managedResourceTemplatesState,
|
||||||
>(managedResourceTemplatesState?.getResourceTemplates() ?? []);
|
"resourceTemplatesChange",
|
||||||
|
readResourceTemplates,
|
||||||
useEffect(() => {
|
NO_RESOURCE_TEMPLATES,
|
||||||
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 error = useListError(managedResourceTemplatesState);
|
const error = useListError(managedResourceTemplatesState);
|
||||||
|
|
||||||
const refresh = useCallback(async (): Promise<ResourceTemplate[]> => {
|
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
|
// A user-initiated refresh forces a cache-bypassing round trip
|
||||||
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
|
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
|
||||||
// return stale — and re-stores the fresh aggregate.
|
// return stale — and re-stores the fresh aggregate. The store dispatches
|
||||||
const next = await managedResourceTemplatesState.refresh(
|
// `resourceTemplatesChange` as it commits, so the snapshot updates on its
|
||||||
undefined,
|
// own.
|
||||||
"refresh",
|
return managedResourceTemplatesState.refresh(undefined, "refresh");
|
||||||
);
|
|
||||||
setResourceTemplates(next);
|
|
||||||
return next;
|
|
||||||
}, [client, managedResourceTemplatesState]);
|
}, [client, managedResourceTemplatesState]);
|
||||||
|
|
||||||
return { resourceTemplates, error, refresh };
|
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 { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||||
import type {
|
import type { ManagedResourcesState } from "../mcp/state/managedResourcesState.js";
|
||||||
ManagedResourcesState,
|
|
||||||
ManagedResourcesStateEventMap,
|
|
||||||
} from "../mcp/state/managedResourcesState.js";
|
|
||||||
import type { Resource } from "@modelcontextprotocol/client";
|
import type { Resource } from "@modelcontextprotocol/client";
|
||||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
|
||||||
import { useListError } from "./useListError.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 {
|
export interface UseManagedResourcesResult {
|
||||||
/**
|
/**
|
||||||
|
|
@ -32,61 +41,23 @@ export function useManagedResources(
|
||||||
client: InspectorClientProtocol | null,
|
client: InspectorClientProtocol | null,
|
||||||
managedResourcesState: ManagedResourcesState | null,
|
managedResourcesState: ManagedResourcesState | null,
|
||||||
): UseManagedResourcesResult {
|
): UseManagedResourcesResult {
|
||||||
const [resources, setResources] = useState<Resource[]>(
|
const resources = useStoreSnapshot(
|
||||||
managedResourcesState?.getResources() ?? [],
|
managedResourcesState,
|
||||||
|
"resourcesChange",
|
||||||
|
readResources,
|
||||||
|
NO_RESOURCES,
|
||||||
);
|
);
|
||||||
const [listChanged, setListChanged] = useState<boolean>(
|
const listChanged = useStoreSnapshot(
|
||||||
managedResourcesState?.getListChanged() ?? false,
|
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 error = useListError(managedResourcesState);
|
||||||
|
|
||||||
const refresh = useCallback(async (): Promise<Resource[]> => {
|
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
|
// A user-initiated refresh acknowledges the change — clear the indicator
|
||||||
// BEFORE awaiting the fetch, not after. If a `resources/list_changed`
|
// BEFORE awaiting the fetch, not after. If a `resources/list_changed`
|
||||||
// arrives mid-fetch, the state re-sets the flag (and auto-refreshes);
|
// arrives mid-fetch, the state re-sets the flag (and auto-refreshes);
|
||||||
|
|
@ -95,10 +66,9 @@ export function useManagedResources(
|
||||||
managedResourcesState.clearListChanged();
|
managedResourcesState.clearListChanged();
|
||||||
// A user-initiated refresh forces a cache-bypassing round trip
|
// A user-initiated refresh forces a cache-bypassing round trip
|
||||||
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
|
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
|
||||||
// return stale — and re-stores the fresh aggregate.
|
// return stale — and re-stores the fresh aggregate. The store dispatches
|
||||||
const next = await managedResourcesState.refresh(undefined, "refresh");
|
// `resourcesChange` as it commits, so the snapshot updates on its own.
|
||||||
setResources(next);
|
return managedResourcesState.refresh(undefined, "refresh");
|
||||||
return next;
|
|
||||||
}, [client, managedResourcesState]);
|
}, [client, managedResourcesState]);
|
||||||
|
|
||||||
const clearListChanged = useCallback(() => {
|
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 { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||||
import type { ManagedToolsState } from "../mcp/state/managedToolsState.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 { Tool } from "@modelcontextprotocol/client";
|
||||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
|
||||||
import { useListError } from "./useListError.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 {
|
export interface UseManagedToolsResult {
|
||||||
/**
|
/**
|
||||||
|
|
@ -33,49 +43,23 @@ export function useManagedTools(
|
||||||
client: InspectorClientProtocol | null,
|
client: InspectorClientProtocol | null,
|
||||||
managedToolsState: ManagedToolsState | null,
|
managedToolsState: ManagedToolsState | null,
|
||||||
): UseManagedToolsResult {
|
): UseManagedToolsResult {
|
||||||
const [tools, setTools] = useState<Tool[]>(
|
const tools = useStoreSnapshot(
|
||||||
managedToolsState?.getTools() ?? [],
|
managedToolsState,
|
||||||
|
"toolsChange",
|
||||||
|
readTools,
|
||||||
|
NO_TOOLS,
|
||||||
);
|
);
|
||||||
const [listChanged, setListChanged] = useState<boolean>(
|
const listChanged = useStoreSnapshot(
|
||||||
managedToolsState?.getListChanged() ?? false,
|
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 error = useListError(managedToolsState);
|
||||||
|
|
||||||
const refresh = useCallback(async (): Promise<Tool[]> => {
|
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
|
// A user-initiated refresh acknowledges the change — clear the indicator
|
||||||
// BEFORE awaiting the fetch, not after. If a `tools/list_changed` arrives
|
// BEFORE awaiting the fetch, not after. If a `tools/list_changed` arrives
|
||||||
// mid-fetch, the state re-sets the flag (and auto-refreshes); clearing
|
// mid-fetch, the state re-sets the flag (and auto-refreshes); clearing
|
||||||
|
|
@ -84,10 +68,10 @@ export function useManagedTools(
|
||||||
managedToolsState.clearListChanged();
|
managedToolsState.clearListChanged();
|
||||||
// A user-initiated refresh forces a cache-bypassing round trip
|
// A user-initiated refresh forces a cache-bypassing round trip
|
||||||
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
|
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
|
||||||
// return stale — and re-stores the fresh aggregate.
|
// return stale — and re-stores the fresh aggregate. The store dispatches
|
||||||
const next = await managedToolsState.refresh(undefined, "refresh");
|
// `toolsChange` as it commits, so the snapshot above updates on its own;
|
||||||
setTools(next);
|
// there is no local state left to push the result into.
|
||||||
return next;
|
return managedToolsState.refresh(undefined, "refresh");
|
||||||
}, [client, managedToolsState]);
|
}, [client, managedToolsState]);
|
||||||
|
|
||||||
const clearListChanged = useCallback(() => {
|
const clearListChanged = useCallback(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import type { MessageEntry } from "../mcp/types.js";
|
import type { MessageEntry } from "../mcp/types.js";
|
||||||
import type {
|
import type { MessageLogState } from "../mcp/state/messageLogState.js";
|
||||||
MessageLogState,
|
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||||
MessageLogStateEventMap,
|
|
||||||
} from "../mcp/state/messageLogState.js";
|
/**
|
||||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.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 {
|
export interface UseMessageLogResult {
|
||||||
messages: MessageEntry[];
|
messages: MessageEntry[];
|
||||||
|
|
@ -12,30 +18,22 @@ export interface UseMessageLogResult {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React hook that subscribes to MessageLogState and returns the message list.
|
* 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(
|
export function useMessageLog(
|
||||||
messageLogState: MessageLogState | null,
|
messageLogState: MessageLogState | null,
|
||||||
): UseMessageLogResult {
|
): UseMessageLogResult {
|
||||||
const [messages, setMessages] = useState<MessageEntry[]>(
|
const messages = useStoreSnapshot(
|
||||||
messageLogState?.getMessages() ?? [],
|
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 };
|
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 { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||||
import type { RequestMetadata } from "../mcp/types.js";
|
import type { RequestMetadata } from "../mcp/types.js";
|
||||||
import type {
|
import type {
|
||||||
PagedPromptsState,
|
PagedPromptsState,
|
||||||
PagedPromptsStateEventMap,
|
|
||||||
LoadPageResult,
|
LoadPageResult,
|
||||||
} from "../mcp/state/pagedPromptsState.js";
|
} from "../mcp/state/pagedPromptsState.js";
|
||||||
|
import type { PagePaginationState } from "../mcp/state/pagedToolsState.js";
|
||||||
import type { Prompt } from "@modelcontextprotocol/client";
|
import type { Prompt } from "@modelcontextprotocol/client";
|
||||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
|
||||||
import { useListError } from "./useListError.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 {
|
export interface UsePagedPromptsResult {
|
||||||
prompts: Prompt[];
|
prompts: Prompt[];
|
||||||
|
|
@ -38,47 +50,18 @@ export function usePagedPrompts(
|
||||||
client: InspectorClientProtocol | null,
|
client: InspectorClientProtocol | null,
|
||||||
pagedPromptsState: PagedPromptsState | null,
|
pagedPromptsState: PagedPromptsState | null,
|
||||||
): UsePagedPromptsResult {
|
): UsePagedPromptsResult {
|
||||||
const [prompts, setPrompts] = useState<Prompt[]>(
|
const prompts = useStoreSnapshot(
|
||||||
pagedPromptsState?.getPrompts() ?? [],
|
pagedPromptsState,
|
||||||
|
"promptsChange",
|
||||||
|
readPrompts,
|
||||||
|
NO_PROMPTS,
|
||||||
);
|
);
|
||||||
const [nextCursor, setNextCursor] = useState<string | undefined>(
|
const { nextCursor, pageCount } = useStoreSnapshot(
|
||||||
pagedPromptsState?.getPagination().nextCursor,
|
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);
|
const error = useListError(pagedPromptsState);
|
||||||
|
|
||||||
|
|
@ -88,7 +71,7 @@ export function usePagedPrompts(
|
||||||
metadata?: RequestMetadata,
|
metadata?: RequestMetadata,
|
||||||
): Promise<LoadPageResult> => {
|
): Promise<LoadPageResult> => {
|
||||||
if (!pagedPromptsState || !client) {
|
if (!pagedPromptsState || !client) {
|
||||||
return { prompts: [], nextCursor: undefined };
|
return { prompts: NO_PROMPTS, nextCursor: undefined };
|
||||||
}
|
}
|
||||||
return pagedPromptsState.loadPage(cursor, metadata);
|
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 { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||||
import type {
|
import type {
|
||||||
PagedRequestorTasksState,
|
PagedRequestorTasksState,
|
||||||
PagedRequestorTasksStateEventMap,
|
|
||||||
LoadPageResult,
|
LoadPageResult,
|
||||||
} from "../mcp/state/pagedRequestorTasksState.js";
|
} from "../mcp/state/pagedRequestorTasksState.js";
|
||||||
import type { Task } from "@modelcontextprotocol/client";
|
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 {
|
export interface UsePagedRequestorTasksResult {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
|
|
@ -23,45 +35,27 @@ export function usePagedRequestorTasks(
|
||||||
client: InspectorClientProtocol | null,
|
client: InspectorClientProtocol | null,
|
||||||
pagedRequestorTasksState: PagedRequestorTasksState | null,
|
pagedRequestorTasksState: PagedRequestorTasksState | null,
|
||||||
): UsePagedRequestorTasksResult {
|
): UsePagedRequestorTasksResult {
|
||||||
const [tasks, setTasks] = useState<Task[]>(
|
const tasks = useStoreSnapshot(
|
||||||
pagedRequestorTasksState?.getTasks() ?? [],
|
pagedRequestorTasksState,
|
||||||
|
"tasksChange",
|
||||||
|
readTasks,
|
||||||
|
NO_TASKS,
|
||||||
);
|
);
|
||||||
const [nextCursor, setNextCursor] = useState<string | undefined>(
|
const nextCursor = useStoreSnapshot(
|
||||||
pagedRequestorTasksState?.getNextCursor() ?? undefined,
|
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(
|
const loadPage = useCallback(
|
||||||
async (cursor?: string): Promise<LoadPageResult> => {
|
async (cursor?: string): Promise<LoadPageResult> => {
|
||||||
if (!pagedRequestorTasksState || !client) {
|
if (!pagedRequestorTasksState || !client) {
|
||||||
return { tasks: [], nextCursor: undefined };
|
return { tasks: NO_TASKS, nextCursor: undefined };
|
||||||
}
|
}
|
||||||
const result = await pagedRequestorTasksState.loadPage(cursor);
|
// The store dispatches `tasksChange` as it commits the page, so both
|
||||||
setTasks(pagedRequestorTasksState.getTasks());
|
// snapshots above update on their own.
|
||||||
setNextCursor(pagedRequestorTasksState.getNextCursor() ?? undefined);
|
return pagedRequestorTasksState.loadPage(cursor);
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
[client, pagedRequestorTasksState],
|
[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 { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||||
import type { RequestMetadata } from "../mcp/types.js";
|
import type { RequestMetadata } from "../mcp/types.js";
|
||||||
import type {
|
import type {
|
||||||
PagedResourceTemplatesState,
|
PagedResourceTemplatesState,
|
||||||
PagedResourceTemplatesStateEventMap,
|
|
||||||
LoadPageResult,
|
LoadPageResult,
|
||||||
} from "../mcp/state/pagedResourceTemplatesState.js";
|
} from "../mcp/state/pagedResourceTemplatesState.js";
|
||||||
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
|
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 {
|
export interface UsePagedResourceTemplatesResult {
|
||||||
resourceTemplates: ResourceTemplate[];
|
resourceTemplates: ResourceTemplate[];
|
||||||
|
|
@ -26,35 +36,12 @@ export function usePagedResourceTemplates(
|
||||||
client: InspectorClientProtocol | null,
|
client: InspectorClientProtocol | null,
|
||||||
pagedResourceTemplatesState: PagedResourceTemplatesState | null,
|
pagedResourceTemplatesState: PagedResourceTemplatesState | null,
|
||||||
): UsePagedResourceTemplatesResult {
|
): UsePagedResourceTemplatesResult {
|
||||||
const [resourceTemplates, setResourceTemplates] = useState<
|
const resourceTemplates = useStoreSnapshot(
|
||||||
ResourceTemplate[]
|
pagedResourceTemplatesState,
|
||||||
>(pagedResourceTemplatesState?.getResourceTemplates() ?? []);
|
"resourceTemplatesChange",
|
||||||
|
readResourceTemplates,
|
||||||
useEffect(() => {
|
NO_RESOURCE_TEMPLATES,
|
||||||
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 loadPage = useCallback(
|
const loadPage = useCallback(
|
||||||
async (
|
async (
|
||||||
|
|
@ -62,14 +49,14 @@ export function usePagedResourceTemplates(
|
||||||
metadata?: RequestMetadata,
|
metadata?: RequestMetadata,
|
||||||
): Promise<LoadPageResult> => {
|
): Promise<LoadPageResult> => {
|
||||||
if (!pagedResourceTemplatesState || !client) {
|
if (!pagedResourceTemplatesState || !client) {
|
||||||
return { resourceTemplates: [], nextCursor: undefined };
|
return {
|
||||||
|
resourceTemplates: NO_RESOURCE_TEMPLATES,
|
||||||
|
nextCursor: undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const result = await pagedResourceTemplatesState.loadPage(
|
// The store dispatches `resourceTemplatesChange` as it commits the page,
|
||||||
cursor,
|
// so the snapshot above updates on its own.
|
||||||
metadata,
|
return pagedResourceTemplatesState.loadPage(cursor, metadata);
|
||||||
);
|
|
||||||
setResourceTemplates(pagedResourceTemplatesState.getResourceTemplates());
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
[client, pagedResourceTemplatesState],
|
[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 { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||||
import type { RequestMetadata } from "../mcp/types.js";
|
import type { RequestMetadata } from "../mcp/types.js";
|
||||||
import type {
|
import type {
|
||||||
PagedResourcesState,
|
PagedResourcesState,
|
||||||
PagedResourcesStateEventMap,
|
|
||||||
LoadPageResult,
|
LoadPageResult,
|
||||||
} from "../mcp/state/pagedResourcesState.js";
|
} from "../mcp/state/pagedResourcesState.js";
|
||||||
|
import type { PagePaginationState } from "../mcp/state/pagedToolsState.js";
|
||||||
import type { Resource } from "@modelcontextprotocol/client";
|
import type { Resource } from "@modelcontextprotocol/client";
|
||||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
|
||||||
import { useListError } from "./useListError.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 {
|
export interface UsePagedResourcesResult {
|
||||||
resources: Resource[];
|
resources: Resource[];
|
||||||
|
|
@ -38,53 +51,18 @@ export function usePagedResources(
|
||||||
client: InspectorClientProtocol | null,
|
client: InspectorClientProtocol | null,
|
||||||
pagedResourcesState: PagedResourcesState | null,
|
pagedResourcesState: PagedResourcesState | null,
|
||||||
): UsePagedResourcesResult {
|
): UsePagedResourcesResult {
|
||||||
const [resources, setResources] = useState<Resource[]>(
|
const resources = useStoreSnapshot(
|
||||||
pagedResourcesState?.getResources() ?? [],
|
pagedResourcesState,
|
||||||
|
"resourcesChange",
|
||||||
|
readResources,
|
||||||
|
NO_RESOURCES,
|
||||||
);
|
);
|
||||||
const [nextCursor, setNextCursor] = useState<string | undefined>(
|
const { nextCursor, pageCount } = useStoreSnapshot(
|
||||||
pagedResourcesState?.getPagination().nextCursor,
|
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);
|
const error = useListError(pagedResourcesState);
|
||||||
|
|
||||||
|
|
@ -94,7 +72,7 @@ export function usePagedResources(
|
||||||
metadata?: RequestMetadata,
|
metadata?: RequestMetadata,
|
||||||
): Promise<LoadPageResult> => {
|
): Promise<LoadPageResult> => {
|
||||||
if (!pagedResourcesState || !client) {
|
if (!pagedResourcesState || !client) {
|
||||||
return { resources: [], nextCursor: undefined };
|
return { resources: NO_RESOURCES, nextCursor: undefined };
|
||||||
}
|
}
|
||||||
return pagedResourcesState.loadPage(cursor, metadata);
|
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 { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||||
import type {
|
import type {
|
||||||
PagedToolsState,
|
PagedToolsState,
|
||||||
PagedToolsStateEventMap,
|
PagePaginationState,
|
||||||
LoadPageResult,
|
LoadPageResult,
|
||||||
} from "../mcp/state/pagedToolsState.js";
|
} from "../mcp/state/pagedToolsState.js";
|
||||||
import type { Tool } from "@modelcontextprotocol/client";
|
import type { Tool } from "@modelcontextprotocol/client";
|
||||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
|
||||||
import { useListError } from "./useListError.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 {
|
export interface UsePagedToolsResult {
|
||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
|
|
@ -35,52 +47,25 @@ export function usePagedTools(
|
||||||
client: InspectorClientProtocol | null,
|
client: InspectorClientProtocol | null,
|
||||||
pagedToolsState: PagedToolsState | null,
|
pagedToolsState: PagedToolsState | null,
|
||||||
): UsePagedToolsResult {
|
): UsePagedToolsResult {
|
||||||
const [tools, setTools] = useState<Tool[]>(pagedToolsState?.getTools() ?? []);
|
const tools = useStoreSnapshot(
|
||||||
const [nextCursor, setNextCursor] = useState<string | undefined>(
|
pagedToolsState,
|
||||||
pagedToolsState?.getPagination().nextCursor,
|
"toolsChange",
|
||||||
|
readTools,
|
||||||
|
NO_TOOLS,
|
||||||
);
|
);
|
||||||
const [pageCount, setPageCount] = useState<number>(
|
const { nextCursor, pageCount } = useStoreSnapshot(
|
||||||
pagedToolsState?.getPagination().pageCount ?? 0,
|
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 error = useListError(pagedToolsState);
|
||||||
|
|
||||||
const loadPage = useCallback(
|
const loadPage = useCallback(
|
||||||
async (cursor?: string): Promise<LoadPageResult> => {
|
async (cursor?: string): Promise<LoadPageResult> => {
|
||||||
if (!pagedToolsState || !client) {
|
if (!pagedToolsState || !client) {
|
||||||
return { tools: [], nextCursor: undefined };
|
return { tools: NO_TOOLS, nextCursor: undefined };
|
||||||
}
|
}
|
||||||
return pagedToolsState.loadPage(cursor);
|
return pagedToolsState.loadPage(cursor);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue