diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4ac0fc739..5c1f748c8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -93,11 +93,12 @@ jobs: - run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build # Client-bundle budget gate — rides this job because it is the one - # place CI already has a production build on disk. Catches the - # regressions the dead webpack-era tooling (ANALYZE=1, the old - # bundle-report) let through: a statically imported message catalog - # (~100 KB gz on every route) or a chart import bypassing the shared - # chart-runtime boundary (a duplicated ~92 KB gz recharts chunk). + # place CI already has a production build on disk. Measures the first + # load of every page (files deduplicated by content hash) against its + # baseline, the p95 and max across pages, and the distinct client code + # (modules deduplicated by id); see the script header. Catches a + # statically imported message catalog, a duplicated recharts chunk, or + # a heavy module (Zod) pulled into a page that had none. - run: pnpm bundle-check - run: pnpm exec playwright test --shard=${{ matrix.shard }}/3 diff --git a/bundle-budget.json b/bundle-budget.json index 2fdc452d6..0553bb199 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,14 +1,121 @@ { - "$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. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route. Raised to 4155 at v1.40.2 on a measured delta. The Coach redesign (the right conversation panel, its row menu and the quick-settings overlay, all loaded on demand), the answer action row, the dashboard's Today overview, the document procedure filter and their copy in seven locales took the whole-build sum from 4080 to 4129 KB gz; most of it is new strings in the seven lazy locale catalogs and the on-demand Coach chunks, which a first paint does not load. The four eager routes stay well inside their drift allowances (/ +1.9, /measurements +1.9, /insights/mood +1.4, /insights +1.0 KB of 20). The aggregate cap moves from 4100 to 4155, about 26 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4182 at v1.42.0 on a measured delta. Main measured 4146 KB gz (lazy catalogs 1177, other chunks 2969); the release branch's shared contract change measured 4158 (lazy catalogs 1188, other chunks 2969): the whole 12 KB is new strings in the seven lazy locale catalogs (the Health Connect import card, air quality and pollen, the managed-profile handover and claim, own vaccines, the lab conversion note, the per-type Apple Health list), and no eager route moved (/ +3.6, /measurements +1.5, /insights/mood +1.1, /insights +0.7 KB of 20). The aggregate cap moves from 4155 to 4182, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Re-based at v1.42.0 when the settings sections and Zod left the shared bundles. The settings section route imported all twenty-one sections statically; each now loads on its own, and /settings/[section] measured 611 KB gz before and 403 after. The account-access parse that runs on every boot was the shell's only Zod import, about 63 KB gz on every route; it is plain TypeScript now, held to the published schema by an equivalence test, and the layout entry fell from 199 to 136 KB gz. The canonical unit table moved out of the measurement validation module, which took Zod off the routes whose only reason for it was the display transform: /insights 369 to 304, /medications 428 to 365, /labs 400 to 337. Across the 627 client routes 62 fell, none grew by more than 2 KB, and the per-route sum fell by 1844 KB gz. The /insights baseline moves down to 304 and /settings/[section] joins the watched routes at 403, so both gains are held by the same 20 KB allowance rather than spent. The aggregate moves the other way, 4167 to 4311, for the reason described at v1.37.27 and v1.40.0: the aggregate counts each emitted chunk once, and modules that used to sit in one shared chunk are now emitted into each route group that still needs them (the measurement validation module and the unit table into about twenty-five each, the settings sections into their own chunks). Bytes on disk rose; bytes a reader downloads fell on every route that moved. The aggregate cap moves from 4182 to 4335, about 24 KB above the build, the same headroom as before. Raised to 4400 at v1.42.0 on a measured delta. The release branch after its tickets merge measured 4365 KB gz (lazy catalogs 1206, other chunks 3159), already 30 KB over the 4335 cap; the day view and timeline contract adds 10 more (catalogs 1215, other 3160), almost all of it the base copy for the day, the timeline and life events in seven locales. The aggregate cap moves from 4335 to 4400, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4470 at v1.42.0 on a measured delta. The release branch with the day view, the timeline and their server measured 4386 KB gz (lazy catalogs 1228, other chunks 3158); giving every chart drawn in days the same doors to its day (one shared hook for the click, the touch tooltip's button, the open day's line, the row of day dots and the caption, a shared calendar adapter, a data table under each newly linked chart) measured 4446 (catalogs 1228, other chunks 3218). The 60 KB is the aggregate's double count again: the hook, the data table and the day link are emitted into each chart's own chunk group rather than once. The eager routes moved by a kilobyte or a few (/ +1.6, /measurements +1.7, /settings/[section] +0.8, /insights/mood +4.6, /insights +1.5 KB) and stay inside their allowances. The aggregate cap moves from 4400 to 4470, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4760 at v1.42.0 on a measured delta, the other way round from a feature: the day panel and the date field's calendar left the first load. The day view (its scores, sections and calendar) now loads when a day first opens, not with the shell, and the date field loads react-day-picker and the seven date-fns locales when its popover first opens. Every watched route fell: / 460 to 420, /measurements 439 to 398, /insights/mood 438 to 395, /settings/[section] 432 to 387, /insights 360 to 317 KB gz, all five back under their unchanged ceilings. The aggregate rose from 4426 to 4736 for the reason described at v1.37.27 and v1.40.0: while the day view sat in the shell, the small primitives it shares with the pages (card, table, popover, switch, input, badge, the chart day links) were emitted once into the shared chunk; with the day view on demand, each route group that uses them gets its own copy, and the aggregate counts every copy. Bytes on disk rose; bytes a reader downloads fell on every route. The aggregate cap moves from 4470 to 4760, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4790 at v1.42.3 on a measured delta. The release branch measured 4762 KB gz (lazy catalogs 1243, other chunks 3519) against the 4760 cap; the 15 KB catalog rise is new copy in the seven lazy locale catalogs (the Withings plan note and its type labels, the Oura reconnect hint, the lab conversion and printed-range notes, the issuer refusal), and the rest is the conversion badge and range note in the scan review's own chunk. The five watched routes stay inside their allowances (/ 422, /measurements 399, /insights/mood 396, /settings/[section] 389, /insights 318 KB gz). The aggregate cap moves from 4760 to 4790, about 28 KB above the build; per-route baselines are unchanged. Raised to 4860 at v1.43.0 on a measured delta. The release branch measured 4834 KB gz (lazy catalogs 1302, other chunks 3532) against the 4790 cap. 29 KB is new copy in the seven lazy locale catalogs (the AI usage levels, the life circumstances and AUDIT-C, the supplement composition, the marker merge, Dawarich, the related-records lists, the pulse day-mean card and the support report), and 13 KB is the on-demand chunks of those surfaces. The five watched routes stay inside their allowances and barely moved since v1.42.3 (/ 423, /measurements 400, /insights/mood 397, /settings/[section] 405, /insights 320 KB gz). The aggregate cap moves from 4790 to 4860, about 26 KB above the build; per-route baselines are unchanged. Re-based at v1.43.0 when Zod left four eager routes. Between the v1.43 budget raise and the merged release branch the four Zod-carrying eager routes grew by about 26 KB gz each (/ 423 to 450, /settings/[section] 405 to 431, /measurements 400 to 426, /insights/mood 397 to 423) while /insights, which loads no Zod, did not move. The whole step was the shared Zod chunk, 63.0 to 88.9 KB gz: zod 4.6 adds an ahead-of-time schema compiler and its memoizer to the core, and because every schema is written against the `z` namespace the bundler keeps the whole namespace (every locale, the JSON Schema converters and now the compiler) wherever Zod loads at all. None of the four routes needed Zod for its first paint. The dashboard and the measurement list read the source list, the notes limit and the screening check out of the two measurement validation modules, the mood page read the Coach source list out of the Coach request schemas, and every settings section rendered a gate that imported the managed settings form and the allergy and family-history forms for a branch only a shared or managed record reaches. The values now live in value-only modules (`@/lib/measurements/sources`, `@/lib/measurements/notes-limit`, `@/lib/measurement-reminders/screening`, `@/lib/ai/coach/scope-sources`) the schemas are built from, and the gate loads its two record-scoped sections on demand. Measured on a local arm64 build: / 450 to 355, /measurements 426 to 335, /insights/mood 423 to 334, /settings/[section] 431 to 307 KB gz; /insights stays at 320. The four route baselines move down to those values, so the gain is held by the same 20 KB allowance rather than spent; a Zod import back on any of them is about 89 KB and fails. The aggregate moves the other way, 4883 to 4949, for the reason described at v1.37.27, v1.40.0 and v1.42.0: the small primitives the removed forms shared with the lazy settings sections and quick-entry forms (popover, switch, the date and time fields) used to be emitted once into the routes' first load, and each on-demand chunk group now carries its own copy, which the aggregate counts every time. Bytes on disk rose; bytes a reader downloads fell by 89 to 124 KB gz on every route that moved. The aggregate cap moves from 4860 to 4975, about 26 KB above the build, the same headroom as before. Raised to 5000 at v1.43.3 on a measured delta. The release branch measured 4977 KB gz (lazy catalogs 1307, other chunks 3670) against the 4975 cap. Every fresh-factor refusal now opens the re-proof dialog, so the hook and the dialog behind it are imported by the managed-profile create and edit forms, the guardian and handover panels, the card, and the advanced and admin key-rotation sections as well, each of them an on-demand chunk the aggregate counts on its own. Loading the dialog on demand through next/dynamic was measured and not kept: 4979 KB gz, and every watched route grew by 0.1 to 0.7 KB for the loader. The five watched routes stay inside their allowances (/ 355, /measurements 335, /insights/mood 334, /settings/[section] 308, /insights 321 KB gz). The aggregate cap moves from 4975 to 5000, about 23 KB above the build; per-route baselines are unchanged. Split at v1.43.7 for the eighth interface language (Dutch). The aggregate counted every lazy message catalog, although a reader downloads only the one for their language, so each new language read as a regression of about 190 KB and the cap had to be raised for copy nobody else loads. The aggregate now covers the client chunks without the lazy catalogs (3670 KB gz measured at v1.43.3, cap 3700, the same headroom as before), and each catalog is held to its own ceiling (about 187 KB gz each at v1.43.3, cap 210). The static-catalog guard, the route baselines and the one-Recharts-chunk guard are unchanged. Raised to 3755 at v1.43.8 on a measured delta, and the /insights baseline re-based. The release branch measured 3729 KB gz without the lazy catalogs against the 3700 cap (v1.43.7: 3665). Bundling the client entry graph of v1.43.7 and of the release branch once each, deduplicated, measured 3462.6 and 3462.8 KB gz: no dependency, schema or catalog entered the client. The rise is the double count described at v1.37.27 and v1.40.0. The release replaces hand-written controls with shared primitives (the segmented toggle and its roving focus hook, the inline action, the score pill, the page header with its back link, the display-unit resolver), each well under a kilobyte, and the aggregate counts a copy in every chunk group that uses them, up to 88 of them. The /insights baseline still stated the 304 measured when Zod left the page at v1.42.0, although the page measured 320 at v1.43.0 and 321 at v1.43.3, so it had been running on a 4 KB allowance for three releases; it moves to the 323 measured on the runner now, protected by the same 20 KB allowance. The other four watched routes moved by 1.8 to 2.5 KB and keep their baselines. The aggregate cap moves from 3700 to 3755, about 26 KB above the build, the same headroom as before. Raised to 3900 at v1.44.0 on a measured delta, with two first-load reductions in the same release. Main measured 3737 KB gz without the lazy catalogs and the release branch 3863, the same figure the CI runner reported. Bundling every client module of each build once, deduplicated, measured 1494 and 1509 KB gz: the release adds about 15 KB of new client code (the CSV import card, the activity calendar and its goals sheet, the source provenance view, the efficacy summary, the training load and lab trend cards, the settings sheet on phones) and no dependency. The rest is the double count described at v1.37.27 and v1.40.0, attributed module by module from source maps: the shared destructive-control confirmation now used by fifteen more settings sections sits in 26 chunks instead of 11, and the two new routes (/settings/modules/[module] and /insights/sources) each carry their own copy of the record-settings gate's on-demand chunks, the date field and its popover, the settings shell and the type label map. Two reductions land beside the raise. The value domain read the plausibility band out of the measurement validation module, which put Zod into the first load of every metric sub-page; the band lives in a Zod-free module now and about fifty metric routes fell by 88.5 KB gz each (/insights/pulse 428 to 339). The arrangement pages imported their module link card from the file that holds the labs, mood and injection-site settings and shipped all three; the card has its own module (/settings/layout/[module] 391 to 380). Across the 108 pages the summed first load fell from 39998 to 35895 KB gz; 48 pages fell by more than 1 KB, 13 grew by more than 1 KB and none by more than 9 (/insights/workouts with the training load card, /enroll-mfa with the sessions and trusted devices that moved into the security section). The aggregate moved the other way, 3863 to 3875: the band is a small module now copied into each chunk group that reads it, where it used to ride in the one shared schema chunk. The five watched routes stay inside their allowances (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311 KB gz). The aggregate cap moves from 3755 to 3900, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged.", - "routeBaselineKbGz": { - "/page": 355, - "/insights/page": 323, - "/measurements/page": 335, - "/insights/mood/page": 334, - "/settings/[section]/page": 307 - }, + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs --check after `pnpm build` in CI; the script header describes the method. Numbers are KB gzip. routeBaselineKbGz is the measured first load of every page (the shared rootMainFiles plus the page's client-reference chunks, each file counted once by content hash), written with --write-baselines; a page fails when it is more than routeDriftAllowanceKbGz over its baseline, and a page without a baseline fails until one is written in the same change. firstLoadP95KbGz and firstLoadMaxKbGz cap the distribution across pages. uniqueClientKbGz caps the distinct client code: every module counted once by its module id, the lazy message catalogs excluded. maxCatalogKbGz holds each catalog, maxRechartsChunks the one shared chart chunk. The summed-chunk aggregate the gate used up to v1.44.0 (3875 KB there, capped at 3900) is still printed but no longer gated: it counted a module once per chunk group it landed in, so it rose whenever a page moved code into on-demand chunks and needed a raise in most releases while no reader downloaded more. Re-based at v1.45.0 from the v1.44.0 build, measured on a local arm64 machine: 110 pages, first load p50 336, p95 437, max 451 KB; unique client code 1904 KB in 1224 modules. The five pages watched before keep their numbers within a kilobyte (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311), and the metric pages that no watched route covered before are gated now too (/insights/weight 339, /insights/pulse 339). Caps: p95 447 and max 461 KB (10 KB over the measurement), unique client code 1930 KB (about 25 KB, roughly two releases of new client code). A raise or a re-base is a reviewed change with the measured numbers and the reason written here; earlier re-bases are in the history of this file.", "routeDriftAllowanceKbGz": 20, - "totalClientKbGz": 3900, + "firstLoadP95KbGz": 447, + "firstLoadMaxKbGz": 461, + "uniqueClientKbGz": 1930, "maxRechartsChunks": 1, - "maxCatalogKbGz": 210 + "maxCatalogKbGz": 210, + "routeBaselineKbGz": { + "/_global-error/page": 133, + "/_not-found/page": 282, + "/about/page": 282, + "/achievements/page": 292, + "/admin/[section]/page": 451, + "/admin/page": 297, + "/auth/claim/page": 298, + "/auth/login/page": 290, + "/auth/register/page": 288, + "/c/[token]/page": 284, + "/checkups/page": 365, + "/checkups/practitioners/page": 289, + "/claim/[token]/page": 282, + "/coach/conversations/page": 294, + "/coach/page": 355, + "/coach/plans/page": 288, + "/confirm-access/page": 287, + "/custom-metrics/[id]/page": 303, + "/custom-metrics/[id]/values/page": 311, + "/cycle/page": 412, + "/documents/page": 378, + "/enroll-mfa/page": 305, + "/illness/[id]/page": 447, + "/illness/page": 421, + "/insights/active-energy/page": 336, + "/insights/audio-events/page": 337, + "/insights/blood-glucose/page": 340, + "/insights/blood-pressure/page": 336, + "/insights/bmi/page": 331, + "/insights/body-fat/page": 336, + "/insights/body-temperature/page": 336, + "/insights/body-water/page": 336, + "/insights/bone-mass/page": 336, + "/insights/breathing-disturbances/page": 337, + "/insights/cardio-fitness/page": 336, + "/insights/cardio-recovery/page": 337, + "/insights/catalog/page": 317, + "/insights/daylight/page": 336, + "/insights/double-support-time/page": 336, + "/insights/ecg/[id]/page": 316, + "/insights/ecg/page": 316, + "/insights/environmental-audio/page": 337, + "/insights/falls/page": 336, + "/insights/fat-free-mass/page": 336, + "/insights/fat-mass/page": 336, + "/insights/flights-climbed/page": 336, + "/insights/grip-strength/page": 337, + "/insights/headphone-audio/page": 336, + "/insights/health-score/page": 315, + "/insights/hrv/page": 337, + "/insights/lean-body-mass/page": 336, + "/insights/medications/page": 338, + "/insights/mood/page": 337, + "/insights/muscle-mass/page": 337, + "/insights/nutrients/page": 319, + "/insights/oxygen/page": 337, + "/insights/page": 323, + "/insights/pain/page": 336, + "/insights/pulse-wave-velocity/page": 336, + "/insights/pulse/page": 339, + "/insights/recovery/page": 330, + "/insights/respiratory-rate/page": 336, + "/insights/resting-pulse/page": 337, + "/insights/scores/[metric]/page": 328, + "/insights/six-minute-walk/page": 336, + "/insights/skin-temperature/page": 338, + "/insights/sleep/page": 337, + "/insights/sources/page": 321, + "/insights/stair-ascent-speed/page": 336, + "/insights/stair-descent-speed/page": 336, + "/insights/step-length/page": 336, + "/insights/steps/page": 337, + "/insights/values/[type]/page": 350, + "/insights/vascular-age/page": 337, + "/insights/visceral-fat/page": 336, + "/insights/waist-to-height/page": 336, + "/insights/waist/page": 336, + "/insights/walking-asymmetry/page": 336, + "/insights/walking-distance/page": 336, + "/insights/walking-heart-rate/page": 337, + "/insights/walking-speed/page": 336, + "/insights/walking-steadiness/page": 336, + "/insights/weight/page": 339, + "/insights/workouts/[id]/page": 437, + "/insights/workouts/page": 437, + "/insights/wrist-temperature/page": 336, + "/invite/[token]/page": 282, + "/labs/[biomarkerId]/page": 327, + "/labs/[biomarkerId]/values/page": 335, + "/labs/page": 423, + "/measurements/page": 339, + "/medications/[id]/history/page": 283, + "/medications/[id]/page": 402, + "/medications/new/page": 282, + "/medications/page": 355, + "/mental-wellbeing/page": 297, + "/mood/page": 438, + "/notifications/page": 289, + "/onboarding/[step]/page": 445, + "/onboarding/handover/page": 295, + "/onboarding/page": 286, + "/page": 359, + "/privacy/page": 282, + "/profile/page": 285, + "/settings/[section]/page": 311, + "/settings/layout/[module]/page": 380, + "/settings/modules/[module]/page": 374, + "/settings/page": 282, + "/timeline/page": 331, + "/vaccinations/page": 327 + } } diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs index e92589d79..e88adbc56 100644 --- a/scripts/check-bundle-budget.mjs +++ b/scripts/check-bundle-budget.mjs @@ -2,55 +2,90 @@ /** * Client-bundle report + budget gate for the Turbopack build. * - * The webpack-era tooling died on Next 16: `ANALYZE=1` is a silent no-op - * under Turbopack and the old `.next/analyze/client.json` reader had - * nothing to read — so bundle regressions (a statically imported message - * catalog, a duplicated recharts chunk group) shipped invisibly. This - * script derives the signal straight from the build output instead: + * What a reader downloads is a set of chunk files: the shared + * `rootMainFiles` from `build-manifest.json` plus the chunks a page's + * `*_client-reference-manifest.js` names. This script measures that set per + * page and gates it, and separately measures how much distinct client code + * the build holds. * - * - per-route client-JS totals (gzip) from the Turbopack - * `*_client-reference-manifest.js` files plus the shared - * `rootMainFiles` baseline from `build-manifest.json`; - * - the total gzip weight of every emitted client chunk; - * - a recharts-duplication guard: the number of chunks carrying the - * recharts library fingerprint must stay exactly 1 (the shared - * chart-runtime chunk group); - * - a catalog guard: no message-catalog-fingerprinted chunk may be - * referenced by any route's client-reference manifest or by the - * shared baseline (catalogs load only lazily / via /i18n/). + * METHOD + * + * First load per page. The union of `rootMainFiles` and the page's + * client-reference chunks, deduplicated by content hash (sha-256 of the + * file), each file gzipped at level 9 and summed. A file that two + * manifests list under different names counts once. Every page in the + * build is measured, not a hand-picked few: a heavy import into a page + * nobody watched is how Zod sat in fifty metric pages for three months. + * + * Across pages. p50, p95 and max of the per-page first loads. + * + * Unique client code. Every emitted chunk is evaluated in a `node:vm` + * sandbox with a stub `TURBOPACK` array, which collects the module + * factories the chunk registers without running any of them. Each factory + * is keyed by its Turbopack module id, which is global to the build, so a + * module the bundler copied into forty chunk groups counts once. (Not by + * the factory's text: each chunk is minified on its own, so the copies of + * one module differ in their mangled local names.) One copy per module, + * from the first chunk in file-name order, without the lazy message + * catalogs, is concatenated in id order and gzipped once. A file that + * registers no factories (the runtime, the polyfills) counts as one unit + * by its content hash. + * + * The old aggregate (every emitted chunk gzipped and summed) is still + * printed, for continuity, but no longer gated: it counts a module once + * per chunk group it lands in, so it rose every time a page got lighter + * by moving code into on-demand chunks. + * + * GATES (bundle-budget.json) + * + * - routeBaselineKbGz: measured first load per page; a page fails when it + * is more than routeDriftAllowanceKbGz over its baseline, or when it is + * a page with no baseline (write one with --write-baselines). + * - firstLoadP95KbGz / firstLoadMaxKbGz: caps on the distribution. + * - uniqueClientKbGz: cap on the distinct client code. + * - maxCatalogKbGz: per lazy message catalog. + * - maxRechartsChunks: chunks carrying the Recharts fingerprint (one, the + * shared chart-runtime group). + * - no message catalog referenced by a page or the shared baseline. * * Usage: - * pnpm bundle-report # print the report table - * pnpm bundle-report --check # enforce bundle-budget.json (CI gate) + * pnpm bundle-report # print the report + * pnpm bundle-check # enforce bundle-budget.json + * node scripts/check-bundle-budget.mjs --write-baselines + * # rewrite routeBaselineKbGz from + * # this build (a reviewed change) + * node scripts/check-bundle-budget.mjs --dir [--json ] * - * 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`. + * Numbers are KB gzip. A local arm64 build measures each page about 1.5 KB + * lighter than the CI runner; the drift allowance covers that and ordinary + * week-to-week movement, while the step changes the gate exists for (a + * statically imported catalog, a duplicated chart library, Zod on a page + * that had none: 60 to 100 KB each) are several times larger. */ -import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { join } from "node:path"; +import vm from "node:vm"; import { gzipSync } from "node:zlib"; -const CHECK = process.argv.includes("--check"); -const BUILD_DIR = join(process.cwd(), ".next"); +const argv = process.argv.slice(2); +const flag = (name) => argv.includes(name); +const option = (name) => { + const i = argv.indexOf(name); + return i >= 0 ? argv[i + 1] : undefined; +}; + +const CHECK = flag("--check"); +const WRITE_BASELINES = flag("--write-baselines"); +const BUILD_DIR = option("--dir") ?? join(process.cwd(), ".next"); +const JSON_OUT = option("--json"); const APP_DIR = join(BUILD_DIR, "server", "app"); const BUDGET_PATH = join(process.cwd(), "bundle-budget.json"); if (!existsSync(join(BUILD_DIR, "build-manifest.json"))) { console.error( - "check-bundle-budget: no .next/build-manifest.json — run `pnpm build` first.", + `check-bundle-budget: no ${join(BUILD_DIR, "build-manifest.json")} — run \`pnpm build\` first.`, ); process.exit(2); } @@ -58,30 +93,47 @@ if (!existsSync(join(BUILD_DIR, "build-manifest.json"))) { /** Recharts library fingerprint (present once in the shared runtime chunk). */ const RECHARTS_MARK = "CartesianGrid"; /** - * Message-catalog fingerprint. The key set is identical across all six - * locales, so a KEY-position match ("typeTimeInDaylight" directly followed - * by a colon, in either the plain or the JSON.parse-escaped chunk encoding) - * marks a bundled catalog. Source modules only ever carry the namespaced - * call-site form (`measurements.typeTimeInDaylight`), which matches neither. + * Message-catalog fingerprint. The key set is identical across all locales, + * so a KEY-position match ("typeTimeInDaylight" directly followed by a colon, + * in either the plain or the JSON.parse-escaped chunk encoding) marks a + * bundled catalog. Source modules only ever carry the namespaced call-site + * form (`measurements.typeTimeInDaylight`), which matches neither. */ const CATALOG_MARKS = ['"typeTimeInDaylight":', 'typeTimeInDaylight\\":']; -const sizeCache = new Map(); +const sha = (buf) => createHash("sha256").update(buf).digest("hex"); +const gz = (buf) => gzipSync(buf, { level: 9 }).length; +const kb = (bytes) => bytes / 1024; +const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`; +const round1 = (n) => Math.round(n * 10) / 10; + +const fileCache = new Map(); function chunkInfo(file) { - if (!sizeCache.has(file)) { + if (!fileCache.has(file)) { + let info = { + hash: `missing:${file}`, + raw: 0, + gz: 0, + recharts: false, + catalog: false, + }; try { const buf = readFileSync(join(BUILD_DIR, file)); - sizeCache.set(file, { + info = { + hash: sha(buf), raw: buf.length, - gz: gzipSync(buf, { level: 9 }).length, + gz: gz(buf), recharts: buf.includes(RECHARTS_MARK), catalog: CATALOG_MARKS.some((m) => buf.includes(m)), - }); + buf, + }; } catch { - sizeCache.set(file, { raw: 0, gz: 0, recharts: false, catalog: false }); + // A manifest naming a file the build did not emit: counted as zero and + // reported by its missing hash rather than crashing the report. } + fileCache.set(file, info); } - return sizeCache.get(file); + return fileCache.get(file); } function routeChunks(manifestFile) { @@ -97,24 +149,54 @@ function routeChunks(manifestFile) { return files; } -function findRouteManifests(dir, prefix = "") { +function findPageManifests(dir, prefix = "") { let out = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, entry.name); if (entry.isDirectory()) { - out = out.concat(findRouteManifests(p, `${prefix}/${entry.name}`)); - } else if (entry.name.endsWith("_client-reference-manifest.js")) { - out.push({ - route: `${prefix}/${entry.name.replace("_client-reference-manifest.js", "")}`, - file: p, - }); + out = out.concat(findPageManifests(p, `${prefix}/${entry.name}`)); + } else if (entry.name === "page_client-reference-manifest.js") { + out.push({ route: `${prefix}/page`, file: p }); } } return out; } -const kb = (bytes) => bytes / 1024; -const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`; +function percentile(sorted, p) { + if (sorted.length === 0) return 0; + const i = Math.min( + sorted.length - 1, + Math.ceil((p / 100) * sorted.length) - 1, + ); + return sorted[Math.max(0, i)]; +} + +/** + * The `[module id, factory source]` pairs a Turbopack chunk registers, + * without running them: `TURBOPACK.push([script, id, factory, id, factory, + * …])`. Null when the file is not in that shape. + */ +function chunkFactories(buf) { + const sandbox = { TURBOPACK: [] }; + sandbox.globalThis = sandbox; + sandbox.self = sandbox; + try { + vm.runInNewContext(buf.toString("utf8"), sandbox, { timeout: 5000 }); + } catch { + return null; + } + const modules = []; + for (const entry of sandbox.TURBOPACK) { + if (!Array.isArray(entry)) continue; + for (let i = 1; i < entry.length; i++) { + if (typeof entry[i] !== "function") continue; + const id = entry[i - 1]; + if (typeof id !== "number" && typeof id !== "string") continue; + modules.push([String(id), entry[i].toString()]); + } + } + return modules.length > 0 ? modules : null; +} // ── Shared baseline ───────────────────────────────────────────────────────── const buildManifest = JSON.parse( @@ -123,88 +205,135 @@ const buildManifest = JSON.parse( const rootFiles = buildManifest.rootMainFiles ?? []; let rootGz = 0; for (const f of rootFiles) rootGz += chunkInfo(f).gz; +const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog); -// ── Per-route totals ──────────────────────────────────────────────────────── -const manifests = findRouteManifests(APP_DIR); -const routeTotals = new Map(); -for (const { route, file } of manifests) { - let gz = 0; +// ── Per-page first load, deduplicated by content hash ────────────────────── +const pages = new Map(); +for (const { route, file } of findPageManifests(APP_DIR)) { + const byHash = new Map(); let catalogRef = false; - for (const chunk of routeChunks(file)) { + for (const chunk of [...rootFiles, ...routeChunks(file)]) { const info = chunkInfo(chunk); - gz += info.gz; - if (info.catalog) catalogRef = true; + byHash.set(info.hash, info.gz); + if (info.catalog && !rootFiles.includes(chunk)) catalogRef = true; } - routeTotals.set(route, { gz: gz + rootGz, catalogRef }); + let total = 0; + for (const size of byHash.values()) total += size; + pages.set(route, { gz: total, catalogRef }); } +const firstLoads = [...pages.values()].map((p) => p.gz).sort((a, b) => a - b); +const p50 = percentile(firstLoads, 50); +const p95 = percentile(firstLoads, 95); +const max = firstLoads[firstLoads.length - 1] ?? 0; -// ── Whole-build chunk scan ────────────────────────────────────────────────── +// ── Whole build: old aggregate, catalogs, recharts, unique modules ───────── const chunkDir = join(BUILD_DIR, "static", "chunks"); -let totalGz = 0; +let summedGz = 0; let rechartsChunks = 0; let catalogChunks = 0; let catalogGz = 0; let largestCatalog = { file: "-", gz: 0 }; -let largest = { file: "-", gz: 0 }; -for (const f of readdirSync(chunkDir)) { +const uniqueUnits = new Map(); +for (const f of readdirSync(chunkDir).sort()) { if (!f.endsWith(".js")) continue; const info = chunkInfo(join("static", "chunks", f)); - totalGz += info.gz; + summedGz += info.gz; if (info.recharts) rechartsChunks += 1; if (info.catalog) { catalogChunks += 1; catalogGz += info.gz; if (info.gz > largestCatalog.gz) largestCatalog = { file: f, gz: info.gz }; + continue; + } + const modules = info.buf ? chunkFactories(info.buf) : null; + if (modules) { + for (const [id, source] of modules) { + if (!uniqueUnits.has(`m:${id}`)) uniqueUnits.set(`m:${id}`, source); + } + } else if (info.buf) { + uniqueUnits.set(`f:${info.hash}`, info.buf.toString("utf8")); } - if (info.gz > largest.gz) largest = { file: f, gz: info.gz }; } -const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog); +const uniqueKeys = [...uniqueUnits.keys()].sort(); +const uniqueGz = gz( + Buffer.from(uniqueKeys.map((k) => uniqueUnits.get(k)).join("\n"), "utf8"), +); // ── Report ────────────────────────────────────────────────────────────────── const budget = existsSync(BUDGET_PATH) ? JSON.parse(readFileSync(BUDGET_PATH, "utf8")) : null; +const baselines = budget?.routeBaselineKbGz ?? {}; +const allowanceKb = budget?.routeDriftAllowanceKbGz ?? 0; +console.log(`pages measured: ${pages.size}`); console.log(`shared baseline (rootMainFiles): ${fmt(rootGz)} gz`); -console.log(`all client chunks: ${fmt(totalGz)} gz`); console.log( - ` lazy message catalogs: ${fmt(catalogGz)} gz (${catalogChunks} chunks)`, -); -console.log(` other client chunks: ${fmt(totalGz - catalogGz)} gz`); -console.log( - ` largest message catalog: ${fmt(largestCatalog.gz)} gz (${largestCatalog.file})`, + `first load across pages: p50 ${fmt(p50)}, p95 ${fmt(p95)}, max ${fmt(max)} gz`, ); console.log( - `largest chunk: ${fmt(largest.gz)} gz ${largest.file}`, + `unique client code: ${fmt(uniqueGz)} gz (${uniqueKeys.length} distinct modules, catalogs excluded)`, +); +console.log( + `lazy message catalogs: ${fmt(catalogGz)} gz (${catalogChunks} chunks, largest ${fmt(largestCatalog.gz)} ${largestCatalog.file})`, +); +console.log( + `summed chunks, not gated: ${fmt(summedGz - catalogGz)} gz (every emitted chunk once, catalogs excluded)`, ); console.log(`recharts-fingerprint chunks: ${rechartsChunks}`); console.log(""); -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 baseline = baselines[route]; - if (baseline == null) { - console.log(`${fmt(gz).padStart(8)} gz ${route}`); - continue; - } - const delta = kb(gz) - baseline; - const sign = delta >= 0 ? "+" : "−"; +const rows = [...pages.entries()] + .map(([route, { gz: size }]) => { + const baseline = baselines[route]; + return { + route, + kb: round1(kb(size)), + baseline: baseline ?? null, + delta: baseline == null ? null : round1(kb(size) - baseline), + }; + }) + .sort((a, b) => (b.delta ?? Infinity) - (a.delta ?? Infinity) || b.kb - a.kb); +console.log("largest movers against their baseline:"); +for (const r of rows.slice(0, 15)) { + const delta = + r.delta == null + ? "no baseline" + : `${r.delta >= 0 ? "+" : "−"}${Math.abs(r.delta).toFixed(1)} KB of ${allowanceKb}`; console.log( - `${fmt(gz).padStart(8)} gz ${route} (baseline ${baseline} KB, ` + - `${sign}${Math.abs(delta).toFixed(1)} KB of ${allowanceKb} KB allowed, ` + - `cap ${capFor(route)} KB)`, + `${`${r.kb.toFixed(0)} KB`.padStart(8)} gz ${r.route} (${delta})`, ); } +const report = { + pages: Object.fromEntries(rows.map((r) => [r.route, r.kb])), + firstLoadKbGz: { + p50: round1(kb(p50)), + p95: round1(kb(p95)), + max: round1(kb(max)), + }, + uniqueClientKbGz: round1(kb(uniqueGz)), + summedChunksKbGz: round1(kb(summedGz - catalogGz)), + catalogKbGz: round1(kb(catalogGz)), + largestCatalogKbGz: round1(kb(largestCatalog.gz)), + rechartsChunks, +}; +if (JSON_OUT) writeFileSync(JSON_OUT, `${JSON.stringify(report, null, 2)}\n`); + +if (WRITE_BASELINES) { + if (!budget) { + console.error("check-bundle-budget: bundle-budget.json missing."); + process.exit(2); + } + budget.routeBaselineKbGz = Object.fromEntries( + rows + .map((r) => [r.route, Math.round(r.kb)]) + .sort(([a], [b]) => a.localeCompare(b)), + ); + writeFileSync(BUDGET_PATH, `${JSON.stringify(budget, null, 2)}\n`); + console.log(`\nwrote ${rows.length} page baselines to bundle-budget.json`); +} + if (!CHECK) process.exit(0); // ── Budget gate ───────────────────────────────────────────────────────────── @@ -212,42 +341,48 @@ if (!budget) { console.error("check-bundle-budget: bundle-budget.json missing."); 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) { +// A budget that states no page baselines gates nothing, and would do it +// quietly. A build with no pages is a broken build, not a light one. +if (Object.keys(baselines).length === 0 || pages.size === 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.", + "check-bundle-budget: no page baselines in bundle-budget.json, or no " + + "pages in the build. Measure them with --write-baselines.", ); process.exit(2); } const failures = []; -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; - } - const delta = kb(actual.gz) - baselineKb; - if (delta > allowanceKb) { +for (const r of rows) { + if (r.baseline == null) { failures.push( - `${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)`, + `${r.route}: ${r.kb} KB gz and no baseline. A new page gets one with --write-baselines in the same change.`, + ); + } else if (r.delta > allowanceKb) { + failures.push( + `${r.route}: ${r.kb} KB gz is ${r.delta.toFixed(1)} KB over its ${r.baseline} KB baseline, past the ${allowanceKb} KB drift allowance`, ); } } -// The aggregate leaves the lazy message catalogs out. A reader downloads one -// catalog, the one for their language, so counting all of them made every new -// interface language look like a regression. Each catalog is held to its own -// ceiling instead. -const nonCatalogGz = totalGz - catalogGz; -if (budget.totalClientKbGz && kb(nonCatalogGz) > budget.totalClientKbGz) { +for (const route of Object.keys(baselines)) { + if (!pages.has(route)) { + failures.push( + `${route} has a baseline but is not in the build; remove it with --write-baselines`, + ); + } +} +if (budget.firstLoadP95KbGz && kb(p95) > budget.firstLoadP95KbGz) { failures.push( - `client JS without the lazy catalogs ${fmt(nonCatalogGz)} gz exceeds the ${budget.totalClientKbGz} KB budget`, + `first-load p95 ${kb(p95).toFixed(1)} KB gz exceeds the ${budget.firstLoadP95KbGz} KB budget`, + ); +} +if (budget.firstLoadMaxKbGz && kb(max) > budget.firstLoadMaxKbGz) { + failures.push( + `largest first load ${kb(max).toFixed(1)} KB gz exceeds the ${budget.firstLoadMaxKbGz} KB budget`, + ); +} +if (budget.uniqueClientKbGz && kb(uniqueGz) > budget.uniqueClientKbGz) { + failures.push( + `unique client code ${kb(uniqueGz).toFixed(1)} KB gz exceeds the ${budget.uniqueClientKbGz} KB budget`, ); } if (budget.maxCatalogKbGz && kb(largestCatalog.gz) > budget.maxCatalogKbGz) { @@ -263,12 +398,12 @@ if ( `${rechartsChunks} recharts-fingerprint chunks (budget ${budget.maxRechartsChunks}) — a chart import bypassed the shared chart-runtime boundary`, ); } -const catalogRefRoutes = [...routeTotals.entries()] +const catalogRefPages = [...pages.entries()] .filter(([, v]) => v.catalogRef) .map(([r]) => r); -if (catalogRefRoutes.length > 0 || rootCatalogRef) { +if (catalogRefPages.length > 0 || rootCatalogRef) { failures.push( - `message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefRoutes.join(", ")} — catalogs must stay lazy (/i18n/ + dynamic import)`, + `message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefPages.join(", ")} — catalogs must stay lazy (/i18n/ + dynamic import)`, ); }