Conversation
… await every async one The statements a wrapper runs before its body (init_x() calls, require_x() calls, __reExport calls) were grouped by kind: sync init calls first, then one await of the async ones, then everything else. An export * of a wrapped module was never awaited. Both reorder module evaluation against the module graph. They now stay in source order. Once an async dependency is seen, every later dependency joins that statement as await __promiseAll([init_a(), init_b(), ns = require_c()]), so each one starts before the wrapper suspends and the body runs after all settle. import() of an async module in the same bundle printed init_a().then(...). Called from inside a's own first synchronous segment, init_a() returns undefined and the .then throws. It now defers the call to a microtask, like import() of a sync module already did. A module that imports an async module through an import cycle could miss the async flag: the depth-first walk read a dependency's flag while that dependency was still on the stack. The propagation is now a worklist over reverse edges, so it reaches a fixpoint. __promiseAll is marked live from the parts that hold the import statements that join it, so an unwrapped entry point gets the helper too.
|
Warning Review limit reached
On-demand reviews are free for the next 14 days. After that, they cost $0.25 per reviewed file. Or wait 3 minutes for your next included review. View limit detailsLimit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (11)
Comment |
|
Updated 5:42 PM PT - Sep 6th, 2026
❌ @robobun, your commit 184b4e0 has 6 failures in
The baseline build contains instructions not available on Static scan violations
|
…der change The bundle of libraries.js now calls each module's dependencies in source order. The same js and jsc hashes were produced on every CI platform.
… export star The re-exporter appends __reExport(exports, exports_r) right after the init_r() of the same record, so it joins the awaited list even though no later record exists.
…argets before deferring Emit await Promise.all([...]) through an unbound Promise symbol instead of the __promiseAll runtime helper. The helper needed a per-part liveness count that mirrored the prefix's join rule, which broke twice; an unbound symbol is reserved by the renamer and needs no bookkeeping. __promiseAll leaves runtime.js and the runtime imports table. import() of an async module calls init_foo() first and only defers when the call returns undefined, which happens inside foo's own first synchronous segment. This keeps a top-level await import() whose target imports the awaiter back evaluating at once, the same self-deadlock skip the runtime applies, while the re-entrant case resolves after foo settles instead of throwing on undefined.then.
|
Status Reproduced all five reported divergences with The red jobs at 184b4e0 are not touched by this diff: |
…se.all never awaits it A CommonJS module's exports, or the namespace object __reExport returns, can be a thenable. As a bare element of Promise.all([...]) it would be awaited. void (...) keeps the evaluation order and drops the value.
|
One more case this branch fixes, found independently. It is the plain synchronous form of the first Problem bullet: no top-level await anywhere, one CommonJS import followed by one import of an // entry.js
import "./legacy.js"; // touches `module`, so it is __commonJS-wrapped
import "./a.js"; // also import()ed below, so it is __esm-wrapped
console.log("entry");
import("./a.js");
// legacy.js
if (typeof module !== "undefined") module.exports = 1;
console.log("legacy");
// a.js
console.log("a");
export const A = 1;
None of the nine new itBundled("edgecase/WrappedCommonJSDependencyKeepsImportOrder", {
files: {
"/entry.js": `
import "./legacy.js";
import "./a.js";
console.log("entry");
import("./a.js");
`,
"/legacy.js": `if (typeof module !== "undefined") module.exports = 1; console.log("legacy");`,
"/a.js": `console.log("a"); export const A = 1;`,
},
run: { stdout: "legacy\na\nentry" },
}); |
…or a top-level using (#42981) ### Problem - `bun build` without `--splitting` prints the missing wrapper of a module as `__INVALID__REF__`. Two inputs reach it on 1.4.2 and main (`b8eacead4`). - A dead top-level await: `export function f() {}` plus `false && await 0;`. `import()` of it prints `await __INVALID__REF__()`: `ReferenceError: __INVALID__REF__ is not defined`. The parser records the `await` before it folds away, so the linker marks the module async. `require_or_import_meta_for_source` (`src/bundler/LinkerContext.rs:2528`) reports an async wrapper that does not exist. - A top-level `using` with `--target=bun`: `await using a = null;`. The linker keeps it inside the wrapper (`generateCodeForFileInChunkJS.rs:755`, #29538). `needs_wrapper_ref` (`src/js_parser/p.rs:9580`) misses that rule. Output: `var __INVALID__REF__ = __esm(...)`, then `ReferenceError: __esm is not defined`. ### Fix - `is_wrapper_async` is true only when the module has a wrapper. Without one, `import()` prints as for a sync module: `Promise.resolve()`, plus `.then(() => exports_t)` if code reads the namespace. - `needs_wrapper_ref` returns true for a `using` declaration. - Correct because a module with no wrapper has no import statement and nothing that stays inside a wrapper. - Verified: `dynamic_import_dce/ElideDeadTopLevelAwaitInImportee` and `edgecase/UsingWithHoistableInitializerKeepsWrapper` fail on canary `c6b7fcb5b`. Suites in Notes. ### Background - Without `--splitting`, a module that `import()` or `require()` reaches is wrapped: `var init_t = __esm(() => {...})`. `import()` prints `Promise.resolve().then(() => (init_t(), exports_t))`, or `init_t().then(...)` for an async module. - Function declarations and constant initializers hoist out of the wrapper. `needs_wrapper_ref` decides if anything stays inside. If nothing does, `wrapper_ref` is `Ref::NONE`. - The printer resolves `Ref::NONE` to symbol 0 of `runtime.js`, named `__INVALID__REF__`. <details><summary>Notes</summary> **Repro 1, dead top-level await.** One entry point is enough. ```sh printf 'const { f } = await import("./f3.ts");\nf();\nconsole.log("done");\n' > entry1.ts printf 'export function f() {}\nfalse && await 0;\n' > f3.ts bun build ./entry1.ts --outdir out --target=bun && bun out/entry1.js # out/entry1.js: await __INVALID__REF__(); # ReferenceError: __INVALID__REF__ is not defined ``` Every form of the call site printed the missing wrapper: | source | before | after | | --- | --- | --- | | `const { f } = await import("./f3.ts");` (every name bound to an export) | `await __INVALID__REF__();` | `await Promise.resolve();` | | `const ns = await import("./f3.ts");` with only `ns.x` reads | `await __INVALID__REF__().then(() => ({}));` | `await Promise.resolve().then(() => ({}));` | | `void await import("./f3.ts");` | `await __INVALID__REF__().then(() => exports_f3);` | `await Promise.resolve().then(() => exports_f3);` | | `import("./f3.ts");` | `__INVALID__REF__();` | `Promise.resolve();` | With `--minify-identifiers` the printed name is a short one: `ReferenceError: n is not defined`. **Repro 2, top-level `using`.** ```sh printf 'const { f } = await import("./f3.ts");\nf();\nconsole.log("done");\n' > entry1.ts printf 'await using x = null;\nexport function f() {}\n' > f3.ts bun build ./entry1.ts --outdir out --target=bun && bun out/entry1.js # out/entry1.js: var __INVALID__REF__ = __esm(async () => { await using x = null; }); # await __INVALID__REF__(); # ReferenceError: __esm is not defined ``` A debug build stops earlier, at `debug_assert!(!ast.wrapper_ref.is_empty())` in `generateCodeForFileInChunkJS.rs`. The sync forms fail the same way: `using x = 1;`, `using x = () => {};`, also through `require("./f3.ts")`. With an unused `import()` result the module was dropped from the bundle, so the `TypeError` of `using b = 1;` never ran. After the fix the module keeps `var init_f3 = __esm(...)`, and the bundle throws the same `TypeError` as the unbundled program. A review comment on this PR pointed at this input. **Where the check lives.** The first commit put the check in the printer. The last commit moves it to `require_or_import_meta_for_source`, which builds the `RequireOrImportMeta` that the printer reads. The printer is the only reader of `is_wrapper_async`, and it is unchanged from main. The sync `import()` and `require()` path in the printer and the static import path (`LinkerContext.rs:2329`) already check the wrapper. **Modules that keep the async path.** An importee with an import statement always has a wrapper, so `import "./tla.ts"` in the importee, or a live `await`, still prints `await init_f3()`. An unwrapped `require()` of the packages in `DEFAULT_UNWRAP_COMMONJS_PACKAGES` also adds an `S.Import` statement (`parse_entry.rs:1405`), so that importee keeps its wrapper too. I checked these forms with the debug build. **History.** A fuzz run found the first output through another door: two entry points, and an importee whose only statement is a type-only import of a module with top-level await. A namespace request from the second entry point loaded the target of the import that TypeScript dropped, so the linker marked the statement-less importee async. #42737 closed that door: 1.4.2 fails, main passes. The dead `await` still reaches the same state on main. **Not changed.** The parser keeps one top-level-await range per file and records it before dead code folds away. So a `require()` of a module whose only `await` is dead code is still a build error (`This require call is not allowed because the transitive dependency "t.js" contains a top-level await`), and a wrapped module with a dead `await` still gets an `async` wrapper. The second case is valid output. **Overlap.** #41601 rewrites the expression that the async `import()` path prints. It does not touch `require_or_import_meta_for_source`. **Suites run with the debug build (linux x64, ASAN).** `test/bundler/bundler_dynamic_import_dce.test.ts`, `test/bundler/bundler_edgecase.test.ts`, `test/bundler/esbuild/default.test.ts`, `test/bundler/bundler_browser.test.ts`, `test/js/bun/resolve/lower-using-bun-target.test.ts`, `test/regression/issue/11100.test.ts`. </details>
Problem
bun buildreorders the evaluation of a lazily wrapped module's dependencies.InsideWrapperPrefix(src/bundler/LinkerContext.rs) inserted syncinit_x()calls at a cursor, put async ones in oneawait, and appendedexport *inits,require_x()calls and__reExportcalls after both. Anexport *of an async module was never awaited: the re-exporter and the entry ran while it was suspended, and its rejection did not stop them (exit 0).import()of an async module in the same bundle printedinit_a().then(...)(src/js_printer/lib.rs). Inside a's own first synchronous segmentinit_a()returnsundefined:TypeError: undefined is not an object (evaluating 'init_a().then').propagate_async_dependencies(src/bundler/LinkerGraph.rs) read a dependency's async flag while that dependency was still on the DFS stack. A module on an import cycle stayed sync and printedawaitinside a plain arrow:error: "await" can only be used inside an "async" function, build exit 0.Fix
await init_a(). Every dependency after it joins that statement,await Promise.all([init_a(), init_b(), ns = require_c()]), withvar ns;hoisted above. Each dependency starts before the wrapper suspends, as the module graph evaluates them, and the body runs after all settle.export *of a wrapped module is the same kind of dependency asimport "path".Promiseis an unbound symbol, so the__promiseAllruntime helper and its liveness bookkeeping go away.import()of an async module prints(init_a() || Promise.resolve().then(() => init_a())).then(() => exports_a). The re-entrant call resolves after a settles instead of throwing. A first call still starts the target at once, so a top-levelawait import()whose target imports the awaiter back keeps working, the same self-deadlock skip the runtime applies (moduleLoaderImportModule: thread referrer asyncEvaluationOrder for TLA self-deadlock skip #32437).importedges, so it reaches a fixpoint around cycles.test/bundler/bundler_edgecase.test.ts(9 new cases checked againstnodeon the unbundled files, 8 fail on main, 1 guards the re-export join). Also all oftest/bundler/andtest/js/bun/resolve/dynamic-import-tla-cycle.test.ts.Background
import()s without--splittingis wrapped invar init_x = __esm(() => { ... }). The wrapper runs the module once, on first call. If the module or one of itsimports has top-level await, the arrow isasyncand returns a promise.importof another wrapped module becomesinit_dep(). The module graph evaluates dependencies in source order: an async one runs up to its firstawait, the next one starts, and the importer's body waits for all of them.Promise.alljoin so that siblings on an async cycle all start before any is awaited. esbuild instead awaits each async dependency where it stands.Notes
Ordering choice. esbuild's rule (
await init_a(); init_b();) is order-preserving but linearises:bdoes not start untilahas fully settled. The join keeps #22704's cycle behaviour and matches node's trace on the reported graphs (a start, b, a end, x body), at the cost that a sync dependency's body can run before an earlier async sibling finishes its post-await part, exactly as in the unbundled module graph. #33337 implemented the esbuild rule for the sync-ordering subset of this; this PR supersedes it (it also coversexport *,import(), the cycle propagation, and removes the helper whose liveness #33337 had to patch).import()of an async module is still started synchronously on a first call, like esbuild. Deferring it to a microtask would match node forimport('./c'); console.log(await 1)(node prints1then0, the bundle prints0then1, unchanged from main), but it turns theawait import()back-edge cycle indynamic_import_dce/NoSplitCycleThroughImporterinto a silent hang, because bun's runtime deliberately lets that cycle through (#32437,dynamic-import-tla-cycle.test.ts). That test is unchanged.Changes visible in existing tests: two inline snapshots (
await Promise.all([instead ofawait __promiseAll([, and the(init_x() || ...)form), thebundler_promiseall_deadcodeassertions now check forPromise.alland the absence of the helper, and thelibraries.jshash inbundler_bytecode_portablemoved because three wrappers in that corpus now call their dependencies in source order (same hash on every CI platform).A separate runtime divergence found while testing (a static import of an evaluating-async module reached through
import()does not wait for it; node does) is tracked separately.Suites run with the debug build: all of
test/bundler/,test/bake/dev/,test/js/bun/resolve/dynamic-import-tla-cycle.test.ts. The only local failures are 5 s timeouts of builds under the debug binary (bun-build-apibytecode sweeps,bake/dev/production), identical on main.no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bundler/bundler_bytecode_portable.test.ts