diff --git a/bundle-budget.json b/bundle-budget.json index 868b80aa6..d0777b781 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,11 +1,12 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch \u2014 the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands \u2014 here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up.", - "routesKbGz": { - "/page": 460, - "/insights/page": 445, - "/measurements/page": 445, - "/insights/mood/page": 460 + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before.", + "routeBaselineKbGz": { + "/page": 447, + "/insights/page": 434, + "/measurements/page": 433, + "/insights/mood/page": 447 }, + "routeDriftAllowanceKbGz": 20, "totalClientKbGz": 3700, "maxRechartsChunks": 1 } diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs index cd2518981..f53c6f44d 100644 --- a/scripts/check-bundle-budget.mjs +++ b/scripts/check-bundle-budget.mjs @@ -23,11 +23,20 @@ * pnpm bundle-report # print the report table * pnpm bundle-report --check # enforce bundle-budget.json (CI gate) * - * Budgets live in `bundle-budget.json` (repo root). Numbers are KB gzip - * with deliberate headroom over the measured value — the gate exists to - * catch step-change regressions (a ~100 KB catalog, a ~90 KB duplicate - * library), not day-to-day noise. Raise a budget consciously, in the same - * PR that pays the cost, with the reason in the PR body. + * Budgets live in `bundle-budget.json` (repo root). Numbers are KB gzip. + * + * Per-route the file states a MEASURED baseline and one drift allowance + * over it, and a route fails on the delta rather than on a ceiling it is + * already touching. Flat ceilings had been re-stated but never re-based, so + * three routes ended up sitting within a kilobyte of theirs and any + * unrelated dependency bump failed the e2e job on a regression nobody had + * made. The delta is printed on every run, so a route that has crept most of + * the way through its allowance is readable in the log before it is red. + * + * The gate still exists to catch step changes (a ~100 KB statically imported + * catalog, a ~90 KB duplicate library), which are several times the + * allowance. Re-base consciously, in the same PR that pays the cost, with + * the reason written into `$comment`. */ import { readFileSync, readdirSync, existsSync } from "node:fs"; @@ -166,14 +175,28 @@ console.log( console.log(`recharts-fingerprint chunks: ${rechartsChunks}`); console.log(""); -const watched = budget ? Object.keys(budget.routesKbGz ?? {}) : []; +const baselines = budget?.routeBaselineKbGz ?? {}; +const allowanceKb = budget?.routeDriftAllowanceKbGz ?? 0; +/** The ceiling a route's measured baseline plus the drift allowance buys it. */ +const capFor = (route) => + baselines[route] == null ? null : baselines[route] + allowanceKb; + +const watched = Object.keys(baselines); const rows = [...routeTotals.entries()] .filter(([route]) => !watched.length || watched.includes(route)) .sort((a, b) => b[1].gz - a[1].gz); for (const [route, { gz }] of rows.slice(0, watched.length || 15)) { - const cap = budget?.routesKbGz?.[route]; + const baseline = baselines[route]; + if (baseline == null) { + console.log(`${fmt(gz).padStart(8)} gz ${route}`); + continue; + } + const delta = kb(gz) - baseline; + const sign = delta >= 0 ? "+" : "−"; console.log( - `${fmt(gz).padStart(8)} gz ${route}${cap ? ` (budget ${cap} KB)` : ""}`, + `${fmt(gz).padStart(8)} gz ${route} (baseline ${baseline} KB, ` + + `${sign}${Math.abs(delta).toFixed(1)} KB of ${allowanceKb} KB allowed, ` + + `cap ${capFor(route)} KB)`, ); } @@ -185,16 +208,30 @@ if (!budget) { process.exit(2); } +// A budget file that states no baselines gates nothing, and would do it +// quietly — the loop below would simply have nothing to walk. Say so instead. +if (watched.length === 0) { + console.error( + "check-bundle-budget: bundle-budget.json states no routeBaselineKbGz " + + "entries, so no route is gated. Measure the eager routes and write " + + "them in, with a routeDriftAllowanceKbGz over them.", + ); + process.exit(2); +} + const failures = []; -for (const [route, capKb] of Object.entries(budget.routesKbGz ?? {})) { +for (const [route, baselineKb] of Object.entries(baselines)) { const actual = routeTotals.get(route); if (!actual) { failures.push(`route ${route} not found in the build output`); continue; } - if (kb(actual.gz) > capKb) { + const delta = kb(actual.gz) - baselineKb; + if (delta > allowanceKb) { failures.push( - `${route}: ${fmt(actual.gz)} gz exceeds the ${capKb} KB budget`, + `${route}: ${fmt(actual.gz)} gz is ${delta.toFixed(1)} KB over the ` + + `${baselineKb} KB baseline, past the ${allowanceKb} KB drift ` + + `allowance (cap ${capFor(route)} KB)`, ); } }