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
2 changed files with 523 additions and 42 deletions

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).