From ca6508117b390b8ddc43021379e05574bf21ab99 Mon Sep 17 00:00:00 2001 From: kn Date: Fri, 9 Oct 2026 13:39:43 +0200 Subject: [PATCH] Paged Build History and Build Library in the Svelte client ModuleExecutionList and ModuleBuildList rendered every entry. With 10,000 executions, Build History created 10,003 rows (130,189 DOM elements) and froze the browser. Both lists now show one page of module.entriesperpage rows, with a first/previous/next/last pager. The Build History scroll area capped its root height but not its viewport, so rows past about 500 px were clipped and could not be scrolled to. The cap now applies to the viewport. Adds an npm test script that runs the paging tests with node --test. --- Client/svelte/package.json | 3 +- Client/svelte/src/lib/amcf/ListPager.svelte | 34 +++++++++++++ .../lib/amcf/modules/ModuleBuildList.svelte | 12 +++-- .../amcf/modules/ModuleExecutionList.svelte | 10 +++- Client/svelte/src/lib/amcf/paging.test.ts | 49 +++++++++++++++++++ Client/svelte/src/lib/amcf/paging.ts | 22 +++++++++ 6 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 Client/svelte/src/lib/amcf/ListPager.svelte create mode 100644 Client/svelte/src/lib/amcf/paging.test.ts create mode 100644 Client/svelte/src/lib/amcf/paging.ts diff --git a/Client/svelte/package.json b/Client/svelte/package.json index f08f8581..4a0a6699 100644 --- a/Client/svelte/package.json +++ b/Client/svelte/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "node --test 'src/**/*.test.ts'" }, "devDependencies": { "@internationalized/date": "^3.11.0", diff --git a/Client/svelte/src/lib/amcf/ListPager.svelte b/Client/svelte/src/lib/amcf/ListPager.svelte new file mode 100644 index 00000000..83c7272d --- /dev/null +++ b/Client/svelte/src/lib/amcf/ListPager.svelte @@ -0,0 +1,34 @@ + + +{#if listPage.pageCount > 1} +
+ + {listPage.first}–{listPage.last} of {listPage.total} + + + + + {listPage.page} / {listPage.pageCount} + + + +
+{/if} diff --git a/Client/svelte/src/lib/amcf/modules/ModuleBuildList.svelte b/Client/svelte/src/lib/amcf/modules/ModuleBuildList.svelte index 328d58ad..eeb679b5 100644 --- a/Client/svelte/src/lib/amcf/modules/ModuleBuildList.svelte +++ b/Client/svelte/src/lib/amcf/modules/ModuleBuildList.svelte @@ -6,6 +6,8 @@ import Image from '@lucide/svelte/icons/image'; import { useModuleTick } from '$lib/amcf/poll.svelte'; + import { paginate } from '$lib/amcf/paging'; + import ListPager from '$lib/amcf/ListPager.svelte'; let { module, app }: { module: any; app: any } = $props(); const poll = useModuleTick(() => module); @@ -14,6 +16,9 @@ let entries = $derived.by(() => { poll.v; return [...(module.entries || [])]; }); let entrybuttons = $derived.by(() => { poll.v; return [...(module.entrybuttons || [])]; }); let loaded = $derived.by(() => { poll.v; return module.loaded === true; }); + let entriesPerPage = $derived.by(() => { poll.v; return module.entriesperpage; }); + let pageNumber = $state(1); + let listPage = $derived(paginate(entries, pageNumber, entriesPerPage)); let detailItem: any = $state(null); const nullUUID = '00000000-0000-0000-0000-000000000000'; @@ -53,8 +58,8 @@ {#if visible} -
- +
+ @@ -75,7 +80,7 @@ {:else} - {#each entries as build, idx (build.buildUUID || idx)} + {#each listPage.rows as build, idx (build.buildUUID || idx)} { if (entrybuttons.length > 0) { detailItem = build; } else { selectBuild(build); } }} @@ -117,6 +122,7 @@ + { pageNumber = page; }} />
diff --git a/Client/svelte/src/lib/amcf/modules/ModuleExecutionList.svelte b/Client/svelte/src/lib/amcf/modules/ModuleExecutionList.svelte index 7eca6a71..24857be9 100644 --- a/Client/svelte/src/lib/amcf/modules/ModuleExecutionList.svelte +++ b/Client/svelte/src/lib/amcf/modules/ModuleExecutionList.svelte @@ -7,6 +7,8 @@ import Image from '@lucide/svelte/icons/image'; import { useModuleTick } from '$lib/amcf/poll.svelte'; + import { paginate } from '$lib/amcf/paging'; + import ListPager from '$lib/amcf/ListPager.svelte'; let { module, app }: { module: any; app: any } = $props(); const poll = useModuleTick(() => module); @@ -15,6 +17,9 @@ let entries = $derived.by(() => { poll.v; return [...(module.entries || [])]; }); let entrybuttons = $derived.by(() => { poll.v; return [...(module.entrybuttons || [])]; }); let loaded = $derived.by(() => { poll.v; return module.loaded === true; }); + let entriesPerPage = $derived.by(() => { poll.v; return module.entriesperpage; }); + let pageNumber = $state(1); + let listPage = $derived(paginate(entries, pageNumber, entriesPerPage)); let detailItem: any = $state(null); const nullUUID = '00000000-0000-0000-0000-000000000000'; @@ -70,7 +75,7 @@ {#if visible}
- + @@ -91,7 +96,7 @@ {:else} - {#each entries as exec, idx (exec.executionUUID || idx)} + {#each listPage.rows as exec, idx (exec.executionUUID || idx)} { detailItem = exec; }} @@ -137,6 +142,7 @@ + { pageNumber = page; }} />
diff --git a/Client/svelte/src/lib/amcf/paging.test.ts b/Client/svelte/src/lib/amcf/paging.test.ts new file mode 100644 index 00000000..b82f5c43 --- /dev/null +++ b/Client/svelte/src/lib/amcf/paging.test.ts @@ -0,0 +1,49 @@ +// @ts-nocheck -- run by `node --test` (npm test); the project has no @types/node. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { paginate } from './paging.ts'; + +const executions = Array.from({ length: 10000 }, (_, i) => ({ executionUUID: `exec-${i}` })); + +test('a long list shows only the first page of entries', () => { + const p = paginate(executions, 1, 20); + + assert.equal(p.rows.length, 20); + assert.equal(p.rows[0].executionUUID, 'exec-0'); + assert.equal(p.rows[19].executionUUID, 'exec-19'); + assert.equal(p.page, 1); + assert.equal(p.pageCount, 500); + assert.equal(p.first, 1); + assert.equal(p.last, 20); + assert.equal(p.total, 10000); +}); + +test('the last page holds the remaining entries', () => { + const p = paginate(executions.slice(0, 43), 3, 20); + + assert.deepEqual(p.rows.map((r) => r.executionUUID), ['exec-40', 'exec-41', 'exec-42']); + assert.equal(p.pageCount, 3); + assert.equal(p.first, 41); + assert.equal(p.last, 43); +}); + +test('a page past the end shows the last page, e.g. after entries were deleted', () => { + const p = paginate(executions.slice(0, 25), 3, 20); + + assert.equal(p.page, 2); + assert.deepEqual(p.rows.map((r) => r.executionUUID), ['exec-20', 'exec-21', 'exec-22', 'exec-23', 'exec-24']); + assert.equal(p.first, 21); + assert.equal(p.last, 25); +}); + +test('an empty list is a single empty page', () => { + const p = paginate([], 1, 20); + + assert.deepEqual(p.rows, []); + assert.equal(p.page, 1); + assert.equal(p.pageCount, 1); + assert.equal(p.first, 0); + assert.equal(p.last, 0); + assert.equal(p.total, 0); +}); diff --git a/Client/svelte/src/lib/amcf/paging.ts b/Client/svelte/src/lib/amcf/paging.ts new file mode 100644 index 00000000..922c488d --- /dev/null +++ b/Client/svelte/src/lib/amcf/paging.ts @@ -0,0 +1,22 @@ +/* + * Client-side paging of list modules. Only the rows of one page are rendered, so that long + * lists (e.g. thousands of executions) do not block the browser. + */ +export interface ListPage { + rows: T[]; + page: number; // 1-based + pageCount: number; + first: number; // 1-based index of the first row shown + last: number; // 1-based index of the last row shown + total: number; +} + +export function paginate (entries: T[], page: number, perPage: number): ListPage { + const total = entries.length; + const pageCount = Math.max(1, Math.ceil(total / perPage)); + page = Math.min(page, pageCount); + const start = (page - 1) * perPage; + const rows = entries.slice(start, start + perPage); + + return { rows, page, pageCount, first: rows.length > 0 ? start + 1 : 0, last: start + rows.length, total }; +}