fix(644): page SPA list loaders to completeness instead of inflating pageSize

Seven call sites (rerun-collections, multi-collections, library/browse) requested
pageSize far above each endpoint's server-side MaxPageSize=100 clamp and took the
single response page as the whole list, so rows past 100 silently vanished with no
error or truncation indicator.

Extract the loadAllRerunCollections pattern from SchedulesScreen (#634) into a
shared, generic web/src/api/paging.ts::loadAllPages helper that pages against
totalCount with an empty-page defensive break, and refactor SchedulesScreen plus
the seven over-cap call sites in RerunCollectionsScreen, MultiCollectionsScreen,
PlaylistsScreen, and FillerPresetsScreen to use it. Server caps are unchanged
(api.search-allitems-paging precedent: client pages, server stays bounded).

Document the convention in docs/spa-conventions.md §3b.
This commit is contained in:
2026-07-26 20:32:06 +02:00
parent 34591c3ef6
commit fe342a6a0b
9 changed files with 295 additions and 43 deletions
+28
View File
@@ -121,6 +121,34 @@ Convention — when a screen keeps stale results visible during a refetch:
current (compare against a ref that always holds the committed value — `SearchScreen` reuses
`lastQueryRef`) and **discard** otherwise. Checking only `activeRef` (mounted) is insufficient.
## 3b. Paged list endpoints clamp server-side — page to completeness, don't inflate `pageSize`
Every paged `/api/v1` list endpoint (rerun-collections, multi-collections, library/browse, search,
trakt-lists, …) clamps `pageSize` to its own controller's `MaxPageSize` (100, as of #644) regardless
of what the client requests. A screen that asks for `pageSize: 1000` to "get everything in one call"
gets only the first `MaxPageSize` rows back, silently — no error, no truncation indicator, no paging
UI to notice the gap. This was issue #644 (following on from #634, which fixed the first instance —
`SchedulesScreen`'s rerun-collections picker load).
**If a screen genuinely needs the complete list** (not a paginated view — e.g. a picker/typeahead
data source), use the shared `loadAllPages` helper (`web/src/api/paging.ts`, re-exported via
`web/src/api/index.ts`) instead of an inflated `pageSize`:
```ts
loadAllPages(getMultiCollections) // pages against totalCount, cap defaults to 100
loadAllPages(getLibraryBrowseItems, { mediaType: 'Movie' }) // extra fixed params thread into every page
```
It pages `pageNum` from 0 (per §"paging-zero-based" in `api-conventions.md`) against the response's
`totalCount`, breaking early on an empty page as a defensive guard against a `totalCount` that never
converges. **Do not raise the server-side cap to work around this** — the `api.search-allitems-paging`
precedent is that the client pages and the server stays bounded; that's a backend decision, out of
scope for a screen fix.
**If a screen shows a bounded preview or has real paging UI** (a "load more" button, a page-size
selector, a fixed-size typeahead result list), a `pageSize` at or below the cap is correct as-is —
`loadAllPages` is only for "I need literally everything" call sites.
## 4. API client modules
One file per domain in `web/src/api/`, e.g. `logs.ts`, `blocks.ts`, `playouts.ts`. Pattern (see
+1
View File
@@ -23,6 +23,7 @@ export * from './mediaDetail';
export * from './mediaItems';
export * from './mediaSources';
export * from './multiCollections';
export * from './paging';
export * from './pickers';
export * from './playlists';
export * from './playoutHistory';
+179
View File
@@ -0,0 +1,179 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { loadAllPages, type PagedResult } from './paging';
import { getMultiCollections } from './multiCollections';
import { getLibraryBrowseItems } from './libraryBrowse';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
// Builds a "server" of `total` items with predictable ids/names, clamped to `cap` per page —
// mirrors the real RerunCollectionController/MultiCollectionController/LibraryBrowseController
// behavior (MaxPageSize=100 for all three, confirmed by reading the controllers for #644).
function fakeItem(id: number) {
return { id, name: `item-${id}` };
}
describe('loadAllPages', () => {
it('stops after a single page when totalCount fits within pageSize', async () => {
const fetchPage = vi.fn(async (params: { pageNum?: number; pageSize?: number }): Promise<PagedResult<{ id: number }>> => {
expect(params.pageNum).toBe(0);
expect(params.pageSize).toBe(100);
return { page: [fakeItem(1), fakeItem(2)], totalCount: 2 };
});
const result = await loadAllPages(fetchPage);
expect(result).toEqual([fakeItem(1), fakeItem(2)]);
expect(fetchPage).toHaveBeenCalledTimes(1);
});
it('pages to completeness across more than one page boundary (250 items @ cap 100 -> 3 requests)', async () => {
const total = 250;
const cap = 100;
const all = Array.from({ length: total }, (_, i) => fakeItem(i + 1));
const fetchPage = vi.fn(async (params: { pageNum?: number; pageSize?: number }): Promise<PagedResult<{ id: number }>> => {
const pageNum = params.pageNum ?? 0;
const pageSize = params.pageSize ?? cap;
const start = pageNum * pageSize;
return { page: all.slice(start, start + pageSize), totalCount: total };
});
const result = await loadAllPages(fetchPage, {}, cap);
expect(fetchPage).toHaveBeenCalledTimes(3);
expect(fetchPage.mock.calls.map((call) => call[0])).toEqual([
{ pageNum: 0, pageSize: 100 },
{ pageNum: 1, pageSize: 100 },
{ pageNum: 2, pageSize: 100 }
]);
// Pin the exact set: ids 1..250 in order, nothing dropped at either page boundary.
expect(result.map((item) => item.id)).toEqual(Array.from({ length: total }, (_, i) => i + 1));
expect(result[99]).toEqual(fakeItem(100));
expect(result[100]).toEqual(fakeItem(101));
expect(result[249]).toEqual(fakeItem(250));
});
it('threads extra base params (e.g. mediaType) into every page request', async () => {
const fetchPage = vi.fn(async (): Promise<PagedResult<{ id: number }>> => ({ page: [fakeItem(1)], totalCount: 1 }));
await loadAllPages(fetchPage, { mediaType: 'Movie' });
expect(fetchPage).toHaveBeenCalledWith({ mediaType: 'Movie', pageNum: 0, pageSize: 100 });
});
it('breaks on an empty page even if totalCount claims more remain (defensive, never loops forever)', async () => {
const fetchPage = vi.fn(async (params: { pageNum?: number }): Promise<PagedResult<{ id: number }>> => {
if ((params.pageNum ?? 0) === 0) {
return { page: [fakeItem(1)], totalCount: 5 };
}
return { page: [], totalCount: 5 };
});
const result = await loadAllPages(fetchPage);
expect(result).toEqual([fakeItem(1)]);
expect(fetchPage).toHaveBeenCalledTimes(2);
});
});
describe('loadAllPages against real domain loaders', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('getMultiCollections: pins the exact merged set across a >cap (250-item) list and asserts fetch call params', async () => {
const total = 250;
const cap = 100;
const all = Array.from({ length: total }, (_, i) => ({ id: i + 1, items: [], name: `MC ${i + 1}` }));
const fetchMock = vi.spyOn(window, 'fetch').mockImplementation(async (input) => {
const url = new URL(String(input), 'http://localhost');
const pageNum = Number(url.searchParams.get('pageNum') ?? '0');
const pageSize = Number(url.searchParams.get('pageSize') ?? String(cap));
const start = pageNum * pageSize;
return jsonResponse({ page: all.slice(start, start + pageSize), totalCount: total });
});
const result = await loadAllPages(getMultiCollections);
expect(fetchMock).toHaveBeenCalledTimes(3);
const requestedUrls = fetchMock.mock.calls.map((call) => new URL(String(call[0]), 'http://localhost'));
expect(requestedUrls.map((url) => url.pathname)).toEqual([
'/api/v1/multi-collections',
'/api/v1/multi-collections',
'/api/v1/multi-collections'
]);
expect(requestedUrls.map((url) => [url.searchParams.get('pageNum'), url.searchParams.get('pageSize')])).toEqual([
['0', '100'],
['1', '100'],
['2', '100']
]);
// Pin the exact ids/names returned, including the two page boundaries (index 99/100, 199/200).
expect(result.map((entry) => entry.id)).toEqual(Array.from({ length: total }, (_, i) => i + 1));
expect(result[99].name).toBe('MC 100');
expect(result[100].name).toBe('MC 101');
expect(result[199].name).toBe('MC 200');
expect(result[200].name).toBe('MC 201');
expect(result[249].name).toBe('MC 250');
});
it('getMultiCollections: a list at exactly the cap (100) still issues only one request', async () => {
const all = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, items: [], name: `MC ${i + 1}` }));
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: all, totalCount: 100 }));
const result = await loadAllPages(getMultiCollections);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.map((entry) => entry.id)).toEqual(Array.from({ length: 100 }, (_, i) => i + 1));
});
it('getLibraryBrowseItems: pins the exact merged set across a >cap (150-item) list and threads mediaType into every page', async () => {
const total = 150;
const cap = 100;
const all = Array.from({ length: total }, (_, i) => ({
id: i + 1,
mediaItemId: i + 1,
mediaType: 'Movie' as const,
title: `Movie ${i + 1}`
}));
const fetchMock = vi.spyOn(window, 'fetch').mockImplementation(async (input) => {
const url = new URL(String(input), 'http://localhost');
const pageNum = Number(url.searchParams.get('pageNum') ?? '0');
const pageSize = Number(url.searchParams.get('pageSize') ?? String(cap));
const start = pageNum * pageSize;
return jsonResponse({ page: all.slice(start, start + pageSize), totalCount: total });
});
const result = await loadAllPages(getLibraryBrowseItems, { mediaType: 'Movie' });
expect(fetchMock).toHaveBeenCalledTimes(2);
const requestedUrls = fetchMock.mock.calls.map((call) => new URL(String(call[0]), 'http://localhost'));
expect(
requestedUrls.map((url) => [
url.searchParams.get('mediaType'),
url.searchParams.get('pageNum'),
url.searchParams.get('pageSize')
])
).toEqual([
['Movie', '0', '100'],
['Movie', '1', '100']
]);
// Pin the exact titles across the page boundary at index 99/100.
expect(result.map((item) => item.title)).toEqual(Array.from({ length: total }, (_, i) => `Movie ${i + 1}`));
expect(result[99].title).toBe('Movie 100');
expect(result[100].title).toBe('Movie 101');
expect(result[149].title).toBe('Movie 150');
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* Shared client-side paging helper (issue #644, extending the `loadAllRerunCollections` pattern
* introduced for SchedulesScreen in #634).
*
* Paged list endpoints under `/api/v1` clamp `pageSize` server-side (each controller's own
* `MaxPageSize`, currently 100 for rerun-collections, multi-collections, and library/browse — see
* `RerunCollectionController`/`MultiCollectionController`/`LibraryBrowseController`). Requesting a
* `pageSize` above the cap buys nothing: the server silently clamps it, so a single oversized
* request only ever returns the first page's worth of rows and the rest vanish with no error and
* no truncation indicator.
*
* A screen that needs the FULL list (not a paginated view) must page to completeness against
* `totalCount` instead of inflating `pageSize` — the client pages, the server stays bounded
* (the `api.search-allitems-paging` precedent). Use this helper rather than copying the loop.
*/
export interface PagedResult<T> {
page?: T[] | null;
totalCount?: number | null;
}
/** Matches every generated paged-list params shape (`GetMultiCollectionsParams`, etc). */
export interface PagingParams {
pageNum?: number;
pageSize?: number;
}
/**
* Repeatedly calls `fetchPage` with increasing `pageNum` (0-based, per `api.paging-zero-based`)
* until the accumulated results reach `totalCount`, or a page comes back empty (defensive break
* against a `totalCount` that never converges). `pageSize` defaults to 100, the cap shared by
* every paged `/api/v1` list endpoint today; pass a smaller value only if a specific endpoint's
* cap is lower.
*/
export async function loadAllPages<T, P extends PagingParams>(
fetchPage: (params: P) => Promise<PagedResult<T>>,
baseParams: Omit<P, 'pageNum' | 'pageSize'> = {} as Omit<P, 'pageNum' | 'pageSize'>,
pageSize = 100
): Promise<T[]> {
const first = await fetchPage({ ...baseParams, pageNum: 0, pageSize } as P);
let all = first.page ?? [];
const totalCount = first.totalCount ?? all.length;
let pageNum = 1;
while (all.length < totalCount) {
const next = await fetchPage({ ...baseParams, pageNum, pageSize } as P);
const nextPage = next.page ?? [];
if (nextPage.length === 0) {
break;
}
all = [...all, ...nextPage];
pageNum += 1;
}
return all;
}
+4 -3
View File
@@ -9,6 +9,7 @@ import {
getFillerPreset,
getFillerPresets,
getLibraryBrowseItems,
loadAllPages,
messageFromFillerPresetError,
updateFillerPreset,
type CreateFillerPresetRequest,
@@ -486,10 +487,10 @@ function FillerEditor({ mode }: { mode: { id: number; kind: 'edit' } | { kind: '
let active = true;
getLibraryBrowseItems({ mediaType: config.browse, pageSize: 500 })
.then((result) => {
loadAllPages(getLibraryBrowseItems, { mediaType: config.browse })
.then((list) => {
if (active) {
setPickerItems(result.page ?? []);
setPickerItems(list);
setPickerError(null);
}
})
+4 -3
View File
@@ -10,6 +10,7 @@ import {
getMultiCollectionWithMeta,
getMultiCollections,
getSmartCollections,
loadAllPages,
messageFromMultiCollectionError,
updateMultiCollection,
type MediaCollection,
@@ -37,10 +38,10 @@ function useMultiCollectionsData() {
}, []);
const load = useCallback(() => {
getMultiCollections({ pageSize: 1000 })
.then((result) => {
loadAllPages(getMultiCollections)
.then((list) => {
if (activeRef.current) {
setState({ data: result.page ?? [], error: null, status: 'success' });
setState({ data: list, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
+5 -4
View File
@@ -31,6 +31,7 @@ import {
getPlaylistItemsWithMeta,
getPlaylists,
getSmartCollections,
loadAllPages,
messageFromPlaylistError,
previewPlaylist,
updatePlaylist,
@@ -149,14 +150,14 @@ function loadPickerOptions(type: CollectionType): Promise<PickerOption[]> {
case 'collection':
return getCollections().then((list) => list.map((entry) => ({ id: entry.id, name: entry.name ?? `#${entry.id}` })));
case 'multi':
return getMultiCollections({ pageSize: 1000 }).then((result) =>
(result.page ?? []).map((entry) => ({ id: entry.id, name: entry.name ?? `#${entry.id}` }))
return loadAllPages(getMultiCollections).then((list) =>
list.map((entry) => ({ id: entry.id, name: entry.name ?? `#${entry.id}` }))
);
case 'smart':
return getSmartCollections().then((list) => list.map((entry) => ({ id: entry.id, name: entry.name ?? `#${entry.id}` })));
default:
return getLibraryBrowseItems({ mediaType: config.browse, pageSize: 500 }).then((result) =>
(result.page ?? []).map((item) => ({ id: item.mediaItemId ?? item.id, name: item.title ?? `#${item.id}` }))
return loadAllPages(getLibraryBrowseItems, { mediaType: config.browse }).then((list) =>
list.map((item) => ({ id: item.mediaItemId ?? item.id, name: item.title ?? `#${item.id}` }))
);
}
}
+8 -7
View File
@@ -13,6 +13,7 @@ import {
getRerunCollectionWithMeta,
getRerunCollections,
getSmartCollections,
loadAllPages,
messageFromRerunCollectionError,
updateRerunCollection,
type CreateRerunCollectionRequest,
@@ -102,14 +103,14 @@ function loadPickerOptions(type: RerunCollectionType): Promise<PickerOption[]> {
case 'collection':
return getCollections().then((list) => list.map((entry) => ({ id: entry.id, name: entry.name ?? `#${entry.id}` })));
case 'multi':
return getMultiCollections({ pageSize: 1000 }).then((result) =>
(result.page ?? []).map((entry) => ({ id: entry.id, name: entry.name ?? `#${entry.id}` }))
return loadAllPages(getMultiCollections).then((list) =>
list.map((entry) => ({ id: entry.id, name: entry.name ?? `#${entry.id}` }))
);
case 'smart':
return getSmartCollections().then((list) => list.map((entry) => ({ id: entry.id, name: entry.name ?? `#${entry.id}` })));
default:
return getLibraryBrowseItems({ mediaType: config.browse, pageSize: 500 }).then((result) =>
(result.page ?? []).map((item) => ({ id: item.mediaItemId ?? item.id, name: item.title ?? `#${item.id}` }))
return loadAllPages(getLibraryBrowseItems, { mediaType: config.browse }).then((list) =>
list.map((item) => ({ id: item.mediaItemId ?? item.id, name: item.title ?? `#${item.id}` }))
);
}
}
@@ -133,10 +134,10 @@ function useRerunCollectionsData() {
}, []);
const load = useCallback(() => {
getRerunCollections({ pageSize: 1000 })
.then((result) => {
loadAllPages(getRerunCollections)
.then((list) => {
if (activeRef.current) {
setState({ data: result.page ?? [], error: null, status: 'success' });
setState({ data: list, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
+8 -26
View File
@@ -20,6 +20,7 @@ import {
getSchedules,
getGraphicsElements,
getWatermarks,
loadAllPages,
messageFromScheduleError,
type ProgramSchedule,
replaceScheduleItems,
@@ -45,33 +46,14 @@ const DIRTY_PROMPT = 'You have unsaved schedule changes. Discard them?';
// Server clamps pageSize to MaxPageSize=100 (RerunCollectionController) regardless of what's
// requested, so a single `pageSize: 1000` request only ever returns the first 100 rows. Page to
// completeness against totalCount instead of raising the server cap (api.search-allitems-paging
// precedent — the client pages, the server stays bounded). Mirrors CollectionsScreen.enterReorder.
// precedent — the client pages, the server stays bounded) via the shared `loadAllPages` helper
// (`web/src/api/paging.ts`, #644). Mirrors CollectionsScreen.enterReorder.
//
// `pageNum` is 0-BASED here and everywhere on /api/v1 (api.paging-zero-based): the first page is 0,
// so the loop below starts its follow-up requests at 1. Before ersatztv#616 this call site passed
// `pageNum: 1`, which skipped the first page entirely and showed nothing at all for the ordinary
// case of <=100 rerun collections — the same call site, one defect earlier.
const RERUN_PAGE_SIZE = 100;
async function loadAllRerunCollections(): Promise<RerunCollection[]> {
const first = await getRerunCollections({ pageNum: 0, pageSize: RERUN_PAGE_SIZE });
let all = first.page ?? [];
const totalCount = first.totalCount ?? all.length;
let pageNum = 1;
while (all.length < totalCount) {
const next = await getRerunCollections({ pageNum, pageSize: RERUN_PAGE_SIZE });
const nextPage = next.page ?? [];
if (nextPage.length === 0) {
break;
}
all = [...all, ...nextPage];
pageNum += 1;
}
return all;
// `pageNum` is 0-BASED here and everywhere on /api/v1 (api.paging-zero-based). Before ersatztv#616
// this call site passed `pageNum: 1`, which skipped the first page entirely and showed nothing at
// all for the ordinary case of <=100 rerun collections — the same call site, one defect earlier.
function loadAllRerunCollections(): Promise<RerunCollection[]> {
return loadAllPages(getRerunCollections);
}
type BootState =