inspector/clients/web/src/hooks/usePaginatedList.ts
cliffhall de60983ccc 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>
2026-08-17 20:40:23 -04:00

105 lines
4.1 KiB
TypeScript

import { useCallback } from "react";
/**
* The list + pagination controls a screen renders, produced by
* {@link usePaginatedList}. `items` is already the correct source for the
* active mode (the aggregate list in all-pages mode, the accumulated paged
* list in paginated mode).
*/
export interface PaginatedListModel<T> {
items: T[];
/** True when fetching one page at a time (the `paginatedLists` setting). */
paginated: boolean;
/** Paginated mode: the server returned a `nextCursor` still to load. */
canLoadMore: boolean;
/** Paginated mode: pages loaded so far. */
loadedPages: number;
/**
* Paginated mode: fetch the next page (no-op otherwise). Returns the load
* 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
* Refresh button calls. Returns the underlying promise so the caller can wrap
* it in auth recovery.
*/
onRefresh: () => Promise<unknown>;
}
export interface UsePaginatedListParams<T> {
/** Whether the client is connected (masks the paged progress when not). */
connected: boolean;
/** The `paginatedLists` server setting (the active mode). */
paginated: boolean;
/** The auto-aggregated list (all-pages mode display source). */
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>;
}
/**
* Select a list's display source and pagination controls between the managed
* (auto-aggregate-all-pages) and paged (one-page-at-a-time) state stores,
* driven by the `paginatedLists` server setting (#1721).
*
* Loading is owned by the state stores, not this hook: the paged store
* auto-loads page 1 on connect in paginated mode (and the managed store skips
* its all-page walk there), so this hook is pure — it derives the display list,
* the load-more affordance, and a mode-aware Refresh from store state. Mode
* *changes* (which trigger a load) are driven from the sidebar toggle handler
* in App, keeping data-loading out of React effects.
*/
export function usePaginatedList<T>({
connected,
paginated,
managedItems,
managedRefresh,
managedError,
pagedItems,
pagedNextCursor,
pagedPageCount,
pagedError,
loadPage,
}: UsePaginatedListParams<T>): PaginatedListModel<T> {
const onLoadMore = useCallback((): Promise<unknown> => {
if (pagedNextCursor === undefined) return Promise.resolve();
return loadPage(pagedNextCursor);
}, [pagedNextCursor, loadPage]);
const onRefresh = useCallback((): Promise<unknown> => {
return paginated ? loadPage(undefined) : managedRefresh();
}, [paginated, loadPage, managedRefresh]);
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).
canLoadMore: connected && paginated && pagedNextCursor !== undefined,
loadedPages: connected ? pagedPageCount : 0,
onLoadMore,
onRefresh,
};
}