Commit graph

1 commit

Author SHA1 Message Date
Daniel Han
1bf4be70e3
Studio: budget the JavaScript that runs before the first screen (#8964)
* Studio: budget the JavaScript that runs before the first screen

* Report the eager chunk count without budgeting it

* Close the ways this gate could pass without measuring anything

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.

* Judge a script tag on whether the browser runs it

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.

* Charge deferred scripts, and size non-asset files as they are served

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.

* Anchor attribute matching on whitespace, not a word boundary

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.

* Read index.html with a tag scanner instead of a regex

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.

* Count async scripts that are asked to block rendering

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.

* Recognise every JavaScript MIME essence a browser still runs

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.

* Require a module entry before trusting the preload links

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.
2026-08-17 03:35:18 -07:00