Repository navigation
Conversation
When an ESM module is wrapped in a lazy __esm init closure, top-level declarations are hoisted to an outer `var` and their bodies become assignments inside the closure. A "can_be_moved" fast path was pushing side-effect-free initializers (strings, numbers, arrows, classes) into the outer `var` as well, so they were assigned before the wrapper ran. In an ESM<->CJS cycle the CJS side calls init_foo() while it is already on the stack, gets the partially-populated namespace back, and could observe the hoisted binding as already initialized. bun's own runtime, Node and esbuild all return the binding as undefined at that point. The linker now always hoists only the bare binding and keeps the assignment inside the closure, matching esbuild. needs_wrapper_ref() in the parser is updated to agree so that wrapper_ref is allocated whenever a declaration leaves an assignment inside the closure.
WalkthroughChangesThe bundler now requires wrappers for additional initializer cases and routes top-level declarations, including classes, through wrapper hoist assignments. New tests cover cyclic ESM/CommonJS initialization and updated circular top-level-await output. ESM wrapper cycles
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 8:50 AM PT - Jul 21st, 2026
❌ @robobun, your commit 612c6ad has 2 failures in
🧪 To try this PR locally: bunx bun-pr 34934That installs a local version of the PR into your bun-34934 --bun |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
Checked the two suggested issues and neither is closed by this change:
Leaving them off the PR body. |
There was a problem hiding this comment.
Beyond the inline nit, I also checked: needs_wrapper_ref's new SExportDefault arm against convertStmtsForChunk.rs — the four lowered forms (SFunction/SClass/SExpr/Expr) line up with the function-only continue; existing wrapped-ESM tests (EsmWrapperClassHoisting, EsmWrapperElimination1, DeepImportChainWrappedTLA) assert runtime behavior rather than hoisted output shape, so they should be unaffected; and no __esm snapshot fixtures under test/bundler/ need updating.
Extended reasoning...
The nit is cosmetic. The substantive change — removing the can_be_moved() fast path so initializers stay inside the __esm closure, plus the matching needs_wrapper_ref update — reads correctly and matches esbuild. The CircularTLADependency2 expectation change is a legitimate consequence (the old "c" on the first cyclic read was the bug being fixed; undefined is the closest a const→var bundle can get to the unbundled TDZ). Deferring rather than approving because this reshapes output for every wrapped ESM module and CI hasn't reported yet.
There was a problem hiding this comment.
I didn't find any bugs, but this changes bundler output semantics for every wrapped ESM module and updates default/CircularTLADependency2's expected output — worth a maintainer's eyes.
What was reviewed:
- Traced the
SLocal/SClasshoist rewrite againstneeds_wrapper_ref— the two now agree (any initializer / any class / any non-functionexport defaultrequests a wrapper ref). - Checked that
ExportDefault::can_be_movedandG::Class::can_be_movedremain live (still used inparse_entry.rsandlower_esm_exports_hmr.rs). - Verified
EsmWrapperElimination1/EsmWrapperClassHoistingstill exercise the modified paths without expectation changes.
Extended reasoning...
Overview
Removes the can_be_moved() fast-path in the WrapKind::Esm hoist loop of generateCodeForFileInChunkJS: for SLocal and SClass, only the bare binding is now hoisted to the outer var; initializers stay inside the __esm closure as assignments. needs_wrapper_ref() in p.rs is updated to predict a non-empty wrapper body under the new rule (any SClass, any SLocal with a non-missing initializer, any SExportDefault that isn't an SFunction). Three new itBundled tests cover const/arrow/class cycle variants, and default/CircularTLADependency2's expected stdout changes from "c" to "undefined" on the first cyclic read.
Security risks
None. Pure bundler code-generation change; no untrusted-input parsing, auth, or FFI surface touched.
Level of scrutiny
Medium-high. The diff itself is small and mechanically clean, but it (a) changes emitted output for every ESM module that gets __esm-wrapped, (b) removes a size optimization (movable initializers now always become in-closure assignments), and (c) modifies an existing test's expected output. REVIEW.md flags existing-test changes as needing explicit justification — the PR body provides one (unbundled Node/Bun throw a TDZ error at that read, so undefined under const→var lowering is the closest a bundler can do, and it now matches esbuild), but a maintainer should confirm that trade-off is the desired one.
Other factors
- The prior review nit (5-line comment) was addressed in 046215b.
SExportEqualsinneeds_wrapper_refstill usescan_be_moved(); that's TSexport =(CJS-only), so it's not subject to the__esmhoist path and looks intentionally left alone.- CI on the latest commit was still building at review time; the PR body notes the new tests weren't run locally ("Platform-specific test(s) that do not run on this machine"), so green CI is the actual verification signal here.
There was a problem hiding this comment.
No bugs found, but this changes bundler codegen semantics for every wrapped ESM module and updates an existing test's expected output (default/CircularTLADependency2 now prints undefined instead of "c"), so it's worth a human sign-off on the behavior/size trade-off.
What was reviewed:
- The
SLocal/SClasshoist rewrite ingenerateCodeForFileInChunkJS.rs— initializer now always becomes an in-wrapper assignment, matching esbuild. needs_wrapper_refinp.rsstays consistent with the new hoist rule;export default functionstill elides the wrapper.- Checked that
edgecase/EsmWrapperClassHoistingandedgecase/EsmWrapperElimination1remain correct under the new lowering (assignments run in source order inside the closure). S::ExportDefault::can_be_moved()is now unused — flagged and ruled out as intentional dead code.
Extended reasoning...
Overview
Removes the can_be_moved() fast path in the WrapKind::Esm branch of generateCodeForFileInChunkJS: top-level SLocal initializers and SClass bodies are no longer hoisted into the outer var even when side-effect-free; only the bare binding is hoisted and the value becomes an assignment inside the __esm closure. needs_wrapper_ref() in the parser is updated to match (any initializer / any class / any non-function export default now requests a wrapper ref). Three new itBundled tests cover const/arrow/class in an ESM↔CJS cycle, and default/CircularTLADependency2's expected stdout is changed from c to undefined.
Security risks
None — pure bundler output-shape change with no untrusted-input handling.
Level of scrutiny
Medium-high. The diff itself is small and mechanically simple (deleting a fast path), and the reasoning is sound and well-documented against esbuild. But it is a behavior change to codegen for every wrapped ESM module: bundles get slightly larger (initializers move from the merged outer var into separate assignment statements), the wrapper-elimination optimization fires less often (needs_wrapper_ref now returns true for any module with an initialized top-level binding), and one existing test's expected output changed. That output change is arguably more correct (unbundled it's a TDZ error), but per REVIEW.md a modified existing assertion warrants a maintainer look.
Other factors
- The prior inline nit (5-line comment) was addressed in 046215b.
- The
SExportEqualsarm inneeds_wrapper_refstill usescan_be_moved(); that path is TSexport =which lowers differently, so leaving it is consistent. S::ExportDefault::can_be_moved()loses its only caller; a finder flagged this and it was ruled out as harmless dead code, though REVIEW.md prefers deleting dead code in the same PR.
|
On the review note about |
|
The diff is ready. All bundler suites pass on every lane of both CI builds. Remaining red on build 76915 is unrelated to this change:
Ready for a maintainer to review/merge. |
|
Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-21, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main. |
Repro
Cause
When an ESM module is wrapped in a lazy
__esminit closure, top-leveldeclarations are hoisted to an outer
varand their bodies become assignmentsinside the closure.
generateCodeForFileInChunkJShad a fast path that, whenthe initializer satisfied
can_be_moved()(strings, numbers, arrows,side-effect-free classes), pushed the whole declarator (binding and value)
into the outer
var:In an ESM<->CJS cycle the CJS side re-enters
init_entry()while it is alreadyon the stack, gets the partial namespace back, and sees
fromEsmalready set to"E"even though the ESM body has not reached that declaration. Bun's ownruntime returns the partial
{}, Node throws on the in-progress ESM require,and esbuild's bundle prints
{}. Only Bun's bundle printed{"fromEsm":"E"}.The same fast path existed for
SClass, soexport class Foo {}was emitted asa top-level
class Foo {}outside the wrapper and was likewise observableduring the cycle.
Fix
Always hoist only the bare binding and keep the initializer as an assignment
inside the closure, matching esbuild:
needs_wrapper_ref()in the parser predicted an empty wrapper body based on theold hoist rule, so it is updated to agree: any
SLocalwith an initializer, anySClass, and anyexport defaultthat does not lower to anSFunctionnowrequest a wrapper ref.
default/CircularTLADependency2previously expected the first cyclic read ofcto print"c"; unbundled, both Node and Bun throw a TDZReferenceErrorthere. The bundle lowers
consttovar, so the first read now observesundefined, which is the closest approximation a bundler can give. The test'sexpected output is updated accordingly.
Verification
New tests in
bundler_edgecase.test.tscover the string-constant, arrow, andclass-declaration variants. Each fails on
mainand passes with the fix.Output on the repro is now byte-identical to esbuild's.
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_edgecase.test.ts