Skip to content

bake: make import() reject instead of throwing when a module fails to evaluate in the dev server - #37734

Closed
robobun wants to merge 5 commits into
mainfrom
farm/2a1f1a76/bake-dynamic-import-rejects
Closed

robobun wants to merge 5 commits into
mainfrom
farm/2a1f1a76/bake-dynamic-import-rejects

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • In the dev server (bun ./index.html, or a Bake app in development), import() of a module that fails to evaluate throws synchronously into the caller instead of returning a rejected promise. So import(x).catch(...), Promise.all([...]) and Promise.allSettled([...]) work in production and break in development.
  • Cause: the dev server rewrites every import() into a call on its own module loader, and that loader reports load failures by throwing. Nothing between the loader and the caller turned the throw into a rejection.
  • It throws for a module body that throws (ESM or CommonJS), a static dependency that throws, a failure recorded by an earlier import, and CommonJS exports that cannot be converted to a namespace.
  • A native import() never throws synchronously, and neither does bun build output, which the dev runtime is documented to match.

Fix

  • The method that import() is rewritten to becomes async, so anything it throws becomes a rejection of the promise it returns. The source is shared by the client and server dev runtimes, so both are fixed.
  • Why nothing else changes: the method contains no await, so the loader still runs synchronously at the call site, and importer tracking and evaluation order are as before. The fallback to the runtime's own import() for unbundled modules is untouched (its missing return is a separate bug, bake: return the promise from import() with options in the dev server runtime #37727).
  • Verification: a dev server test calls import() on each failure shape above and checks that each call returns a promise that rejects with the module's error. Seven shapes throw synchronously without the fix; all pass with it. It runs in a route module, so it covers the server runtime; the client runtime is covered only by existing success-path tests.
  • Out of scope: a module whose dependency throws, or whose own top-level await rejects, is left pending, so a second import() of it resolves to null. Tracked separately.

Background

  • Bake is bun's dev server (bun ./index.html, or a framework app in development). It bundles modules on the fly and evaluates them through its own hot-reloading module registry rather than the engine's native module loader.
  • In that mode the bundler rewrites each import(x) in user code into a method call on the dev runtime, so import() in development behaves exactly as that method does. The engine's own import() is reached only for modules the dev server did not bundle, such as builtins.
  • The registry's runtime source is one TypeScript file compiled into both the browser and the server dev runtimes.
  • An async function runs synchronously up to its first await; with no await it runs entirely synchronously and only changes how the outcome is delivered: a return value resolves the returned promise, a throw rejects it, a returned promise is adopted. That property is the whole fix.
  • Native import() returns a promise in every case, including link and evaluation failures; bun build output keeps that by running the module initialiser inside Promise.resolve().then(...).

[review] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bake/dev/esm.test.ts
bun test v1.4.0 (973255421)

test/bake/dev/esm.test.ts:
Dev server testing directory: /tmp/bun-dev-test-HYNI1m
^[[0;30mdev|^[[0m Started development server: http://localhost:39783
^[[0;30mdev|^[[0m ^[[32mBundled page in 206ms^[[0m^[[2m:^[[0m routes/index.ts ^[[2m+ 1 more^[[0m
^[[0;30mdev|^[[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
^[[0;30mdev|^[[0m 
^[[0;30mdev|^[[0m Module "routes/index.ts" is a root module that does not self-accept.
^[[0;30mdev|^[[0m ^[[32mReloaded in 76ms^[[0m^[[2m:^[[0m routes/index.ts
^[[0;30mdev|^[[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
^[[0;30mdev|^[[0m 
^[[0;30mdev|^[[0m Module "state.ts" is not accepted by routes/index.ts,
^[[0;30mdev|^[[0m ^[[36m[x2]^[[0m ^[[32mReloaded in
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (da3851e57)

test/bake/dev/esm.test.ts:
Dev server testing directory: /tmp/bun-dev-test-ku6N4H
^[[0;30mdev|^[[0m Started development server: http://localhost:37345
^[[0;30mdev|^[[0m ^[[32mBundled page in 3ms^[[0m^[[2m:^[[0m routes/index.ts ^[[2m+ 1 more^[[0m
^[[0;30mdev|^[[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
^[[0;30mdev|^[[0m 
^[[0;30mdev|^[[0m Module "routes/index.ts" is a root module that does not self-accept.
^[[0;30mdev|^[[0m ^[[32mReloaded in 2ms^[[0m^[[2m:^[[0m routes/index.ts
^[[0;30mdev|^[[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
^[[0;30mdev|^[[0m 
^[[0;30mdev|^[[0m Module "state.ts" is not accepted by routes/index.ts,
^[[0;30mdev|^[[0m ^[[36m[x2]^[[0m ^[[32mReloaded in 2ms^[[0m^[[2m:^[[0m state.ts
(pass)  DEV:esm-1: live bindings with `var` [214.09ms]
^[[0;30mdev|^[[0m Started development server: http://localhost:41789
^[[0;
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bake/dev/esm.test.ts
bun test v1.4.0 (973255421)

test/bake/dev/esm.test.ts:
Dev server testing directory: /tmp/bun-dev-test-0GMetC
^[[0;30mdev|^[[0m Started development server: http://localhost:34057
^[[0;30mdev|^[[0m ^[[32mBundled page in 172ms^[[0m^[[2m:^[[0m routes/index.ts ^[[2m+ 1 more^[[0m
^[[0;30mdev|^[[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
^[[0;30mdev|^[[0m 
^[[0;30mdev|^[[0m Module "routes/index.ts" is a root module that does not self-accept.
^[[0;30mdev|^[[0m ^[[32mReloaded in 78ms^[[0m^[[2m:^[[0m routes/index.ts
^[[0;30mdev|^[[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
^[[0;30mdev|^[[0m 
^[[0;30mdev|^[[0m Module "state.ts" is not accepted by routes/index.ts,
^[[0;30mdev|^[[0m ^[[36m[x2]^[[0m ^[[32mReloaded in
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     9732554217
  features     baseline

22 deps, 107 codegen, 1176 objects in 1350ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] install /workspace/bun
bun install v1.4.0-canary.1 (da3851e57)

Checked 107 installs across 153 packages (no changes) [13.00ms]
[2/1238] gen ErrorCode+*.h
[3/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (da3851e57)

Checked 1 install across 2 packages (no changes) [1.00ms]
[4/1238] gen bindgenv2
[5/1238] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (da3851e57)

Checked 129 installs across 147 packages (no changes) [7.00ms]
[6/1238] fetch tinycc
[tinycc] up to date
[7/1237] fetch picohttpparser
[picohttpparser] up to date
[8/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[9/1237] gen .bind.ts → GeneratedBindings.cpp
[10/1237] fetch libjpeg-turbo
[libjp
... (truncated)
diff hotspot
src/runtime/bake/hmr-module.ts |  6 +--
 test/bake/dev/esm.test.ts      | 96 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 99 insertions(+), 3 deletions(-)

gate history · 1 passed · 1 rejected · iteration 2

evidence per changed file
file                            reads  edits  tests
src/runtime/bake/hmr-module.ts      3      3      0
test/bake/dev/esm.test.ts           2      4      0

root cause · written by the author bot

In the Bake dev server every dynamic import() is lowered to HMRModule.dynamicImport, which invoked the module loader directly and let its synchronous throws (a module body or CommonJS module throwing during evaluation, a missing or failing dependency, a previously recorded failure, or a namespace conversion error) escape into the caller, whereas native import() and bun build output always return a rejected promise. The fix makes dynamicImport an async method, so any failure raised on these paths becomes a rejection of the returned promise and patterns such as import().catch() and Promise.al…

Original description

What does this PR do?

In the dev server (bun ./index.html, or a Bake app in development), a dynamic import() of a module that fails to evaluate throws synchronously into the caller instead of returning a rejected promise:

// throws.ts
export const value = 1;
throw new Error("boom");

// routes/index.ts, served by the dev server
import("../throws").catch(handle);        // .catch never runs: the call itself throws "boom"
Promise.all([import("../a"), import("../throws")]); // same, Promise.all is never reached

The dev server lowers every import() to hmr.dynamicImport(id) (InternalBakeDev branches in src/js_printer/lib.rs). HMRModule.dynamicImport in src/runtime/bake/hmr-module.ts calls loadModuleAsync directly, and the loader throws synchronously in several cases:

  • the module is ESM without top-level-await dependencies and its body throws (finishLoadModuleAsync rethrows),
  • the module is CommonJS and its body throws,
  • a statically imported dependency throws while being loaded (parseEsmDependencies),
  • the module already failed earlier and is in the error state (throw mod.failure), which also applies to modules whose top-level-await dependency rejected on a previous import,
  • the module loads but converting its CommonJS exports to a namespace throws (getEsmExports in the already-loaded branch).

A native import() never throws synchronously, and neither does bun build output (Promise.resolve().then(() => (init_x(), exports_x)), which rejects), which is the behavior the dev runtime is documented to follow (see the header comment of hmr-module.ts). So import(x).catch(...), Promise.all([...]), Promise.allSettled([...]) and similar patterns that work in production break in development.

The fix marks dynamicImport as async, so any of the throws above becomes a rejection of the returned promise. Nothing else moves: the loader is still invoked synchronously when import() is called (an async function runs synchronously up to its first await, and this one has none), so importer tracking and module evaluation order are unchanged; the already-loaded branch returns the namespace directly instead of wrapping it in Promise.resolve; the promise-returning branches are adopted by the async function's promise. The fallback to the runtime's own import() for modules the dev server did not bundle is also unchanged (the missing return in its with-options variant is a separate bug, fixed in #37727; the two changes touch different lines and compose).

hmr-module.ts is shared by the client and server runtimes, so both are fixed.

How did you verify your code works?

Added a dev server test to test/bake/dev/esm.test.ts that calls import() on each failure shape listed above (ESM body throws, the same module again, CJS body throws, the same module again, a dependency throws, a top-level-await dependency rejects and the same module again, and a CJS module whose exports cannot be converted), plus two that already worked and must keep working (a module that loads fine, imported twice, and a builtin that falls through to the runtime's import()). Each call is checked to return a promise and to settle with the module's error. The imports run in a route module, so the test exercises the server runtime; the client runtime is built from the same hmr-module.ts source. Note that "the same module again" means different things for the two module kinds: an ESM failure is recorded and rethrown, while a failed CJS module is marked stale and evaluated again (which throws again); the test comments say which is which.

Without the fix (USE_SYSTEM_BUN=1 bun test test/bake/dev/esm.test.ts, and likewise with src/ stashed):

-   "cjs": "rejected: boom cjs",
-   "cjsAgain": "rejected: boom cjs",
-   "cjsProxy": "rejected: boom ownKeys",
-   "dep": "rejected: boom dep",
-   "esm": "rejected: boom esm",
-   "esmAgain": "rejected: boom esm",
+   "cjs": "threw synchronously: boom cjs",
+   "cjsAgain": "threw synchronously: boom cjs",
+   "cjsProxy": "threw synchronously: boom ownKeys",
+   "dep": "threw synchronously: boom dep",
+   "esm": "threw synchronously: boom esm",
+   "esmAgain": "threw synchronously: boom esm",
-   "tlaAgain": "rejected: boom tla",
+   "tlaAgain": "threw synchronously: boom tla",

With the fix, bun bd test test/bake/dev/esm.test.ts passes (18 tests). test/bake/dev/bundle.test.ts and test/bake/dev/react-spa.test.ts, which import() bundled modules on the client side (the success path of the changed method in the client runtime), and test/bake/dev/hot.test.ts, as a general hot-reload regression check, also pass.

While writing the test I noticed a separate loader bookkeeping issue: a module whose dependency throws synchronously (or whose own top-level await rejects) is left in the pending state, so a second import() of it resolves to null. That is independent of this change and is tracked separately; the new test imports those two shapes only once.

… evaluate in the dev server

The dev server lowers import() to HMRModule.dynamicImport, which called
loadModuleAsync without a try/catch. The loader throws synchronously when
the target module (or one of its synchronously loaded dependencies) throws
while evaluating, when the module already failed earlier, or when
converting CommonJS exports into a namespace fails, so import() threw into
the caller instead of returning a rejected promise like a native import()
or bun build output does.

Mark dynamicImport async so every failure surfaces as a rejection of the
returned promise. The loader is still invoked synchronously, so importer
tracking and evaluation order are unchanged.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d6af0607-4a90-4a43-955c-401c88478500

📥 Commits

Reviewing files that changed from the base of the PR and between 423efb5 and 9732554.

📒 Files selected for processing (2)
  • src/runtime/bake/hmr-module.ts
  • test/bake/dev/esm.test.ts

Walkthrough

HMRModule.dynamicImport is now asynchronous. Development ESM tests verify promise resolution for successful imports and promise rejection for module evaluation, dependency, top-level-await, namespace conversion, and CommonJS failures.

Changes

Dynamic import promise handling

Layer / File(s) Summary
Promise semantics and validation
src/runtime/bake/hmr-module.ts, test/bake/dev/esm.test.ts
HMRModule.dynamicImport now uses async promise handling. Development ESM tests cover successful imports, repeated imports, builtin modules, and failure paths.

Possibly related PRs

  • oven-sh/bun#37765: Both PRs modify HMRModule and development ESM tests, but address different dynamic import behaviors.
  • oven-sh/bun#37779: Both PRs modify src/runtime/bake/hmr-module.ts and development ESM tests for top-level-await failure behavior.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary fix: rejected promises for failed dynamic imports in the dev server.
Description check ✅ Passed The description includes the required sections and provides detailed problem, fix, verification, background, and scope information.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced with the new test in test/bake/dev/esm.test.ts against the released binary (USE_SYSTEM_BUN=1 bun test test/bake/dev/esm.test.ts: the new test is the only one of the 18 that fails; seven of its probed import() calls throw synchronously instead of rejecting, diff in the PR description). With this branch all 18 pass.
  • Fix is the async on HMRModule.dynamicImport (a0aa3b8); see the description for why that is the right layer. The later commits only touch comments (6318f78 shortens the doc comment, a6537c6 corrects the test comment about the CommonJS case) or are empty reruns. Self-review found nothing further.
  • Related, not part of this PR: bake: return the promise from import() with options in the dev server runtime #37727 fixes the missing return in the with-options fallback (next line, composes with this change). bake: evaluate failed modules again when a module they import is hot-updated #37765 makes failed modules recover on hot update (different code). bake: do not rewrite a recorded module failure when require() hits top-level await #37779 fixes the require() top-level-await error message (different method); it adds its test at the same spot in esm.test.ts as this PR, so whichever of the two lands second needs a trivial rebase that keeps both tests. The loader also leaves a module in the pending state when a dependency throws synchronously or its own top-level await rejects, so a second import() of it resolves to null; that is being handled separately.
  • CI: builds 92802, 93034 and 93145 (the last one is the current content, a6537c6) passed every test lane; the only red in them was the darwin 26 aarch64 test lane, whose jobs expired waiting for an agent each time (the lane passes on main builds, so it is agent capacity, not this change), plus a few unrelated flaky tests that passed on retry. Build 93519, for the empty rerun commit, failed in the build stage on many lanes because the agents could not download vendored dependencies (lolhtml, c-ares, mimalloc, WebKit: "Failed to download after 5 attempts"), which is unrelated to the diff; on the lanes that did build, the only red test was test/cli/install/bun-install.test.ts, whose GitHub-tarball cases got a 503 from github.com during the same outage (reported separately, since those cases fetch from the live site). The diff is ready from my side.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — adding async to dynamicImport is the minimal correct fix for matching native import() rejection semantics.

What was reviewed:

  • Confirmed the only callers are the printer's hmr.dynamicImport(...) lowering (js_printer/lib.rs:2648, :3305), which already expects a promise; the .then(toESM) chaining it may append still composes.
  • Checked evaluation order is preserved: the body has no await, so loadModuleAsync and importer tracking still run synchronously at the import() call site.
  • Verified the removed Promise.resolve() wrapper is redundant under async, and that the not-bundled fallback and .then(getEsmExports) branches are correctly adopted by the async function's promise.
  • Test covers each throw path enumerated in the description plus the success/cached and builtin-fallback cases with exact error messages.
Extended reasoning...

Overview

The PR changes HMRModule.dynamicImport in src/runtime/bake/hmr-module.ts from a plain method to an async method, so that synchronous throws from loadModuleAsync / getEsmExports become promise rejections. It also drops the now-redundant Promise.resolve() wrapper on the already-loaded branch and adds a short comment explaining why the method is async. A new dev-server test in test/bake/dev/esm.test.ts exercises every failure shape (ESM body throw, CJS body throw, cached failure, dependency throw, TLA-dependency rejection, CJS-namespace conversion throw) plus the success and builtin-fallback paths, asserting each returns a promise and settles with the expected value.

Security risks

None. This is dev-server-only module-loading glue; no auth, crypto, or untrusted-input parsing is involved. The change only affects whether an already-thrown error surfaces synchronously or as a rejection.

Level of scrutiny

Low-to-medium. The source change is a one-keyword addition plus a mechanical Promise.resolve unwrap. The semantics of async (synchronous body execution up to the first await, throws → rejections, returned thenables adopted) directly match the desired behavior, so the fix is correct by construction. I checked the two printer emit sites in src/js_printer/lib.rs — both substitute hmr.dynamicImport(...) for import(...) and may append .then(...), all of which compose unchanged with an async method. No other callers exist.

Other factors

The test is thorough and follows the existing devTest/minimalFramework pattern in the file, asserts exact error messages via a single toEqual on the full result object, and includes positive controls (ok/okAgain/builtin) so regressions in the happy path would also fail. The PR description documents the USE_SYSTEM_BUN=1 failure diff and passing suites. The unrelated pending-state loader bookkeeping issue is called out and intentionally scoped out. No CODEOWNERS entry covers src/runtime/bake/. No prior human review comments to address.

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:10 PM PT - Aug 12th, 2026

❌ @robobun, your commit 9732554 has 3 failures in Build #93519 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37734

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

bun-37734 --bun

Comment thread src/runtime/bake/hmr-module.ts Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — adding async to dynamicImport is the minimal correct fix at the right layer, and the test covers every synchronous-throw path in the loader.

What was reviewed:

  • Traced each throw site in loadModuleAsync/finishLoadModuleAsync/getEsmExports against the test's probes; all are covered and assert the exact error message.
  • Confirmed the only callers are the two .dynamicImport( lowerings in js_printer/lib.rs, which already expect a promise; no caller depends on a synchronous throw.
  • Checked that evaluation order and importer tracking are preserved (no await before loadModuleAsync, so it still runs synchronously); the dropped Promise.resolve is redundant under async.
  • The comment-cop feedback was addressed in 6318f78 and the thread is resolved.
Extended reasoning...

Overview

The PR changes two lines in src/runtime/bake/hmr-module.ts: it marks HMRModule.dynamicImport as async and drops the now-redundant Promise.resolve() wrapper on the already-loaded branch. It adds a ~90-line dev-server test in test/bake/dev/esm.test.ts that probes eleven import() shapes (ESM/CJS body throws, cached failure, dependency throw, TLA rejection and its cached failure, CJS-namespace-conversion throw, plus successful and builtin cases) and asserts each returns a promise that settles with the module's specific error.

Security risks

None. This is dev-server-only bundled runtime code that changes how a load failure surfaces (rejection vs. synchronous throw); no new inputs are accepted and no security-sensitive path is touched.

Level of scrutiny

Low-to-medium. The source change is a two-line semantic adjustment whose effect is fully described by JS async semantics: any throw inside the body becomes a rejection, and returned promises are adopted. The function body has no await, so loadModuleAsync still executes synchronously at the import() call site — importer-set registration and module evaluation order are unchanged. I grepped for all callers of dynamicImport: the only ones on this method are the two InternalBakeDev lowerings in src/js_printer/lib.rs, which emit it in place of import() and therefore already treat the result as a promise. loadExports (the other loadModuleAsync consumer in this file) is already async, so it was never affected.

Other factors

The test is thorough and follows the harness conventions in the file (uses minimalFramework, devTest, exact-object toEqual with specific error messages, and includes negative controls that must keep working). The PR description documents that it fails on the released binary and passes on the debug build, and that the client-side import() suites (bundle.test.ts, hot.test.ts, react-spa.test.ts) still pass. The one piece of review feedback (comment-cop on doc-comment length) was addressed in 6318f78 and resolved. The pre-existing missing return in the with-options fallback and the pending-state bookkeeping issue are correctly identified as separate and out of scope.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — adding async to dynamicImport is the minimal correct fix for making lowered import() reject instead of throw.

What was reviewed:

  • Confirmed the only callers are the printer's lowered import() emits (src/js_printer/lib.rs:2648, :3305), which already expect a promise; no site relied on the synchronous throw.
  • Checked that loadModuleAsync still runs synchronously (no await before it), so importer tracking and evaluation order are unchanged; the removed Promise.resolve wrapper is redundant under async.
  • Test covers each enumerated throw path plus the already-working success/builtin cases, asserts exact rejection messages, and was verified to fail on the released binary.
Extended reasoning...

Overview

The PR changes two files: src/runtime/bake/hmr-module.ts adds the async keyword to HMRModule.dynamicImport (and drops a now-redundant Promise.resolve wrapper), and test/bake/dev/esm.test.ts adds a dev-server test exercising every failure shape of a dynamically imported module. The production code change is effectively one keyword.

Security risks

None. This is dev-server-only runtime glue (hmr-module.ts, shared client/server HMR loader). No auth, crypto, filesystem, or network parsing is touched; the change only alters whether a thrown error surfaces as a synchronous exception or a rejected promise from the lowered import() call.

Level of scrutiny

Low-to-moderate. The mechanism is well understood: an async function with no await executes its body synchronously and converts any throw into a rejection of its returned promise. I grepped for all consumers of dynamicImport — the only ones are the two InternalBakeDev printer sites in src/js_printer/lib.rs that emit hmr.dynamicImport(...) in place of import(...), and callers of import() already treat the result as a promise. The already-loaded branch previously returned Promise.resolve(getEsmExports(...)) and now returns getEsmExports(...) directly, which async re-wraps identically; the .then(getEsmExports) and native-import() fallback branches are adopted by the async promise unchanged.

Other factors

The test is comprehensive (ESM throw, cached failure, CJS throw, dependency throw, TLA rejection + cached, CJS-namespace-conversion throw, plus positive controls for a good module and a builtin fallthrough), asserts exact messages via toEqual on the full result object, and the PR description shows the USE_SYSTEM_BUN=1 failure diff. The comment-cop feedback about doc-comment length was addressed in 6318f78 and the thread is resolved. The unrelated pre-existing issues (missing return in the with-options fallback, pending-state bookkeeping) are correctly noted as out of scope and tracked separately. First CI build was green on all test lanes.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — adding async to dynamicImport is the minimal correct fix for matching native import() rejection semantics.

What was reviewed:

  • Confirmed loadModuleAsync still runs synchronously (no await before it), so importer tracking and evaluation order are unchanged.
  • Checked the printer call sites in src/js_printer/lib.rs — the lowered .dynamicImport(...) is only ever used as a promise expression, so no caller depends on synchronous throw.
  • The dropped Promise.resolve() wrapper is equivalent under async; if getEsmExports throws (the cjsProxy case) it now correctly rejects.
  • Test covers each throw path enumerated in the description plus the success and builtin-fallthrough paths, with exact error messages.
Extended reasoning...

Overview

This PR changes HMRModule.dynamicImport in src/runtime/bake/hmr-module.ts from a plain method to an async method, and drops the now-redundant Promise.resolve() wrapper on the already-loaded branch. It adds a dev-server test in test/bake/dev/esm.test.ts that probes eleven import() shapes and asserts each returns a promise that settles with the expected value or error.

Security risks

None. This is a dev-server-only module loader (src/runtime/bake/), and the change only alters whether an existing throw surfaces synchronously or as a promise rejection. No new inputs are parsed, no auth/crypto/permissions code is touched.

Level of scrutiny

Low-to-moderate. The source change is effectively one keyword. The subtlety — whether making the function async shifts evaluation timing — is addressed: async functions execute synchronously to the first await, and there is none, so loadModuleAsync(id, true, this) still runs at call time and importers.add(importer) still fires before the caller sees a promise. I checked the two printer call sites (src/js_printer/lib.rs:2648, :3305) that emit .dynamicImport( — both replace an import() expression, whose consumers already expect a promise, so nothing depends on the old synchronous-throw behavior. The .then(getEsmExports) and native-import(id) fallthrough branches already returned promises and are adopted unchanged by the async wrapper.

Other factors

The comment-cop bot's feedback (long doc comment) was addressed in 6318f78 and the thread is resolved; there are no outstanding human review comments. The test is thorough: it distinguishes "threw synchronously" from "rejected" from "returned a non-promise", asserts exact error messages, and covers the cached-failure re-import paths for both ESM (recorded) and CJS (re-evaluated). The PR description documents that the test fails on the released binary and passes on this branch, and that adjacent dev-server suites (bundle.test.ts, react-spa.test.ts, hot.test.ts) still pass. No CODEOWNERS entry covers src/runtime/bake/ or test/bake/. The separately-noted pending-state bookkeeping issue and the missing return in the with-options fallback are pre-existing and explicitly out of scope.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #39488, which consolidates the open Bake PRs from this period into one branch. It carries this PR's change and its test, reworked where needed (see the commit in #39488 that names this PR). Closing this one in its favor.

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.

1 participant