Skip to content

ES module support in blitz-script - #81

Closed
pathscale wants to merge 5 commits into
masterfrom
feat/es-modules
Closed

ES module support in blitz-script#81
pathscale wants to merge 5 commits into
masterfrom
feat/es-modules

Conversation

@pathscale

@pathscale pathscale commented Aug 31, 2026

Copy link
Copy Markdown
Owner

The finding

Measured over the 104-site corpus, this was the largest unfixed rendering defect: 15 sites.

It was first recorded as "import.meta does not parse", which is wrong. The two error signatures say what it actually was:

SyntaxError: expected token '.', got '{' in import.meta at line 1, col 7
SyntaxError: invalid `import.meta` expression outside a module

Column 7 is the character right after import , and the character is {. That is import {x} from '...' — an ES module being parsed as a classic script. document.rs admitted 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 (new module.rs), installed on the Boa context at construction:

  • resolves specifiers with Url::join and fetches over the document's existing ScriptFetcher, synchronously — a module graph blocks exactly as a classic <script src> already does;
  • Boa addresses modules by Path and the web by Url. 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;
  • 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. It is populated before linking, so cycles terminate;
  • 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. integrity is 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, so import("./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 await is not treated as a failure. A module opening with await 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.

nomodule is 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 it rather 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-script suite is green. The multi-module graphs are fetched over a real loopback socket through a ScriptFetcher, 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_unchanged is 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_scripts makes two passes: parser-blocking classics in document order, then modules and defer scripts together, also in document order.

The expected sequence in deferred_scripts_run_after_the_parser_blocking_ones 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. That is also how defer and module were confirmed to interleave by document position rather than forming two groups. async was 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.

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

Copy link
Copy Markdown
Owner Author

Superseded by #83, which stacks all three into one PR with the 0.4.1 workspace bump.

@pathscale pathscale closed this Aug 31, 2026
@pathscale
pathscale deleted the feat/es-modules branch September 1, 2026 04:37
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