Files
iptvnator/tools/performance/measure-initial-bytes.mjs
T
4grayandClaude Opus 5.5 d57da4dfec perf(web): take the Material snack-bar off the startup path (#1939)
* perf(web): take the Material snack-bar off the startup path

The seven services and the app shell created with the app injected
MatSnackBar, and snack-bar.mjs statically imports Material's button, the
icon button chunk, ripple and the CDK a11y module, so about 110 KB sat in
the initial bundle although a toast shows only after an event.

A root LazySnackBar (@iptvnator/services/lazy-snack-bar) resolves the
snack-bar from a dynamic import the way LazyMatDialog does, retrying after
a failed chunk load, and opens text toasts with MatSnackBar.open's
arguments; a failed load is logged without the message. The eager
importers open their toasts through it with unchanged messages, actions,
durations and panel classes; lazy code keeps injecting MatSnackBar. The
startup status defers its Retry button, the other eager importer of the
button chunk, with a native button as the error fallback.

Local production build: renderer.initialBytes 1,349,048 -> 1,236,918
(-112,130, -8.3%); no Material module is in the initial bundle any more.
The CDK overlay, scrolling and portal chunks (about 78 KB) stay through
the FullscreenOverlayContainer provider in app.config.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(web): keep a native Retry while the startup button chunk loads

Greptile (local round 1): the failure screen had no Retry button until
the deferred Material button chunk arrived. One native button, drawn in
the flat button's shape with theme colours, is now the placeholder and
the error view of the defer block, so a retry is possible while the
chunk downloads and after a failed load. Baseline and docs follow the
re-measured value, 1,237,440 bytes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(perf): point the initialBytes evidence at the opened pull request

The baseline and the performance doc named the predicted number; the
pull request opened as #1939.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(web): cover the toast actions behind the lazy snack-bar

Greptile (PR round 1): the Refresh and Trust-host actions now run after
LazySnackBar.open resolves, and no test exercised them. The PWA update
toast test holds the toast promise, then resolves it and picks Refresh;
the page reload is a field the spec substitutes, as jsdom's location
cannot be spied on. The Electron test does the same for the TLS trust
toast: the confirmation opens only once the toast is up, approving it
records the host and retries the fetch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(web): type the stubbed toast ref as Observable<undefined>

CI's spec typecheck rejected `typeof EMPTY` (Observable<never>) for a
stub that returns `of(undefined)`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(web): lower the initialBytes baseline to the merged measurement

Merged with #1938 the branch measures 1,231,455 bytes on a local
production build (the radio play control's fab tokens left the
stylesheet); the baseline follows it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(web): lower the initialBytes baseline to the zoneless merged measurement

Merged with #1929, #1936 and #1938 the branch measures 1,178,109 bytes
on a local production build; the baseline follows it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(web): re-measure initialBytes after merging #1903 and #1942

Merged with #1903 (self-hosted server storage, which raised master's
baseline by 7,245 bytes on purpose) and #1942 the branch measures
1,187,983 bytes on a local production build, below master's 1,285,523;
no Material module is in the initial bundle. The new favorites effect
spec provides the LazySnackBar stub the merged effect injects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(xtream): open favorites toasts through LazySnackBar

#1934 gave the Xtream store's favorites feature a static MatSnackBar
injection. The store exists from startup, so that one import put
Material's snack-bar, button, icon-button and ripple chunks (about
95 KB) back on the renderer's initial path. The feature now opens its
two toasts through LazySnackBar with the same messages and durations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(perf): count statically imported chunks in renderer.initialBytes

Angular's builder emits at most ten modulepreload hints. Once the
startup graph passed ten chunks, the rest were fetched through
main.js's own imports but never counted, so the counter stopped growing
and an eager MatSnackBar import on master went unnoticed. The script now
follows the static import graph of every counted script (dynamic
import() targets stay lazy; a missing imported file is an error), which
matches the bundler's metafile closure exactly: 1,196,205 bytes for this
branch, where the old counter read 1,098,535.

The baseline follows the new measurement and its note now explains the
current limit (Greptile).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(perf): parse startup imports and count them per request URL

Codex (PR round): the regexes also matched import-shaped text in strings,
templates and comments, which could count a lazy chunk or fail on a file
that does not exist. Static declarations are now read from TypeScript's
syntax tree (top-level import/export-from statements only). Imports are
also deduplicated by request URL, like the index.html references, so a
preload of chunk.js?v=1 no longer hides an import of ./chunk.js. The
real build still measures 1,196,205 bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 22:59:31 +02:00

354 lines
13 KiB
JavaScript

/**
* Measures the bytes a browser fetches before the Angular app can bootstrap:
* `index.html` itself plus every same-origin script, stylesheet and
* `modulepreload` chunk it references, and every chunk those modules import
* statically. Angular emits at most ten `modulepreload` hints, so a larger
* startup graph has chunks only the module imports name; the browser still
* fetches them before bootstrap. This is the J1 ("launch to usable")
* counter `renderer.initialBytes` from docs/architecture/performance-journeys.md.
*
* The number is read from the built output, not estimated from source, so it
* is deterministic for a given build and can be ratcheted in CI.
*
* Usage:
* node tools/performance/measure-initial-bytes.mjs [--dist dist/apps/web]
* [--json] [--summary dist/performance/journey-summary.json]
*/
import { existsSync } from 'node:fs';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'parse5';
import ts from 'typescript';
export const DEFAULT_DIST_DIR = 'dist/apps/web';
export const INITIAL_BYTES_COUNTER = 'renderer.initialBytes';
export const LAUNCH_JOURNEY = 'launch';
const EXTERNAL_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
/**
* A resource only counts when the browser fetches it on the initial path from
* the same origin. Manifest, icons and external URLs are not part of the
* payload the ratchet guards: icons load lazily, and external hosts are
* outside the build's control.
*/
function classify(tag, attributes) {
if (tag === 'script') {
return attributes.src ? { kind: 'script', url: attributes.src } : null;
}
const rel = (attributes.rel ?? '').toLowerCase().split(/\s+/);
if (!attributes.href) return null;
if (rel.includes('stylesheet')) {
return { kind: 'stylesheet', url: attributes.href };
}
if (rel.includes('modulepreload')) {
return { kind: 'modulepreload', url: attributes.href };
}
return null;
}
/**
* Yields the `script` and `link` elements the browser actually creates, in
* document order, by parsing `index.html` with the same HTML5 algorithm the
* browser uses (parse5, scripting enabled): comments, doctype and bogus
* comments are not elements, script/style/noscript/textarea/title bodies are
* text, `<template>` contents are inert and live in a separate fragment,
* attribute values arrive with character references decoded, and an SVG
* `<script>` inside inline SVG is in another namespace (it uses `href`, and
* the HTML parser does not fetch it), while HTML inside `<foreignObject>`
* is back in the HTML namespace.
*/
export function scanLiveTags(html) {
const tags = [];
const visit = (node) => {
for (const child of node.childNodes ?? []) {
if (
child.namespaceURI === HTML_NAMESPACE &&
(child.nodeName === 'script' || child.nodeName === 'link')
) {
tags.push({
tag: child.nodeName,
attributes: Object.fromEntries(
child.attrs.map((attribute) => [
attribute.name.toLowerCase(),
attribute.value,
])
),
});
}
visit(child);
}
};
visit(parse(html, { scriptingEnabled: true }));
return tags;
}
/**
* Lists the same-origin resources `index.html` puts on the initial path, in
* 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();
const resources = [];
for (const { tag, attributes } of scanLiveTags(html)) {
const resource = classify(tag, attributes);
if (!resource || EXTERNAL_URL.test(resource.url)) continue;
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;
}
/**
* Relative specifiers of the static `import` and `export … from`
* declarations in an emitted ES module, read from a parsed syntax tree, so
* text that only looks like an import (a string, template literal or comment)
* never counts. Static declarations are top-level statements only; a dynamic
* `import("…")` loads later and is an expression, not a declaration. Bare
* and absolute specifiers never point into the build.
*/
export function extractStaticImports(source) {
const tree = ts.createSourceFile(
'module.js',
source,
ts.ScriptTarget.Latest,
false,
ts.ScriptKind.JS
);
const specifiers = new Set();
for (const statement of tree.statements) {
if (
(ts.isImportDeclaration(statement) ||
ts.isExportDeclaration(statement)) &&
statement.moduleSpecifier &&
ts.isStringLiteral(statement.moduleSpecifier) &&
/^\.{1,2}\//.test(statement.moduleSpecifier.text)
) {
specifiers.add(statement.moduleSpecifier.text);
}
}
return [...specifiers];
}
async function sizeOf(filePath) {
const stats = await stat(filePath);
return stats.size;
}
/**
* Reads the built output and returns the per-file breakdown plus the counter
* value. Missing files are an error rather than zero bytes: a broken reference
* would otherwise look like a bundle-size win.
*/
export async function measureInitialBytes({ distDir, readSize = sizeOf }) {
const indexPath = path.join(distDir, 'index.html');
if (!existsSync(indexPath)) {
throw new Error(
`No index.html under ${distDir}. Build the web app first (pnpm nx build web).`
);
}
const html = await readFile(indexPath, 'utf8');
const indexBytes = await readSize(indexPath);
const resources = [];
const missing = [];
for (const resource of extractInitialResources(html)) {
const absolute = path.join(distDir, resource.path);
if (!existsSync(absolute)) {
missing.push(resource.path);
continue;
}
resources.push({ ...resource, bytes: await readSize(absolute) });
}
if (missing.length > 0) {
throw new Error(
`index.html references files that are not in ${distDir}: ${missing.join(', ')}`
);
}
// Follow the static import graph of every counted script. Like the
// index.html references, imports count once per request URL: the browser
// fetches `chunk.js?v=1` and `chunk.js` as two modules. A file's own
// imports are read once, whichever URL loaded it.
const countedUrls = new Set(resources.map((resource) => resource.url));
const scanned = new Set();
const queue = resources
.filter((resource) => /\.m?js$/.test(resource.path))
.map(({ url, path: file }) => ({ url, file }));
const unresolved = [];
while (queue.length > 0) {
const importer = queue.shift();
if (scanned.has(importer.file)) continue;
scanned.add(importer.file);
const source = await readFile(
path.join(distDir, importer.file),
'utf8'
);
const base = path.posix.dirname(importer.url.replace(/\?.*$/, ''));
for (const specifier of extractStaticImports(source)) {
const url = path.posix
.normalize(path.posix.join(base, specifier))
.replace(/#.*$/, '');
if (countedUrls.has(url)) continue;
countedUrls.add(url);
const file = url.replace(/\?.*$/, '');
const absolute = path.join(distDir, file);
if (!existsSync(absolute)) {
unresolved.push(`${file} (imported by ${importer.file})`);
continue;
}
resources.push({
path: file,
url,
kind: 'staticImport',
bytes: await readSize(absolute),
});
queue.push({ url, file });
}
}
if (unresolved.length > 0) {
throw new Error(
`Startup modules import files that are not in ${distDir}: ${unresolved.join(', ')}`
);
}
const totals = {
indexHtml: indexBytes,
script: 0,
stylesheet: 0,
modulepreload: 0,
staticImport: 0,
};
for (const resource of resources) totals[resource.kind] += resource.bytes;
const initialBytes =
totals.indexHtml +
totals.script +
totals.stylesheet +
totals.modulepreload +
totals.staticImport;
return {
distDir,
indexHtml: { path: 'index.html', bytes: indexBytes },
resources,
totals: { ...totals, initialBytes },
counters: { [INITIAL_BYTES_COUNTER]: initialBytes },
};
}
/** The journey summary shape consumed by check-journey-ratchet.mjs. */
export function toJourneySummary(
measurement,
{ measuredAt = new Date() } = {}
) {
return {
version: 1,
measuredAt: measuredAt.toISOString(),
journeys: {
[LAUNCH_JOURNEY]: {
counters: { ...measurement.counters },
},
},
};
}
function formatBytes(bytes) {
return bytes.toLocaleString('en-US');
}
export function formatReport(measurement) {
const rows = [...measurement.resources].sort((a, b) => b.bytes - a.bytes);
const width = Math.max(
...rows.map((row) => row.url.length),
'index.html'.length
);
const lines = [
`Initial payload of ${measurement.distDir}`,
'',
`${'index.html'.padEnd(width)} html ${formatBytes(measurement.indexHtml.bytes).padStart(11)}`,
...rows.map(
(row) =>
`${row.url.padEnd(width)} ${row.kind.padEnd(13)} ${formatBytes(row.bytes).padStart(11)}`
),
'',
`scripts ${formatBytes(measurement.totals.script).padStart(11)}`,
`stylesheets ${formatBytes(measurement.totals.stylesheet).padStart(11)}`,
`modulepreload ${formatBytes(measurement.totals.modulepreload).padStart(11)}`,
`static imports ${formatBytes(measurement.totals.staticImport).padStart(11)}`,
`${INITIAL_BYTES_COUNTER} = ${formatBytes(measurement.totals.initialBytes)} bytes (${measurement.resources.length} files + index.html)`,
];
return lines.join('\n');
}
export function parseArgs(argv) {
const options = { distDir: DEFAULT_DIST_DIR, json: false, summary: null };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--') continue;
if (argument === '--json') {
options.json = true;
} else if (argument === '--dist') {
options.distDir = argv[++index];
} else if (argument.startsWith('--dist=')) {
options.distDir = argument.slice('--dist='.length);
} else if (argument === '--summary') {
options.summary = argv[++index];
} else if (argument.startsWith('--summary=')) {
options.summary = argument.slice('--summary='.length);
} else {
throw new Error(`Unknown argument: ${argument}`);
}
if (options.distDir === undefined || options.summary === undefined) {
throw new Error(`Missing value for ${argument}`);
}
}
return options;
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) ===
path.resolve(fileURLToPath(import.meta.url));
if (isMain) {
try {
const options = parseArgs(process.argv.slice(2));
const measurement = await measureInitialBytes({
distDir: path.resolve(options.distDir),
});
measurement.distDir =
path.relative(process.cwd(), measurement.distDir) || '.';
if (options.summary) {
const summaryPath = path.resolve(options.summary);
await mkdir(path.dirname(summaryPath), { recursive: true });
await writeFile(
summaryPath,
`${JSON.stringify(toJourneySummary(measurement), null, 4)}\n`
);
}
console.log(
options.json
? JSON.stringify(measurement, null, 4)
: formatReport(measurement)
);
if (options.summary && !options.json) {
console.log(`\nJourney summary written to ${options.summary}`);
}
} catch (error) {
console.error(`measure-initial-bytes: ${error.message}`);
process.exitCode = 1;
}
}