Commit Graph
6 Commits
Author SHA1 Message Date
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
0a006cb027 ci(perf): enforce the journey counters stable on master (#1829)
* ci(perf): enforce the journey counters stable on master

Promote the J1, J2 and J3 counters that were identical in all 55 measured
iterations of the 11 master runs from 2026-10-03 to 2026-10-04 to
journey-baselines.json, and check them in the Performance journeys job
(still warn-only), in one step together with #1828's two validated J1
entries. None of the new ones has Principle 3 evidence, so each carries a
"guard only, not validated" note that the checker prints with a failure.
A performance-tools test keeps the job's --only list equal to the journey
entries. Number formatting uses three decimals, the precision of the
layout-shift scores.

J2 renderer.layoutShiftScore is 0.233, not the window's 0.222: every
master run from #1814 (page Back buttons in the header) on reads 0.233.

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

* ci(perf): check the journey counters whenever a summary was written

Review follow-up (Greptile): the check ran only after the composite
action succeeded, so a failed job-summary report after a written
summary.json skipped every baseline. It now runs unless the job was
cancelled, as long as the action produced a summary path.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:43:55 +02:00
4gray ec8b931dbf ci(perf): add the weekly baseline tightening workflow (#1760) 2026-09-30 18:50:30 +02:00
4grayandClaude Fable 5.1 d3b6e548cc ci(performance): fail when the web app's initial bytes grow (#1694)
Third step of the performance-journeys ratchet, stacked on #1693 (which is stacked on #1692; merge in order, GitHub retargets each to `master`).

- New `Initial bytes ratchet` job in `.github/workflows/ci.yml` (ubuntu-latest): install, `pnpm nx build web --skip-nx-cache` (production configuration, the one users download), then `pnpm run perf:initial-bytes:check`. The job fails when `renderer.initialBytes` exceeds `tools/performance/journey-baselines.json`.
- `dist/performance/` is uploaded as the `performance-journey-summary` artifact on every run, so a failing or tightenable run carries its evidence.
- After review: the job first runs the new `tools/performance/check-baseline-direction.mjs`, which compares `journey-baselines.json` with the revision the change is measured against (the target branch of a pull request, `github.event.before` for a `master` push, `master` for a manual dispatch) and fails on any raised enforced limit (`value × toleranceRatio`), any widened or newly added tolerance, or any removed entry, so a PR cannot grow the payload and raise the baseline to match (lowered limits and new entries pass; a target branch without the file has nothing to weaken). Node tests cover it.
- Docs: the performance-journeys contract and the validation map name the job, and the contract now states that this runner is the canonical measurer (take baseline values from its output, not from a local build).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 13:44:01 +02:00
4grayandClaude Fable 5.1 7abaad29e7 chore(performance): commit the initial-bytes baseline and ratchet checker (#1693)
Second step of the performance-journeys ratchet, stacked on #1692 (merge that first; this PR retargets to `master` automatically).

- `tools/performance/journey-baselines.json`: J1 `launch` / `renderer.initialBytes` = **2,739,510 bytes**, the ubuntu runner's production build of `apps/web` at this content (after #1692 stopped bundling `package.json` into `main.js`). A local macOS build of this pre-#1695 code is 2 bytes smaller in `main.js` (the eager locale imports); once #1695 removes them the two are byte-identical. Correction to an earlier version of this description: the "556-byte macOS vs Linux difference" was almost entirely `package.json` text embedded in `main.js`, which moved with every script edit in this stack, plus this 2-byte residue. The runner is the canonical measurer; the CI run on the stacked #1694 branch (this content plus the job) is where the number is confirmed.
- `tools/performance/check-journey-ratchet.mjs` compares a journey summary with the baselines: a counter above its value fails (exact, no slack), wall-clock entries fail above `value × toleranceRatio`, a baseline without a measurement fails so dropping a measurement cannot disable the ratchet, values below baseline print a "tighten" hint, and measured counters without a baseline are noted only. After review: checking nothing (empty file, or `--only` naming a missing entry) fails; a counter is read only from `counters` and a wall-clock entry only from `wallClock`; the repeatable `--only <journey>/<counter>` flag scopes a check.
- Root scripts: `perf:initial-bytes:check` (measures into its own `dist/performance/initial-bytes.summary.json`, then checks `--only launch/renderer.initialBytes`) and `perf:ratchet:check` (full check); `perf:tools:test` runs both test files, as does `pnpm nx test performance-tools`.
- `docs/architecture/performance-journeys.md` gains the Ratchet section (file format, rules, "baselines only move down"); the validation map lists the check.

The CI job that runs the check on every PR is #1694; C1 (lazy Angular date locales, #1695) then lowers the baseline with the measured output as evidence.

Note: `ci.yml` only triggers on pull requests targeting `master`, so this stacked PR shows no Actions runs until #1692 merges. The evidence runs above were dispatched with `gh workflow run ci.yml --ref <branch>`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 13:43:33 +02:00
4grayandClaude Fable 5.1 8bc877b625 chore(performance): measure initial bytes of the built web app (#1692)
First step of the performance-journeys ratchet (plan thread: J1 `launch`, counter `renderer.initialBytes`).

- `tools/performance/measure-initial-bytes.mjs` reads the built `dist/apps/web/index.html` and sums `index.html` plus every same-origin `<script src>`, `<link rel="stylesheet">` and `<link rel="modulepreload">` it references. Manifest, icons, external URLs and lazy chunks are not counted. A referenced file missing from the build fails the measurement instead of counting as zero bytes.
- `--json` prints the breakdown; `--summary <file>` writes the `journeys.<journey>.counters` shape a ratchet checker will consume (next PR).
- New Nx project `performance-tools` (test + lint targets), Tier B in `tools/coverage/coverage-policy.json`, root scripts `perf:initial-bytes` and `perf:tools:test`.
- New contract `docs/architecture/performance-journeys.md`, linked from the validation map, the agent context map and the README.
- **Review follow-ups:** resources are deduplicated by request URL (query kept, fragment dropped); `index.html` is parsed with parse5 (already a repository dependency, scripting enabled), so comments, bogus comments, raw-text bodies (script/style/noscript/title/textarea), inert `<template>` contents and character references in attributes all follow the HTML5 algorithm instead of a hand-written scanner; the review's edge cases stay as regression tests; docs show the `pnpm --silent` form for JSON output and explain how the counter relates to Angular's rounded "Initial total".
- **Found while measuring:** the environment files and the playback diagnostic panel imported the whole `package.json` (`import packageJson from '@package'`), which esbuild cannot tree-shake, so `main.js` carried the complete file and the counter moved with every script or dependency edit. They now import `{ version }` only (eb662c485): `main.js` shrinks by **11,539 bytes** and the counter no longer depends on `package.json`. Jest's ESM loader exposes JSON only as a default export, so the two web Jest configs map `@package` to a stub that serves the real file's fields as named exports. Release note: `.changes/web-version-only-from-package-json.md` (`type: perf`).

Production build after this PR measures **2,739,508 bytes** (11 files + `index.html`); Angular's "Initial total" is this minus `index.html` and `assets/app-config.js`. The baseline file and the CI check land in the follow-up PRs; C1 (lazy Angular date locales) then lowers it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 13:31:27 +02:00