Skip to content

bundler: emit __promiseAll helper when an unwrapped file awaits multiple async ESM wrappers - #36189

Closed
robobun wants to merge 2 commits into
mainfrom
farm/d3c4ee74/bundler-promiseall-unwrapped-entry
Closed

robobun wants to merge 2 commits into
mainfrom
farm/d3c4ee74/bundler-promiseall-unwrapped-entry

Conversation

@robobun

@robobun robobun commented Jul 28, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes bun build producing a bundle that references the __promiseAll runtime helper without defining it, so the build exits 0 but the artifact throws ReferenceError: __promiseAll is not defined on first evaluation.

Repro

// a.mjs
export const a = await Promise.resolve("A");
// b.mjs
export const b = await Promise.resolve("B");
// entry.mjs
import { a } from "./a.mjs";
import { b } from "./b.mjs";
console.log("got", a, b);
console.log("dyn", Object.keys(await import("./a.mjs")).join(","), Object.keys(await import("./b.mjs")).join(","));
$ bun build entry.mjs --outdir dist && bun dist/entry.js
38 | await __promiseAll([
           ^
ReferenceError: __promiseAll is not defined

Reproduces on any target, with or without --minify, via CLI and Bun.build (success: true, empty logs). --splitting is unaffected. esbuild 0.28.1 builds and runs the same input correctly.

Cause

The helper exists but is never marked used at the failing emission site:

  • The helper is defined at src/runtime.js:328 and resolved to promise_all_runtime_ref at src/bundler/LinkerContext.rs:546-547.
  • should_remove_import_export_stmt (LinkerContext.rs:2073, call at :2192-2195) hands each static import of an async ESM-wrapped module to InsideWrapperPrefix::append_async_dependency (:4300), which on the second async dependency rewrites await init_a() into await __promiseAll([init_a(), init_b()]) (:4354-4400). This runs for every importer, wrapped or not.
  • The only place promise_all_runtime_ref is marked used is create_wrapper_for_file (:3120) inside the WrapKind::Esm arm (count at :3239-3260, generate_symbol_import_and_use at :3328-3339), i.e. only when the importer is itself lazily wrapped. WrapKind::None => {} (:3342) does nothing, so an unwrapped importer (the entry, or a statically linked intermediate) references the helper but never pulls its runtime part in, and tree-shaking drops the definition.

Fix

In step 6 of scan_imports_and_exports, where each part's import records are already scanned to count __toESM / __toCommonJS / __require / __reExport uses, also count static imports of async ESM-wrapped modules across the file's parts. When the second such import is seen, generate a __promiseAll runtime symbol use on that part so the helper survives tree-shaking. A single async wrapped import still emits a bare await init_x() and does not pull the helper in.

Trigger / control matrix

Fires when an unwrapped module statically imports two or more modules that are both top-level-await (transitively async) and lazily ESM-wrapped (because they are also reached by import() somewhere).

  • Triggers (failed before, pass now): 2 TLA modules static+dynamic at the entry; 3 TLA modules; unwrapped non-entry intermediate importing 2 TLA modules.
  • Controls (unchanged): 1 async wrapped dep still emits a bare await init_a() with no __promiseAll; the existing wrapped-importer cases in this file keep passing.

Relationship to #33337

#33337 contains the same step-6 counting as part of a larger change that also reworks InsideWrapperPrefix to preserve import order and removes the create_wrapper_for_file counting. This PR is the minimal __promiseAll fix on its own; if #33337 lands first this one is redundant.

How did you verify your code works?

New tests in test/bundler/bundler_promiseall_deadcode.test.ts cover the three trigger shapes and the single-dependency control, and each runs the bundled output as well as asserting on its contents. The three existing __promiseAll tests in that file and bundler_edgecase.test.ts / bundler_splitting.test.ts / bundler_regressions.test.ts continue to pass.

…ple async ESM wrappers

When an entry (or any unwrapped file) statically imports two or more
top-level-await modules that are also dynamically imported, the linker
emits `await __promiseAll([init_a(), init_b()])` at the top level of the
chunk. The `__promiseAll` runtime helper was only marked as used by
`create_wrapper_for_file`, which runs for wrapped files and hits
`WrapKind::None => {}` for the file that actually emits the call, so
tree-shaking dropped the helper's definition and the bundle threw
`ReferenceError: __promiseAll is not defined` at load time.

Count static imports of async ESM-wrapped modules per file in step 6 of
`scan_imports_and_exports` (alongside the existing `__toESM` /
`__toCommonJS` / `__reExport` tracking) and pull in `__promiseAll`
from the part that observes the second such import.
@coderabbitai

coderabbitai Bot commented Jul 28, 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: 4e8d0758-2ecb-450a-a8a1-bd34e82971d8

📥 Commits

Reviewing files that changed from the base of the PR and between 0341a4e and e01a1d5.

📒 Files selected for processing (2)
  • src/bundler/linker_context/scanImportsAndExports.rs
  • test/bundler/bundler_promiseall_deadcode.test.ts

Walkthrough

Changes

The linker now counts async wrapped-ESM initializations and conditionally emits __promiseAll. New bundler tests cover two or three async wrappers, non-entry initialization, and the single-wrapper direct-init path.

Async ESM initialization

Layer / File(s) Summary
Track async initialization and emit __promiseAll
src/bundler/linker_context/scanImportsAndExports.rs
Counts relevant async ESM dependencies, enables promise_all_uses for multiple initializations, and emits the runtime helper dependency.
Validate conditional helper emission
test/bundler/bundler_promiseall_deadcode.test.ts
Tests helper emission for multiple wrappers and direct initialization without the helper for one wrapper.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: emitting __promiseAll for unwrapped files with multiple async ESM wrappers.
Description check ✅ Passed The description follows the template with a clear purpose section and a verification section, plus helpful repro and fix details.
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.

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

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. bundler: keep wrapped ESM dependencies in their import position #33337 - Also fixes __promiseAll not being emitted for unwrapped files that import multiple async ESM-wrapped modules, with the same root cause (symbol only marked live in create_wrapper_for_file's WrapKind::Esm arm) and same fix location (scan_imports_and_exports)

🤖 Generated with Claude Code

@robobun

robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #33337, which already carries the same scan_imports_and_exports change for the __promiseAll helper plus the import-order fix. Closing in favour of that one.

@robobun robobun closed this Jul 28, 2026

@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 — targeted fix that mirrors the existing __toESM/__toCommonJS/__require/__reExport counting pattern in step 6.

What was reviewed:

  • Condition matches the codegen path in should_remove_import_export_stmt → append_async_dependency (Stmt kind, WrapKind::Esm, valid wrapper_ref, is_async_or_has_async_dependency).
  • is_async_or_has_async_dependency and wrap are both finalized before step 6 runs (propagate_async_dependencies → scan_imports_and_exports).
  • generate_runtime_symbol_import_and_use early-returns on count == 0, so the extra call is free on the common path.
  • Cross-part counter can only over-mark (harmless extra bytes), never under-mark; the single-import negative test guards the common case.
Extended reasoning...

Overview

Fixes ReferenceError: __promiseAll is not defined in bundled output when an unwrapped file (WrapKind::None) statically imports two or more async ESM-wrapped modules. Codegen coalesces the resulting await init_*() calls into await __promiseAll([...]), but the runtime helper dependency was only registered from create_wrapper_for_file, which is a no-op for WrapKind::None. The fix adds a per-file counter in step 6 of scan_imports_and_exports and calls generate_runtime_symbol_import_and_use(b"__promiseAll", ...) on the part where the counter reaches 2 — exactly the pattern already used for __toESM, __toCommonJS, __require, and __reExport in the same loop.

Correctness

I traced the ordering: validate_tla + propagate_async_dependencies run at LinkerContext.rs:819-832 before scan_imports_and_exports at :835, and wrap is set in steps 1-2, so by step 6 both other_flags.wrap and other_flags.is_async_or_has_async_dependency are final. The gate (kind == ImportKind::Stmt && wrap == WrapKind::Esm && is_async && wrapper_ref.is_valid()) matches the exact codegen path at LinkerContext.rs:2165-2200 that emits the __promiseAll reference. generate_runtime_symbol_import_and_use early-returns on count == 0 (LinkerGraph.rs:563), so the extra call adds no work in the vast majority of parts.

The counter is per-file and cumulative across parts, while promise_all_uses is per-part. If the same import record appears in multiple parts, or if the two async imports live in different parts and one is later tree-shaken, the worst outcome is that __promiseAll is included when only one await init_*() survives — a ~30-byte code-size no-op, never a runtime error. I could not construct a case where two live async init_* calls are emitted without the mark landing on at least one live part.

Security risks

None. This only affects which runtime helper stubs are copied into bundler output.

Level of scrutiny

Bundler linker is a hot/critical path, but the change is ~15 lines that slot into a well-established local pattern with four existing precedents in the same block. The risk surface is narrow: either the helper is still missing (caught by the four new run: tests, which execute the bundle) or it is over-included (harmless).

Other factors

Four new itBundled tests all execute the bundled output and assert exact stdout — the strongest possible check for this bug class. The negative test (single async import → not.toContain("__promiseAll")) guards against regressing tree-shaking. Existing __promiseAll tests in the same file are untouched.

@robobun robobun reopened this Jul 28, 2026
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs
@robobun

robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Closed as duplicate of #33337, which carries the same scan_imports_and_exports fix for __promiseAll plus the import-order change and wider test coverage.

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