fix: surface list-fetch failures in paginated mode (#1998)

#1954 wired the list-load error off the aggregate managed stores only.
With `paginatedLists` on, those stores deliberately skip their all-page
walk, so their error is permanently null — while the paged stores that
actually drive the sidebar had no error state at all and `loadPage` had
a try/finally with no catch. A failing list showed an empty panel with
no alert and no Retry, and the connect-time `void loadPage(undefined)`
left an unhandled rejection.

- Give PagedTools/Prompts/ResourcesState observable error state: an
  `errorChange` event plus `getError()`, set in a new catch in
  `loadPage`, cleared on the next success and on disconnect. The
  rejection is still re-thrown, matching ManagedListState — callers'
  auth-recovery wrappers key off it to detect a 401.
- Catch the connect-time load's rejection (`.catch(() => {})`) now that
  the failure is recorded as state rather than lost.
- Select the error by mode in `usePaginatedList`, the same way `items`
  already is, and read it in App.tsx from the pagination model.
- Rename `useManagedListError` to `useListError`: both store families
  expose an identical `errorChange`, so the hook declares that one-event
  contract itself instead of importing ManagedListEventMap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-17 20:40:23 -04:00
commit de60983ccc
21 changed files with 523 additions and 42 deletions

View file

@ -1031,18 +1031,21 @@ function App() {
tools: pagedTools,
nextCursor: pagedToolsCursor,
pageCount: pagedToolsPageCount,
error: pagedToolsLoadError,
loadPage: loadToolsPage,
} = usePagedTools(inspectorClient, pagedToolsState);
const {
prompts: pagedPrompts,
nextCursor: pagedPromptsCursor,
pageCount: pagedPromptsPageCount,
error: pagedPromptsLoadError,
loadPage: loadPromptsPage,
} = usePagedPrompts(inspectorClient, pagedPromptsState);
const {
resources: pagedResources,
nextCursor: pagedResourcesCursor,
pageCount: pagedResourcesPageCount,
error: pagedResourcesLoadError,
loadPage: loadResourcesPage,
} = usePagedResources(inspectorClient, pagedResourcesState);
// The active server's persisted paginated setting drives the display mode.
@ -1073,9 +1076,11 @@ function App() {
paginated: paginatedLists,
managedItems: managedTools,
managedRefresh: refreshTools,
managedError: toolsLoadError,
pagedItems: pagedTools,
pagedNextCursor: pagedToolsCursor,
pagedPageCount: pagedToolsPageCount,
pagedError: pagedToolsLoadError,
loadPage: loadToolsPage,
});
const promptsPagination = usePaginatedList({
@ -1083,9 +1088,11 @@ function App() {
paginated: paginatedLists,
managedItems: managedPrompts,
managedRefresh: refreshPrompts,
managedError: promptsLoadError,
pagedItems: pagedPrompts,
pagedNextCursor: pagedPromptsCursor,
pagedPageCount: pagedPromptsPageCount,
pagedError: pagedPromptsLoadError,
loadPage: loadPromptsPage,
});
const resourcesPagination = usePaginatedList({
@ -1093,9 +1100,11 @@ function App() {
paginated: paginatedLists,
managedItems: managedResources,
managedRefresh: refreshResources,
managedError: resourcesLoadError,
pagedItems: pagedResources,
pagedNextCursor: pagedResourcesCursor,
pagedPageCount: pagedResourcesPageCount,
pagedError: pagedResourcesLoadError,
loadPage: loadResourcesPage,
});
const tools = toolsPagination.items;
@ -4407,9 +4416,11 @@ function App() {
toolsListChanged={toolsListChanged}
promptsListChanged={promptsListChanged}
resourcesListChanged={resourcesListChanged}
toolsLoadError={toolsLoadError}
promptsLoadError={promptsLoadError}
resourcesLoadError={resourcesLoadError ?? resourceTemplatesLoadError}
toolsLoadError={toolsPagination.error}
promptsLoadError={promptsPagination.error}
resourcesLoadError={
resourcesPagination.error ?? resourceTemplatesLoadError
}
subscriptions={subscriptions}
subscriptionStreamState={subscriptionStreamState}
logs={logs}

View file

@ -7,9 +7,11 @@ interface Params {
paginated: boolean;
managedItems: string[];
managedRefresh: () => Promise<unknown>;
managedError: Error | null;
pagedItems: string[];
pagedNextCursor?: string;
pagedPageCount: number;
pagedError: Error | null;
loadPage: (cursor?: string) => Promise<unknown>;
}
@ -19,9 +21,11 @@ function makeParams(over: Partial<Params> = {}): Params {
paginated: false,
managedItems: ["m1", "m2"],
managedRefresh: vi.fn(async () => []),
managedError: null,
pagedItems: ["p1"],
pagedNextCursor: undefined,
pagedPageCount: 0,
pagedError: null,
loadPage: vi.fn(async () => ({})),
...over,
};
@ -88,6 +92,35 @@ describe("usePaginatedList", () => {
expect(loadPage).toHaveBeenCalledWith(undefined);
});
// #1998: the managed store deliberately never fetches in paginated mode, so
// reading its (permanently null) error there would leave a failed page load
// showing an empty panel with no alert and no Retry.
it("reports the managed error in all-pages mode", () => {
const managedError = new Error("aggregate failed");
const pagedError = new Error("page failed");
const params = makeParams({ managedError, pagedError });
const { result } = renderHook(() => usePaginatedList(params));
expect(result.current.error).toBe(managedError);
});
it("reports the paged error in paginated mode", () => {
const managedError = new Error("aggregate failed");
const pagedError = new Error("page failed");
const params = makeParams({ paginated: true, managedError, pagedError });
const { result } = renderHook(() => usePaginatedList(params));
expect(result.current.error).toBe(pagedError);
});
it("reports no error when the active source succeeded", () => {
const params = makeParams({
paginated: true,
managedError: new Error("aggregate failed"),
pagedError: null,
});
const { result } = renderHook(() => usePaginatedList(params));
expect(result.current.error).toBeNull();
});
it("onRefresh re-fetches the aggregate in all-pages mode", () => {
const managedRefresh = vi.fn(async () => []);
const params = makeParams({ managedRefresh });

View file

@ -19,6 +19,13 @@ export interface PaginatedListModel<T> {
* promise so the caller can wrap it in auth recovery.
*/
onLoadMore: () => Promise<unknown>;
/**
* The active source's last load failure, or `null`. Selected by mode for the
* same reason `items` is: in paginated mode the managed store never fetches,
* so its error is permanently `null` and reading it would leave a failed page
* showing an empty panel with no alert and no Retry (#1998).
*/
error: Error | null;
/**
* Refresh the list: reload page 1 in paginated mode, or re-fetch the whole
* aggregate in all-pages mode. This is what the list-changed indicator's
@ -37,12 +44,16 @@ export interface UsePaginatedListParams<T> {
managedItems: T[];
/** Re-fetch the whole aggregate (all-pages mode Refresh). */
managedRefresh: () => Promise<unknown>;
/** The aggregate store's last-fetch error (all-pages mode). */
managedError: Error | null;
/** The accumulated paged list (paginated mode display source). */
pagedItems: T[];
/** The paged store's current `nextCursor` (undefined = at the end). */
pagedNextCursor?: string;
/** The paged store's page count (page 1 = 1). */
pagedPageCount: number;
/** The paged store's last page-load error (paginated mode). */
pagedError: Error | null;
/** Fetch one page; `undefined` cursor = page 1 (replaces the paged list). */
loadPage: (cursor?: string) => Promise<unknown>;
}
@ -64,9 +75,11 @@ export function usePaginatedList<T>({
paginated,
managedItems,
managedRefresh,
managedError,
pagedItems,
pagedNextCursor,
pagedPageCount,
pagedError,
loadPage,
}: UsePaginatedListParams<T>): PaginatedListModel<T> {
const onLoadMore = useCallback((): Promise<unknown> => {
@ -80,6 +93,7 @@ export function usePaginatedList<T>({
return {
items: paginated ? pagedItems : managedItems,
error: paginated ? pagedError : managedError,
paginated,
// Masked by `connected`: while disconnected there is no page to load and no
// meaningful page count (the store resets on disconnect).

View file

@ -28,6 +28,14 @@ function waitForChange(state: PagedPromptsState): Promise<Prompt[]> {
});
}
function waitForError(state: PagedPromptsState): Promise<Error | null> {
return new Promise((resolve) => {
state.addEventListener("errorChange", (e) => resolve(e.detail), {
once: true,
});
});
}
describe("PagedPromptsState", () => {
let client: FakeInspectorClient;
let state: PagedPromptsState;
@ -201,4 +209,61 @@ describe("PagedPromptsState", () => {
expect(client.listPrompts).not.toHaveBeenCalled();
});
});
// #1998: in paginated mode this store — not the managed one — drives the
// sidebar, so a failed page must surface here or the panel renders empty
// with no alert and no Retry.
describe("load errors (#1998)", () => {
it("records the failure, dispatches errorChange, and re-throws", async () => {
client.setStatus("connected");
const boom = new Error("list failed");
client.listPrompts.mockRejectedValueOnce(boom);
const errorPromise = waitForError(state);
await expect(state.loadPage()).rejects.toThrow("list failed");
expect(await errorPromise).toBe(boom);
expect(state.getError()).toBe(boom);
});
it("wraps a non-Error rejection", async () => {
client.setStatus("connected");
client.listPrompts.mockRejectedValueOnce("plain string");
await expect(state.loadPage()).rejects.toBe("plain string");
expect(state.getError()?.message).toBe("plain string");
});
it("clears the error on the next successful load", async () => {
client.setStatus("connected");
client.listPrompts.mockRejectedValueOnce(new Error("list failed"));
await expect(state.loadPage()).rejects.toThrow();
client.queuePromptPages({ prompts: [prompt("a")] });
const cleared = waitForError(state);
await state.loadPage();
expect(await cleared).toBeNull();
expect(state.getError()).toBeNull();
});
it("clears the error on disconnect", async () => {
client.setStatus("connected");
client.listPrompts.mockRejectedValueOnce(new Error("list failed"));
await expect(state.loadPage()).rejects.toThrow();
const cleared = waitForError(state);
client.setStatus("disconnected");
expect(await cleared).toBeNull();
});
it("records a connect-time auto-load failure instead of floating it", async () => {
const spClient = new FakeInspectorClient({
serverSettings: PAGINATED_SETTINGS,
});
spClient.setStatus("connected");
const spState = new PagedPromptsState(spClient);
const boom = new Error("connect-time list failed");
spClient.listPrompts.mockRejectedValueOnce(boom);
const errored = waitForError(spState);
spClient.dispatchTypedEvent("connect");
expect(await errored).toBe(boom);
expect(spState.getError()).toBe(boom);
spState.destroy();
});
});
});

View file

@ -28,6 +28,14 @@ function waitForChange(state: PagedResourcesState): Promise<Resource[]> {
});
}
function waitForError(state: PagedResourcesState): Promise<Error | null> {
return new Promise((resolve) => {
state.addEventListener("errorChange", (e) => resolve(e.detail), {
once: true,
});
});
}
describe("PagedResourcesState", () => {
let client: FakeInspectorClient;
let state: PagedResourcesState;
@ -206,4 +214,61 @@ describe("PagedResourcesState", () => {
expect(client.listResources).not.toHaveBeenCalled();
});
});
// #1998: in paginated mode this store — not the managed one — drives the
// sidebar, so a failed page must surface here or the panel renders empty
// with no alert and no Retry.
describe("load errors (#1998)", () => {
it("records the failure, dispatches errorChange, and re-throws", async () => {
client.setStatus("connected");
const boom = new Error("list failed");
client.listResources.mockRejectedValueOnce(boom);
const errorPromise = waitForError(state);
await expect(state.loadPage()).rejects.toThrow("list failed");
expect(await errorPromise).toBe(boom);
expect(state.getError()).toBe(boom);
});
it("wraps a non-Error rejection", async () => {
client.setStatus("connected");
client.listResources.mockRejectedValueOnce("plain string");
await expect(state.loadPage()).rejects.toBe("plain string");
expect(state.getError()?.message).toBe("plain string");
});
it("clears the error on the next successful load", async () => {
client.setStatus("connected");
client.listResources.mockRejectedValueOnce(new Error("list failed"));
await expect(state.loadPage()).rejects.toThrow();
client.queueResourcePages({ resources: [resource("a://1")] });
const cleared = waitForError(state);
await state.loadPage();
expect(await cleared).toBeNull();
expect(state.getError()).toBeNull();
});
it("clears the error on disconnect", async () => {
client.setStatus("connected");
client.listResources.mockRejectedValueOnce(new Error("list failed"));
await expect(state.loadPage()).rejects.toThrow();
const cleared = waitForError(state);
client.setStatus("disconnected");
expect(await cleared).toBeNull();
});
it("records a connect-time auto-load failure instead of floating it", async () => {
const spClient = new FakeInspectorClient({
serverSettings: PAGINATED_SETTINGS,
});
spClient.setStatus("connected");
const spState = new PagedResourcesState(spClient);
const boom = new Error("connect-time list failed");
spClient.listResources.mockRejectedValueOnce(boom);
const errored = waitForError(spState);
spClient.dispatchTypedEvent("connect");
expect(await errored).toBe(boom);
expect(spState.getError()).toBe(boom);
spState.destroy();
});
});
});

View file

@ -28,6 +28,14 @@ function waitForChange(state: PagedToolsState): Promise<Tool[]> {
});
}
function waitForError(state: PagedToolsState): Promise<Error | null> {
return new Promise((resolve) => {
state.addEventListener("errorChange", (e) => resolve(e.detail), {
once: true,
});
});
}
describe("PagedToolsState", () => {
let client: FakeInspectorClient;
let state: PagedToolsState;
@ -238,4 +246,61 @@ describe("PagedToolsState", () => {
expect(state.getTools()).toEqual([]);
});
});
// #1998: in paginated mode this store — not the managed one — drives the
// sidebar, so a failed page must surface here or the panel renders empty
// with no alert and no Retry.
describe("load errors (#1998)", () => {
it("records the failure, dispatches errorChange, and re-throws", async () => {
client.setStatus("connected");
const boom = new Error("list failed");
client.listTools.mockRejectedValueOnce(boom);
const errorPromise = waitForError(state);
await expect(state.loadPage()).rejects.toThrow("list failed");
expect(await errorPromise).toBe(boom);
expect(state.getError()).toBe(boom);
});
it("wraps a non-Error rejection", async () => {
client.setStatus("connected");
client.listTools.mockRejectedValueOnce("plain string");
await expect(state.loadPage()).rejects.toBe("plain string");
expect(state.getError()?.message).toBe("plain string");
});
it("clears the error on the next successful load", async () => {
client.setStatus("connected");
client.listTools.mockRejectedValueOnce(new Error("list failed"));
await expect(state.loadPage()).rejects.toThrow();
client.queueToolPages({ tools: [tool("a")] });
const cleared = waitForError(state);
await state.loadPage();
expect(await cleared).toBeNull();
expect(state.getError()).toBeNull();
});
it("clears the error on disconnect", async () => {
client.setStatus("connected");
client.listTools.mockRejectedValueOnce(new Error("list failed"));
await expect(state.loadPage()).rejects.toThrow();
const cleared = waitForError(state);
client.setStatus("disconnected");
expect(await cleared).toBeNull();
});
it("records a connect-time auto-load failure instead of floating it", async () => {
const spClient = new FakeInspectorClient({
serverSettings: PAGINATED_SETTINGS,
});
spClient.setStatus("connected");
const spState = new PagedToolsState(spClient);
const boom = new Error("connect-time list failed");
spClient.listTools.mockRejectedValueOnce(boom);
const errored = waitForError(spState);
spClient.dispatchTypedEvent("connect");
expect(await errored).toBe(boom);
expect(spState.getError()).toBe(boom);
spState.destroy();
});
});
});

View file

@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState";
import { useListError } from "@inspector/core/react/useListError";
// The shared subscription behind the four `useManaged*` hooks' `error` field
// (#1953). Exercised through ManagedToolsState — any managed list would do,
// since the error lives entirely in the shared base.
describe("useListError", () => {
let client: FakeInspectorClient;
let state: ManagedToolsState;
const boom = new Error("Invalid result for tools/list: ttlMs required");
beforeEach(() => {
client = new FakeInspectorClient({
status: "connected",
capabilities: { tools: {} },
});
state = new ManagedToolsState(client, 0);
});
it("returns null when there is no state", () => {
const { result } = renderHook(() => useListError(null));
expect(result.current).toBeNull();
});
it("returns null before any load fails", () => {
const { result } = renderHook(() => useListError(state));
expect(result.current).toBeNull();
});
it("seeds from an error the state already holds", async () => {
client.listAllTools.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
const { result } = renderHook(() => useListError(state));
expect(result.current).toBe(boom);
});
it("updates when the state dispatches errorChange", async () => {
const { result } = renderHook(() => useListError(state));
client.listAllTools.mockRejectedValueOnce(boom);
await act(async () => {
await expect(state.refresh()).rejects.toThrow(boom);
});
expect(result.current).toBe(boom);
});
it("clears when a later load succeeds", async () => {
client.listAllTools.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
const { result } = renderHook(() => useListError(state));
expect(result.current).toBe(boom);
await act(async () => {
await state.refresh();
});
expect(result.current).toBeNull();
});
it("resets to null when the state becomes null", async () => {
client.listAllTools.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
const { result, rerender } = renderHook(
({ s }: { s: ManagedToolsState | null }) => useListError(s),
{ initialProps: { s: state as ManagedToolsState | null } },
);
expect(result.current).toBe(boom);
rerender({ s: null });
expect(result.current).toBeNull();
});
it("unsubscribes on unmount", async () => {
const { unmount } = renderHook(() => useListError(state));
unmount();
client.listAllTools.mockRejectedValueOnce(boom);
// No act() wrapper: a listener still attached would warn about an update
// outside act, and the assertion below would be the only other signal.
await expect(state.refresh()).rejects.toThrow(boom);
expect(state.getError()).toBe(boom);
});
// The useState+useEffect subscribe pattern would render one frame carrying
// the PREVIOUS store's error here, before the effect re-synced. With
// useSyncExternalStore the snapshot is read during render, so the swap lands
// in the same frame — asserted immediately after rerender, with no waitFor
// and no act() flush, which is what makes it a regression test rather than a
// restatement of eventual consistency.
it("reflects a store swap in the same frame", async () => {
client.listAllTools.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
const other = new ManagedToolsState(client, 0);
const { result, rerender } = renderHook(
({ s }: { s: ManagedToolsState }) => useListError(s),
{ initialProps: { s: state } },
);
expect(result.current).toBe(boom);
rerender({ s: other });
expect(result.current).toBeNull();
rerender({ s: state });
expect(result.current).toBe(boom);
});
});

View file

@ -118,4 +118,31 @@ describe("usePagedPrompts", () => {
await state.loadPage();
expect(result.current.prompts).toEqual([]);
});
// #1998: paginated mode renders this store, so its load failure is what the
// panel's alert + Retry key off.
it("exposes the state's last load error and clears it on success", async () => {
const { result } = renderHook(() => usePagedPrompts(client, state));
expect(result.current.error).toBeNull();
const boom = new Error("list failed");
client.listPrompts.mockRejectedValueOnce(boom);
await act(async () => {
await expect(result.current.loadPage()).rejects.toThrow("list failed");
});
await waitFor(() => {
expect(result.current.error).toBe(boom);
});
client.queuePromptPages({ prompts: [prompt("a")] });
await act(async () => {
await result.current.loadPage();
});
await waitFor(() => {
expect(result.current.error).toBeNull();
});
});
it("reports no error when state is null", () => {
const { result } = renderHook(() => usePagedPrompts(client, null));
expect(result.current.error).toBeNull();
});
});

View file

@ -125,4 +125,31 @@ describe("usePagedResources", () => {
await state.loadPage();
expect(result.current.resources).toEqual([]);
});
// #1998: paginated mode renders this store, so its load failure is what the
// panel's alert + Retry key off.
it("exposes the state's last load error and clears it on success", async () => {
const { result } = renderHook(() => usePagedResources(client, state));
expect(result.current.error).toBeNull();
const boom = new Error("list failed");
client.listResources.mockRejectedValueOnce(boom);
await act(async () => {
await expect(result.current.loadPage()).rejects.toThrow("list failed");
});
await waitFor(() => {
expect(result.current.error).toBe(boom);
});
client.queueResourcePages({ resources: [resource("a://1")] });
await act(async () => {
await result.current.loadPage();
});
await waitFor(() => {
expect(result.current.error).toBeNull();
});
});
it("reports no error when state is null", () => {
const { result } = renderHook(() => usePagedResources(client, null));
expect(result.current.error).toBeNull();
});
});

View file

@ -125,4 +125,31 @@ describe("usePagedTools", () => {
await state.loadPage();
expect(result.current.tools).toEqual([]);
});
// #1998: paginated mode renders this store, so its load failure is what the
// panel's alert + Retry key off.
it("exposes the state's last load error and clears it on success", async () => {
const { result } = renderHook(() => usePagedTools(client, state));
expect(result.current.error).toBeNull();
const boom = new Error("list failed");
client.listTools.mockRejectedValueOnce(boom);
await act(async () => {
await expect(result.current.loadPage()).rejects.toThrow("list failed");
});
await waitFor(() => {
expect(result.current.error).toBe(boom);
});
client.queueToolPages({ tools: [tool("a")] });
await act(async () => {
await result.current.loadPage();
});
await waitFor(() => {
expect(result.current.error).toBeNull();
});
});
it("reports no error when state is null", () => {
const { result } = renderHook(() => usePagedTools(client, null));
expect(result.current.error).toBeNull();
});
});

View file

@ -21,6 +21,8 @@ import type { PagePaginationState } from "./pagedToolsState.js";
export interface PagedPromptsStateEventMap {
promptsChange: Prompt[];
paginationChange: PagePaginationState;
/** The last page load's failure, or `null` once a load succeeds. */
errorChange: Error | null;
}
export interface LoadPageResult {
@ -35,6 +37,11 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
// Double-click guard: a load in flight makes the next `loadPage` a no-op so
// the same page can't be appended twice (#1721).
private loading = false;
// The last page load's failure, kept as observable state so a failing page
// renders an alert with a Retry instead of an empty sidebar. The aggregate
// stores carry the same state, but they are not the display source in
// paginated mode, so their error never fires there (#1998).
private error: Error | null = null;
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
@ -43,7 +50,11 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
this.client = client;
const onConnect = (): void => {
if (this.client?.getServerSettings()?.paginatedLists) {
void this.loadPage(undefined);
// No caller to await this one, so its rejection is caught here rather
// than left to become an unhandled rejection. Not a swallow:
// `loadPage` has already recorded the failure via `setError`, and the
// list panel renders it (#1998).
void this.loadPage(undefined).catch(() => {});
}
};
const onStatusChange = (): void => {
@ -70,6 +81,20 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
return { nextCursor: this.nextCursor, pageCount: this.pageCount };
}
/** The last page load's failure, or `null` when the last load succeeded. */
getError(): Error | null {
return this.error;
}
// Compared by identity rather than message: two distinct failures with the
// same text are still two events, and a re-render on a repeat failure is
// cheap next to silently coalescing them.
private setError(value: Error | null): void {
if (this.error === value) return;
this.error = value;
this.dispatchTypedEvent("errorChange", value);
}
clear(): void {
this.reset();
}
@ -80,6 +105,9 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
this.pageCount = 0;
this.dispatchTypedEvent("promptsChange", this.prompts);
this.dispatchTypedEvent("paginationChange", this.getPagination());
// A disconnect ends the session the error belonged to — a stale
// "couldn't load" must not outlive it into the next connect.
this.setError(null);
}
async loadPage(
@ -102,9 +130,17 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
: [...this.prompts, ...result.prompts];
this.pageCount = cursor === undefined ? 1 : this.pageCount + 1;
this.nextCursor = result.nextCursor;
this.setError(null);
this.dispatchTypedEvent("promptsChange", this.prompts);
this.dispatchTypedEvent("paginationChange", this.getPagination());
return { prompts: result.prompts, nextCursor: result.nextCursor };
} catch (err) {
// Recorded as observable state AND re-thrown: the state drives the
// panel's alert, while the rejection is what a caller's auth-recovery
// wrapper keys off to detect a 401 and start a re-authorization. The
// connect-time load, which has no such caller, catches it above.
this.setError(err instanceof Error ? err : new Error(String(err)));
throw err;
} finally {
this.loading = false;
}
@ -116,5 +152,6 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
this.prompts = [];
this.nextCursor = undefined;
this.pageCount = 0;
this.error = null;
}
}

View file

@ -22,6 +22,8 @@ import type { PagePaginationState } from "./pagedToolsState.js";
export interface PagedResourcesStateEventMap {
resourcesChange: Resource[];
paginationChange: PagePaginationState;
/** The last page load's failure, or `null` once a load succeeds. */
errorChange: Error | null;
}
export interface LoadPageResult {
@ -36,6 +38,11 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
// Double-click guard: a load in flight makes the next `loadPage` a no-op so
// the same page can't be appended twice (#1721).
private loading = false;
// The last page load's failure, kept as observable state so a failing page
// renders an alert with a Retry instead of an empty sidebar. The aggregate
// stores carry the same state, but they are not the display source in
// paginated mode, so their error never fires there (#1998).
private error: Error | null = null;
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
@ -44,7 +51,11 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
this.client = client;
const onConnect = (): void => {
if (this.client?.getServerSettings()?.paginatedLists) {
void this.loadPage(undefined);
// No caller to await this one, so its rejection is caught here rather
// than left to become an unhandled rejection. Not a swallow:
// `loadPage` has already recorded the failure via `setError`, and the
// list panel renders it (#1998).
void this.loadPage(undefined).catch(() => {});
}
};
const onStatusChange = (): void => {
@ -71,6 +82,20 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
return { nextCursor: this.nextCursor, pageCount: this.pageCount };
}
/** The last page load's failure, or `null` when the last load succeeded. */
getError(): Error | null {
return this.error;
}
// Compared by identity rather than message: two distinct failures with the
// same text are still two events, and a re-render on a repeat failure is
// cheap next to silently coalescing them.
private setError(value: Error | null): void {
if (this.error === value) return;
this.error = value;
this.dispatchTypedEvent("errorChange", value);
}
clear(): void {
this.reset();
}
@ -81,6 +106,9 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
this.pageCount = 0;
this.dispatchTypedEvent("resourcesChange", this.resources);
this.dispatchTypedEvent("paginationChange", this.getPagination());
// A disconnect ends the session the error belonged to — a stale
// "couldn't load" must not outlive it into the next connect.
this.setError(null);
}
async loadPage(
@ -103,9 +131,17 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
: [...this.resources, ...result.resources];
this.pageCount = cursor === undefined ? 1 : this.pageCount + 1;
this.nextCursor = result.nextCursor;
this.setError(null);
this.dispatchTypedEvent("resourcesChange", this.resources);
this.dispatchTypedEvent("paginationChange", this.getPagination());
return { resources: result.resources, nextCursor: result.nextCursor };
} catch (err) {
// Recorded as observable state AND re-thrown: the state drives the
// panel's alert, while the rejection is what a caller's auth-recovery
// wrapper keys off to detect a 401 and start a re-authorization. The
// connect-time load, which has no such caller, catches it above.
this.setError(err instanceof Error ? err : new Error(String(err)));
throw err;
} finally {
this.loading = false;
}
@ -117,5 +153,6 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
this.resources = [];
this.nextCursor = undefined;
this.pageCount = 0;
this.error = null;
}
}

View file

@ -30,6 +30,8 @@ export interface PagePaginationState {
export interface PagedToolsStateEventMap {
toolsChange: Tool[];
paginationChange: PagePaginationState;
/** The last page load's failure, or `null` once a load succeeds. */
errorChange: Error | null;
}
export interface LoadPageResult {
@ -45,6 +47,11 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
// page"): both calls would otherwise read the same cursor and append the
// same page twice. A load in flight makes the next `loadPage` a no-op (#1721).
private loading = false;
// The last page load's failure, kept as observable state so a failing page
// renders an alert with a Retry instead of an empty sidebar. The aggregate
// stores carry the same state, but they are not the display source in
// paginated mode, so their error never fires there (#1998).
private error: Error | null = null;
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
@ -55,7 +62,11 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
// Auto-load page 1 only in paginated mode — otherwise the managed
// (aggregate) state is the display source and this stays idle (#1721).
if (this.client?.getServerSettings()?.paginatedLists) {
void this.loadPage(undefined);
// No caller to await this one, so its rejection is caught here rather
// than left to become an unhandled rejection. Not a swallow:
// `loadPage` has already recorded the failure via `setError`, and the
// list panel renders it (#1998).
void this.loadPage(undefined).catch(() => {});
}
};
const onStatusChange = (): void => {
@ -82,6 +93,20 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
return { nextCursor: this.nextCursor, pageCount: this.pageCount };
}
/** The last page load's failure, or `null` when the last load succeeded. */
getError(): Error | null {
return this.error;
}
// Compared by identity rather than message: two distinct failures with the
// same text are still two events, and a re-render on a repeat failure is
// cheap next to silently coalescing them.
private setError(value: Error | null): void {
if (this.error === value) return;
this.error = value;
this.dispatchTypedEvent("errorChange", value);
}
/** Clear the accumulated list and pagination progress. */
clear(): void {
this.reset();
@ -93,6 +118,9 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
this.pageCount = 0;
this.dispatchTypedEvent("toolsChange", this.tools);
this.dispatchTypedEvent("paginationChange", this.getPagination());
// A disconnect ends the session the error belonged to — a stale
// "couldn't load" must not outlive it into the next connect.
this.setError(null);
}
async loadPage(cursor?: string): Promise<LoadPageResult> {
@ -116,9 +144,17 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
: [...this.tools, ...result.tools];
this.pageCount = cursor === undefined ? 1 : this.pageCount + 1;
this.nextCursor = result.nextCursor;
this.setError(null);
this.dispatchTypedEvent("toolsChange", this.tools);
this.dispatchTypedEvent("paginationChange", this.getPagination());
return { tools: result.tools, nextCursor: result.nextCursor };
} catch (err) {
// Recorded as observable state AND re-thrown: the state drives the
// panel's alert, while the rejection is what a caller's auth-recovery
// wrapper keys off to detect a 401 and start a re-authorization. The
// connect-time load, which has no such caller, catches it above.
this.setError(err instanceof Error ? err : new Error(String(err)));
throw err;
} finally {
this.loading = false;
}
@ -130,5 +166,6 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
this.tools = [];
this.nextCursor = undefined;
this.pageCount = 0;
this.error = null;
}
}

View file

@ -0,0 +1,77 @@
import { useCallback, useSyncExternalStore } from "react";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
/**
* The single event both list-state families expose for this. Declared here
* rather than imported from either so the hook depends on neither: the managed
* (aggregate) states and the paged states each carry an `errorChange` of this
* shape, and this is the whole contract the hook needs.
*/
interface ListErrorEventMap {
errorChange: Error | null;
}
/**
* The slice of a list state this hook needs. Declared structurally rather than
* as a concrete state class so every list hook can share it without threading
* its item type through — the error is the same shape for all of them.
*/
export interface ListErrorSource {
getError(): Error | null;
addEventListener(
type: "errorChange",
listener: (
event: TypedEventGeneric<ListErrorEventMap, "errorChange">,
) => void,
): void;
removeEventListener(
type: "errorChange",
listener: (
event: TypedEventGeneric<ListErrorEventMap, "errorChange">,
) => void,
): void;
}
/**
* Subscribe to a list state's last-fetch error (#1953, #1998).
*
* Shared by the four `useManaged*` hooks and the three `usePaged*` hooks so a
* list load that fails — including the connect-time one, which has no caller
* to await it — reaches the UI instead of only the console. `null` means the
* last fetch succeeded. Both families need it because the paged stores are the
* display source in paginated mode, where the managed stores deliberately
* never fetch (#1998).
*
* Built on `useSyncExternalStore` rather than the `useState` + `useEffect`
* subscribe pattern the sibling hooks use. Re-syncing state from the `state`
* prop inside an effect would render one frame carrying the PREVIOUS store's
* error after `state` changes (switching servers) before the effect corrects
* it — the "don't derive state from props in an effect" rule in AGENTS.md.
* `useSyncExternalStore` has no such window: the snapshot is read during
* render, so a store swap is reflected in the same frame, and it also closes
* the gap where an error recorded between render and subscribe would be missed.
*
* The snapshot must be referentially stable across reads that mean "no change",
* which it is: it returns the stored `Error` instance itself (or `null`), never
* a fresh object.
*/
export function useListError(state: ListErrorSource | null): Error | null {
const subscribe = useCallback(
(onStoreChange: () => void) => {
if (!state) return () => {};
const listener = () => onStoreChange();
state.addEventListener("errorChange", listener);
return () => {
state.removeEventListener("errorChange", listener);
};
},
[state],
);
const getSnapshot = useCallback(() => state?.getError() ?? null, [state]);
// Server snapshot: same read. The stores are browser/Node runtime objects
// with no SSR path, and passing the same getter keeps hydration consistent
// rather than throwing on a server render.
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

View file

@ -6,7 +6,7 @@ import type {
} from "../mcp/state/managedPromptsState.js";
import type { Prompt } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useManagedListError } from "./useManagedListError.js";
import { useListError } from "./useListError.js";
export interface UseManagedPromptsResult {
/**
@ -72,7 +72,7 @@ export function useManagedPrompts(
};
}, [managedPromptsState]);
const error = useManagedListError(managedPromptsState);
const error = useListError(managedPromptsState);
const refresh = useCallback(async (): Promise<Prompt[]> => {
if (!managedPromptsState || !client) return [];

View file

@ -6,7 +6,7 @@ import type {
} from "../mcp/state/managedResourceTemplatesState.js";
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useManagedListError } from "./useManagedListError.js";
import { useListError } from "./useListError.js";
export interface UseManagedResourceTemplatesResult {
/**
@ -57,7 +57,7 @@ export function useManagedResourceTemplates(
};
}, [managedResourceTemplatesState]);
const error = useManagedListError(managedResourceTemplatesState);
const error = useListError(managedResourceTemplatesState);
const refresh = useCallback(async (): Promise<ResourceTemplate[]> => {
if (!managedResourceTemplatesState || !client) return [];

View file

@ -6,7 +6,7 @@ import type {
} from "../mcp/state/managedResourcesState.js";
import type { Resource } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useManagedListError } from "./useManagedListError.js";
import { useListError } from "./useListError.js";
export interface UseManagedResourcesResult {
/**
@ -83,7 +83,7 @@ export function useManagedResources(
};
}, [managedResourcesState]);
const error = useManagedListError(managedResourcesState);
const error = useListError(managedResourcesState);
const refresh = useCallback(async (): Promise<Resource[]> => {
if (!managedResourcesState || !client) return [];

View file

@ -4,7 +4,7 @@ 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 { useManagedListError } from "./useManagedListError.js";
import { useListError } from "./useListError.js";
export interface UseManagedToolsResult {
/**
@ -72,7 +72,7 @@ export function useManagedTools(
};
}, [managedToolsState]);
const error = useManagedListError(managedToolsState);
const error = useListError(managedToolsState);
const refresh = useCallback(async (): Promise<Tool[]> => {
if (!managedToolsState || !client) return [];

View file

@ -7,6 +7,7 @@ import type {
} from "../mcp/state/pagedPromptsState.js";
import type { Prompt } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useListError } from "./useListError.js";
export interface UsePagedPromptsResult {
prompts: Prompt[];
@ -14,6 +15,12 @@ export interface UsePagedPromptsResult {
nextCursor?: string;
/** Pages loaded since the last reset (page 1 = 1). */
pageCount: number;
/**
* The last page load's failure, or `null` when it succeeded. In paginated
* mode this store is the display source, so this — not the managed
* store's error — is what the panel renders (#1998).
*/
error: Error | null;
loadPage: (
cursor?: string,
metadata?: Record<string, string>,
@ -72,6 +79,8 @@ export function usePagedPrompts(
};
}, [pagedPromptsState]);
const error = useListError(pagedPromptsState);
const loadPage = useCallback(
async (
cursor?: string,
@ -89,5 +98,5 @@ export function usePagedPrompts(
pagedPromptsState?.clear();
}, [pagedPromptsState]);
return { prompts, nextCursor, pageCount, loadPage, clear };
return { prompts, nextCursor, pageCount, error, loadPage, clear };
}

View file

@ -7,6 +7,7 @@ import type {
} from "../mcp/state/pagedResourcesState.js";
import type { Resource } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useListError } from "./useListError.js";
export interface UsePagedResourcesResult {
resources: Resource[];
@ -14,6 +15,12 @@ export interface UsePagedResourcesResult {
nextCursor?: string;
/** Pages loaded since the last reset (page 1 = 1). */
pageCount: number;
/**
* The last page load's failure, or `null` when it succeeded. In paginated
* mode this store is the display source, so this — not the managed
* store's error — is what the panel renders (#1998).
*/
error: Error | null;
loadPage: (
cursor?: string,
metadata?: Record<string, string>,
@ -78,6 +85,8 @@ export function usePagedResources(
};
}, [pagedResourcesState]);
const error = useListError(pagedResourcesState);
const loadPage = useCallback(
async (
cursor?: string,
@ -95,5 +104,5 @@ export function usePagedResources(
pagedResourcesState?.clear();
}, [pagedResourcesState]);
return { resources, nextCursor, pageCount, loadPage, clear };
return { resources, nextCursor, pageCount, error, loadPage, clear };
}

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