fix(core): read state stores during render instead of re-syncing in an effect
Every state-subscription hook in core/react/ seeded local React state from its store prop and re-synced it inside a useEffect. That shape paints one frame of the previous store's data when the prop changes (switching servers), and loses any event dispatched between the render that took the snapshot and the effect that attached the listener. Convert all 16 to useSyncExternalStore through a new shared useStoreSnapshot, following useManagedListError (#1954). useSyncExternalStore needs a referentially stable snapshot, and these getters hand back a defensive copy, so TypedEventTarget now keeps a per-event-type dispatch counter (getEventRevision) that the snapshot is cached against. A contents comparison would not do: MessageLogState folds a response into its request entry by mutating that entry in place, which a shallow compare reports as unchanged. The bump lives on the dispatchEvent override so no dispatch — including a hand-rolled CustomEvent — can bypass it. useInspectorClient is included though the issue did not list it; its nine subscribed slices made the stale frame most visible. Its lastError stays useState (accumulated from events, no getter to read back) but resets on client swap during render rather than in an effect. The settings-draft and async fetch hooks are deliberately untouched: the former hold genuinely local editable state, the latter are I/O rather than store subscriptions. Also answers the issue's open question. react-hooks/set-state-in-effect never fired here because eslint-plugin-react-hooks has never linted core/react/ at all — it is a clients/web devDependency and the root config loads no React plugin. Corrected the config comment that gave "no JSX" as the reason; the gap itself is #2192, measured at 15 findings, none of them in the hooks converted here. Closes #1955 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
8c91ae8820
commit
50502269f4
25 changed files with 987 additions and 772 deletions
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
|
|
@ -55,6 +55,9 @@ 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.
|
||||
- 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).
|
||||
- **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. Use **`useStoreSnapshot(store, event, read, whenAbsent)`** (`core/react/useStoreSnapshot.ts`), once per value; `useValueChange` is web-only and `core/react/` is shared with the CLI and TUI. Pass `read`/`whenAbsent` as **module-scope constants**.
|
||||
- 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
|
||||
|
||||
|
|
|
|||
14
AGENTS.md
14
AGENTS.md
|
|
@ -392,7 +392,13 @@ v2/main/
|
|||
│ │ # predicate the guard is built on, isLoopbackHost gates the OAuth
|
||||
│ │ # callback listener; also stripBrackets. Used across
|
||||
│ │ # 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 through useStoreSnapshot.ts
|
||||
│ │ # (useSyncExternalStore + a snapshot cached against
|
||||
│ │ # TypedEventTarget's per-event dispatch counter), so a
|
||||
│ │ # store swap lands in the same frame and an event
|
||||
│ │ # dispatched between render and subscribe is not lost
|
||||
│ │ # — #1955
|
||||
│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends
|
||||
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
|
||||
│ ├── src/ # TypeScript sources. (modern-tasks.ts: SEP-2663 modern
|
||||
|
|
@ -1205,6 +1211,12 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage `
|
|||
- Use **`useValueChange(value, onChange)`** (`clients/web/src/hooks/useValueChange.ts`) instead. It is React's documented ["adjusting state during render"](https://react.dev/reference/react/useState#storing-information-from-previous-renders) pattern: it compares `value` against the previous render's with `Object.is` and calls `onChange(next)` during render, so React discards the in-progress output and re-runs the component before anything reaches the DOM. It does **not** fire on the first render — seed the dependent state with `useState` instead. Because the comparison is `Object.is`, the value you pass **must be referentially stable** across renders that mean "no change": prefer a primitive key derived from the data (an id, a name, a URI), and otherwise a memoized value. A fresh object/array literal would compare unequal every render and loop.
|
||||
- The `onChange` you pass runs **during render**, so it must be pure — `setState` calls and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks: a render can be replayed (StrictMode) or abandoned (concurrent React), so external work would run an unpredictable number of times.
|
||||
- An effect is still the right tool for genuine synchronization with an external system (DOM measurement, `requestAnimationFrame`, subscriptions, timers). The rule is about deriving React state from React props, not about effects in general. `NetworkEntry` shows the split: the reveal's force-open is a state update and uses `useValueChange`, while its `requestAnimationFrame` scroll stays a `useEffect`.
|
||||
- **Subscribing to an `@inspector/core` state store is `useSyncExternalStore`, never `useState` + a subscribing `useEffect`.** That second shape looks like the legitimate "synchronize with an external system" case above and is not, because it also *seeds and re-seeds local state from the store prop* — so it carries the same stale frame (switching servers paints the previous server's tools for one frame) plus a window where an event dispatched between the render and the effect is lost outright. Every hook in `core/react/` was converted away from it in #1955.
|
||||
- Use **`useStoreSnapshot(store, event, read, whenAbsent)`** (`core/react/useStoreSnapshot.ts`) for one value driven by one store event; call it once per value. `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 **module-scope constants**, not built in the component body: they are `getSnapshot` dependencies, and a fresh `[]` literal per render defeats the caching for no reason.
|
||||
- The snapshot is cached against the store's **per-event dispatch counter** (`TypedEventTarget.getEventRevision`), which every dispatch advances automatically — not against the snapshot's contents. That is deliberate and load-bearing: these getters return a defensive copy, so contents are the only alternative, and a contents comparison cannot see a dispatch that mutated an entry the list already holds (`MessageLogState` folding a response into its request entry does exactly that). Don't "optimize" it into a shallow compare.
|
||||
- Consequently the **store is the source of truth and the event is only the signal** — the hook re-reads the store rather than taking the event's `detail`. A test that fires a store event must put the value on the store first; dispatching alone announces a change that isn't there. A hand-rolled fake must extend the real `TypedEventTarget` (a bare `EventTarget` has no `getEventRevision` and fails at runtime).
|
||||
- Genuinely local state accumulated from an event stream, with no getter to read it back from, stays `useState` — `useInspectorClient`'s `lastError` is the one such case. Reset it on a store swap **during render**, not in an effect.
|
||||
- Theme files vs. Storybook element components
|
||||
- **Theme files** (`src/theme/<Component>.ts`) and **element components** (`src/components/elements/`) serve different purposes and both are needed.
|
||||
- Theme files customize every instance of a Mantine component app-wide — defaults (size, radius), custom variants, and global style overrides. They are applied automatically by `MantineProvider`.
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ inspector/
|
|||
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import,
|
||||
│ │ # 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)
|
||||
│ ├── react/ # React hooks over the state stores
|
||||
│ ├── react/ # React hooks over the state stores, all reading through useStoreSnapshot
|
||||
│ └── storage/ # File I/O helpers for the OAuth persist backends
|
||||
├── 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)
|
||||
|
|
|
|||
|
|
@ -44,12 +44,21 @@ const { messageLogClear } = vi.hoisted(() => ({ messageLogClear: vi.fn() }));
|
|||
// trigger the handlers). See #1368.
|
||||
|
||||
// --- Fake InspectorClient ---------------------------------------------------
|
||||
// Extends EventTarget so the App's `addEventListener("disconnect", …)` wiring
|
||||
// is real; the test fires `dispatchEvent(new Event("disconnect"))` to simulate
|
||||
// any of the three disconnect paths (toggle, header button, transport failure).
|
||||
// Extends the real TypedEventTarget so the App's `addEventListener("disconnect",
|
||||
// …)` wiring is real; the test fires `dispatchEvent(new Event("disconnect"))` to
|
||||
// simulate any of the three disconnect paths (toggle, header button, transport
|
||||
// failure).
|
||||
//
|
||||
// The real base rather than a bare EventTarget because the hooks this fake is
|
||||
// handed to read state through `useStoreSnapshot`, which caches its snapshot
|
||||
// against `getEventRevision` — a method the base owns and a bare EventTarget
|
||||
// does not have (#1955). Inheriting it also means the counter is advanced by
|
||||
// the `dispatchEvent` calls below, exactly as it would be in production.
|
||||
vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@inspector/core/mcp/index.js")>();
|
||||
const { TypedEventTarget } =
|
||||
await import("@inspector/core/mcp/typedEventTarget.js");
|
||||
// Each armed value makes one `connect()` reject, in FIFO order, so a test can
|
||||
// exercise the handshake-failure path — or a two-connect sequence such as the
|
||||
// auth-recovery retry, where the first call rejects with the recovery error
|
||||
|
|
@ -68,7 +77,9 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
|
|||
// control-flow decision from its resolving `false` (#2108): a throw is
|
||||
// surfaced as a failed attempt rather than falling through to the redirect.
|
||||
let nextChallengeCheckRejection: unknown = null;
|
||||
class FakeInspectorClient extends EventTarget {
|
||||
class FakeInspectorClient extends TypedEventTarget<
|
||||
import("@inspector/core/mcp/index.js").InspectorClientEventMap
|
||||
> {
|
||||
connect = vi.fn(() => {
|
||||
if (connectRejections.length > 0) {
|
||||
return Promise.reject(connectRejections.shift());
|
||||
|
|
@ -695,6 +706,50 @@ const clientInstances = (
|
|||
McpIndex as unknown as { __clientInstances: EventTarget[] }
|
||||
).__clientInstances;
|
||||
|
||||
/**
|
||||
* Mirror how the real client enqueues a server-initiated request: put it on the
|
||||
* queue, then announce the change.
|
||||
*
|
||||
* Both halves matter now that `usePendingClientRequests` reads the queue back
|
||||
* off the client (`useStoreSnapshot`, #1955) rather than taking the event's
|
||||
* `detail`. The store is the source of truth and the event is only the signal
|
||||
* that it moved, so dispatching alone would announce a change that isn't there
|
||||
* — and the modal under test would never open.
|
||||
*/
|
||||
function enqueuePendingRequests(
|
||||
client: EventTarget,
|
||||
queue: "getPendingSamples" | "getPendingElicitations",
|
||||
event: "pendingSamplesChange" | "pendingElicitationsChange",
|
||||
entries: readonly unknown[],
|
||||
): void {
|
||||
const withQueue = client as EventTarget &
|
||||
Record<typeof queue, ReturnType<typeof vi.fn>>;
|
||||
withQueue[queue].mockReturnValue(entries);
|
||||
client.dispatchEvent(new CustomEvent(event, { detail: entries }));
|
||||
}
|
||||
|
||||
const enqueuePendingSamples = (
|
||||
client: EventTarget,
|
||||
samples: readonly unknown[],
|
||||
): void =>
|
||||
enqueuePendingRequests(
|
||||
client,
|
||||
"getPendingSamples",
|
||||
"pendingSamplesChange",
|
||||
samples,
|
||||
);
|
||||
|
||||
const enqueuePendingElicitations = (
|
||||
client: EventTarget,
|
||||
elicitations: readonly unknown[],
|
||||
): void =>
|
||||
enqueuePendingRequests(
|
||||
client,
|
||||
"getPendingElicitations",
|
||||
"pendingElicitationsChange",
|
||||
elicitations,
|
||||
);
|
||||
|
||||
// The mock factory adds four test-only arming hooks to the module namespace.
|
||||
// Intersecting with `typeof McpIndex` keeps the real module's shape checked and
|
||||
// narrows this to a single cast — `as unknown as` would discard the former and
|
||||
|
|
@ -1417,9 +1472,7 @@ describe("App pending server-initiated request modal", () => {
|
|||
reject: vi.fn(),
|
||||
};
|
||||
act(() => {
|
||||
clientInstances[0].dispatchEvent(
|
||||
new CustomEvent("pendingSamplesChange", { detail: [sample] }),
|
||||
);
|
||||
enqueuePendingSamples(clientInstances[0], [sample]);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
|
|
@ -1433,9 +1486,7 @@ describe("App pending server-initiated request modal", () => {
|
|||
|
||||
// The client clearing its queue (empty event) closes the modal.
|
||||
act(() => {
|
||||
clientInstances[0].dispatchEvent(
|
||||
new CustomEvent("pendingSamplesChange", { detail: [] }),
|
||||
);
|
||||
enqueuePendingSamples(clientInstances[0], []);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Sampling Request")).not.toBeInTheDocument(),
|
||||
|
|
@ -1581,9 +1632,7 @@ describe("App task wiring", () => {
|
|||
respond,
|
||||
};
|
||||
act(() => {
|
||||
clientInstances[0].dispatchEvent(
|
||||
new CustomEvent("pendingElicitationsChange", { detail: [elicitation] }),
|
||||
);
|
||||
enqueuePendingElicitations(clientInstances[0], [elicitation]);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
|
|
|
|||
218
clients/web/src/test/core/react/useStoreSnapshot.test.tsx
Normal file
218
clients/web/src/test/core/react/useStoreSnapshot.test.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { describe, it, expect, beforeEach } 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("stops listening on unmount", () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useStoreSnapshot(store, "itemsChange", readItems, NO_ITEMS),
|
||||
);
|
||||
const before = result.current;
|
||||
unmount();
|
||||
|
||||
// No act(): after unmount there is nothing to re-render, and a listener
|
||||
// still attached here would be a leak rather than an update.
|
||||
store.add("a");
|
||||
expect(result.current).toBe(before);
|
||||
});
|
||||
|
||||
it("unmounts cleanly with no store to unsubscribe from", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useStoreSnapshot(null, "itemsChange", readItems, NO_ITEMS),
|
||||
);
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -25,6 +25,55 @@ export class TypedEventGeneric<
|
|||
* implementation so the subclass remains assignable to EventTarget.
|
||||
*/
|
||||
export class TypedEventTarget<EventMap extends object> extends EventTarget {
|
||||
/**
|
||||
* Per-event-type dispatch counter. Read through `getEventRevision` by
|
||||
* `useStoreSnapshot` (core/react) to tell "nothing has happened on this
|
||||
* event since I last looked" from "something has", without comparing the
|
||||
* snapshot's contents (#1955).
|
||||
*/
|
||||
private readonly eventRevisions = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* How many times an event of this type has been dispatched from this
|
||||
* target. Monotonic per type, and per type only: an `errorChange` does not
|
||||
* advance `toolsChange`, so a subscriber to one is not re-read because of
|
||||
* the other.
|
||||
*
|
||||
* The counter — not the list's contents — is what a `useSyncExternalStore`
|
||||
* snapshot is cached against. That distinction is load-bearing rather than
|
||||
* an optimization: these stores hand out a defensive copy, so comparing
|
||||
* contents is the only alternative, and a contents comparison cannot see an
|
||||
* in-place mutation of an entry the list already holds. `MessageLogState`
|
||||
* does exactly that when a response is folded into its request entry
|
||||
* (filling in `response`/`duration`), and it dispatches — so the counter
|
||||
* moves and the fold reaches the UI, where a shallow compare would report
|
||||
* "unchanged" and never re-render it.
|
||||
*/
|
||||
getEventRevision(type: keyof EventMap & string): number {
|
||||
return this.eventRevisions.get(type) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overridden solely to advance the counter. It sits on `dispatchEvent`
|
||||
* rather than on `dispatchTypedEvent` because that is the one funnel every
|
||||
* dispatch passes through — the typed helper below delegates to it, and so
|
||||
* does a hand-rolled `dispatchEvent(new CustomEvent(...))`, which the tests
|
||||
* use extensively. A counter bumped only in the typed helper would be
|
||||
* silently stale for those, i.e. exactly where nobody would look for it.
|
||||
*
|
||||
* Bumped BEFORE dispatching: the state mutation always precedes the
|
||||
* dispatch, so by the time a listener runs the counter must already reflect
|
||||
* it, or a snapshot read from inside that listener would return the cached
|
||||
* pre-change value.
|
||||
*/
|
||||
override dispatchEvent(event: Event): boolean {
|
||||
this.eventRevisions.set(
|
||||
event.type,
|
||||
(this.eventRevisions.get(event.type) ?? 0) + 1,
|
||||
);
|
||||
return super.dispatchEvent(event);
|
||||
}
|
||||
|
||||
dispatchTypedEvent<K extends keyof EventMap>(
|
||||
type: K,
|
||||
...args: EventMap[K] extends void ? [] : [detail: EventMap[K]]
|
||||
|
|
|
|||
15
core/react/pagination.ts
Normal file
15
core/react/pagination.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { PagePaginationState } from "../mcp/state/pagedToolsState.js";
|
||||
|
||||
/**
|
||||
* The pagination snapshot reported while no paged store is attached (no
|
||||
* active server): nothing loaded, nowhere to go next.
|
||||
*
|
||||
* Module scope and frozen because it is a `useStoreSnapshot` fallback — a
|
||||
* fresh object literal per render would make the snapshot look different on
|
||||
* every read. Shared by the three `usePaged*` list hooks, which is safe
|
||||
* precisely because it is immutable.
|
||||
*/
|
||||
export const NO_PAGINATION: PagePaginationState = Object.freeze({
|
||||
nextCursor: undefined,
|
||||
pageCount: 0,
|
||||
});
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import type { FetchRequestEntry } from "../mcp/types.js";
|
||||
import type {
|
||||
FetchRequestLogState,
|
||||
FetchRequestLogStateEventMap,
|
||||
} from "../mcp/state/fetchRequestLogState.js";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import type { FetchRequestLogState } from "../mcp/state/fetchRequestLogState.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
|
||||
/**
|
||||
* Shared stable empty list for the no-server case. Module scope so the
|
||||
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
|
||||
* Read-only by contract: nothing mutates a list this hook returns.
|
||||
*/
|
||||
const NO_FETCH_REQUESTS: FetchRequestEntry[] = [];
|
||||
|
||||
const readFetchRequests = (state: FetchRequestLogState): FetchRequestEntry[] =>
|
||||
state.getFetchRequests();
|
||||
|
||||
export interface UseFetchRequestLogResult {
|
||||
fetchRequests: FetchRequestEntry[];
|
||||
|
|
@ -17,35 +23,12 @@ export interface UseFetchRequestLogResult {
|
|||
export function useFetchRequestLog(
|
||||
fetchRequestLogState: FetchRequestLogState | null,
|
||||
): UseFetchRequestLogResult {
|
||||
const [fetchRequests, setFetchRequests] = useState<FetchRequestEntry[]>(
|
||||
fetchRequestLogState?.getFetchRequests() ?? [],
|
||||
const fetchRequests = useStoreSnapshot(
|
||||
fetchRequestLogState,
|
||||
"fetchRequestsChange",
|
||||
readFetchRequests,
|
||||
NO_FETCH_REQUESTS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchRequestLogState) {
|
||||
setFetchRequests([]);
|
||||
return;
|
||||
}
|
||||
setFetchRequests(fetchRequestLogState.getFetchRequests());
|
||||
const onFetchRequestsChange = (
|
||||
event: TypedEventGeneric<
|
||||
FetchRequestLogStateEventMap,
|
||||
"fetchRequestsChange"
|
||||
>,
|
||||
) => {
|
||||
setFetchRequests(event.detail);
|
||||
};
|
||||
fetchRequestLogState.addEventListener(
|
||||
"fetchRequestsChange",
|
||||
onFetchRequestsChange,
|
||||
);
|
||||
return () => {
|
||||
fetchRequestLogState.removeEventListener(
|
||||
"fetchRequestsChange",
|
||||
onFetchRequestsChange,
|
||||
);
|
||||
};
|
||||
}, [fetchRequestLogState]);
|
||||
|
||||
return { fetchRequests };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type { AppRendererClient } from "../mcp/inspectorClientProtocol.js";
|
||||
import type { TypedEvent } from "../mcp/inspectorClientEventTarget.js";
|
||||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
} from "@modelcontextprotocol/client";
|
||||
import type { ExcludedTool } from "../mcp/types.js";
|
||||
import type { MalformedListItem } from "../mcp/listSalvage.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
|
||||
// Module-scope frozen object so the `?? EMPTY_CLIENT_CAPABILITIES`
|
||||
// fallback below doesn't return a fresh literal on every render —
|
||||
|
|
@ -19,6 +20,40 @@ import type { MalformedListItem } from "../mcp/listSalvage.js";
|
|||
// would otherwise invalidate every tick when no client is attached.
|
||||
const EMPTY_CLIENT_CAPABILITIES: ClientCapabilities = Object.freeze({});
|
||||
|
||||
/**
|
||||
* Stable fallbacks for the no-client case, module scope for the same reason as
|
||||
* the constant above — a `useStoreSnapshot` fallback built in the component
|
||||
* body would look like a new value on every read.
|
||||
*/
|
||||
const NO_EXCLUDED_TOOLS: ExcludedTool[] = [];
|
||||
const NO_MALFORMED_LIST_ITEMS: MalformedListItem[] = [];
|
||||
|
||||
const readStatus = (client: InspectorClientProtocol): ConnectionStatus =>
|
||||
client.getStatus();
|
||||
const readCapabilities = (
|
||||
client: InspectorClientProtocol,
|
||||
): ServerCapabilities | undefined => client.getCapabilities();
|
||||
const readServerInfo = (
|
||||
client: InspectorClientProtocol,
|
||||
): Implementation | undefined => client.getServerInfo();
|
||||
const readInstructions = (
|
||||
client: InspectorClientProtocol,
|
||||
): string | undefined => client.getInstructions();
|
||||
const readProtocolVersion = (
|
||||
client: InspectorClientProtocol,
|
||||
): string | undefined => client.getProtocolVersion();
|
||||
const readProtocolEra = (
|
||||
client: InspectorClientProtocol,
|
||||
): ProtocolEra | undefined => client.getProtocolEra();
|
||||
const readDiscoverResult = (
|
||||
client: InspectorClientProtocol,
|
||||
): DiscoverResult | undefined => client.getDiscoverResult();
|
||||
const readExcludedTools = (client: InspectorClientProtocol): ExcludedTool[] =>
|
||||
client.getExcludedTools();
|
||||
const readMalformedListItems = (
|
||||
client: InspectorClientProtocol,
|
||||
): MalformedListItem[] => client.getMalformedListItems();
|
||||
|
||||
export interface UseInspectorClientResult {
|
||||
status: ConnectionStatus;
|
||||
capabilities?: ServerCapabilities;
|
||||
|
|
@ -69,6 +104,11 @@ export interface UseInspectorClientResult {
|
|||
* connection state. Log lists (message / stderr / fetch) live in dedicated
|
||||
* state managers consumed via useMessageLog / useStderrLog / useFetchRequestLog.
|
||||
*
|
||||
* Every value the client can be *asked* for is read through `useStoreSnapshot`
|
||||
* rather than mirrored into local state by an effect (#1955): the effect ran
|
||||
* after the commit, so swapping servers painted one frame of the previous
|
||||
* client's status and capabilities before correcting itself.
|
||||
*
|
||||
* Note: `appRendererClient` is read lazily from the client on every render
|
||||
* and is NOT subscribed. It changes once at connect time and is not expected
|
||||
* to change again during a session, so callers will see the current value
|
||||
|
|
@ -80,169 +120,99 @@ export interface UseInspectorClientResult {
|
|||
export function useInspectorClient(
|
||||
inspectorClient: InspectorClientProtocol | null,
|
||||
): UseInspectorClientResult {
|
||||
const [status, setStatus] = useState<ConnectionStatus>(
|
||||
inspectorClient?.getStatus() ?? "disconnected",
|
||||
const status = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"statusChange",
|
||||
readStatus,
|
||||
"disconnected" as ConnectionStatus,
|
||||
);
|
||||
const [capabilities, setCapabilities] = useState<
|
||||
ServerCapabilities | undefined
|
||||
>(inspectorClient?.getCapabilities());
|
||||
const [serverInfo, setServerInfo] = useState<Implementation | undefined>(
|
||||
inspectorClient?.getServerInfo(),
|
||||
const capabilities = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"capabilitiesChange",
|
||||
readCapabilities,
|
||||
undefined,
|
||||
);
|
||||
const [instructions, setInstructions] = useState<string | undefined>(
|
||||
inspectorClient?.getInstructions(),
|
||||
const serverInfo = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"serverInfoChange",
|
||||
readServerInfo,
|
||||
undefined,
|
||||
);
|
||||
const [protocolVersion, setProtocolVersion] = useState<string | undefined>(
|
||||
inspectorClient?.getProtocolVersion(),
|
||||
const instructions = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"instructionsChange",
|
||||
readInstructions,
|
||||
undefined,
|
||||
);
|
||||
const [protocolEra, setProtocolEra] = useState<ProtocolEra | undefined>(
|
||||
inspectorClient?.getProtocolEra(),
|
||||
const protocolVersion = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"protocolVersionChange",
|
||||
readProtocolVersion,
|
||||
undefined,
|
||||
);
|
||||
const [discoverResult, setDiscoverResult] = useState<
|
||||
DiscoverResult | undefined
|
||||
>(inspectorClient?.getDiscoverResult());
|
||||
const [excludedTools, setExcludedTools] = useState<ExcludedTool[]>(
|
||||
inspectorClient?.getExcludedTools() ?? [],
|
||||
const protocolEra = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"protocolEraChange",
|
||||
readProtocolEra,
|
||||
undefined,
|
||||
);
|
||||
const [malformedListItems, setMalformedListItems] = useState<
|
||||
MalformedListItem[]
|
||||
>(inspectorClient?.getMalformedListItems() ?? []);
|
||||
const [lastError, setLastError] = useState<string | undefined>(undefined);
|
||||
const discoverResult = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"discoverResultChange",
|
||||
readDiscoverResult,
|
||||
undefined,
|
||||
);
|
||||
const excludedTools = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"excludedToolsChange",
|
||||
readExcludedTools,
|
||||
NO_EXCLUDED_TOOLS,
|
||||
);
|
||||
const malformedListItems = useStoreSnapshot(
|
||||
inspectorClient,
|
||||
"malformedListItemsChange",
|
||||
readMalformedListItems,
|
||||
NO_MALFORMED_LIST_ITEMS,
|
||||
);
|
||||
|
||||
// `lastError` is the one value here that is NOT a snapshot of something the
|
||||
// client stores: there is no `getLastError()`, because the client emits the
|
||||
// failure and moves on. It is accumulated from the event stream instead, so
|
||||
// it stays local state — but it is still tied to one client, and must reset
|
||||
// when a different one is passed in.
|
||||
//
|
||||
// That reset is done during render (React's documented "adjusting state
|
||||
// when a prop changes" pattern, the same one `useValueChange` implements for
|
||||
// the web client) rather than in an effect. An effect would paint one frame
|
||||
// carrying the previous client's error, which is precisely the defect this
|
||||
// hook was converted to fix.
|
||||
const [errorState, setErrorState] = useState<{
|
||||
client: InspectorClientProtocol | null;
|
||||
message?: string;
|
||||
}>({ client: inspectorClient });
|
||||
if (errorState.client !== inspectorClient) {
|
||||
setErrorState({ client: inspectorClient });
|
||||
}
|
||||
const lastError =
|
||||
errorState.client === inspectorClient ? errorState.message : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!inspectorClient) {
|
||||
setStatus("disconnected");
|
||||
setCapabilities(undefined);
|
||||
setServerInfo(undefined);
|
||||
setInstructions(undefined);
|
||||
setProtocolVersion(undefined);
|
||||
setProtocolEra(undefined);
|
||||
setDiscoverResult(undefined);
|
||||
setExcludedTools([]);
|
||||
setMalformedListItems([]);
|
||||
setLastError(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(inspectorClient.getStatus());
|
||||
setCapabilities(inspectorClient.getCapabilities());
|
||||
setServerInfo(inspectorClient.getServerInfo());
|
||||
setInstructions(inspectorClient.getInstructions());
|
||||
setProtocolVersion(inspectorClient.getProtocolVersion());
|
||||
setProtocolEra(inspectorClient.getProtocolEra());
|
||||
setDiscoverResult(inspectorClient.getDiscoverResult());
|
||||
setExcludedTools(inspectorClient.getExcludedTools());
|
||||
setMalformedListItems(inspectorClient.getMalformedListItems());
|
||||
setLastError(undefined);
|
||||
|
||||
if (!inspectorClient) return;
|
||||
const onStatusChange = (event: TypedEvent<"statusChange">) => {
|
||||
setStatus(event.detail);
|
||||
// A fresh connection attempt clears any stale error from the prior
|
||||
// session so the UI doesn't keep showing why the last transport died.
|
||||
if (event.detail === "connecting") {
|
||||
setLastError(undefined);
|
||||
setErrorState({ client: inspectorClient });
|
||||
}
|
||||
};
|
||||
const onError = (event: TypedEvent<"error">) => {
|
||||
setLastError(event.detail.message);
|
||||
setErrorState({ client: inspectorClient, message: event.detail.message });
|
||||
};
|
||||
const onCapabilitiesChange = (event: TypedEvent<"capabilitiesChange">) => {
|
||||
setCapabilities(event.detail);
|
||||
};
|
||||
const onServerInfoChange = (event: TypedEvent<"serverInfoChange">) => {
|
||||
setServerInfo(event.detail);
|
||||
};
|
||||
const onInstructionsChange = (event: TypedEvent<"instructionsChange">) => {
|
||||
setInstructions(event.detail);
|
||||
};
|
||||
const onProtocolVersionChange = (
|
||||
event: TypedEvent<"protocolVersionChange">,
|
||||
) => {
|
||||
setProtocolVersion(event.detail);
|
||||
};
|
||||
const onProtocolEraChange = (event: TypedEvent<"protocolEraChange">) => {
|
||||
setProtocolEra(event.detail);
|
||||
};
|
||||
const onDiscoverResultChange = (
|
||||
event: TypedEvent<"discoverResultChange">,
|
||||
) => {
|
||||
setDiscoverResult(event.detail);
|
||||
};
|
||||
const onMalformedListItemsChange = (
|
||||
event: TypedEvent<"malformedListItemsChange">,
|
||||
) => {
|
||||
setMalformedListItems(event.detail);
|
||||
};
|
||||
const onExcludedToolsChange = (
|
||||
event: TypedEvent<"excludedToolsChange">,
|
||||
) => {
|
||||
setExcludedTools(event.detail);
|
||||
};
|
||||
|
||||
inspectorClient.addEventListener("statusChange", onStatusChange);
|
||||
inspectorClient.addEventListener("error", onError);
|
||||
inspectorClient.addEventListener(
|
||||
"capabilitiesChange",
|
||||
onCapabilitiesChange,
|
||||
);
|
||||
inspectorClient.addEventListener("serverInfoChange", onServerInfoChange);
|
||||
inspectorClient.addEventListener(
|
||||
"instructionsChange",
|
||||
onInstructionsChange,
|
||||
);
|
||||
inspectorClient.addEventListener(
|
||||
"protocolVersionChange",
|
||||
onProtocolVersionChange,
|
||||
);
|
||||
inspectorClient.addEventListener("protocolEraChange", onProtocolEraChange);
|
||||
inspectorClient.addEventListener(
|
||||
"discoverResultChange",
|
||||
onDiscoverResultChange,
|
||||
);
|
||||
inspectorClient.addEventListener(
|
||||
"excludedToolsChange",
|
||||
onExcludedToolsChange,
|
||||
);
|
||||
inspectorClient.addEventListener(
|
||||
"malformedListItemsChange",
|
||||
onMalformedListItemsChange,
|
||||
);
|
||||
|
||||
return () => {
|
||||
inspectorClient.removeEventListener("statusChange", onStatusChange);
|
||||
inspectorClient.removeEventListener("error", onError);
|
||||
inspectorClient.removeEventListener(
|
||||
"capabilitiesChange",
|
||||
onCapabilitiesChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"serverInfoChange",
|
||||
onServerInfoChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"instructionsChange",
|
||||
onInstructionsChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"protocolVersionChange",
|
||||
onProtocolVersionChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"protocolEraChange",
|
||||
onProtocolEraChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"discoverResultChange",
|
||||
onDiscoverResultChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"excludedToolsChange",
|
||||
onExcludedToolsChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"malformedListItemsChange",
|
||||
onMalformedListItemsChange,
|
||||
);
|
||||
};
|
||||
}, [inspectorClient]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type {
|
||||
ManagedPromptsState,
|
||||
ManagedPromptsStateEventMap,
|
||||
} from "../mcp/state/managedPromptsState.js";
|
||||
import type { ManagedPromptsState } from "../mcp/state/managedPromptsState.js";
|
||||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
|
||||
/**
|
||||
* Shared stable empty list for the no-server case. Module scope so the
|
||||
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
|
||||
* Read-only by contract: nothing mutates a list this hook returns.
|
||||
*/
|
||||
const NO_PROMPTS: Prompt[] = [];
|
||||
|
||||
const readPrompts = (state: ManagedPromptsState): Prompt[] =>
|
||||
state.getPrompts();
|
||||
const readListChanged = (state: ManagedPromptsState): boolean =>
|
||||
state.getListChanged();
|
||||
|
||||
export interface UseManagedPromptsResult {
|
||||
/**
|
||||
|
|
@ -30,52 +39,23 @@ export function useManagedPrompts(
|
|||
client: InspectorClientProtocol | null,
|
||||
managedPromptsState: ManagedPromptsState | null,
|
||||
): UseManagedPromptsResult {
|
||||
const [prompts, setPrompts] = useState<Prompt[]>(
|
||||
managedPromptsState?.getPrompts() ?? [],
|
||||
const prompts = useStoreSnapshot(
|
||||
managedPromptsState,
|
||||
"promptsChange",
|
||||
readPrompts,
|
||||
NO_PROMPTS,
|
||||
);
|
||||
const [listChanged, setListChanged] = useState<boolean>(
|
||||
managedPromptsState?.getListChanged() ?? false,
|
||||
const listChanged = useStoreSnapshot(
|
||||
managedPromptsState,
|
||||
"listChangedChange",
|
||||
readListChanged,
|
||||
false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!managedPromptsState) {
|
||||
setPrompts([]);
|
||||
setListChanged(false);
|
||||
return;
|
||||
}
|
||||
setPrompts(managedPromptsState.getPrompts());
|
||||
setListChanged(managedPromptsState.getListChanged());
|
||||
const onPromptsChange = (
|
||||
event: TypedEventGeneric<ManagedPromptsStateEventMap, "promptsChange">,
|
||||
) => {
|
||||
setPrompts(event.detail);
|
||||
};
|
||||
const onListChangedChange = (
|
||||
event: TypedEventGeneric<
|
||||
ManagedPromptsStateEventMap,
|
||||
"listChangedChange"
|
||||
>,
|
||||
) => {
|
||||
setListChanged(event.detail);
|
||||
};
|
||||
managedPromptsState.addEventListener("promptsChange", onPromptsChange);
|
||||
managedPromptsState.addEventListener(
|
||||
"listChangedChange",
|
||||
onListChangedChange,
|
||||
);
|
||||
return () => {
|
||||
managedPromptsState.removeEventListener("promptsChange", onPromptsChange);
|
||||
managedPromptsState.removeEventListener(
|
||||
"listChangedChange",
|
||||
onListChangedChange,
|
||||
);
|
||||
};
|
||||
}, [managedPromptsState]);
|
||||
|
||||
const error = useListError(managedPromptsState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Prompt[]> => {
|
||||
if (!managedPromptsState || !client) return [];
|
||||
if (!managedPromptsState || !client) return NO_PROMPTS;
|
||||
// A user-initiated refresh acknowledges the change — clear the indicator
|
||||
// BEFORE awaiting the fetch, not after. If a `prompts/list_changed` arrives
|
||||
// mid-fetch, the state re-sets the flag (and auto-refreshes); clearing
|
||||
|
|
@ -84,10 +64,9 @@ export function useManagedPrompts(
|
|||
managedPromptsState.clearListChanged();
|
||||
// A user-initiated refresh forces a cache-bypassing round trip
|
||||
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
|
||||
// return stale — and re-stores the fresh aggregate.
|
||||
const next = await managedPromptsState.refresh(undefined, "refresh");
|
||||
setPrompts(next);
|
||||
return next;
|
||||
// return stale — and re-stores the fresh aggregate. The store dispatches
|
||||
// `promptsChange` as it commits, so the snapshot above updates on its own.
|
||||
return managedPromptsState.refresh(undefined, "refresh");
|
||||
}, [client, managedPromptsState]);
|
||||
|
||||
const clearListChanged = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type {
|
||||
ManagedRequestorTasksState,
|
||||
ManagedRequestorTasksStateEventMap,
|
||||
} from "../mcp/state/managedRequestorTasksState.js";
|
||||
import type { ManagedRequestorTasksState } from "../mcp/state/managedRequestorTasksState.js";
|
||||
import type { Task } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
|
||||
/**
|
||||
* Shared stable empty list for the no-server case. Module scope so the
|
||||
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
|
||||
* Read-only by contract: nothing mutates a list this hook returns.
|
||||
*/
|
||||
const NO_TASKS: Task[] = [];
|
||||
|
||||
const readTasks = (state: ManagedRequestorTasksState): Task[] =>
|
||||
state.getTasks();
|
||||
|
||||
export interface UseManagedRequestorTasksResult {
|
||||
tasks: Task[];
|
||||
|
|
@ -21,38 +28,18 @@ export function useManagedRequestorTasks(
|
|||
client: InspectorClientProtocol | null,
|
||||
managedRequestorTasksState: ManagedRequestorTasksState | null,
|
||||
): UseManagedRequestorTasksResult {
|
||||
const [tasks, setTasks] = useState<Task[]>(
|
||||
managedRequestorTasksState?.getTasks() ?? [],
|
||||
const tasks = useStoreSnapshot(
|
||||
managedRequestorTasksState,
|
||||
"tasksChange",
|
||||
readTasks,
|
||||
NO_TASKS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!managedRequestorTasksState) {
|
||||
setTasks([]);
|
||||
return;
|
||||
}
|
||||
setTasks(managedRequestorTasksState.getTasks());
|
||||
const onTasksChange = (
|
||||
event: TypedEventGeneric<
|
||||
ManagedRequestorTasksStateEventMap,
|
||||
"tasksChange"
|
||||
>,
|
||||
) => {
|
||||
setTasks(event.detail);
|
||||
};
|
||||
managedRequestorTasksState.addEventListener("tasksChange", onTasksChange);
|
||||
return () => {
|
||||
managedRequestorTasksState.removeEventListener(
|
||||
"tasksChange",
|
||||
onTasksChange,
|
||||
);
|
||||
};
|
||||
}, [managedRequestorTasksState]);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Task[]> => {
|
||||
if (!managedRequestorTasksState || !client) return [];
|
||||
const next = await managedRequestorTasksState.refresh();
|
||||
setTasks(next);
|
||||
return next;
|
||||
if (!managedRequestorTasksState || !client) return NO_TASKS;
|
||||
// The store dispatches `tasksChange` as it commits the new list, so the
|
||||
// snapshot above updates on its own — nothing local to push it into.
|
||||
return managedRequestorTasksState.refresh();
|
||||
}, [client, managedRequestorTasksState]);
|
||||
|
||||
const clearCompleted = useCallback((): void => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type {
|
||||
ManagedResourceTemplatesState,
|
||||
ManagedResourceTemplatesStateEventMap,
|
||||
} from "../mcp/state/managedResourceTemplatesState.js";
|
||||
import type { ManagedResourceTemplatesState } from "../mcp/state/managedResourceTemplatesState.js";
|
||||
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
|
||||
/**
|
||||
* Shared stable empty list for the no-server case. Module scope so the
|
||||
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
|
||||
* Read-only by contract: nothing mutates a list this hook returns.
|
||||
*/
|
||||
const NO_RESOURCE_TEMPLATES: ResourceTemplate[] = [];
|
||||
|
||||
const readResourceTemplates = (
|
||||
state: ManagedResourceTemplatesState,
|
||||
): ResourceTemplate[] => state.getResourceTemplates();
|
||||
|
||||
export interface UseManagedResourceTemplatesResult {
|
||||
/**
|
||||
|
|
@ -27,49 +35,25 @@ export function useManagedResourceTemplates(
|
|||
client: InspectorClientProtocol | null,
|
||||
managedResourceTemplatesState: ManagedResourceTemplatesState | null,
|
||||
): UseManagedResourceTemplatesResult {
|
||||
const [resourceTemplates, setResourceTemplates] = useState<
|
||||
ResourceTemplate[]
|
||||
>(managedResourceTemplatesState?.getResourceTemplates() ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!managedResourceTemplatesState) {
|
||||
setResourceTemplates([]);
|
||||
return;
|
||||
}
|
||||
setResourceTemplates(managedResourceTemplatesState.getResourceTemplates());
|
||||
const onResourceTemplatesChange = (
|
||||
event: TypedEventGeneric<
|
||||
ManagedResourceTemplatesStateEventMap,
|
||||
"resourceTemplatesChange"
|
||||
>,
|
||||
) => {
|
||||
setResourceTemplates(event.detail);
|
||||
};
|
||||
managedResourceTemplatesState.addEventListener(
|
||||
"resourceTemplatesChange",
|
||||
onResourceTemplatesChange,
|
||||
);
|
||||
return () => {
|
||||
managedResourceTemplatesState.removeEventListener(
|
||||
"resourceTemplatesChange",
|
||||
onResourceTemplatesChange,
|
||||
);
|
||||
};
|
||||
}, [managedResourceTemplatesState]);
|
||||
const resourceTemplates = useStoreSnapshot(
|
||||
managedResourceTemplatesState,
|
||||
"resourceTemplatesChange",
|
||||
readResourceTemplates,
|
||||
NO_RESOURCE_TEMPLATES,
|
||||
);
|
||||
|
||||
const error = useListError(managedResourceTemplatesState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<ResourceTemplate[]> => {
|
||||
if (!managedResourceTemplatesState || !client) return [];
|
||||
if (!managedResourceTemplatesState || !client) {
|
||||
return NO_RESOURCE_TEMPLATES;
|
||||
}
|
||||
// A user-initiated refresh forces a cache-bypassing round trip
|
||||
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
|
||||
// return stale — and re-stores the fresh aggregate.
|
||||
const next = await managedResourceTemplatesState.refresh(
|
||||
undefined,
|
||||
"refresh",
|
||||
);
|
||||
setResourceTemplates(next);
|
||||
return next;
|
||||
// return stale — and re-stores the fresh aggregate. The store dispatches
|
||||
// `resourceTemplatesChange` as it commits, so the snapshot updates on its
|
||||
// own.
|
||||
return managedResourceTemplatesState.refresh(undefined, "refresh");
|
||||
}, [client, managedResourceTemplatesState]);
|
||||
|
||||
return { resourceTemplates, error, refresh };
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type {
|
||||
ManagedResourcesState,
|
||||
ManagedResourcesStateEventMap,
|
||||
} from "../mcp/state/managedResourcesState.js";
|
||||
import type { ManagedResourcesState } from "../mcp/state/managedResourcesState.js";
|
||||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
|
||||
/**
|
||||
* Shared stable empty list for the no-server case. Module scope so the
|
||||
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
|
||||
* Read-only by contract: nothing mutates a list this hook returns.
|
||||
*/
|
||||
const NO_RESOURCES: Resource[] = [];
|
||||
|
||||
const readResources = (state: ManagedResourcesState): Resource[] =>
|
||||
state.getResources();
|
||||
const readListChanged = (state: ManagedResourcesState): boolean =>
|
||||
state.getListChanged();
|
||||
|
||||
export interface UseManagedResourcesResult {
|
||||
/**
|
||||
|
|
@ -32,61 +41,23 @@ export function useManagedResources(
|
|||
client: InspectorClientProtocol | null,
|
||||
managedResourcesState: ManagedResourcesState | null,
|
||||
): UseManagedResourcesResult {
|
||||
const [resources, setResources] = useState<Resource[]>(
|
||||
managedResourcesState?.getResources() ?? [],
|
||||
const resources = useStoreSnapshot(
|
||||
managedResourcesState,
|
||||
"resourcesChange",
|
||||
readResources,
|
||||
NO_RESOURCES,
|
||||
);
|
||||
const [listChanged, setListChanged] = useState<boolean>(
|
||||
managedResourcesState?.getListChanged() ?? false,
|
||||
const listChanged = useStoreSnapshot(
|
||||
managedResourcesState,
|
||||
"listChangedChange",
|
||||
readListChanged,
|
||||
false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!managedResourcesState) {
|
||||
setResources([]);
|
||||
setListChanged(false);
|
||||
return;
|
||||
}
|
||||
setResources(managedResourcesState.getResources());
|
||||
setListChanged(managedResourcesState.getListChanged());
|
||||
const onResourcesChange = (
|
||||
event: TypedEventGeneric<
|
||||
ManagedResourcesStateEventMap,
|
||||
"resourcesChange"
|
||||
>,
|
||||
) => {
|
||||
setResources(event.detail);
|
||||
};
|
||||
const onListChangedChange = (
|
||||
event: TypedEventGeneric<
|
||||
ManagedResourcesStateEventMap,
|
||||
"listChangedChange"
|
||||
>,
|
||||
) => {
|
||||
setListChanged(event.detail);
|
||||
};
|
||||
managedResourcesState.addEventListener(
|
||||
"resourcesChange",
|
||||
onResourcesChange,
|
||||
);
|
||||
managedResourcesState.addEventListener(
|
||||
"listChangedChange",
|
||||
onListChangedChange,
|
||||
);
|
||||
return () => {
|
||||
managedResourcesState.removeEventListener(
|
||||
"resourcesChange",
|
||||
onResourcesChange,
|
||||
);
|
||||
managedResourcesState.removeEventListener(
|
||||
"listChangedChange",
|
||||
onListChangedChange,
|
||||
);
|
||||
};
|
||||
}, [managedResourcesState]);
|
||||
|
||||
const error = useListError(managedResourcesState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Resource[]> => {
|
||||
if (!managedResourcesState || !client) return [];
|
||||
if (!managedResourcesState || !client) return NO_RESOURCES;
|
||||
// A user-initiated refresh acknowledges the change — clear the indicator
|
||||
// BEFORE awaiting the fetch, not after. If a `resources/list_changed`
|
||||
// arrives mid-fetch, the state re-sets the flag (and auto-refreshes);
|
||||
|
|
@ -95,10 +66,9 @@ export function useManagedResources(
|
|||
managedResourcesState.clearListChanged();
|
||||
// A user-initiated refresh forces a cache-bypassing round trip
|
||||
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
|
||||
// return stale — and re-stores the fresh aggregate.
|
||||
const next = await managedResourcesState.refresh(undefined, "refresh");
|
||||
setResources(next);
|
||||
return next;
|
||||
// return stale — and re-stores the fresh aggregate. The store dispatches
|
||||
// `resourcesChange` as it commits, so the snapshot updates on its own.
|
||||
return managedResourcesState.refresh(undefined, "refresh");
|
||||
}, [client, managedResourcesState]);
|
||||
|
||||
const clearListChanged = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type { ManagedToolsState } from "../mcp/state/managedToolsState.js";
|
||||
import type { ManagedToolsStateEventMap } from "../mcp/state/managedToolsState.js";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
|
||||
/**
|
||||
* Shared stable empty list for the no-server case. Module scope so the
|
||||
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
|
||||
* Read-only by contract: nothing mutates a list this hook returns.
|
||||
*/
|
||||
const NO_TOOLS: Tool[] = [];
|
||||
|
||||
const readTools = (state: ManagedToolsState): Tool[] => state.getTools();
|
||||
const readListChanged = (state: ManagedToolsState): boolean =>
|
||||
state.getListChanged();
|
||||
|
||||
export interface UseManagedToolsResult {
|
||||
/**
|
||||
|
|
@ -33,49 +43,23 @@ export function useManagedTools(
|
|||
client: InspectorClientProtocol | null,
|
||||
managedToolsState: ManagedToolsState | null,
|
||||
): UseManagedToolsResult {
|
||||
const [tools, setTools] = useState<Tool[]>(
|
||||
managedToolsState?.getTools() ?? [],
|
||||
const tools = useStoreSnapshot(
|
||||
managedToolsState,
|
||||
"toolsChange",
|
||||
readTools,
|
||||
NO_TOOLS,
|
||||
);
|
||||
const [listChanged, setListChanged] = useState<boolean>(
|
||||
managedToolsState?.getListChanged() ?? false,
|
||||
const listChanged = useStoreSnapshot(
|
||||
managedToolsState,
|
||||
"listChangedChange",
|
||||
readListChanged,
|
||||
false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!managedToolsState) {
|
||||
setTools([]);
|
||||
setListChanged(false);
|
||||
return;
|
||||
}
|
||||
setTools(managedToolsState.getTools());
|
||||
setListChanged(managedToolsState.getListChanged());
|
||||
const onToolsChange = (
|
||||
event: TypedEventGeneric<ManagedToolsStateEventMap, "toolsChange">,
|
||||
) => {
|
||||
setTools(event.detail);
|
||||
};
|
||||
const onListChangedChange = (
|
||||
event: TypedEventGeneric<ManagedToolsStateEventMap, "listChangedChange">,
|
||||
) => {
|
||||
setListChanged(event.detail);
|
||||
};
|
||||
managedToolsState.addEventListener("toolsChange", onToolsChange);
|
||||
managedToolsState.addEventListener(
|
||||
"listChangedChange",
|
||||
onListChangedChange,
|
||||
);
|
||||
return () => {
|
||||
managedToolsState.removeEventListener("toolsChange", onToolsChange);
|
||||
managedToolsState.removeEventListener(
|
||||
"listChangedChange",
|
||||
onListChangedChange,
|
||||
);
|
||||
};
|
||||
}, [managedToolsState]);
|
||||
|
||||
const error = useListError(managedToolsState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Tool[]> => {
|
||||
if (!managedToolsState || !client) return [];
|
||||
if (!managedToolsState || !client) return NO_TOOLS;
|
||||
// A user-initiated refresh acknowledges the change — clear the indicator
|
||||
// BEFORE awaiting the fetch, not after. If a `tools/list_changed` arrives
|
||||
// mid-fetch, the state re-sets the flag (and auto-refreshes); clearing
|
||||
|
|
@ -84,10 +68,10 @@ export function useManagedTools(
|
|||
managedToolsState.clearListChanged();
|
||||
// A user-initiated refresh forces a cache-bypassing round trip
|
||||
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
|
||||
// return stale — and re-stores the fresh aggregate.
|
||||
const next = await managedToolsState.refresh(undefined, "refresh");
|
||||
setTools(next);
|
||||
return next;
|
||||
// return stale — and re-stores the fresh aggregate. The store dispatches
|
||||
// `toolsChange` as it commits, so the snapshot above updates on its own;
|
||||
// there is no local state left to push the result into.
|
||||
return managedToolsState.refresh(undefined, "refresh");
|
||||
}, [client, managedToolsState]);
|
||||
|
||||
const clearListChanged = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import type { MessageEntry } from "../mcp/types.js";
|
||||
import type {
|
||||
MessageLogState,
|
||||
MessageLogStateEventMap,
|
||||
} from "../mcp/state/messageLogState.js";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import type { MessageLogState } from "../mcp/state/messageLogState.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
|
||||
/**
|
||||
* Shared stable empty list for the no-server case. Module scope so the
|
||||
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
|
||||
* Read-only by contract: nothing mutates a list this hook returns.
|
||||
*/
|
||||
const NO_MESSAGES: MessageEntry[] = [];
|
||||
|
||||
const readMessages = (state: MessageLogState): MessageEntry[] =>
|
||||
state.getMessages();
|
||||
|
||||
export interface UseMessageLogResult {
|
||||
messages: MessageEntry[];
|
||||
|
|
@ -12,30 +18,22 @@ export interface UseMessageLogResult {
|
|||
|
||||
/**
|
||||
* React hook that subscribes to MessageLogState and returns the message list.
|
||||
*
|
||||
* Note this log's changes are not all appends: folding a response into its
|
||||
* request entry mutates that entry in place and dispatches. The snapshot is
|
||||
* cached against the store's dispatch count rather than the list's contents
|
||||
* precisely so that case still produces a new list identity and re-renders —
|
||||
* see `TypedEventTarget.getEventRevision`.
|
||||
*/
|
||||
export function useMessageLog(
|
||||
messageLogState: MessageLogState | null,
|
||||
): UseMessageLogResult {
|
||||
const [messages, setMessages] = useState<MessageEntry[]>(
|
||||
messageLogState?.getMessages() ?? [],
|
||||
const messages = useStoreSnapshot(
|
||||
messageLogState,
|
||||
"messagesChange",
|
||||
readMessages,
|
||||
NO_MESSAGES,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageLogState) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
setMessages(messageLogState.getMessages());
|
||||
const onMessagesChange = (
|
||||
event: TypedEventGeneric<MessageLogStateEventMap, "messagesChange">,
|
||||
) => {
|
||||
setMessages(event.detail);
|
||||
};
|
||||
messageLogState.addEventListener("messagesChange", onMessagesChange);
|
||||
return () => {
|
||||
messageLogState.removeEventListener("messagesChange", onMessagesChange);
|
||||
};
|
||||
}, [messageLogState]);
|
||||
|
||||
return { messages };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,26 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type { RequestMetadata } from "../mcp/types.js";
|
||||
import type {
|
||||
PagedPromptsState,
|
||||
PagedPromptsStateEventMap,
|
||||
LoadPageResult,
|
||||
} from "../mcp/state/pagedPromptsState.js";
|
||||
import type { PagePaginationState } from "../mcp/state/pagedToolsState.js";
|
||||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
import { NO_PAGINATION } from "./pagination.js";
|
||||
|
||||
/**
|
||||
* Shared stable empty list for the no-server case. Module scope so the
|
||||
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
|
||||
* Read-only by contract: nothing mutates a list this hook returns.
|
||||
*/
|
||||
const NO_PROMPTS: Prompt[] = [];
|
||||
|
||||
const readPrompts = (state: PagedPromptsState): Prompt[] => state.getPrompts();
|
||||
const readPagination = (state: PagedPromptsState): PagePaginationState =>
|
||||
state.getPagination();
|
||||
|
||||
export interface UsePagedPromptsResult {
|
||||
prompts: Prompt[];
|
||||
|
|
@ -38,47 +50,18 @@ export function usePagedPrompts(
|
|||
client: InspectorClientProtocol | null,
|
||||
pagedPromptsState: PagedPromptsState | null,
|
||||
): UsePagedPromptsResult {
|
||||
const [prompts, setPrompts] = useState<Prompt[]>(
|
||||
pagedPromptsState?.getPrompts() ?? [],
|
||||
const prompts = useStoreSnapshot(
|
||||
pagedPromptsState,
|
||||
"promptsChange",
|
||||
readPrompts,
|
||||
NO_PROMPTS,
|
||||
);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(
|
||||
pagedPromptsState?.getPagination().nextCursor,
|
||||
const { nextCursor, pageCount } = useStoreSnapshot(
|
||||
pagedPromptsState,
|
||||
"paginationChange",
|
||||
readPagination,
|
||||
NO_PAGINATION,
|
||||
);
|
||||
const [pageCount, setPageCount] = useState<number>(
|
||||
pagedPromptsState?.getPagination().pageCount ?? 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pagedPromptsState) {
|
||||
setPrompts([]);
|
||||
setNextCursor(undefined);
|
||||
setPageCount(0);
|
||||
return;
|
||||
}
|
||||
setPrompts(pagedPromptsState.getPrompts());
|
||||
setNextCursor(pagedPromptsState.getPagination().nextCursor);
|
||||
setPageCount(pagedPromptsState.getPagination().pageCount);
|
||||
const onPromptsChange = (
|
||||
event: TypedEventGeneric<PagedPromptsStateEventMap, "promptsChange">,
|
||||
) => {
|
||||
setPrompts(event.detail);
|
||||
};
|
||||
const onPaginationChange = (
|
||||
event: TypedEventGeneric<PagedPromptsStateEventMap, "paginationChange">,
|
||||
) => {
|
||||
setNextCursor(event.detail.nextCursor);
|
||||
setPageCount(event.detail.pageCount);
|
||||
};
|
||||
pagedPromptsState.addEventListener("promptsChange", onPromptsChange);
|
||||
pagedPromptsState.addEventListener("paginationChange", onPaginationChange);
|
||||
return () => {
|
||||
pagedPromptsState.removeEventListener("promptsChange", onPromptsChange);
|
||||
pagedPromptsState.removeEventListener(
|
||||
"paginationChange",
|
||||
onPaginationChange,
|
||||
);
|
||||
};
|
||||
}, [pagedPromptsState]);
|
||||
|
||||
const error = useListError(pagedPromptsState);
|
||||
|
||||
|
|
@ -88,7 +71,7 @@ export function usePagedPrompts(
|
|||
metadata?: RequestMetadata,
|
||||
): Promise<LoadPageResult> => {
|
||||
if (!pagedPromptsState || !client) {
|
||||
return { prompts: [], nextCursor: undefined };
|
||||
return { prompts: NO_PROMPTS, nextCursor: undefined };
|
||||
}
|
||||
return pagedPromptsState.loadPage(cursor, metadata);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type {
|
||||
PagedRequestorTasksState,
|
||||
PagedRequestorTasksStateEventMap,
|
||||
LoadPageResult,
|
||||
} from "../mcp/state/pagedRequestorTasksState.js";
|
||||
import type { Task } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
|
||||
/**
|
||||
* Shared stable empty list for the no-server case. Module scope so the
|
||||
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
|
||||
* Read-only by contract: nothing mutates a list this hook returns.
|
||||
*/
|
||||
const NO_TASKS: Task[] = [];
|
||||
|
||||
const readTasks = (state: PagedRequestorTasksState): Task[] => state.getTasks();
|
||||
// This store has no dedicated pagination event — the cursor advances with the
|
||||
// page, so it rides `tasksChange` like the list does.
|
||||
const readNextCursor = (state: PagedRequestorTasksState): string | undefined =>
|
||||
state.getNextCursor() ?? undefined;
|
||||
|
||||
export interface UsePagedRequestorTasksResult {
|
||||
tasks: Task[];
|
||||
|
|
@ -23,45 +35,27 @@ export function usePagedRequestorTasks(
|
|||
client: InspectorClientProtocol | null,
|
||||
pagedRequestorTasksState: PagedRequestorTasksState | null,
|
||||
): UsePagedRequestorTasksResult {
|
||||
const [tasks, setTasks] = useState<Task[]>(
|
||||
pagedRequestorTasksState?.getTasks() ?? [],
|
||||
const tasks = useStoreSnapshot(
|
||||
pagedRequestorTasksState,
|
||||
"tasksChange",
|
||||
readTasks,
|
||||
NO_TASKS,
|
||||
);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(
|
||||
pagedRequestorTasksState?.getNextCursor() ?? undefined,
|
||||
const nextCursor = useStoreSnapshot(
|
||||
pagedRequestorTasksState,
|
||||
"tasksChange",
|
||||
readNextCursor,
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pagedRequestorTasksState) {
|
||||
setTasks([]);
|
||||
setNextCursor(undefined);
|
||||
return;
|
||||
}
|
||||
setTasks(pagedRequestorTasksState.getTasks());
|
||||
setNextCursor(pagedRequestorTasksState.getNextCursor() ?? undefined);
|
||||
const onTasksChange = (
|
||||
event: TypedEventGeneric<PagedRequestorTasksStateEventMap, "tasksChange">,
|
||||
) => {
|
||||
setTasks(event.detail);
|
||||
setNextCursor(pagedRequestorTasksState.getNextCursor() ?? undefined);
|
||||
};
|
||||
pagedRequestorTasksState.addEventListener("tasksChange", onTasksChange);
|
||||
return () => {
|
||||
pagedRequestorTasksState.removeEventListener(
|
||||
"tasksChange",
|
||||
onTasksChange,
|
||||
);
|
||||
};
|
||||
}, [pagedRequestorTasksState]);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (cursor?: string): Promise<LoadPageResult> => {
|
||||
if (!pagedRequestorTasksState || !client) {
|
||||
return { tasks: [], nextCursor: undefined };
|
||||
return { tasks: NO_TASKS, nextCursor: undefined };
|
||||
}
|
||||
const result = await pagedRequestorTasksState.loadPage(cursor);
|
||||
setTasks(pagedRequestorTasksState.getTasks());
|
||||
setNextCursor(pagedRequestorTasksState.getNextCursor() ?? undefined);
|
||||
return result;
|
||||
// The store dispatches `tasksChange` as it commits the page, so both
|
||||
// snapshots above update on their own.
|
||||
return pagedRequestorTasksState.loadPage(cursor);
|
||||
},
|
||||
[client, pagedRequestorTasksState],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type { RequestMetadata } from "../mcp/types.js";
|
||||
import type {
|
||||
PagedResourceTemplatesState,
|
||||
PagedResourceTemplatesStateEventMap,
|
||||
LoadPageResult,
|
||||
} from "../mcp/state/pagedResourceTemplatesState.js";
|
||||
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
|
||||
/**
|
||||
* Shared stable empty list for the no-server case. Module scope so the
|
||||
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
|
||||
* Read-only by contract: nothing mutates a list this hook returns.
|
||||
*/
|
||||
const NO_RESOURCE_TEMPLATES: ResourceTemplate[] = [];
|
||||
|
||||
const readResourceTemplates = (
|
||||
state: PagedResourceTemplatesState,
|
||||
): ResourceTemplate[] => state.getResourceTemplates();
|
||||
|
||||
export interface UsePagedResourceTemplatesResult {
|
||||
resourceTemplates: ResourceTemplate[];
|
||||
|
|
@ -26,35 +36,12 @@ export function usePagedResourceTemplates(
|
|||
client: InspectorClientProtocol | null,
|
||||
pagedResourceTemplatesState: PagedResourceTemplatesState | null,
|
||||
): UsePagedResourceTemplatesResult {
|
||||
const [resourceTemplates, setResourceTemplates] = useState<
|
||||
ResourceTemplate[]
|
||||
>(pagedResourceTemplatesState?.getResourceTemplates() ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pagedResourceTemplatesState) {
|
||||
setResourceTemplates([]);
|
||||
return;
|
||||
}
|
||||
setResourceTemplates(pagedResourceTemplatesState.getResourceTemplates());
|
||||
const onResourceTemplatesChange = (
|
||||
event: TypedEventGeneric<
|
||||
PagedResourceTemplatesStateEventMap,
|
||||
"resourceTemplatesChange"
|
||||
>,
|
||||
) => {
|
||||
setResourceTemplates(event.detail);
|
||||
};
|
||||
pagedResourceTemplatesState.addEventListener(
|
||||
"resourceTemplatesChange",
|
||||
onResourceTemplatesChange,
|
||||
);
|
||||
return () => {
|
||||
pagedResourceTemplatesState.removeEventListener(
|
||||
"resourceTemplatesChange",
|
||||
onResourceTemplatesChange,
|
||||
);
|
||||
};
|
||||
}, [pagedResourceTemplatesState]);
|
||||
const resourceTemplates = useStoreSnapshot(
|
||||
pagedResourceTemplatesState,
|
||||
"resourceTemplatesChange",
|
||||
readResourceTemplates,
|
||||
NO_RESOURCE_TEMPLATES,
|
||||
);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (
|
||||
|
|
@ -62,14 +49,14 @@ export function usePagedResourceTemplates(
|
|||
metadata?: RequestMetadata,
|
||||
): Promise<LoadPageResult> => {
|
||||
if (!pagedResourceTemplatesState || !client) {
|
||||
return { resourceTemplates: [], nextCursor: undefined };
|
||||
return {
|
||||
resourceTemplates: NO_RESOURCE_TEMPLATES,
|
||||
nextCursor: undefined,
|
||||
};
|
||||
}
|
||||
const result = await pagedResourceTemplatesState.loadPage(
|
||||
cursor,
|
||||
metadata,
|
||||
);
|
||||
setResourceTemplates(pagedResourceTemplatesState.getResourceTemplates());
|
||||
return result;
|
||||
// The store dispatches `resourceTemplatesChange` as it commits the page,
|
||||
// so the snapshot above updates on its own.
|
||||
return pagedResourceTemplatesState.loadPage(cursor, metadata);
|
||||
},
|
||||
[client, pagedResourceTemplatesState],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type { RequestMetadata } from "../mcp/types.js";
|
||||
import type {
|
||||
PagedResourcesState,
|
||||
PagedResourcesStateEventMap,
|
||||
LoadPageResult,
|
||||
} from "../mcp/state/pagedResourcesState.js";
|
||||
import type { PagePaginationState } from "../mcp/state/pagedToolsState.js";
|
||||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
import { NO_PAGINATION } from "./pagination.js";
|
||||
|
||||
/**
|
||||
* Shared stable empty list for the no-server case. Module scope so the
|
||||
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
|
||||
* Read-only by contract: nothing mutates a list this hook returns.
|
||||
*/
|
||||
const NO_RESOURCES: Resource[] = [];
|
||||
|
||||
const readResources = (state: PagedResourcesState): Resource[] =>
|
||||
state.getResources();
|
||||
const readPagination = (state: PagedResourcesState): PagePaginationState =>
|
||||
state.getPagination();
|
||||
|
||||
export interface UsePagedResourcesResult {
|
||||
resources: Resource[];
|
||||
|
|
@ -38,53 +51,18 @@ export function usePagedResources(
|
|||
client: InspectorClientProtocol | null,
|
||||
pagedResourcesState: PagedResourcesState | null,
|
||||
): UsePagedResourcesResult {
|
||||
const [resources, setResources] = useState<Resource[]>(
|
||||
pagedResourcesState?.getResources() ?? [],
|
||||
const resources = useStoreSnapshot(
|
||||
pagedResourcesState,
|
||||
"resourcesChange",
|
||||
readResources,
|
||||
NO_RESOURCES,
|
||||
);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(
|
||||
pagedResourcesState?.getPagination().nextCursor,
|
||||
const { nextCursor, pageCount } = useStoreSnapshot(
|
||||
pagedResourcesState,
|
||||
"paginationChange",
|
||||
readPagination,
|
||||
NO_PAGINATION,
|
||||
);
|
||||
const [pageCount, setPageCount] = useState<number>(
|
||||
pagedResourcesState?.getPagination().pageCount ?? 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pagedResourcesState) {
|
||||
setResources([]);
|
||||
setNextCursor(undefined);
|
||||
setPageCount(0);
|
||||
return;
|
||||
}
|
||||
setResources(pagedResourcesState.getResources());
|
||||
setNextCursor(pagedResourcesState.getPagination().nextCursor);
|
||||
setPageCount(pagedResourcesState.getPagination().pageCount);
|
||||
const onResourcesChange = (
|
||||
event: TypedEventGeneric<PagedResourcesStateEventMap, "resourcesChange">,
|
||||
) => {
|
||||
setResources(event.detail);
|
||||
};
|
||||
const onPaginationChange = (
|
||||
event: TypedEventGeneric<PagedResourcesStateEventMap, "paginationChange">,
|
||||
) => {
|
||||
setNextCursor(event.detail.nextCursor);
|
||||
setPageCount(event.detail.pageCount);
|
||||
};
|
||||
pagedResourcesState.addEventListener("resourcesChange", onResourcesChange);
|
||||
pagedResourcesState.addEventListener(
|
||||
"paginationChange",
|
||||
onPaginationChange,
|
||||
);
|
||||
return () => {
|
||||
pagedResourcesState.removeEventListener(
|
||||
"resourcesChange",
|
||||
onResourcesChange,
|
||||
);
|
||||
pagedResourcesState.removeEventListener(
|
||||
"paginationChange",
|
||||
onPaginationChange,
|
||||
);
|
||||
};
|
||||
}, [pagedResourcesState]);
|
||||
|
||||
const error = useListError(pagedResourcesState);
|
||||
|
||||
|
|
@ -94,7 +72,7 @@ export function usePagedResources(
|
|||
metadata?: RequestMetadata,
|
||||
): Promise<LoadPageResult> => {
|
||||
if (!pagedResourcesState || !client) {
|
||||
return { resources: [], nextCursor: undefined };
|
||||
return { resources: NO_RESOURCES, nextCursor: undefined };
|
||||
}
|
||||
return pagedResourcesState.loadPage(cursor, metadata);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type {
|
||||
PagedToolsState,
|
||||
PagedToolsStateEventMap,
|
||||
PagePaginationState,
|
||||
LoadPageResult,
|
||||
} from "../mcp/state/pagedToolsState.js";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
import { NO_PAGINATION } from "./pagination.js";
|
||||
|
||||
/**
|
||||
* Shared stable empty list for the no-server case. Module scope so the
|
||||
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
|
||||
* Read-only by contract: nothing mutates a list this hook returns.
|
||||
*/
|
||||
const NO_TOOLS: Tool[] = [];
|
||||
|
||||
const readTools = (state: PagedToolsState): Tool[] => state.getTools();
|
||||
const readPagination = (state: PagedToolsState): PagePaginationState =>
|
||||
state.getPagination();
|
||||
|
||||
export interface UsePagedToolsResult {
|
||||
tools: Tool[];
|
||||
|
|
@ -35,52 +47,25 @@ export function usePagedTools(
|
|||
client: InspectorClientProtocol | null,
|
||||
pagedToolsState: PagedToolsState | null,
|
||||
): UsePagedToolsResult {
|
||||
const [tools, setTools] = useState<Tool[]>(pagedToolsState?.getTools() ?? []);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(
|
||||
pagedToolsState?.getPagination().nextCursor,
|
||||
const tools = useStoreSnapshot(
|
||||
pagedToolsState,
|
||||
"toolsChange",
|
||||
readTools,
|
||||
NO_TOOLS,
|
||||
);
|
||||
const [pageCount, setPageCount] = useState<number>(
|
||||
pagedToolsState?.getPagination().pageCount ?? 0,
|
||||
const { nextCursor, pageCount } = useStoreSnapshot(
|
||||
pagedToolsState,
|
||||
"paginationChange",
|
||||
readPagination,
|
||||
NO_PAGINATION,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pagedToolsState) {
|
||||
setTools([]);
|
||||
setNextCursor(undefined);
|
||||
setPageCount(0);
|
||||
return;
|
||||
}
|
||||
setTools(pagedToolsState.getTools());
|
||||
setNextCursor(pagedToolsState.getPagination().nextCursor);
|
||||
setPageCount(pagedToolsState.getPagination().pageCount);
|
||||
const onToolsChange = (
|
||||
event: TypedEventGeneric<PagedToolsStateEventMap, "toolsChange">,
|
||||
) => {
|
||||
setTools(event.detail);
|
||||
};
|
||||
const onPaginationChange = (
|
||||
event: TypedEventGeneric<PagedToolsStateEventMap, "paginationChange">,
|
||||
) => {
|
||||
setNextCursor(event.detail.nextCursor);
|
||||
setPageCount(event.detail.pageCount);
|
||||
};
|
||||
pagedToolsState.addEventListener("toolsChange", onToolsChange);
|
||||
pagedToolsState.addEventListener("paginationChange", onPaginationChange);
|
||||
return () => {
|
||||
pagedToolsState.removeEventListener("toolsChange", onToolsChange);
|
||||
pagedToolsState.removeEventListener(
|
||||
"paginationChange",
|
||||
onPaginationChange,
|
||||
);
|
||||
};
|
||||
}, [pagedToolsState]);
|
||||
|
||||
const error = useListError(pagedToolsState);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (cursor?: string): Promise<LoadPageResult> => {
|
||||
if (!pagedToolsState || !client) {
|
||||
return { tools: [], nextCursor: undefined };
|
||||
return { tools: NO_TOOLS, nextCursor: undefined };
|
||||
}
|
||||
return pagedToolsState.loadPage(cursor);
|
||||
},
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue