Studio: budget the JavaScript that runs before the first screen by danielhanchen · Pull Request #8964 · unslothai/unsloth · GitHub
Skip to content

Studio: budget the JavaScript that runs before the first screen - #8964

Merged
danielhanchen merged 12 commits into
mainfrom
perf-bundle-gate
Aug 17, 2026
Merged

Studio: budget the JavaScript that runs before the first screen#8964
danielhanchen merged 12 commits into
mainfrom
perf-bundle-gate

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 16, 2026

Copy link
Copy Markdown
Member

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; only async, 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 through import() carries no preload link, so lazy loading is correctly rewarded.

On this build:

eager startup JS: 5208.3 KB raw, 1497.3 KB transfer, 45 chunks
largest:
   2493.1 KB raw   713.5 KB transfer  assets/settings-C6jvlApc.js
    461.0 KB raw   138.4 KB transfer  assets/training-Dyi_CoO8.js
    438.1 KB raw   131.9 KB transfer  assets/chunk-BO2N2NFS-CsrUolSg.js
    429.6 KB raw   128.4 KB transfer  assets/index-DZBgT93Y.js
    251.0 KB raw    74.5 KB transfer  assets/katex-QVq56Mr3.js

Transfer is what crosses the wire: gzip for the /assets mount, 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:

  • fewer than two chunks attributable to Vite. build.modulePreload: false emits 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.
  • no /assets/ hrefs at all, which is what a relative or non-root base, a changed assetsDir, or renderBuiltUrl pointing at a CDN produces.
  • a file named in index.html that is not in the build. Previously an uncaught readFileSync, which exited 1 with a stack and read as a budget failure.
  • no dist/index.html.

Matching is case-insensitive and treats rel as a token list, so a partial match cannot quietly shrink the set instead of failing. The entry point guard compares realpaths: comparing process.argv[1] to import.meta.url literally 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.ts pins 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.ts runs 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 existing vite.config.ts override.

Testing

  • npm run bundle:check against a production build from a clean npm ci: within budget, exit 0
  • 25 unit tests across the two files; full suite 2,807 passing
  • Verified against real builds with base: "./", base: "/studio/" and build.modulePreload: false: each exits 2 with the reason, none silently passes
  • npm run typecheck, npx eslint, npx biome check clean on the script

Cross-platform: the suite, the build and bundle:check were run on ubuntu-latest, macos-14 and windows-latest. On a build cut from current main, 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.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 16, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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.
danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 16, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

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.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +173 to +175
return name.startsWith("assets/")
? gzipSync(bytes, { level: 6 }).byteLength
: bytes.byteLength;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danielhanchen

Copy link
Copy Markdown
Member Author

Merged current main into this branch (ea0c9835a), which is worth explaining rather than leaving as an unexplained merge commit.

This branch was cut at 17363f8a2 and had drifted 15 commits behind. Two separate things were coming out of that:

  1. The frontend test suite could not run at all. chat-preferences-store.ts imported ../utils/pasted-text without an extension, which the node test runner will not resolve. That came in with Studio: make the paste-to-attachment threshold a chat setting #8963 (aa3f3c279) and was nothing to do with this PR, but unit tests run before every other step, so a red run here said nothing about the bundle gate. It is fixed on main by Give the pasted-text import an extension so the node test runner can load it #8981 (d43892ea7), so the merge is enough and no local workaround is needed.
  2. Most of the Repo tests (CPU) failures on the previous head were the stale base, not this branch. The studio frontend contract tests assert against source files that main has since moved.

For the record, the other red checks on the previous head were not this PR either: Chat UI Tests and (Python 3.12) both ended in The operation was canceled, and Frontend build + bundle sanity got past the whole frontend suite and then failed in playwright_strip_ansi_smoke.py. I am checking that last one separately rather than assuming it is noise.

After the merge, on this machine: npm test is 2879 passed / 0 failed, and the gate itself reports

eager startup JS: 5225.1 KB raw, 1503.8 KB transfer, 49 chunks
within budget (58.7 KB transfer, 146.0 KB raw to spare)

which lines up with the reference measurement of main, so the parser rewrite in 4ff2066d8 changes nothing about what this measures on a real build. The chunk count is still reported and still not budgeted, and the test pinning that it stays unbudgeted still passes.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +303 to +304
if (!url?.startsWith("/") || url.startsWith("//")) {
return undefined; // Absent, external, or protocol-relative: not ours to budget.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +306 to +307
const path = url.slice(1).split(/[?#]/)[0];
return path && !path.split("/").includes("..") ? path : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +444 to +445
// chunk and emits no preload links at all, which is a complete measurement.
const fromVite = entry.length + preloads.length;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

danielhanchen added a commit that referenced this pull request Aug 17, 2026
…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>
@danielhanchen
danielhanchen merged commit 1bf4be7 into main Aug 17, 2026
42 of 44 checks passed
@danielhanchen
danielhanchen deleted the perf-bundle-gate branch August 17, 2026 10:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant