Merge branch 'v2/main' into v2/chore/2163-agent-skills

Three conflicts, all in the files this PR refactored. The code from #1955 and
#2161 (core/react's useSyncExternalStore conversion) merged cleanly.

- `.github/copilot-instructions.md` — modify/delete. This PR deletes it, and
  the four lines v2/main added there are a distillation of AGENTS.md rules that
  arrive in AGENTS.md anyway. Kept deleted.
- `AGENTS.md` — the incoming change expanded the `core/react/` entry in the
  project tree by eleven lines; this PR compacted that tree to one line per
  entry on purpose. Took the compact tree and carried the substance onto its
  one line. The other incoming hunk — the store-subscription rules under React
  instructions — merged cleanly, which is the split working: it is a RULE, so
  it lands in the section that stayed.
- `README.md` — same shape, one tree line. This README no longer enumerates
  `core/`'s subdirectories at all, so the substance went where that content now
  lives: `docs/architecture.md` and the `project-structure` skill's core table.

Nothing from v2/main was dropped; every incoming statement is now in exactly one
place rather than the three it used to occupy.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-29 23:06:01 -04:00
commit 67abdf96b4
27 changed files with 1126 additions and 803 deletions

View file

@ -45,7 +45,7 @@ an MCP server, the request/response lifecycle, and a set of state stores.
| `core/auth/node/` | Node OAuth storage + loopback callback server, **and** the `SecretStore` backends (keychain / file / memory) and their selection policy |
| `core/client/` | Install-level client config (`client.json`): browser-safe parse plus Node load/save, remote backend, secrets, runner |
| `core/json/` | JSON + parameter/argument conversion; the schema normalizations all three form builders share (nullable unions, root composition) and the tool-schema portability lint |
| `core/react/` | React hooks over the state stores — consumed by both the web and TUI React trees |
| `core/react/` | React hooks over the state stores — consumed by both the web and TUI React trees. Every subscription reads its snapshot **during render** via `useSyncExternalStore` (#1955); `useStoreSnapshot.ts` caches the fresh-value-per-read getters |
| `core/node/` | Node-only helpers: version reader, host normalization/detection |
| `core/storage/` | File I/O helpers used by the OAuth persist backends |
| `core/logging/` | Silent pino logger singleton |

View file

@ -49,7 +49,7 @@ inspector/
│ ├── logging/ Silent pino logger singleton
│ ├── mcp/ InspectorClient, transports, state stores, config import
│ ├── node/ Node-only helpers (version reader, host normalization)
│ ├── react/ React hooks over the state stores
│ ├── react/ React hooks over the state stores (read during render — see React instructions)
│ └── storage/ File I/O helpers for the OAuth persist backends
├── test-servers/ Composable MCP test servers + JSON configs
├── scripts/ Root build/verify tooling: install cascade, smokes, verify:* guards
@ -383,6 +383,15 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage `
- Use **`useValueChange(value, onChange)`** (`clients/web/src/hooks/useValueChange.ts`) instead. It is React's documented ["adjusting state during render"](https://react.dev/reference/react/useState#storing-information-from-previous-renders) pattern: it compares `value` against the previous render's with `Object.is` and calls `onChange(next)` during render, so React discards the in-progress output and re-runs the component before anything reaches the DOM. It does **not** fire on the first render — seed the dependent state with `useState` instead. Because the comparison is `Object.is`, the value you pass **must be referentially stable** across renders that mean "no change": prefer a primitive key derived from the data (an id, a name, a URI), and otherwise a memoized value. A fresh object/array literal would compare unequal every render and loop.
- The `onChange` you pass runs **during render**, so it must be pure — `setState` calls and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks: a render can be replayed (StrictMode) or abandoned (concurrent React), so external work would run an unpredictable number of times.
- An effect is still the right tool for genuine synchronization with an external system (DOM measurement, `requestAnimationFrame`, subscriptions, timers). The rule is about deriving React state from React props, not about effects in general. `NetworkEntry` shows the split: the reveal's force-open is a state update and uses `useValueChange`, while its `requestAnimationFrame` scroll stays a `useEffect`.
- **Subscribing to an `@inspector/core` state store is `useSyncExternalStore`, never `useState` + a subscribing `useEffect`.** That second shape looks like the legitimate "synchronize with an external system" case above and is not, because it also *seeds and re-seeds local state from the store prop* — so it carries the same stale frame (switching servers paints the previous server's tools for one frame) plus a window where an event dispatched between the render and the effect is lost outright. Every hook in `core/react/` was converted away from it in #1955.
- Reach for **`useStoreSnapshot(store, event, read, whenAbsent)`** (`core/react/useStoreSnapshot.ts`) when the getter returns a **fresh value per read** — a defensive copy (`getTools()` is `[...this.items]`) or a freshly built object (`getPagination()`). That is nearly all of them, and the caching it adds is what keeps `useSyncExternalStore` from looping. Call it once per value.
- When a getter's value is **already referentially stable**, subscribe with `useSyncExternalStore` directly and skip the helper — `useListError` does, because its snapshot is the stored `Error` instance itself (or `null`). The rule is read-during-render, not "always use the helper".
- Either way `useValueChange` is *not* the tool here — it lives in `clients/web/src`, and `core/react/` is consumed by the CLI and TUI too.
- `read` and `whenAbsent` must be **referentially stable across renders** — they are part of the snapshot's cache key. `read` is a function, so declare it at module scope. `whenAbsent` only needs a module-scope constant when it is an **object or array** (`NO_TOOLS`, `NO_PAGINATION`); a primitive fallback (`false`, `undefined`, `"disconnected"`) is already stable under `Object.is` and is passed inline throughout these hooks.
- An unstable one **fails quietly, so don't expect to be told**: measured on React 19, an inline `read` throws nothing, logs nothing — not even React's "getSnapshot should be cached" dev warning, whose double-call happens within a single render where the closure is unchanged — and forces no extra render. It simply returns a fresh value every render, defeating every downstream `useMemo` / `React.memo` / effect dep that keys on it.
- The snapshot is cached against the store's **per-event dispatch counter** (`TypedEventTarget.getEventRevision`), which every dispatch advances automatically — not against the snapshot's contents. That is deliberate and load-bearing: these getters return a defensive copy, so contents are the only alternative, and a contents comparison cannot see a dispatch that mutated an entry the list already holds (`MessageLogState` folding a response into its request entry does exactly that). Don't "optimize" it into a shallow compare.
- Consequently the **store is the source of truth and the event is only the signal** — the hook re-reads the store rather than taking the event's `detail`. A test that fires a store event must put the value on the store first; dispatching alone announces a change that isn't there. A hand-rolled fake must extend the real `TypedEventTarget` (a bare `EventTarget` has no `getEventRevision` and fails at runtime).
- Genuinely local state accumulated from an event stream, with no getter to read it back from, stays `useState` — `useInspectorClient`'s `lastError` is the one such case. Reset it on a store swap **during render**, not in an effect.
- Theme files vs. Storybook element components
- **Theme files** (`src/theme/<Component>.ts`) and **element components** (`src/components/elements/`) serve different purposes and both are needed.
- Theme files customize every instance of a Mantine component app-wide — defaults (size, radius), custom variants, and global style overrides. They are applied automatically by `MantineProvider`.

View file

@ -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(() =>

View file

@ -245,18 +245,26 @@ export function useConnectionLifecycle({
sessionReset,
seedModernLogLevel,
}: UseConnectionLifecycleOptions): ConnectionLifecycle {
const [connectedServerId, setConnectedServerId] = useState<
string | undefined
>(undefined);
// Track the just-connected server so its card gets the green highlight +
// scroll-into-view (#1682). It is a pure function of the two values it reads,
// so it is derived rather than mirrored into state by an effect: a state copy
// could only ever lag the status it copies by one committed render, which is
// a frame of the wrong highlight for no gain.
const connectedServerId =
connectionStatus === "connected" ? activeServerId : undefined;
// `setupClientForServer` is synchronous and memoized, so a caller that
// awaited the config would still resume with the `sandboxUrl` captured by the
// render it STARTED in — undefined, on the very load this matters for. The
// ref is written every render, so client construction reads the current value
// whichever entry point (connect, deep link, OAuth callback) reached it.
// ref carries the latest value instead, so client construction reads the
// current one whichever entry point (connect, deep link, OAuth callback)
// reached it. Published from a layout effect rather than written during
// render: every reader is an event handler or a passive effect, both of which
// run after the commit this writes in, so the value they see is unchanged.
const sandboxUrlRef = useRef<string | undefined>(undefined);
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
sandboxUrlRef.current = sandboxUrl;
useLayoutEffect(() => {
sandboxUrlRef.current = sandboxUrl;
}, [sandboxUrl]);
const {
clearResultPanels,
@ -270,18 +278,6 @@ export function useConnectionLifecycle({
const deepLinkEnsureRef = useRef(false);
const deepLinkUpdateRef = useRef(false);
const deepLinkConnectRef = useRef(false);
// Track the just-connected server so its card gets the green highlight +
// scroll-into-view (#1682). Unlike `failedServerId` (which must survive the
// `disconnect` event a failed connect fires), "connected" is a stable status,
// so a status-driven effect can both set and clear it: set on connect, clear
// whenever the session isn't connected (disconnect, a new attempt's
// "connecting", or an error).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- pre-existing status-driven effect, unmasked when this component dropped below the React Compiler's bail-out (#2161)
setConnectedServerId(
connectionStatus === "connected" ? activeServerId : undefined,
);
}, [connectionStatus, activeServerId]);
// Disconnect the previous InspectorClient when it's replaced (server
// switch) or when App unmounts (HMR, tests). Without this the prior
@ -843,7 +839,13 @@ export function useConnectionLifecycle({
const alreadyConnected =
activeServerId === deepLink.serverId && connectionStatus === "connected";
if (!alreadyConnected) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- pre-existing status-driven effect, unmasked when this component dropped below the React Compiler's bail-out (#2161)
// `onToggleConnection` is a connect, not a state sync: it opens a
// transport and drives the whole handshake, so the state it sets along
// the way is a consequence of that I/O rather than something derivable
// from props. An effect is the right tool here, and the one-shot
// `deepLinkConnectRef` guard above is what keeps it from re-firing; the
// rule sees only that an effect called something that reaches setState.
// eslint-disable-next-line react-hooks/set-state-in-effect -- justified directly above
void onToggleConnection(deepLink.serverId).catch((err) => {
// The toast fires from inside `onToggleConnection` for the common
// cases; this catch covers the rest (surfaced on `data-error-message`).

View file

@ -1,4 +1,11 @@
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import {
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { RefObject } from "react";
import { notifications } from "@mantine/notifications";
import type { ElicitResult, Resource } from "@modelcontextprotocol/client";
@ -88,9 +95,14 @@ export function useMcpApps({
// hints (`connect-src 'none'`), exactly as before this wiring existed.
// Walking the whole list to close that would issue the very requests the user
// opted out of by turning pagination on, so the setting wins.
//
// Published from a layout effect rather than written during render: the only
// reader is the async `sandboxready` handler below, which runs long after the
// commit this writes in, so it sees the same listing either way.
const listedResourcesRef = useRef<Resource[]>([]);
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
listedResourcesRef.current = resources;
useLayoutEffect(() => {
listedResourcesRef.current = resources;
}, [resources]);
// Best-effort by construction: this reads the list as it stands when the app
// opens, and does not distinguish "no entry for this URI" from "the list
// hasn't arrived yet". Both yield no hints, which is the same outcome as
@ -117,9 +129,15 @@ export function useMcpApps({
[configBaseUrl],
);
// `react-hooks/refs` cannot see through `createAppBridgeFactory`, so it warns
// that a ref-reading callback handed to it (`getListedResourceMeta`) *may* be
// called during render. It is not: the factory only stores its deps, and the
// sole call site is inside the async `sandboxready` handler, which runs once
// an app frame has mounted and its sandbox has handshaked. Reading the
// listing lazily there is the whole point — see `listedResourcesRef` above.
const sandboxBridgeFactory = useMemo(
() =>
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
// eslint-disable-next-line react-hooks/refs -- justified directly above
createAppBridgeFactory({
publishAppDocument: publishDocument,
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
@ -150,9 +168,12 @@ export function useMcpApps({
// handed to every InspectorClient at construction — its `render` is what opts
// this client into advertising the nested MCP Apps `elicitation` capability,
// which is why only the web client (the one with a sandbox) claims it.
const appElicitationControllerRef = useRef<AppElicitationController>(null);
appElicitationControllerRef.current ??= new AppElicitationController();
const appElicitationController = appElicitationControllerRef.current;
// Held as lazily-initialized state rather than a ref seeded during render:
// the instance is never reassigned, so `useState`'s initializer gives the
// same create-once semantics without reading a ref while rendering.
const [appElicitationController] = useState(
() => new AppElicitationController(),
);
// The window onto the controller for the CURRENT client. Closing it when the
// client is replaced both rejects that connection's queued requests and
// refuses any it enqueues during its own (asynchronous) teardown — a late
@ -160,9 +181,7 @@ export function useMcpApps({
// client, i.e. read and answered through a different server.
const appElicitationSessionRef = useRef<AppElicitationSession>(null);
const appElicitations = useSyncExternalStore(
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
appElicitationController.subscribe,
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
appElicitationController.getEntries,
);
@ -171,7 +190,7 @@ export function useMcpApps({
// an elicitation, so telling those apps otherwise would be a false claim.
const elicitationBridgeFactory = useMemo(
() =>
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
// eslint-disable-next-line react-hooks/refs -- same dep, same reason as `sandboxBridgeFactory` above
createAppBridgeFactory({
advertiseElicitation: true,
publishAppDocument: publishDocument,

View 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();
});
});

View file

@ -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
View 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,
});

View file

@ -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 };
}

View file

@ -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]);

View file

@ -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(() => {

View file

@ -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 => {

View file

@ -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 };

View file

@ -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(() => {

View file

@ -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(() => {

View file

@ -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 };
}

View file

@ -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);
},

View file

@ -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],
);

View file

@ -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],
);

View file

@ -1,14 +1,27 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type { RequestMetadata } from "../mcp/types.js";
import type {
PagedResourcesState,
PagedResourcesStateEventMap,
LoadPageResult,
} from "../mcp/state/pagedResourcesState.js";
import type { PagePaginationState } from "../mcp/state/pagedToolsState.js";
import type { Resource } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useListError } from "./useListError.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
import { NO_PAGINATION } from "./pagination.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_RESOURCES: Resource[] = [];
const readResources = (state: PagedResourcesState): Resource[] =>
state.getResources();
const readPagination = (state: PagedResourcesState): PagePaginationState =>
state.getPagination();
export interface UsePagedResourcesResult {
resources: Resource[];
@ -38,53 +51,18 @@ export function usePagedResources(
client: InspectorClientProtocol | null,
pagedResourcesState: PagedResourcesState | null,
): UsePagedResourcesResult {
const [resources, setResources] = useState<Resource[]>(
pagedResourcesState?.getResources() ?? [],
const resources = useStoreSnapshot(
pagedResourcesState,
"resourcesChange",
readResources,
NO_RESOURCES,
);
const [nextCursor, setNextCursor] = useState<string | undefined>(
pagedResourcesState?.getPagination().nextCursor,
const { nextCursor, pageCount } = useStoreSnapshot(
pagedResourcesState,
"paginationChange",
readPagination,
NO_PAGINATION,
);
const [pageCount, setPageCount] = useState<number>(
pagedResourcesState?.getPagination().pageCount ?? 0,
);
useEffect(() => {
if (!pagedResourcesState) {
setResources([]);
setNextCursor(undefined);
setPageCount(0);
return;
}
setResources(pagedResourcesState.getResources());
setNextCursor(pagedResourcesState.getPagination().nextCursor);
setPageCount(pagedResourcesState.getPagination().pageCount);
const onResourcesChange = (
event: TypedEventGeneric<PagedResourcesStateEventMap, "resourcesChange">,
) => {
setResources(event.detail);
};
const onPaginationChange = (
event: TypedEventGeneric<PagedResourcesStateEventMap, "paginationChange">,
) => {
setNextCursor(event.detail.nextCursor);
setPageCount(event.detail.pageCount);
};
pagedResourcesState.addEventListener("resourcesChange", onResourcesChange);
pagedResourcesState.addEventListener(
"paginationChange",
onPaginationChange,
);
return () => {
pagedResourcesState.removeEventListener(
"resourcesChange",
onResourcesChange,
);
pagedResourcesState.removeEventListener(
"paginationChange",
onPaginationChange,
);
};
}, [pagedResourcesState]);
const error = useListError(pagedResourcesState);
@ -94,7 +72,7 @@ export function usePagedResources(
metadata?: RequestMetadata,
): Promise<LoadPageResult> => {
if (!pagedResourcesState || !client) {
return { resources: [], nextCursor: undefined };
return { resources: NO_RESOURCES, nextCursor: undefined };
}
return pagedResourcesState.loadPage(cursor, metadata);
},

Some files were not shown because too many files have changed in this diff Show more