Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1073,29 +1076,35 @@ function App() {
paginated: paginatedLists,
managedItems: managedTools,
managedRefresh: refreshTools,
managedError: toolsLoadError,
pagedItems: pagedTools,
pagedNextCursor: pagedToolsCursor,
pagedPageCount: pagedToolsPageCount,
pagedError: pagedToolsLoadError,
loadPage: loadToolsPage,
});
const promptsPagination = usePaginatedList({
connected,
paginated: paginatedLists,
managedItems: managedPrompts,
managedRefresh: refreshPrompts,
managedError: promptsLoadError,
pagedItems: pagedPrompts,
pagedNextCursor: pagedPromptsCursor,
pagedPageCount: pagedPromptsPageCount,
pagedError: pagedPromptsLoadError,
loadPage: loadPromptsPage,
});
const resourcesPagination = usePaginatedList({
connected,
paginated: paginatedLists,
managedItems: managedResources,
managedRefresh: refreshResources,
managedError: resourcesLoadError,
pagedItems: pagedResources,
pagedNextCursor: pagedResourcesCursor,
pagedPageCount: pagedResourcesPageCount,
pagedError: pagedResourcesLoadError,
loadPage: loadResourcesPage,
});
const tools = toolsPagination.items;
Expand Down Expand Up @@ -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}
Expand Down
33 changes: 33 additions & 0 deletions clients/web/src/hooks/usePaginatedList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
}

Expand All @@ -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,
};
Expand Down Expand Up @@ -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 });
Expand Down
14 changes: 14 additions & 0 deletions clients/web/src/hooks/usePaginatedList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>;
}
Expand All @@ -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> => {
Expand All @@ -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).
Expand Down
65 changes: 65 additions & 0 deletions clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
});
});
65 changes: 65 additions & 0 deletions clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
});
});
Loading