Skip to content

dev server: import() runs the module after the calling code and rejects when it fails - #46770

Draft
robobun wants to merge 1 commit into
robobun/b5282807/hmr-reevaluate-betweenfrom
robobun/ad922343/dev-server-import-rejects-and-defers
Draft

robobun wants to merge 1 commit into
robobun/b5282807/hmr-reevaluate-betweenfrom
robobun/ad922343/dev-server-import-rejects-and-defers

Conversation

@robobun

@robobun robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #46764. Do not merge this one first.

Problem

  • In the dev server, import("./m") runs m inside the call. import("./bad").catch(h) throws Error: boom at the call, and m misses state that the caller sets after it.
  • The cause: HMRModule.dynamicImport (src/runtime/bake/hmr-module.ts:199) calls the loader first, which evaluates the module at once.

Fix

  • dynamicImport is async. It converts the specifier to a string, waits one microtask, then calls the loader, as Node and plain bun order it. A failed load rejects the promise.
  • Verified: test/bake/hmr-module.test.ts (14 new cases, 13 fail on the base) and 5 new dev tests. All of test/bake passes.
  • Self-reviewed an earlier shape: 32 concerns raised, 21 addressed. Notes name the other 11.

Background

  • The printer lowers each import(x) of a dev-server module to hmr.dynamicImport(id), in the browser and on the server.
  • Why stacked: a caller that keeps the promise keeps a rejection (react-server-dom-bun, React.lazy). dev server: re-evaluate every module between a saved file and its accepting module #46764 evaluates that caller again after the fix. Without it, a failed "use client" component stays at 500 until restart.
  • Considered a fast path for loaded modules and for ids that are not bundled: 26 more lines that no test can tell apart.

Downsides

  • A module with top-level await now starts after the call. A bundle without splitting starts it inside the call.
  • Server code that ignores a failed import() now ends the process (unhandled rejection). Before, the route answered 500.
  • Each import() costs 4 or 5 heap cells and 7,551 to 8,303 instructions more (JIT off), and settles one microtask later.
Notes

Question for a maintainer. The header of hmr-module.ts names bun build as the model. A bundle without code splitting starts a module with top-level await inside the import() call (init_x().then(...)). After this PR the dev server starts it after the calling code, as Node, plain bun and a bundle with --splitting do. test/bake/dev-and-prod.test.ts pins both orders. If the dev server must keep the in-call start for such a module, the entry needs a gate on the module's async flag.

Tests (base means src/ of #46764 with the same debug build)

  • test/bake/hmr-module.test.ts, describe("import()"): 14 cases. 13 fail on the base. The one that passes is a control (an id that is not bundled goes to the runtime's import()).
  • test/bake/dev/esm.test.ts: "import() runs a bundled module after the current code" and "import() of a module that fails to load rejects". Both fail on the base.
  • test/bake/dev/hot.test.ts: "import() in a module that a hot update runs again" and "a module whose import() failed is loaded again when it is fixed". Both fail on the base.
  • test/bake/dev-and-prod.test.ts: one fixture in development and in a production build. The development half fails on the base.
  • test/bake/dev/react-response.test.ts: two guards with real React. A "use client" component that fails to load on the server is rendered after it is fixed (500, 500, fix, 200). A page that lazy() loads and that fails is shown after it is fixed, with no page reload. Both pass on the base and here. With the same entry on main, which has no dev server: re-evaluate every module between a saved file and its accepting module #46764, the first one stays at 500 after the fix.
  • Removal of await 0 fails 10 of the 14 cases. Removal of the string conversion fails 1.
  • test/bake/dev/production.test.ts times out at the 5 s default in up to 9 of its 21 tests on this machine, with and without this change. All 21 pass with --timeout 60000.

Measurements (base against this branch, hmr-module.ts bundled alone for the server, bun 1.4.3)

  • heap cells for each completed import(): loaded 2 to 6, first load 15 to 19, first load with top-level await 32 to 37, not bundled 18 to 23 (heapStats().objectTypeCounts over 1,000 calls, no GC in the window).
  • microtask turns: first load body@1 then@2 (a bun build bundle: body@1 then@2, base: 0 and 1). Loaded then@2 (base 1, bundle 2). Top-level await then@6 (base 4). Not bundled then@11 (base 9).
  • instructions for each awaited import(), JIT off, one GC thread: loaded 5,251 to 13,008, first load 36,909 to 44,460, not bundled 31,525 to 39,828. perf and valgrind are not installed here. A wrapper around perf_event_open counted user-mode instructions.
  • runtime text from bake-codegen.ts --debug=OFF: client 55,309 to 55,315 B, server 12,684 to 12,690 B. Client as embedded (zstd 19): 18,676 to 18,683 B.

What changes, by case

  • A failure that was a throw at the call is now a rejection: a module body that throws (ES module and CommonJS), a static dependency that throws, the stored error of an earlier failure, a toString of the specifier that throws, exports that cannot become a namespace.
  • A calls import("./B") and B imports A: B now runs after A is complete. Before, B read null. A module that calls import() of itself gets its namespace.
  • The specifier is converted to a string one time, inside the call. Before, it was two times, and an object specifier that named a bundled module made a new module record for each object.
  • bun:afterUpdate fires before the body of a module that a hot update loads for the first time through import().
  • A .then() caller no longer has the importer in the stack trace of a failed load. A caller that awaits the import() has it (at async <importer>).
  • A rejection that a module outside the importer chain keeps stays rejected after the fix, until that module is saved or the page or server restarts. Node, Vite and webpack do the same.

Not changed here

  • A second import(x) while x is suspended on a top-level await resolves with null, and so does await import() of the module itself. The loader gives such a load the module as it is.
  • import(x, options) of an id that is not bundled resolves with undefined. dev server: return the promise from import() with options #46683 adds the missing return.
  • import() of a specifier that a plugin's onResolve filter matched, and of a stylesheet, is printed as Promise.resolve().then(() => hmr.require(id)) and does not reach dynamicImport.
  • hmr.require(object) still registers a module under an object key.

Self-review. The review ran on an earlier shape of this change (a synchronous entry with a fast path for loaded modules). Of its 32 concerns, 21 are addressed here or by #46764: the smaller async body, the importer that is evaluated again after a fix, the second importer of a failed module, the tests in both modes, the React tests, the stack test. 2 no longer apply, because the fast path and the edit of the options helper are gone. 4 name code that this PR does not change (see "Not changed here"). 5 are about demand and approval: no user report, and no maintainer has said yes to the later start yet (see the question above).

Provenance. No issue reports these faults. They come from an internal fuzz run against Node, plain bun and bun build. #37734 made the call reject and was folded into #39488, which is not merged.


no test proof · iteration 3 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bake/dev/hot.test.ts, test/bake/dev/esm.test.ts, test/bake/dev-and-prod.test.ts

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:59 PM PT - Oct 24th, 2026

✅ @robobun, your commit 55e710d12b109b2446c62f5fcc1e48363bd7f6bc passed in Build #132132! 🎉


🧪   To try this PR locally:

bunx bun-pr 46770

That installs a local version of the PR into your bun-46770 executable, so you can run:

bun-46770 --bun

@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced with test/bake/hmr-module.test.ts (describe("import()")) and the new dev tests. On the base (#46764) import("./bad").catch(h) throws at the call, and console.log("start"); import("./m"); console.log("end") runs m before end. With this branch both follow Node, plain bun and a bun build bundle.

Local runs with the debug build on Linux x64, base src/ against this branch: hmr-module.test.ts 129 pass and 13 fail, then 142 pass. dev/esm.test.ts 20 and 2, then 22. dev/hot.test.ts 18 and 2, then 20. dev-and-prod.test.ts 13 and 1, then 14. dev/react-response.test.ts passes on both (6 tests, two of them guards for the fix-and-retry of React).

This PR is a draft on purpose. It is stacked on #46764 and it asks one question (Notes in the body): is the later start of a module with top-level await wanted.

PR: #46770

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants