Skip to content

js_parser: hoist the declarations of a lowered top-level using out of the ESM wrapper and the REPL IIFE - #38284

Open
robobun wants to merge 5 commits into
mainfrom
farm/39c06205/using-esm-wrapper-hoist
Open

robobun wants to merge 5 commits into
mainfrom
farm/39c06205/using-esm-wrapper-hoist

Conversation

@robobun

@robobun robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build for a target that lowers using (anything but --target=bun) produces a broken bundle when a module containing a top-level using / await using is reached through import(): reading any of its exports throws ReferenceError: result is not defined from the export getter (result: () => result in the generated __export(...) call). A static import of the same module fails the same way once the module is wrapped. Reproduces with released 1.4.0; esbuild 0.25.12 has the same bug.
  • Repro (bun build --target=browser entry.mjs --outfile=out.mjs && node out.mjs):
    // entry.mjs
    const mod = await import("./lazy.mjs");
    console.log(mod.result);
    // lazy.mjs
    await using handle = { val: 42, async [Symbol.asyncDispose]() {} };
    export const result = handle.val;
  • The REPL has the same bug in a different costume: bun repl lowers using on purpose (see the comment in src/runtime/cli/repl.rs), so after using h = ...; const y = 1 on one line, y on the next line throws ReferenceError: y is not defined. Same with new Bun.Transpiler({ replMode: true }).
  • Cause: LowerUsingDeclarationsContext::finalize (src/js_parser/p.rs) runs after the module body has been visited and moves the module's top-level vars into the try block it generates. vars nested inside other statements get relocated to the top level while they are visited (maybe_relocate_vars_to_top_level), but these were top-level statements at that point, so nothing relocated them. Both consumers that hoist a module's declarations, the linker's ESM wrapper (src/bundler/linker_context/generateCodeForFileInChunkJS.rs, WrapKind::Esm) and the REPL transform (src/js_parser/repl_transforms.rs), only look at the module's top-level statements, so the vars stayed inside __esm(() => { try { var result = ... } ... }) (or the REPL's IIFE) while __export and the importers (or the next REPL line) reference them outside of it.

Fix

  • finalize now relocates the module body's vars through the same mechanism as nested vars (relocated_top_level_vars, emitted by append_part): the declarations become top-level var statements and the try block keeps only the assignments. The conversion is split out of maybe_relocate_vars_to_top_level (src/js_parser/fold.rs) into relocate_vars_to_top_level, since the existing function deliberately does nothing at module scope.
  • This restores the invariant both hoisters rely on (every top-level binding of a module is declared by a top-level statement), so the existing wrapper hoist produces var __stack, state, handle, result; before var init_lazy = __esm(...), and the REPL transform hoists var y; next to its IIFE, with no changes to either. Static imports, export getters, hoisted functions reading non-exported module variables and later REPL lines all see the same bindings. Disposal is unchanged: the try/catch/finally still runs where it did, and the vars were module-scoped before, so making the declarations explicit does not change what unwrapped output does.
  • It applies whenever finalize handles the module body itself (current_scope == module_scope), regardless of mode, like the nested-var relocation it reuses; the other finalize call sites (for-of bodies, switch statements) run in nested scopes, where the visit pass already relocated everything. Non-bundled output for a module with a top-level using therefore changes shape too (x = ... in the try plus trailing var x; statements, the shape nested vars already get), which is what the updated using top level snapshot in test/bundler/transpiler/__snapshots__/transpiler.test.js.snap shows; that output still runs and exports correctly.
  • Symbol bookkeeping is the same as for a relocated nested var: wrap_identifier_hoisting records one use per relocated binding for the assignment that now references it, and append_part re-declares it as a top-level symbol of the same part. Tree shaking is not affected either: a module with a lowered top-level using is always emitted as a single part (parse_entry.rs disables per-statement parts for it, since everything lives in one try block), that part is never removable because of the try statement, and the relocated declarations are added to that same part, so the recorded uses only point at the part they are in. The lowering's own temporaries are not affected: __stack is declared by the let that finalize emits outside the try block, and _catch / _err / _hasErr / _promise live in the catch and finally blocks, so none of them go through the relocation.
  • Verified with:
    • test/bundler/bundler_edgecase.test.ts (edgecase/LoweredTopLevel*): a module with a top-level using or await using that is imported both statically and with import(), built for browser and node. The fixture has every kind of declaration the lowering moves into the try block: exported and local const, the using itself, a destructuring pattern, a let without initializer, a function reading the local ones, and export default <expr> (which visit_stmt.rs turns into its own var when a top-level using is present). Plus the same module imported only statically (inlined, not wrapped). The four wrapped cases fail before this change with the ReferenceError above.
    • test/js/bun/repl/repl.test.ts (declarations on a line with a top-level using persist) and test/js/bun/transpiler/repl-transform.test.ts (declarations next to a top-level using are hoisted ...): both fail before this change.
    • test/bundler/bundler_npm.test.ts (npm/ReactSSR) pins the exact size and three source map positions of a minified entry point that has a top-level using; its five var x=... statements inside the try block become x=... plus one trailing var list, so the output is 1 byte smaller and the pinned positions move by the removed var prefixes (4, 8 and 16 bytes). The pinned values are updated accordingly.
    • Also passing locally: the rest of test/bundler/bundler_edgecase.test.ts, test/bundler/transpiler/transpiler.test.js, the rest of repl.test.ts / repl-transform.test.ts, test/js/bun/resolve/lower-using-bun-target.test.ts, test/regression/issue/11100.test.ts, test/js/web/explicit-resource-management.test.ts, test/bundler/bundler_browser.test.ts, test/bundler/esbuild/dce.test.ts, test/bundler/esbuild/default.test.ts. Manually checked --minify, --format=cjs / iife, --splitting, require() of the module from a CommonJS file, a CommonJS file with a top-level using, --format=internal_bake_dev output, and running Bun.Transpiler (non-bundled) output of a module with exports under bun.

Background

  • Lowering using: for targets without native support, a top-level using x = v makes the parser rewrite the whole module body into let __stack = []; try { var x = __using(__stack, v); ...rest of the module as var declarations... } catch (e) { ... } finally { __callDispose(__stack, ...) }, with imports and function declarations kept outside the try and exports collected into a trailing export { ... } clause.
  • ESM wrapper: when a module is needed lazily (import(), require() of ESM), the linker emits its body as var init_x = __esm(() => { ... }) and an exports_x object whose getters read the module's bindings. For that to work the module's top-level declarations are moved out of the closure as var a, b; with the initializers left inside as assignments.
  • REPL transform: each REPL input is turned into hoisted var declarations plus an IIFE containing the rest, so the variables become properties of the REPL's context and survive to the next input. It finds the declarations the same way, by walking the input's top-level statements.
  • Relocating vars: while visiting, the parser already turns a module-level var that sits inside a block (if (c) { var x = 1 }) into a top-level var x; plus an assignment in place, which is what lets those two passes look at top-level statements only. This change applies that same transformation to the declarations the using lowering moves into its try block.
  • Neighbouring, independent bugs in the same lowering (exported destructuring declarations losing their exports: js_parser: keep destructured exports when lowering top-level using #38279; export default function/class being dropped; class declarations staying inside the try block) are tracked separately and are not changed here.

…its ESM wrapper

Lowering a top-level `using` moves the module's top-level `var`s into a
generated try block after the statements were visited, so they were never
relocated to the top level like other nested `var`s. The linker's `__esm`
wrapper only hoists top-level statements out of the closure, so a module
with a top-level `using` that was reached through `import()` kept its
declarations inside the closure and its export getters threw a
ReferenceError.

When bundling, relocate those `var`s the same way `maybe_relocate_vars_to_top_level`
does, keeping only the assignments inside the try block.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 15 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5a0c1336-7358-48a8-908d-b35a73001148

📥 Commits

Reviewing files that changed from the base of the PR and between 18391f6 and 4d6da34.

⛔ Files ignored due to path filters (1)
  • test/bundler/transpiler/__snapshots__/transpiler.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (6)
  • src/js_parser/fold.rs
  • src/js_parser/p.rs
  • test/bundler/bundler_edgecase.test.ts
  • test/bundler/bundler_npm.test.ts
  • test/js/bun/repl/repl.test.ts
  • test/js/bun/transpiler/repl-transform.test.ts

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

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced with released 1.4.0 by bundling a module containing a top-level await using (and a plain using) that is reached through import(), for --target=browser and --target=node; the bundle throws ReferenceError: result is not defined from the export getter under both node and bun. The same lowering also loses the other declarations of a bun repl line that contains a using (ReferenceError: y is not defined on the next line), so the fix is no longer limited to bundling. Failing before / passing after: the edgecase/LoweredTopLevel*InEsmWrapper* tests in test/bundler/bundler_edgecase.test.ts, the new REPL test in test/js/bun/repl/repl.test.ts, and the new replMode test in test/js/bun/transpiler/repl-transform.test.ts. Waiting on CI.

Comment thread src/js_parser/fold.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/fold.rs Outdated
Comment thread src/js_parser/p.rs Outdated

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

I reviewed this PR and didn't find any bugs. Because it changes parser-level AST rewriting (var relocation inside LowerUsingDeclarationsContext::finalize) and the interaction with the linker's __esm wrapper hoist is subtle, a human look would still be worthwhile.

What was reviewed:

  • The extracted relocate_vars_to_top_level is a pure refactor of the existing tail of maybe_relocate_vars_to_top_level; behavior of the original caller is unchanged.
  • The relocate_vars gate (options.bundle && current_scope == module_scope) — confirmed the non-module-scope finalize call sites (for-of body, switch) are excluded, and --no-bundle output is untouched.
  • Ordering: finalize runs inside visit_stmts_and_prepend_temp_refs, and append_part drains relocated_top_level_vars immediately after, so the pushed refs land in the same part.
  • The comment-cop bot comments were addressed in ab52a68/8d4422cd — the current comments are one-liners.
Extended reasoning...

Overview

The PR fixes a bundler bug where a module containing a top-level using/await using produces a broken bundle when reached through import() (or otherwise wrapped in __esm). The lowering moves the module's top-level vars into a generated try block, and the linker's wrapper hoist only scans literal top-level statements, so the export getters referenced undeclared bindings. The fix reuses the existing relocated_top_level_vars mechanism: finalize now converts each module-level var decl inside the try block into an assignment and pushes the binding refs to relocated_top_level_vars, which append_part then emits as a single top-level var list. The conversion body was extracted from maybe_relocate_vars_to_top_level into relocate_vars_to_top_level so it can be called at module scope (the wrapper deliberately no-ops there).

Four files touched: src/js_parser/fold.rs (extract helper), src/js_parser/p.rs (call it in finalize), test/bundler/bundler_edgecase.test.ts (6 new itBundled cases covering using/await using × browser/node × wrapped/inlined), and test/bundler/bundler_npm.test.ts (updated pinned source-map positions and filesize for npm/ReactSSR, whose entry has a top-level using).

Security risks

None. This is a compile-time AST transformation with no untrusted-input parsing, no auth/crypto/permissions surface, and no runtime FFI.

Level of scrutiny

Medium-high. The change itself is small and mechanical, but it sits at the intersection of three subsystems (using lowering, var relocation via to_expr_wrapper_hoisted → wrap_identifier_hoisting → relocated_top_level_vars, and the linker's WrapKind::Esm hoist), and it changes generated code for every bundled module that has a top-level using. The record_usage call inside wrap_identifier_hoisting now fires for these bindings — that mirrors what already happens for nested vars under the same relocation, so tree-shaking bookkeeping should be consistent, but a maintainer familiar with the symbol-use accounting should confirm there's no unintended interaction with the __stack/handle refs the lowering itself generates.

Other factors

  • Test coverage is thorough: both using and await using, both browser and node targets, both the wrapped (__esm) and inlined shapes, plain exports, destructuring, an initializer-less decl, and a hoisted function reading non-exported bindings. The onAfterBundle regex asserts the exact hoisted var __stack, state, handle, result, val, assignedLater; line, and run.stdout verifies runtime behavior.
  • The npm/ReactSSR pinned-position update (-4/-8/-16 columns, -1 byte filesize) is explained in the description as five var prefixes becoming assignments plus one trailing var list; the arithmetic is plausible but I did not re-derive it independently — CI will catch a mismatch.
  • The github-actions comment-cop flagged both source files twice for long comments; the author responded with two follow-up commits that condensed them to single lines, so those are addressed.
  • The description explicitly scopes out three neighbouring bugs in the same lowering (#38279, dropped export default function/class, class decls staying in the try block), which is reasonable for a focused fix.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

On the symbol-use accounting question from the review: the relocation records exactly what it records for a nested var today, one use per relocated binding for the assignment that now references it, and append_part re-declares the binding as a top-level symbol of the same (single) part. The lowering's own temporaries never go through it: __stack is declared by the let that finalize emits outside the try block, and _catch / _err / _hasErr / _promise are declared inside the catch / finally blocks, which finalize generates itself rather than taking from the module body. Added this to the description as well.

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:50 AM PT - Aug 14th, 2026

❌ @robobun, your commit 4d6da34 has 2 failures in Build #95594 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38284

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

bun-38284 --bun

…mode, not only when bundling

The REPL transform hoists a line's declarations the same way the linker's
ESM wrapper does, by looking at top-level statements only, so declarations
sharing a line with a top-level `using` were lost between REPL inputs too.

Also cover `export default <expr>` in the bundler fixture.
@robobun robobun changed the title bundler: hoist the top-level vars of a lowered using module out of its ESM wrapper Keep the declarations of a lowered top-level using reachable from the ESM wrapper and the REPL Aug 14, 2026
@robobun robobun changed the title Keep the declarations of a lowered top-level using reachable from the ESM wrapper and the REPL js_parser: hoist the declarations of a lowered top-level using out of the ESM wrapper and the REPL IIFE Aug 14, 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.

I reviewed this PR and didn't find any bugs. Because it changes parser output for every module with a lowered top-level using (not only wrapped ones — the final commit dropped the options.bundle gate the description still mentions, so Bun.Transpiler output changed too), a human familiar with the bundler's symbol-use accounting should confirm.

What was reviewed:

  • The relocate_vars_to_top_level extraction is a pure refactor of the existing tail of maybe_relocate_vars_to_top_level; behavior identical for the existing caller.
  • relocate_module_vars gate: verified the other finalize callers (for-of body, switch case) run in a nested scope, so only the module-level lowering is affected.
  • Traced wrap_identifier_hoisting → relocated_top_level_vars → append_part's emission loop; the extra record_usage per binding matches what a nested relocated var already does.
  • Snapshot change is semantically equivalent (var inside a module-level try was already module-scoped).
Extended reasoning...

Overview

Two Rust files: fold.rs splits the assignment-generation half of maybe_relocate_vars_to_top_level into a callable relocate_vars_to_top_level; p.rs calls it from LowerUsingDeclarationsContext::finalize for every SLocal of kind KVar when current_scope == module_scope, replacing the declaration with the joined assignments and letting append_part emit the trailing var x; list. Tests: six new itBundled cases in bundler_edgecase.test.ts (using / await using × wrapped / inlined × browser / node), a REPL end-to-end test, a replMode transform-output test, updated pinned sourcemap positions in bundler_npm.test.ts, and one updated transpiler snapshot.

Security risks

None. Pure code-transformation change in the JS parser; no I/O, no untrusted-input parsing surface added, no allocator/refcount changes.

Level of scrutiny

High. This is bundler correctness code where the invariant being restored ("every top-level binding of a module is a top-level statement") is what the linker's __esm hoist and tree-shaking rely on. The mechanism reused (relocated_top_level_vars + wrap_identifier_hoisting) also touches symbol-use counts, which feed DCE decisions. The change is small and reuses an existing well-understood path, but the blast radius of a mistake here is any module with a top-level using on a non-bun target.

Other factors

  • The PR description says the change is "gated on options.bundle"; the final commit (4d6da34) changed the gate to current_scope == module_scope, which now affects Bun.Transpiler and --no-bundle output (visible in the updated snapshot). This is semantically a no-op for unwrapped output — a var inside a module-level try block was already module-scoped — but it is a user-visible output change the description doesn't reflect, and it also fixes the REPL case (covered by the two new REPL tests). A human should confirm the broader gate is intentional and that the extra record_usage per relocated binding doesn't perturb tree-shaking for the unwrapped/inlined path (the LoweredTopLevelUsingInlined tests exercise this by running the output).
  • Test coverage is thorough: the wrapped tests run the bundle and assert the exact hoisted var line, and the author confirmed the four *InEsmWrapper* tests fail with released 1.4.0. The comment-cop feedback about long comments was addressed in ab52a68 (all resolved).
  • Checked that the other two finalize call sites (for-of/for-in with using init, switch-case bodies) always run with a nested current_scope, so the new relocation path cannot fire for them.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

The description was rewritten for the current shape right after that push: the relocation is no longer limited to bundling (it runs whenever the lowering handles the module body itself), which is what fixes the REPL as well, and the updated using top level snapshot shows the non-bundled output it produces.

On tree shaking for the inlined path: a module with a lowered top-level using is always emitted as one part (parse_entry.rs skips per-statement parts for it since the whole body ends up in one try block), that part is never removable because of the try statement, and the relocated declarations are appended to that same part, so the uses recorded by the relocation only point at the part they are already in. Added that to the description too.

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.

1 participant