From 5cb69b1ec1f468258c8199cf64e69a82af8ebeb4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 10:29:36 +0200 Subject: [PATCH] fix(performance): count each initial request URL and soften the CI wording Deduplicate initial resources by request URL rather than by file, so a file referenced twice with different query strings is counted twice, as the browser fetches it; fragments are still ignored. Each resource now carries both its file path and its request URL. Describe the CI ratchet as the goal of the loop rather than as existing enforcement until the baseline and job PRs merge. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- docs/architecture/performance-journeys.md | 10 +++-- tools/performance/measure-initial-bytes.mjs | 19 ++++---- .../measure-initial-bytes.test.mjs | 45 +++++++++++++------ 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index d5b713edc..9e43bd5bc 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ $ pnpm run serve:frontend ``` To see how many bytes the built web app puts on the initial load path (the -number CI ratchets), build it and run the measurement: +number the performance ratchet guards), build it and run the measurement: ``` $ pnpm nx build web diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index db374796e..bde842eb0 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -2,10 +2,12 @@ IPTVnator measures performance through a small set of everyday user journeys. Each journey has deterministic counters that are asserted exactly, and -wall-clock timings that are recorded as evidence. Counters are ratcheted in CI: -a committed baseline may only be lowered, and only with the measured output as -evidence. This document is the contract for that loop; `tools/performance/` -holds the scripts. +wall-clock timings that are recorded as evidence. Counters are meant to be +ratcheted in CI: a committed baseline that may only be lowered, and only with +the measured output as evidence. This document is the contract for that loop; +`tools/performance/` holds the scripts. The measurement script lands first; +the baseline file and the CI job follow in their own PRs (#1693, #1694), so +until they merge the reported number is informational, not enforced. ## Journeys diff --git a/tools/performance/measure-initial-bytes.mjs b/tools/performance/measure-initial-bytes.mjs index 29a2613fb..c3cb6067f 100644 --- a/tools/performance/measure-initial-bytes.mjs +++ b/tools/performance/measure-initial-bytes.mjs @@ -58,8 +58,10 @@ function classify(tag, attributes) { /** * Lists the same-origin resources `index.html` puts on the initial path, in - * document order and without duplicates. Query strings and fragments are - * stripped so the URL maps to a file on disk. + * document order. Duplicates are collapsed by request URL, not by file: the + * browser fetches `chunk.js?v=1` and `chunk.js?v=2` separately, so both count, + * while a fragment never reaches the server and is ignored. `path` is the + * file on disk the URL maps to. */ export function extractInitialResources(html) { const seen = new Set(); @@ -70,10 +72,11 @@ export function extractInitialResources(html) { parseAttributes(rawAttributes) ); if (!resource || EXTERNAL_URL.test(resource.url)) continue; - const file = resource.url.replace(/[?#].*$/, '').replace(/^\.?\//, ''); - if (!file || seen.has(file)) continue; - seen.add(file); - resources.push({ path: file, kind: resource.kind }); + const url = resource.url.replace(/#.*$/, '').replace(/^\.?\//, ''); + const file = url.replace(/\?.*$/, ''); + if (!file || seen.has(url)) continue; + seen.add(url); + resources.push({ path: file, url, kind: resource.kind }); } return resources; } @@ -160,7 +163,7 @@ function formatBytes(bytes) { export function formatReport(measurement) { const rows = [...measurement.resources].sort((a, b) => b.bytes - a.bytes); const width = Math.max( - ...rows.map((row) => row.path.length), + ...rows.map((row) => row.url.length), 'index.html'.length ); const lines = [ @@ -169,7 +172,7 @@ export function formatReport(measurement) { `${'index.html'.padEnd(width)} html ${formatBytes(measurement.indexHtml.bytes).padStart(11)}`, ...rows.map( (row) => - `${row.path.padEnd(width)} ${row.kind.padEnd(13)} ${formatBytes(row.bytes).padStart(11)}` + `${row.url.padEnd(width)} ${row.kind.padEnd(13)} ${formatBytes(row.bytes).padStart(11)}` ), '', `scripts ${formatBytes(measurement.totals.script).padStart(11)}`, diff --git a/tools/performance/measure-initial-bytes.test.mjs b/tools/performance/measure-initial-bytes.test.mjs index 041708013..35dd70d83 100644 --- a/tools/performance/measure-initial-bytes.test.mjs +++ b/tools/performance/measure-initial-bytes.test.mjs @@ -69,14 +69,20 @@ after(async () => { }); test('extracts scripts, stylesheets and modulepreload chunks in document order', () => { - assert.deepEqual(extractInitialResources(BUILT_INDEX_HTML), [ - { path: 'assets/app-config.js', kind: 'script' }, - { path: 'styles-VDU4SQ5F.css', kind: 'stylesheet' }, - { path: 'chunk-B6uziQ1i.js', kind: 'modulepreload' }, - { path: 'chunk-Cn2Agfvf.js', kind: 'modulepreload' }, - { path: 'polyfills-EBB6HFCX.js', kind: 'script' }, - { path: 'main-EI6PCDGR.js', kind: 'script' }, - ]); + assert.deepEqual( + extractInitialResources(BUILT_INDEX_HTML).map(({ path, kind }) => ({ + path, + kind, + })), + [ + { path: 'assets/app-config.js', kind: 'script' }, + { path: 'styles-VDU4SQ5F.css', kind: 'stylesheet' }, + { path: 'chunk-B6uziQ1i.js', kind: 'modulepreload' }, + { path: 'chunk-Cn2Agfvf.js', kind: 'modulepreload' }, + { path: 'polyfills-EBB6HFCX.js', kind: 'script' }, + { path: 'main-EI6PCDGR.js', kind: 'script' }, + ] + ); }); test('ignores manifest, icon and external references', () => { @@ -90,24 +96,37 @@ test('ignores manifest, icon and external references', () => { `; assert.deepEqual(extractInitialResources(html), [ - { path: 'main.js', kind: 'script' }, + { path: 'main.js', url: 'main.js', kind: 'script' }, ]); }); -test('deduplicates references and normalizes relative URLs', () => { +test('deduplicates by request URL, ignores fragments and normalizes relative URLs', () => { const html = ` + + `; assert.deepEqual(extractInitialResources(html), [ - { path: 'chunk-a.js', kind: 'modulepreload' }, - { path: 'chunk-b.js', kind: 'modulepreload' }, - { path: 'main.js', kind: 'script' }, + { path: 'chunk-a.js', url: 'chunk-a.js', kind: 'modulepreload' }, + { path: 'chunk-a.js', url: 'chunk-a.js?v=2', kind: 'modulepreload' }, + { path: 'chunk-b.js', url: 'chunk-b.js', kind: 'modulepreload' }, + { path: 'main.js', url: 'main.js', kind: 'script' }, ]); }); +test('counts a file once per distinct request URL', async () => { + const distDir = await writeDist('cache-busted', { + indexHtml: ``, + files: { 'chunk-a.js': 100 }, + }); + const measurement = await measureInitialBytes({ distDir }); + assert.equal(measurement.resources.length, 2); + assert.equal(measurement.totals.modulepreload, 200); +}); + test('sums index.html and every referenced file into the counter', async () => { const distDir = await writeDist('built'); const measurement = await measureInitialBytes({ distDir });