#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>
105 lines
4.1 KiB
TypeScript
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,
|
|
};
|
|
}
|