ES module support in blitz-script - #81
Closed
pathscale wants to merge 5 commits into
Closed
Conversation
added 5 commits
August 31, 2026 20:22
Modules were collected and handed to the classic-script evaluator, which
failed on the first line of every real module entry point:
SyntaxError: expected token '.', got '{' in import.meta at line 1, col 7
Column 7 is the character after `import `, and the character is `{`. In
the classic-script goal the only legal continuation is `import.meta` or
`import(...)`, so the parser read `import {x} from '...'` as a broken
`import.meta`. Nothing was wrong with the pages.
Parsing modules without resolving imports would not have moved anything,
because module entry points import. So this is the loader as well:
- `BlitzModuleLoader` resolves specifiers with `Url::join` and fetches
them over the document's existing `ScriptFetcher`, synchronously, the
way a classic `<script src>` already blocks.
- Boa addresses modules by `Path` and the web by `Url`. The path is used
as an opaque key holding the URL's own string rather than being routed
through path resolution, whose rules differ from the URL spec's.
- A resolved-URL cache, which the spec requires: without it a diamond
import instantiates the shared module twice and each half of the page
gets its own copy of that module's state.
- `import.meta.url`, which asset helpers read.
The loader can only be installed when the context is built, but an
embedder supplies its fetcher afterwards, so the fetcher now lives in a
cell shared between the document and the loader.
`nomodule` is now honoured. It only becomes meaningful once modules run:
a page shipping both bundles would otherwise mount its application twice.
Six tests, including a two-module graph fetched over a real loopback
socket. Every accept loop in them has a deadline, so a regression fails
the suite instead of hanging it.
Two gaps left by module support on its own.
`<script type="importmap">` is how a page that ships unbundled modules
names its dependencies. `import { h } from "preact"` is a bare
specifier: it means nothing without the map, so the graph stopped at the
entry point's first dependency. Implemented is the resolution half of
the spec — normalisation at parse time, longest-prefix matching for keys
ending in `/`, and scopes selected by the importing module's URL.
`integrity` is not, being a fetch-time concern.
Maps are installed across the whole batch before any script in it runs.
A page may write its map after the module that needs it, and a map
installed too late is the same as no map: the module has already failed
to resolve.
A malformed map yields an empty one rather than an error. What the page
then reports is "this specifier does not resolve", naming the specifier,
which is more use than a parse error naming a byte offset. An unmapped
bare specifier now says exactly that instead of 404ing on an invented
URL the page never wrote.
Classic scripts now carry their own URL as well. They can still call
dynamic `import()`, and the specifier resolves against the script, not
the document: a bundle served from `/assets/` calling
`import("./chunk.js")` was looking for the chunk beside the HTML.
`<script type="module">`, `importmap` and classic now come off one
`ScriptKind` rather than a pair of booleans that could both be wrong.
…raph
Three things module support needs to be safe on a real page.
**Top-level `await` is not a failure.** A module that opens with
`await fetch(...)` has an unsettled evaluation promise when its script
returns, and a browser settles it on a later turn of the event loop.
Reporting that at evaluation time would fire an error against every
module on the modern web that waits for anything. Unsettled evaluations
are now kept and checked on each poll, so a rejection that arrives late
is still attributed to its module instead of leaving a half-mounted page
with nothing anywhere saying why.
**JSON modules.** `import config from "./x.json" with { type: "json" }`
builds a synthetic module with one default export. Not a source-text
module: running JSON through the JavaScript parser accepts what JSON does
not and rejects the object literal most JSON files begin with.
**A bound on the graph.** Module fetches are synchronous on the document
thread, so an unbounded graph is an unbounded hang with a blank window
and an empty console — the browser looks broken rather than the page.
4096 modules per document is far past any real application and still
terminates.
A module is a deferred script, and a browser runs every non-deferred classic script before any deferred one. Document order alone was right until modules existed. It is now wrong in a way that bites: an inline classic script writing `window.__CONFIG__` after a module tag runs *before* that module in a browser, and the module reads the config it expects. `run_pending_scripts` now makes two passes over each batch: classic non-deferred scripts in document order, then modules and `defer` scripts together, also in document order. `defer` is honoured only on external classic scripts, as the spec ignores it on inline ones, and `async` takes a script back out of the deferred group. The expected sequence in the test is measured, not transcribed from the spec: the fixture's exact markup was served to a stock engine over loopback and the recorded order is what it produced. A page mixing all five script forms is the only way to see that `defer` and `module` interleave by document position rather than forming two groups. Also widens the fixture deadlines and stops each fixture server once it has served every route. One full-suite run failed on a 5s loopback read that lost to a concurrent compile — a timeout there never means what the test is asking about. The early exit is what keeps the generous deadlines free: the suite spends 0.2s here rather than the 30s it would take to sit out every backstop.
Owner
Author
|
Superseded by #83, which stacks all three into one PR with the 0.4.1 workspace bump. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The finding
Measured over the 104-site corpus, this was the largest unfixed rendering defect: 15 sites.
It was first recorded as "
import.metadoes not parse", which is wrong. The two error signatures say what it actually was:Column 7 is the character right after
import, and the character is{. That isimport {x} from '...'— an ES module being parsed as a classic script.document.rsadmitted it in a comment:type="module"was collected and handed to the classic evaluator.Fixing the parse alone would have moved nothing, because every real module entry point imports. So this is the loader as well.
What landed
BlitzModuleLoader(newmodule.rs), installed on the Boa context at construction:Url::joinand fetches over the document's existingScriptFetcher, synchronously — a module graph blocks exactly as a classic<script src>already does;Pathand the web byUrl. The path is used purely as an opaque key holding the URL's own string, never routed through path resolution, whose rules differ from the URL spec's;import.meta.url.The loader can only be given to a context at construction, but an embedder supplies its fetcher afterwards through
with_fetcher, so the fetcher now lives in a cell shared between the document and the loader.Context::default()could no longer be used — and its default is not "no loader" but a filesystem loader rooted at the process's working directory, which for a browser is both useless and the wrong thing to expose to a page.Import maps.
import { h } from "preact"is a bare specifier and means nothing without one, so a page shipping unbundled modules stopped at its entry point's first dependency. The resolution half of the spec is implemented: normalisation at parse time, longest-prefix matching for keys ending in/, scopes selected by the importing module's URL.integrityis not, being a fetch-time concern. Maps are installed across the whole batch before any script in it runs, because a page may write its map after the module that needs it.Dynamic
import()from classic scripts. Classic scripts now carry their own URL, soimport("./chunk.js")from a bundle served out of/assets/resolves beside that bundle rather than beside the HTML.JSON modules.
import config from "./x.json" with { type: "json" }builds a synthetic module with one default export — not a source-text module, since running JSON through the JavaScript parser accepts what JSON does not and rejects the object literal most JSON files begin with.Top-level
awaitis not treated as a failure. A module opening withawait fetch(...)has an unsettled evaluation promise when its script returns; a browser settles it on a later turn of the event loop. Unsettled evaluations are kept and rechecked on each poll, so a rejection that arrives late is still attributed to its module rather than leaving a half-mounted page with nothing anywhere saying why.A bound on the graph. Module fetches are synchronous on the document thread, so an unbounded graph is an unbounded hang with a blank window and an empty console — the browser looks broken rather than the page. 4096 modules per document is far past any real application and still terminates.
nomoduleis honoured. It only becomes meaningful once modules run: a page shipping both a module bundle and an ES5 fallback would otherwise mount its application twice.Diagnostics
An unmapped bare specifier now reports
cannot resolve bare module specifier "preact": no import map entry matches itrather than 404ing on a URL the page never wrote. A malformed import map yields an empty map rather than a parse error, because "this specifier does not resolve", naming the specifier, is more use than a byte offset.Tests
11 integration tests plus 5 import-map unit tests; the full
ps-blitz-scriptsuite is green. The multi-module graphs are fetched over a real loopback socket through aScriptFetcher, not an in-memory map — a map keyed by the string the test already wrote down would pass without the resolution ever being right.Every accept loop has a deadline, so a regression fails the suite instead of hanging it.
Not verified in a real browser: that needs the tauri-runtime-blitz release and the chuzz pin bump.
Scope
Landed on its own, as asked. It is a feature on the path every page's scripts take and carries the highest regression risk of the current work packages;
a_classic_script_still_runs_unchangedis the guard that matters.Added since first review
Deferred script ordering. A module is a deferred script, and every non-deferred classic script runs before any of them. Document order alone was right until modules existed; it is now wrong in a way that bites, since an inline classic script writing
window.__CONFIG__after a module tag runs before that module in a browser.run_pending_scriptsmakes two passes: parser-blocking classics in document order, then modules anddeferscripts together, also in document order.The expected sequence in
deferred_scripts_run_after_the_parser_blocking_onesis measured, not transcribed from the spec — the fixture's exact markup was served to a stock engine over loopback and the recorded order is what it produced. That is also howdeferandmodulewere confirmed to interleave by document position rather than forming two groups.asyncwas measured too and is genuinely timing-dependent, so it is treated as non-deferred rather than pretending to reproduce an order.Fixture robustness. One full-suite run failed on a 5s loopback read that lost to a concurrent compile. Deadlines are now generous, and each fixture server exits once it has served every route, so the suite spends 0.2s here instead of the 30s it would take to sit out every backstop.