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
+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;
}