Studio: budget the JavaScript that runs before the first screen - #8964
Conversation
Three of them were reachable. Run through a symlinked checkout, the main-module guard compared process.argv[1] (the path as typed) against import.meta.url (the real path), they disagreed, and the script exited 0 having done nothing. That is anything under /tmp on macOS. Both sides now go through realpath. With build.modulePreload: false Vite emits the entry script and no preload links. The empty-set check did not fire, and a real build of that shape measured 424 KB of a 5,207 KB startup path and reported 4.8 MB to spare. The guard now needs two chunks from Vite, and it counts scripts and links together so the layout Vite uses when the entry module is nothing but imports (one script per chunk, no links) still passes. A chunk named in index.html but missing from the build threw out of readFileSync, exiting 1 with a stack, indistinguishable from a budget failure. It now reports the file and exits 2. Also: a parser-blocking classic script is charged to startup. index.html loads public/theme-boot.js that way, before the module graph, and it was outside the budget it belongs in. defer/async and cross-origin scripts are not counted. Matching is now case-insensitive and treats rel as a token list, so a partial match cannot quietly shrink the measured set. Total on this build goes from 5,207.2 KB raw / 1,496.2 KB gzip over 44 chunks to 5,208.3 KB / 1,496.8 KB over 45, still inside budget.
1 similar comment
|
@codex review |
type="application/javascript" and the other JavaScript MIME types are classic scripts too. Matching only text/javascript would have let one sit outside the budget, which is the same silent under-measurement the rest of this is about. importmap and application/json still do not count: they are not code that runs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 294cfdadd2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // anywhere in the build, not just assets/. `defer`/`async` do not: those | ||
| // do not hold up the first screen. | ||
| if (!(hasAttr(tag, "defer") || hasAttr(tag, "async"))) { | ||
| add(set.blocking, attr(tag, "src")); |
There was a problem hiding this comment.
Include inline startup scripts in the budget
When index.html contains an inline classic script, this passes an absent src to add and silently omits the script; the same happens to inline module scripts in the branch above. The browser still downloads that code as part of the HTML and executes it before the application renders, while the normal external Vite entry and preloads keep fromVite above the shape guard, so arbitrarily large inline bootstrap code can pass this startup budget. Measure executable inline script bodies or reject them explicitly.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not reachable here, in either half.
An inline script authored in studio/frontend/index.html cannot execute: the backend serves script-src 'self' with no unsafe-inline (_build_csp in studio/backend/main.py), so the browser refuses it. That is exactly why the theme bootstrap is an external file, and the comment above the tag in index.html says so. Adding an inline script to the page would not grow the startup path, it would break the page, loudly and immediately.
The one inline script Studio does run is injected at request time by _inject_bootstrap with the per-response nonce, and it is never in dist/index.html, so no build-output check can see it or should try.
An inline module script with no src has nothing to fetch and nothing to resolve; that branch is not dropping a file, there is no file.
Leaving as is rather than measuring script bodies: the budget would then be pricing code the product forbids from running.
A deferred classic script runs after parsing but before DOMContentLoaded, in document order with the module entry, which is itself deferred. It is on exactly the timeline this budgets, so excluding it left a way to move startup JavaScript out of the budget without moving it off the startup path. Only async is excluded now, and async is the attribute tested because it wins when a tag carries both. The transfer column was gzip for everything, but the backend gzips the /assets mount only; anything else goes out through a plain FileResponse. theme-boot.js is the one such file today, and charging it gzip understated what actually crosses the wire. Non-asset files are now charged their raw size, and the column is called transfer rather than gzip, which is what it has always been measuring. 5,208.3 KB raw / 1,497.3 KB transfer over 45 chunks, still inside budget.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b62ad5eb69
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (type === "module") { | ||
| // Vite's entry, always one of its own hashed assets. | ||
| add(set.entry, attr(tag, "src"), "assets/"); | ||
| } else if (!type || CLASSIC_TYPES.has(type)) { |
There was a problem hiding this comment.
Normalize classic script MIME types before matching
When a same-origin startup script uses a valid parameterized JavaScript MIME type such as type="text/javascript; charset=utf-8", the browser executes it based on the MIME essence, but this exact set lookup rejects it. The Vite entry and preloads still satisfy the shape guard, so that classic script is silently excluded from both budgets; strip MIME parameters and whitespace before testing the classic type.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Checked this one and the premise does not hold: the browser does not run that script, so charging it would price bytes nobody fetches.
The type attribute is matched on JavaScript MIME type ESSENCE, and a parameter makes the whole value match nothing. The spec says so directly: 'scripts with their type attribute set to text/javascript; charset=utf-8 will not be evaluated' (https://html.spec.whatwg.org/multipage/scripting.html#attr-script-type).
Verified rather than assumed. Served three script tags from a local origin and watched the network:
text/javascript; charset=utf-8 chromium: not fetched, not executed
firefox: not fetched, not executed
webkit: not fetched, not executed
text/javascript all three: fetched and executed
application/javascript all three: fetched and executed
Not even a request goes out, so there is nothing on the startup path to budget. Keeping the exact essence set, with that reasoning and the three-engine result recorded next to it, and pinned by 'a MIME type with parameters is not a script the browser runs'. The attribute-boundary item alongside this one was right and is fixed in e1702be.
A hyphen is a word boundary, so the \btype pattern matched inside data-type and read the decoy in preference to the real attribute. The same held for data-src, data-href and data-rel. Reproduced: a tag with data-type alongside type="module" dropped the entry entirely while its preload links kept the shape guard satisfied, which is a silent under-measurement of exactly the kind the rest of this exists to prevent. Every attribute in a tag is preceded by whitespace, the tag name included, so whitespace is the boundary HTML actually gives us.
|
@codex review |
1 similar comment
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e1702bea1e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| return name.startsWith("assets/") | ||
| ? gzipSync(bytes, { level: 6 }).byteLength | ||
| : bytes.byteLength; |
There was a problem hiding this comment.
Honor the backend's minimum gzip size
When an eager asset is smaller than 1,024 bytes, studio/backend/main.py serves it uncompressed because _AssetGZipMiddleware is configured with minimum_size = 1024, but this function always charges its gzip size. For small compressible chunks this can undercount almost the entire response body, so enough sub-1 KiB chunks can pass the transfer budget even though the bytes actually served exceed it; apply the same size threshold before using gzipSync.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not taking this one, though the mechanism you describe is real and I checked it rather than assuming.
The backend config is as you say (studio/backend/main.py, minimum_size = 1024), and I read the Starlette source rather than the docs to pin the semantics: if len(body) < self.minimum_size and not more_body in _CompressionResponder.send_with_compression, identical at the installed 1.6.0 and at the 0.46.0 dependency floor. So it is a strict < (exactly 1024 IS compressed), measured on the first body chunk, and FileResponse uses a 64 KiB chunk size, so every sub-1 KiB asset does arrive as a single non-streaming message and does take that branch. Your premise holds.
What stops it is the magnitude. Measured on the current build:
charged by the gate 1,539,917
actually served 1,542,502
net undercount 2,585
headroom 60,083
10 of 49 eager chunks are under 1 KiB. One of them, assets/array-BifhSqXX.js (107 raw, 112 gzipped), is currently overcharged, because gzip framing costs more than it saves at that size.
The undercount is bounded by 1023 bytes per sub-1 KiB chunk, so overtaking the headroom would take roughly 60 more maximally compressible sub-1 KiB eager chunks on top of the 49 that exist. That is not a build shape this app produces, and if it ever did, that shape is itself the anomaly the gate is meant to surface.
It is also below the precision of the thing it would be correcting: transferBytes is a hand-rounded 1,600,000 against a 1,503.8 KB measurement, and the model already ignores index.html and HTTP framing. A 0.17% systematic bias is a modeling approximation, not a gate that passes when it should fail.
Searching the whole tag text for an attribute name found it in other attributes' values, and treating the first > as the end of the tag ended it inside a quoted value. <script data-mode="load async later" src="/theme-boot.js"> <script data-note="a > b" type="module" src="/assets/entry.js"> The first has no async attribute and is parser-blocking, so it belongs in the budget; the second's entry is /assets/entry.js. The old code dropped both, and in each case the rest of the build still satisfied the shape guard, so the gate reported a comfortable pass over a startup path it had not measured. Attributes are now parsed off each start tag: > ends a tag only outside a quoted value, and a name is only read where a name can begin. Comments and inline script bodies are skipped, which a stateful scanner has to do to stay in sync, and which also stops a commented-out script being charged.
|
Merged current This branch was cut at
For the record, the other red checks on the previous head were not this PR either: After the merge, on this machine: which lines up with the reference measurement of |
The exclusion of async assumed it has no ordering relationship to the first screen. blocking="render" creates exactly that relationship, and it is the documented way to keep a boot script off the parser without letting the unthemed page paint, which is what theme-boot.js is for. Per the spec an element is potentially render-blocking if its blocking tokens set contains render, OR if it is implicitly potentially render-blocking; the async carve-out lives only in the implicit half, so the explicit attribute applies to an async script too. Measured rather than assumed. Holding /slow.js for two seconds moved first contentful paint from 28 ms to 2,020 ms in Chromium 151, which also reports the request renderBlockingStatus as blocking, and from 11 ms to 2,009 ms in WebKit 26.5. Firefox has not shipped it and treats the script as plain async. Left uncounted, such a script delays the first screen by its whole fetch and evaluation while the entry and preloads keep the shape guard satisfied.
|
@codex review |
1 similar comment
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dd67046b73
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!url?.startsWith("/") || url.startsWith("//")) { | ||
| return undefined; // Absent, external, or protocol-relative: not ours to budget. |
There was a problem hiding this comment.
Resolve relative same-origin startup URLs
When a classic startup script uses a relative URL such as src="theme-boot.js" or src="./theme-boot.js", the browser resolves and executes it relative to /index.html, but this leading-slash check silently rejects it. The normal root-relative Vite entry and preloads still satisfy the shape guard, so that script can grow without appearing in either budget; resolve same-origin relative URLs against the document URL or fail explicitly when one is encountered.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Reproduced, but not taking it, because the fix you propose makes the gate worse in a way an existing test already pins.
The mechanism is exactly as you describe:
src="/theme-boot.js" blocking=["theme-boot.js"] fromVite=3
src="theme-boot.js" blocking=[] fromVite=3
src="./theme-boot.js" blocking=[] fromVite=3
So it is an undercount and the shape guard does stay satisfied.
The problem is that a relative URL is not only how someone might write that one script tag, it is also what a base: "./" or base: "/studio/" build emits for EVERY href. tests/bundle-budget-cli.test.ts already settles what must happen then: "hrefs that are not site-root asset paths are reported, not silently skipped", asserting exit 2 and an error naming base, with the comment "Measuring the empty remainder as 0 bytes would pass forever."
I implemented your suggested resolution to check rather than argue from theory (new URL(url, "http://dist.invalid/"), origin compare, take pathname) and ran the suite:
not ok 5 - hrefs that are not site-root asset paths are reported, not silently skipped
# pass 7 # fail 1
That is the trade: resolving relative URLs counts your ./theme-boot.js, and in exchange a relative-base build stops erroring and starts reporting a comfortable pass on a set it has misread. The second failure is far more dangerous than the first, and it is the one this file exists to prevent.
Your alternative remedy, "fail explicitly when one is encountered", is what the code already does for the case that actually occurs.
On reachability, this one also announces itself rather than lying quietly. Vite warns at build time on a relative classic src (<script src="./theme-boot.js"> in "/index.html" can't be bundled without type="module" attribute), and the backend serves index.html from a catch-all, so on the one nested route a relative src fetches /data-recipes/theme-boot.js, gets index.html back as text/html, and is refused execution under the app-wide X-Content-Type-Options: nosniff. The two parser items I fixed earlier this round had no such signal anywhere: the app behaved perfectly and only the gate was wrong.
| const path = url.slice(1).split(/[?#]/)[0]; | ||
| return path && !path.split("/").includes("..") ? path : undefined; |
There was a problem hiding this comment.
Preserve query strings when deduplicating requests
When the same local startup script is included under distinct cache-busting URLs such as /boot.js?v=1 and /boot.js?v=2, the browser treats them as separate requests and executes both classic script elements, but stripping the query before the shared seen check collapses them to one measurement. This can undercount both transfer and execution cost while the Vite chunks keep the shape guard satisfied; retain the complete request URL as the deduplication identity while using only its pathname to locate the file.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Reproduced, and rejecting on reachability.
The collapse is real:
<script src="/boot.js?v=1"><script src="/boot.js?v=2"> -> blocking=["boot.js"]
Two requests, two executions, one measurement.
What it needs is an index.html that deliberately includes the same local boot script twice under two cache-busting query strings. Vite cache-busts by content hash in the filename and never by query string, and files copied from the public dir keep their names verbatim, so no build emits this. The real dist/index.html contains zero ? characters and exactly two src values, /theme-boot.js and the hashed entry.
That leaves a hand edit whose own content is the defect: loading and executing the same boot script twice does visible duplicate work, and the double byte count is the least interesting thing wrong with it. Fixing the accounting for a document that is already broken in a louder way is not what this gate is for.
Worth noting the dedupe is not incidental either. seen exists because the entry chunk can legitimately appear as both a <script src> and a <link rel=modulepreload>, where the browser really does fetch it once, and that is the case it has to get right.
The set held the four spellings anyone writes today, but the rule it cites is the spec essence list, which has sixteen. The other twelve are not dead letters: measured in Chromium 151, application/x-javascript, text/jscript, text/javascript1.5, text/livescript, application/x-ecmascript and text/x-javascript all execute. So a startup script tagged with one of those was fetched and run by the browser and left out of the budget, while the entry and preloads kept the shape guard satisfied. The same probe confirms the two exclusions already relied on here: text/javascript; charset=utf-8 and application/json do not execute, because the attribute is matched against the whole essence string and a parameter makes it match nothing. The list is frozen upstream, so it does not grow.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5ff82eeddd
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| function main(): number { | ||
| let html: string; | ||
| try { | ||
| html = readFileSync(join(DIST, "index.html"), "utf8"); |
There was a problem hiding this comment.
Include the backend-injected bootstrap script
On first-boot local and Colab requests, the measured file is not the HTML the browser executes: studio/backend/main.py lines 2075-2100 inject a nonced inline window.__UNSLOTH_BOOTSTRAP__ script before </head>, and lines 2327-2336 authorize it through CSP. Fresh evidence versus the earlier inline-script thread is that this backend-generated script is currently reachable despite the static page's CSP. Reading only dist/index.html therefore omits its transfer and execution bytes, and future growth of that bootstrap can pass this startup budget unnoticed; include the injected script in the measurement or derive the measurement from the served response.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
You are right that the CSP objection from the earlier inline-script thread does not apply here, and that is a fair distinction to draw. It still does not get this over the bar, on size and on scope.
Size. I measured the tag rather than estimating it. It is
<script nonce="...">window.__UNSLOTH_BOOTSTRAP__={"username":...,"password":...}</script>
with a 22 character nonce and a four word diceware passphrase: 142 bytes. Substituting an absurd 64 character password gives 178. That is 0.009% of the 1,600,000 transfer budget. For consistency, I declined a separate suggestion this round about the backend's 1 KiB gzip threshold at a measured 2,585 byte discrepancy against 60,083 bytes of headroom; this is eighteen times smaller than the one I already judged immaterial.
Growth. The payload is not open ended, which is what "future growth can pass unnoticed" would need. It is exactly two fields, a fixed username and a password. Adding a third would be a change to what credentials get handed to the browser, reviewed on its own terms and not something a bundle budget should be the tripwire for.
Scope. This gate guards one specific regression: a static import pulling something into the entry closure, the way a dialog that is closed on load was carrying 2.4 MB. No frontend import can affect this tag. It is generated per request by the backend, only when requires_password_change holds and a bootstrap password exists, so it is a first-boot condition rather than the steady state.
Feasibility of the alternative. "Derive the measurement from the served response" would mean booting the backend inside the frontend build job and driving it into a first-boot password-change state, to attribute 142 bytes. The gate runs at build time against dist/, where no backend exists. That is a large amount of new machinery, and a new way for the check to fail for reasons unrelated to bundle size, bought for four thousandths of a percent of the budget.
The narrower reading, that the file measures dist/index.html and not the served response, is fair. That is deliberate: what it measures is the build output, because the build is what a pull request changes.
|
@codex review |
1 similar comment
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5ff82eeddd
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // chunk and emits no preload links at all, which is a complete measurement. | ||
| const fromVite = entry.length + preloads.length; |
There was a problem hiding this comment.
Require a module entry before trusting preloads
When the module entry is missing or rewritten into an unsupported form while at least two modulepreload links remain, this sum still satisfies the shape guard and the command exits successfully after measuring only the preloads. This is precisely the malformed/changed build shape the guard is intended to reject: for example, an HTML transform that rewrites the entry URL but leaves its preload links lets the entry's bytes disappear from both budgets. Check that entry is nonempty independently, while continuing to allow multiple entry scripts with no preloads.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed, and fixed in a71ff98.
This is the residue of the two parser bugs fixed earlier in this branch, and you have put your finger on why they were able to hide. Both did their damage the same way: the entry dropped out, the 48 preload links kept fromVite well above 2, and the gate measured the remainder and reported a comfortable pass. I fixed the causes. The invariant that would have made either one loud was still missing, which is what you are pointing at.
Your reasoning about the shape is the part that convinced me. A modulepreload link exists to announce the entry's static import closure, so links cannot legitimately outlive the entry they hang off. Preloads with no entry is not something Vite emits; it means the entry was read wrong. And it is the worst possible place for the guard to have a hole, because the entry chunk is the largest single item on the startup path here, 430 KB raw and 128 KB transfer.
The guard now requires entry to be non-empty in addition to the total, with a distinct message for that case. Your caveat about not breaking the multiple-entries-no-preloads layout was well taken and is exactly right: the total still decides whether this is a code-split build, so the inlined-entry case, where Vite emits one module script per imported chunk and no links at all, still passes as a complete measurement. Its test is unchanged and still green.
Regression test removes the entry script from the fixture and asserts exit 2 with "no module entry". Reverting the script with the test kept fails exactly that test. 41 bundle tests, full suite 2885 passed / 0 failed on node 22, and the real build is unaffected at 5225.1 KB raw / 1503.8 KB transfer / 49 chunks.
The shape guard counted entry scripts and preload links together, so 48 links carried it on their own and a build whose entry was misread still measured and passed. The entry chunk is the largest single thing on the startup path, so that is the worst place for the total to paper over a gap. Preloads without an entry is not a shape Vite emits. A modulepreload link exists to announce the entry a static import closure hangs off, so links surviving while the entry does not means the entry was read wrong. The total still decides whether this is a code-split build, which keeps the inlined-entry layout passing: several module scripts and no links at all is a complete measurement. This is the residue of two mis-parses fixed earlier in this branch. Both did their damage the same way, by dropping the entry while the links kept the guard satisfied, so the invariant is worth stating outright rather than relying on the parser never being wrong again.
|
@codex review |
1 similar comment
|
@codex review |
…9026) * Repair the prompt-queue contract test against the queueing refactor Repo tests (CPU) is red on main. test_composer_only_queues_behind_the _current_chat greps thread.tsx for code that #8952 moved or replaced, so it fails on any branch regardless of what that branch changes. It is currently red on #8935, #8964, #8980 and #8983 for this reason alone. The behaviour it guards is intact, and in two of the three cases the code that replaced it is stronger than what the test still asserted, so the assertions are repointed rather than dropped: - Queueing moved out of handleSubmit into an extracted queueComposerText, so the Cmd/Ctrl+Enter path could share it. Assert the delegation in handleSubmit and the queueing inside queueComposerText, which keeps this a contract on behaviour rather than on where the code sits. - promptQueueStartPendingRef.current.has(reservationKey) became a .get(reservationKey) === identity comparison. A reservation can be replaced between the start and the callback, and acting on the successor would dispatch the wrong prompt; presence alone never caught that. - temporary: useChatRuntimeStore.getState().incognito became temporary: incognitoAtQueueStart, captured when the queue starts instead of read live at dispatch. A chat toggled out of temporary mid-queue must not have its queued prompts persisted. Each rewritten assertion was checked against a deliberately broken tree rather than assumed to discriminate. Removing the delegation, swapping the identity check back to presence, reading temporary live again, and stopping queueComposerText from queueing each fail the file. tests/studio 11 passed for this file, whole directory green. * Read the identity check out of the dispatch guard, not the whole file Reported on this PR and correct. The assertion searched thread.tsx for promptQueueStartPendingRef.current.get(reservationKey) === anywhere, and the abort and cleanup branches beside the dispatch carry the same comparison. The dispatch guard could regress to .has(reservationKey) on its own, which is exactly the bug this assertion exists to catch, and the test would still pass on its neighbours. It now slices the if condition that guards the startPromptQueue call and asserts the identity comparison there, with .has excluded from that guard. All three comparisons are still required by count, because the other two are load-bearing too: abort without it reports the successor's start as this one's failure, and cleanup without it deletes the successor's entry. 11 passed. Rewriting the dispatch guard to .has fails it; it passed before. --------- Co-authored-by: danielhanchen <unslothshared@gmail.com>

Frontend CI already budgets
dist/at 75 MB. That is the whole artifact, and a page that is only ever reached through a dynamic import does not make it worse, so nothing today watches the number the user actually waits through: the JavaScript that has to be downloaded, parsed and executed before the first screen exists.Two PRs moved 1.5 MB off that path by hand, each measuring it with a throwaway Chromium harness:
Nothing then stopped the next static import from putting it back, and nothing noticed when one did.
What it measures
Vite already computes the eager set and writes it into
index.html: the entry<script type="module">plus one<link rel="modulepreload">per chunk in that entry's static import closure. This reads those tags, and adds the one thing Vite does not emit but the browser still runs first: a same-origin classic<script src>(public/theme-boot.js). A deferred script counts too, since it runs before DOMContentLoaded in document order with the module entry, which is itself deferred; onlyasync, which has no ordering relationship to the first screen, is left out. It does not parse minified JavaScript or infer a graph, and a chunk reachable only throughimport()carries no preload link, so lazy loading is correctly rewarded.On this build:
Transfer is what crosses the wire: gzip for the
/assetsmount, which is the only thing the backend compresses, and raw for anything served through the plain file path. Raw is what the main thread has to parse and execute, which is the part that shows up as a slow launch on a weak machine, so both are budgeted. The chunk count is printed but not budgeted: splitting a page out of the entry raises it while lowering the bytes, so a cap there would fail the very change this exists to encourage.The budget is set from that measurement with a little headroom, and it is a constant in the script rather than a separate config file, so raising it lands in the same diff as the import that needed it. The failure message says so.
The failure mode that matters
A size gate does not usually break by failing. It breaks by passing while measuring nothing, because nobody reads a green step. So every path that could produce a comfortable number from a build this no longer understands exits 2 instead of 0:
build.modulePreload: falseemits the entry and no preload links; measured as a flat list that is a one-chunk app with 4.8 MB to spare. Scripts and links are counted together rather than both being required, because when the entry module is nothing but imports Vite inlines it into one<script>per chunk and emits no links at all, which is a complete measurement./assets/hrefs at all, which is what a relative or non-rootbase, a changedassetsDir, orrenderBuiltUrlpointing at a CDN produces.index.htmlthat is not in the build. Previously an uncaughtreadFileSync, which exited 1 with a stack and read as a budget failure.dist/index.html.Matching is case-insensitive and treats
relas a token list, so a partial match cannot quietly shrink the set instead of failing. The entry point guard compares realpaths: comparingprocess.argv[1]toimport.meta.urlliterally made the whole script a silent exit 0 for any checkout reached through a symlink, which on macOS is anything under/tmp.Coverage
tests/bundle-budget-closure.test.tspins what counts: the entry plus preloads are included, a dynamic-only chunk is not, a classic script is charged but never mistaken for the entry, deferred scripts count and async ones do not, cross-origin scripts and stylesheets are not counted, a type the browser will not execute is not counted, duplicates collapse, and attribute order, quoting, case and line breaks do not matter.tests/bundle-budget-cli.test.tsruns the script the way CI does and asserts the exit codes above, that a pass always prints the measurement it passed on, and that the symlinked-checkout and no-preload-links cases can never come back.scripts/**is a Node CLI surface, so biome's browser rules (noNodejsModules,noConsole,useTopLevelRegex) are turned off there, matching the existingvite.config.tsoverride.Testing
npm run bundle:checkagainst a production build from a cleannpm ci: within budget, exit 0base: "./",base: "/studio/"andbuild.modulePreload: false: each exits 2 with the reason, none silently passesnpm run typecheck,npx eslint,npx biome checkclean on the scriptCross-platform: the suite, the build and
bundle:checkwere run on ubuntu-latest, macos-14 and windows-latest. On a build cut from currentmain, all three report the same figure to the byte, 5,223.9 KB raw / 1,503.5 KB transfer over 49 chunks, leaving 147.2 KB raw and 59.0 KB transfer of the budget. That margin is deliberately small. The next static import that does not belong on the startup path is meant to hit it.Measurement stability: the compressed figure comes from Node's bundled zlib, which is Chromium's fork. Identical byte counts on Node 20.19.4, 22.20.0 and 24.14.0; a build linked against system zlib differs by about 0.25 percent, well inside the headroom, and the raw figure does not depend on zlib at all.
Interaction with #8966
#8966 moves the settings tab panels off this path. Merged on top of this branch and built, the eager total is 4,814 KB raw / 1,407 KB gzip over 66 chunks, comfortably inside the budget here. Whichever lands second should lower the budget to match, which is the workflow this is meant to create.