Skip to content

bundler: keep a top-level var store of a lazily loaded ES module at its statement - #45529

Draft
robobun wants to merge 5 commits into
robobun/46e46b5b/bundler-wrapper-init-orderfrom
robobun/32d05a9a/esm-wrapper-var-store
Draft

robobun wants to merge 5 commits into
robobun/46e46b5b/bundler-wrapper-init-orderfrom
robobun/32d05a9a/esm-wrapper-var-store

Conversation

@robobun

@robobun robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Draft, stacked on #41601. Not ready: see Downsides.

Problem

  • A lazily loaded ES module (require(), or import() without --splitting) prints wrong values from a bundle: var mode = "dev"; log(mode); var mode = "prod"; log(mode) prints prod twice.
  • Cause: the linker prints var x = <value> above the __esm wrapper if the value is side-effect-free (generateCodeForFileInChunkJS.rs:761). The store then runs at chunk load.

Fix

  • A var, and a lifted exports.x = value, keep their store inside the wrapper. One predicate (Local::value_can_leave_esm_wrapper) serves the linker and needs_wrapper_ref.
  • let, const, class and export default values still leave it: the source throws on a read before the statement.
  • Verified: test/bundler/bundler_edgecase.test.ts (23 new tests, 19 fail on the base). Self-reviewed: 18 concerns raised, 16 addressed, 2 kept (control tests, a TypeScript alias: Notes).

Background

Downsides

Notes

No GitHub issue reports this. It replaces #34934, which kept every initializer inside the wrapper and was closed by the stale sweep.

Not ready: what has to be in the base first

The order by ancestry is main, then the branch of #41601, then this branch. Three changes have to be in the base before this PR leaves draft:

  1. bundler: evaluate a wrapped module's dependencies in source order and await every async one #41601 (the order of init_x() calls). It is the base of this PR.
  2. The nested var: js_parser: relocate module-level vars from brace-less bodies for the bundler and the REPL #45705, and js_parser: print a var merged with a top-level function declaration as an assignment #44001 together with the end of the exception that js_parser: relocate module-level vars from brace-less bodies for the bundler and the REPL #45705 keeps for a file with function f and var f (has_top_level_function_merged_with_var in relocates_single_stmt_body_decls). See "Nested var".
  3. A fix for an import through export * of a side-effect-free file. See "Import through export *". No PR has it.

After that: merge the base, enable the todo tests, add a test for case 3, and delete the symbol mark of #45705 that this predicate makes redundant (VAR_STORES_STAY_IN_WRAPPER).

Repro

// twice.mjs
var mode = "dev"; console.log("twice", mode); var mode = "prod"; console.log("twice", mode); export { mode };
// guard.mjs
console.log("guard", started ? "skipped" : "starting"); var started = true; export { started };
// loops.mjs
var out = []; var i = 0; while (i < 2) { out.push("first" + i); i++; }
var i = 0; while (i < 2) { out.push("second" + i); i++; } console.log(out.join(" ")); export { out };
// entry.cjs
require("./twice.mjs"); require("./guard.mjs"); require("./loops.mjs");

bun build ./entry.cjs --outfile=out.js && bun out.js

output
node and bun on the source twice dev, twice prod, guard starting, first0 first1 second0 second1
bundle from main and from #41601 twice prod, twice prod, guard skipped, first0 first1
bundle from this branch the same as the source

The bundle from main has var mode = "dev", mode = "prod";, var started = true; and var out, i = 0, i = 0; above the wrappers.

What keeps its place, what does not

value_can_leave_esm_wrapper is true for a declarator only if all of these hold:

  • The binding is an identifier (a pattern already stayed inside, bundler: keep an unused destructuring declaration unless the pattern is side-effect free #41172).
  • The statement is not a lifted exports.x = value.
  • The symbol is not a var (Kind::Hoisted). A var that shares its name with a function has a symbol of its own with this kind, so it keeps its place too. The name of a function expression is not a var: export default (function foo() {}) binds it, and that value leaves the wrapper as it does on the base.
  • The value can_be_moved(). It is tested last, so a var does not pay the walk of the initializer.

can_be_moved() is not changed. The class arm of the linker loop is not changed. The parser asks the same predicate with its own symbol table. For a file that the parser classifies as CommonJS the var clause is off, because such a file never gets an __esm wrapper. That keeps the output of those files as it is.

Measurements (the base 1aefaeb against this branch, x64 Linux. The bundle bytes are from 1cf570b. The instruction counts, the wall clock and the binary size are from release builds of 0016a27: the commit after it replaces a test of three symbol kinds by a test of one.)

  • Wrapped bundles, one entry per package (const m = await import(pkg); console.log(Object.keys(m).length);) for hono 4.7.2, lodash-es 4.17.21, rxjs 7.8.1, graphql 16.8.2, zod 3.24.2, uuid 11.1.0. With --minify: 463,287 to 464,347 B (+1,060 B, +0.23%), gzip -9 +779 B (+0.57%). Not minified: 1,148,826 to 1,157,383 B (+8,557 B, +0.74%). Per package, minified: lodash-es +903 B, hono +109 B, rxjs +48 B, the other three 0 B.
  • React 18.3.1 client entry (react and react-dom/client) with --production: 134,943 to 135,226 B (+283 B, +0.21%), gzip -9 +261 B. The production build of react-dom is a required file with top-level vars.
  • Static-import-only bundles of the six packages and of three 0.180.0, plain and --minify: 14 of 14 byte-identical (cmp).
  • Moved var stores in the six wrapped bundles: 338. Of them 37 are function or class values that are no longer created at chunk load. Modules that gain a wrapper: 1 (in rxjs). __esm( 982 to 986, empty wrappers 12 to 9, init_x() calls 3,134 to 3,137.
  • needs_wrapper_ref, instructions per bundled file (gdb single-step, callees included): graphql 7,129 to 8,702 over 125 files, lodash-es 36,285 to 41,963 over 642 files, rxjs 11,834 to 13,774 over 226 files. That is +9 to +13 per file (three.js: 355 to 459 over 4 files). No allocation is added. valgrind and perf are not available in the build container, so the linker function has no instruction count.
  • Wall clock, 60 interleaved runs each: bundle of import("lodash-es") 64.4 to 63.8 ms (base against base: 0.9 ms). The three.js static bundle moved by +1.8 and +2.7 ms in two runs, with a base against base spread of 0.1 to 2.5 ms, and --no-bundle of the same file did not move (16.6 ms both).
  • Release binary: size text +512 bytes. The stripped file has the same size.
  • test/bundler/bundler_bytecode_portable.test.ts: three hashes change (the bundles that require() libraries). libraries.js (2,924 modules) grows by 751 B of 16.7 MB. 1cf570b gives the same three hashes as 0016a27. The commit after it (804c58d) only names the condition: the six minified bundles are byte-identical.
  • A script with no exports that holds only var and function declarations gains a wrapper and an init_x() call (+57 B minified), though nothing can read it.

Random module graphs (sources that never throw: var exports and function declarations. Each bundle runs under node and is compared with node on the source. "var alone" is main with each export var v = x written as var v; v = x; export { v }.)

family graphs main var alone on main #41601 this branch
ES modules with cycles, require() entry 500 91 495 91 500
the same, import() entry 500 91 495 91 500
with top-level await 500 74 476 74 500
with CommonJS modules that require() an ES module 611 125 601 125 611
acyclic, names through export *, top-level await 500 500 492 500 500

The numbers are graphs where every printed value is right. Right on main and wrong on this branch: 0 of 2,611. The generator has no var in a body without braces. That shape is right on main and wrong on this branch alone for a reader outside the module (see "With #45705"). With the var change alone on main, 8 graphs of the last family go from right to undefined, because an export * of a module with top-level await is not awaited there. The self-review of the first version also reported constructed cyclic graphs (CommonJS, top-level await) that go from right to undefined or to a TypeError with the var change alone. That is why this PR is on #41601 and holds no order fix of its own.

Nested var

The rewrite var x = v to var x; outside and x = v inside is only right if x inside the wrapper is the outer variable. The parser leaves two kinds of module-level var declaration inside the module body, and so inside the wrapper, where they declare a second x: a var in an if, else, while or do body without braces, and for (var i; ...) without an initializer. Module: if (globalThis) var x = 1; var x = 2; (node gives 2), loaded with import():

read of x main and the base this branch this branch with #45705 (0e5df92)
in the module body (export const inBody = x) 1 2 2
from outside (export function read() { return x; }) 2 undefined 2

Main is right for the reader outside only because the moved var x = 2 writes the outer variable and the store in the if body writes the inner one. This branch keeps x = 2 in the wrapper, where it writes the inner variable too, so the outer one stays undefined.

#45705 makes it one variable in the parser. Its head has moved since that measurement (76f5a6d): it now also keeps the stores of a var that it relocates inside the wrapper, with a mark on the symbol. This predicate makes that mark redundant.

#45705 does not relocate in a file that has a top-level function f and var f (#44001). Such a script, loaded with require() and --format=cjs: function f() { return 1; } var f = 2; if (globalThis) var x = 1; var x = 2; function read() { return x; }. In the body and from outside: node 2 and 2, main and the base 1 and 2, this branch 2 and undefined, this branch with #45705 (0e5df92) 2 and undefined.

The six wrapped package bundles are byte-identical on this branch with and without the source diff of #45705 (0e5df92): none of their 986 wrapped modules has such a var.

Tests: eight orders (the body of if, else, while and do, an exported var, two names, a for head, a read above both stores), each plain and with minifySyntax. The reads in the module body run now (EsmWrapVarStoreAfterBracelessBody, ...MinifySyntax) and fail on the base. The reads from outside are todo (...ReadFromOutside, ...ReadFromOutsideMinifySyntax). With the source diff of #45705 (0e5df92) applied to this branch they pass, and so do the 20 tests that it adds to bundler_edgecase.test.ts.

Import through export * of a side-effect-free file

// pkg/package.json: { "sideEffects": false, "type": "module" }
// pkg/barrel.mjs
export * from "./lib.mjs";
// pkg/lib.mjs
export var x = 2;
export function get() { return x; }
// entry.mjs
import { get } from "./pkg/barrel.mjs";
console.log(get());
const lib = await import("./pkg/lib.mjs"); // lib.mjs gets a wrapper
bundle from first line
node on the source, main, the base 2
this branch undefined
main and the base, with export var x = [2][0] (a value that cannot move) undefined
esbuild 0.27.1, both forms undefined

The bundle has no init_lib() call before the code of the entry. The call belongs to the export * statement of barrel.mjs. Tree shaking drops that statement, because nothing depends on it: an import that binds through export * depends only on the part that declares the name (the imports_to_bind loop in scanImportsAndExports.rs). A named re-export (export { x } from) is recorded as a dependency (re_exports), and that form is right on every build. So this bug is on main, and the moved value hides it there.

Real packages on this branch: import { OK, NOT_FOUND } from "http-status-codes" beside import("http-status-codes/build/es/legacy.js") prints undefined undefined (main 200 404). import { Registry } from "shiki/textmate" beside import("@shikijs/core") gives undefined (main: a function). A review found the same in more graph shapes: require() from a CommonJS file, import * as ns, two export * hops, a barrel in another package, "sideEffects": ["*.css"], an entry point that re-exports the barrel, --splitting, --format=cjs and --minify.

Why no guard in this PR

A design review weighed a flag from the parser for a file with a nested var, a mark on the symbol, a scan of the wrapper body in the linker, and a copy of #45705 in this branch. While the second var is inside the wrapper, a guard only chooses which reader is wrong: with the old output for such a file the reader in the module body is wrong, with the output of this PR the reader outside is wrong. The parser is the place that leaves the second var, and #45705 fixes it there. A copy would fork an open PR that still changes. So this PR waits as a draft, and a guard is not built.

Self-review

The first version of this change also reordered export * from and declared __promiseAll for an unwrapped file. The review asked for a smaller change: those parts copy #41601 and #42046, the partial order fix could print await in a wrapper that is not async, and the var change needs the full order fix below it. The points about the copied hunks, the broken graphs and the parse failure are addressed by the removal of those hunks and by the new base. The other points are stated here: no user reported the symptom, bundles that wrap a var module get new bytes and content hashes, and the question below is open.

A second review covered the change of the predicate to Kind::Hoisted and the new tests. It bundled 2,296 more modules in up to six load modes and several flag sets (50,528 results) and found no module for which the parser and the linker disagree. The only values that this branch moves away from node are the reads from outside in "With #45705". It raised six points. Four are addressed: the todo tests now state what this branch alone prints, the reads from outside are in Downsides, a test comment claimed node output for entries that node cannot run as written, and this description was stale. Two are kept as they are:

  • Four of the 23 tests pass on the base too. EsmWrapLexicalValuesNeedNoWrapper is the control against a wider predicate. EsmWrapDefaultExportValueNeedsNoWrapper fails on 0016a27, the commit before the last one. The two export * tests pin the order that a var store at its statement depends on.
  • TypeScript import x = E.A with a const enum value still leaves the wrapper. Bun prints it as a const, so a read before the statement throws under bun run. tsc prints a var.

A third review, of the design, looked at the nested var. It kept the design, ruled out a guard (above), and found the import through export *. Both are in Downsides, and the PR is a draft because of them.

edgecase/EsmWrapperElimination1 (from #12729) still passes, but its module declares its exports with var, so it keeps its wrapper now. EsmWrapLexicalValuesNeedNoWrapper asserts the elision for let, const and class.

Question for a maintainer

esbuild keeps every initializer inside __esm. This PR moves only the stores that a running program can read early. let, const, class and export default values keep the #12729 hoist and the wrapper elision. So a CommonJS module that requires its ES importer during an import cycle still sees export const values before the ES module body runs (node throws ERR_REQUIRE_CYCLE_MODULE there, and bun run evaluates the ES module inside the require()). If those should follow esbuild too, the predicate returns false for every initialized local and the class arm goes. That moves every movable let, const and class of a wrapped module, and a module with only such declarations gets a wrapper and an init_x() call.

A narrower rule is possible too, and it is not built: keep the value outside for a var that is declared above the first statement that stays in the wrapper. It fixes each module of the repro, and a module that holds only declarations (the constants of http-status-codes) stays as it is on main. But a module that an import cycle reads early still shows such a var with its value.

One more effect of a value above the wrapper, for the same decision: in cjs and iife output a function value of a "use strict" ES module that leaves the wrapper runs as sloppy code (export const f = function () { return this === undefined; } gives false, node gives true). #46578 prints the directive inside the wrapper and lists this case as still sloppy. With every initializer inside, that case is strict too. A var with such a value is already inside with this PR.

Not changed here

Suites

At 0016a27, test/bundler/ and test/regression/issue/cyclic-imports-async-bundler.test.js with release builds: 7,701 pass on the base, 7,721 pass on this branch (the 20 tests of that commit), 0 fail on both.

At 1cf570b with debug builds: - bundler_edgecase: 221 pass, 13 todo. The 20,000-module DeepImportDiamondDAG exceeds the 60 s limit of its build step in this container, with the debug build of the base too (77 and 136 s for the base, 90 and 112 s for this branch, identical output).

  • bundler_dynamic_import_dce 333 pass, bundler_cjs2esm 113 pass, esbuild/importstar 99 pass, esbuild/splitting 26 pass, cyclic-imports-async-bundler 1 pass.
  • esbuild/default: 153 pass. TopLevelAwaitForbiddenRequireSourceMapCLI and ...API (40 builds in 5 s) time out with the debug build of the base too.
  • The three bundles of bundler_bytecode_portable that this PR re-hashed: the same JS and bytecode hashes as in the test.
  • 372 small modules (each form of default export and of declaration, alone and in pairs, as .mjs, .js, .cjs and .ts), each loaded with import() and with require(): 0 asserts, and all 744 results equal the results of a release build of main (367d939). On 0016a27 the assert !ast.wrapper_ref.is_empty() fires for 50 of them, each with a named function expression as its default export.

no test proof · iteration 4 · 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

…ts statement

A module that is loaded with require(), or with import() without
splitting, is printed as `var init_x = __esm(() => { ... })`. The linker
printed a declaration with a side-effect-free value above that wrapper,
with the value. For a `var` that store has a place in the module: the
binding reads undefined before it, and a second declaration assigns
again. So `var mode = "dev"; log(mode); var mode = "prod"; log(mode)`
printed prod twice.

One predicate, Local::value_can_leave_esm_wrapper, now answers for the
linker and for needs_wrapper_ref in the parser. A var, a var merged with
a function and a lifted `exports.x = value` keep their store inside the
wrapper. let, const and class values still leave it: a read before the
statement throws in the source.
@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:05 AM PT - Oct 19th, 2026

❌ @robobun, your commit 804c58d has 1 failures in Build #128766 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 45529

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

bun-45529 --bun

@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

How I reproduced it:

  1. Write the four files of the "Repro" block in the PR description (twice.mjs, guard.mjs, loops.mjs, entry.cjs).
  2. Run bun build ./entry.cjs --outfile=out.js && bun out.js.
  3. main and the base branch (bundler: evaluate a wrapped module's dependencies in source order and await every async one #41601) print twice prod, twice prod, guard skipped, first0 first1. This branch prints what node prints for the source: twice dev, twice prod, guard starting, first0 first1 second0 second1.

The tests: bun bd test test/bundler/bundler_edgecase.test.ts -t EsmWrap. 19 of the 23 new tests fail on the base branch. Two more are todo until #45705 is in the base.

This PR is a draft. It is stacked on #41601, and it also waits for #45705, #44001 and a fix for an import through export * of a side-effect-free file. The description has the two cases that this branch alone gets wrong.

CI on 804c58d (build 128766): no failure comes from this diff. test/js/web/workers/worker-terminate-lifetime.test.ts is red on one macOS lane, and the other failures passed on a retry.

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

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 src/js_parser/p.rs — A lazily loaded module whose only export is export default (function foo() {}) now fails a debug assert, and in release bundles a wrapper under a bogus symbol so the default export stays undefined. The parser's SExportDefault arm at src/js_parser/p.rs:9655 still answers with ed.can_be_moved() alone, but the linker turns that statement into var foo = function foo() {} whose binding is the function's own HoistedFunction symbol, so value_can_leave_esm_wrapper at src/bundler/linker_context/generateCodeForFileInChunkJS.rs:762 keeps it inside the wrapper while no wrapper_ref was minted. Fix: make both sides agree for a default export of a named function (or async/generator function) expression. [also at: src/ast/s.rs:291 - A lazily loaded module whose default export is a named function expression now trips debug_assert!(!ast.wrapper_ref.is_empty()) and, in release, its consumers read undefined.]

    Why this was flagged

    Input: entry.js does require("./lib.js") (or import() without --splitting) and lib.js is export default (function foo() {}). needs_wrapper_ref only checks ed.can_be_moved() for SExportDefault (src/js_parser/p.rs:9656), EFunction is movable, so wrapper_ref stays Ref::NONE (src/js_parser/p.rs:9404). convert_stmts_for_chunk rewrites the statement into var foo = function foo(){} with binding s.default_name.ref_ (src/bundler/linker_context/convertStmtsForChunk.rs:619). The new value_can_leave_esm_wrapper sees symbol.kind.is_hoisted_or_function() true (src/ast/s.rs:290) and returns false, so the store goes inside the closure and inner_len > 0, tripping debug_assert!(!ast.wrapper_ref.is_empty()) (src/bundler/linker_context/generateCodeForFileInChunkJS.rs:857). On the base branch the old test can_be_moved && BIdentifier hoisted var foo = function foo(){} outside the closure, matching the parser, and the bundle ran correctly.

    Verification: Triggered when a lazily loaded ES module's default export is a named function expression, e.g. export default (function foo() {}). The parser's needs_wrapper_ref SExportDefault arm (src/js_parser/p.rs:9655-9659) still only tests ed.can_be_moved(), so no wrapper_ref is minted. local.value_can_leave_esm_wrapper (generateCodeForFileInChunkJS.rs:762) returns false. On the base both sides agreed, so this is a regression introduced by the PR.

Comment thread src/bundler/linker_context/generateCodeForFileInChunkJS.rs
@robobun

robobun commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-reference: #45705 relocates a module-level var from an if/else/while/do body without braces, and clears a for (var i; ...) head without initializer, when bundling. Such a var then stores into the top-level binding, as a body with braces already does on main.

With #45705 and without this PR, these orders give the value that the braced form gives on main. Each is a module that import() loads, read through an export. Release builds, main d940fbe against the head of #45705, plain and with --minify-syntax:

module node main #45705
if (globalThis) var x = 1; var x = 2; (also else, while, do) 2 2 1
if (globalThis) var x = 1; export var x = 2; 2 2 1
if (globalThis) var x = 1; var x = 2, y = 3; var y = 4; [2,4] [2,4] [1,4]
for (var i; !i;) i = 1; var i = 0; 0 0 1
globalThis.seen = typeof x; var x = 2; if (globalThis) var x = 1; (seen) undefined undefined number

Main is right there only because the nested var is a second variable inside the wrapper. The lift that this PR removes is the cause, so these cases are input for the tests here. They need both changes to be meaningful.

…it binds a function's name

`export default (function foo() {})` reaches the linker as a local that binds
the symbol of `foo`. The predicate took that symbol for a `var` and kept the
store inside the `__esm` wrapper, but the parser had judged the statement by
its value and made no wrapper symbol. Test the kind of a `var` only.

Tests: the default export forms, a `var` merged with a function in both
orders, and a `var` store after a store in a block, in a body without braces
or after a `for` head without an initializer.
Comment thread src/ast/s.rs Outdated
Comment thread src/ast/s.rs Outdated
@robobun

robobun commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator Author

The finding about export default (function foo() {}) is right. On the debug build of 0016a27, bun build fails with assertion failed: !ast.wrapper_ref.is_empty() if a require() or an import() loads that module.

Cause: the predicate tested kind.is_hoisted_or_function(). default_name_for_expr (src/js_parser/p.rs:4692) gives this statement the symbol of the function's own name, a HoistedFunction. The linker prints the statement as a local of that symbol. The parser judged the statement by its value alone.

1cf570b tests the kind of a var only (symbol::Kind::Hoisted). That loses no var. declare_symbol makes a new Hoisted symbol for each top-level var declarator, also if the name merges with a function (ReplaceWithNew). Neither call site follows symbol links.

Checked:

  • New test edgecase/EsmWrapDefaultExportValueNeedsNoWrapper: a named function, async function, generator and class expression and an arrow as the default export, loaded with import() and with require().
  • edgecase/EsmWrapVarMergedWithFunction now has both orders of function f and var f.
  • 372 small modules, each loaded with import() and with require(): every form of default export and of declaration, alone and in pairs, as .mjs, .js, .cjs and .ts. On 0016a27 the assert fires for 50 of them, and each of the 50 has a named function expression as its default export. On 1cf570b it fires for none, and all 744 results are the same as with a release build of main (367d939).
  • A second pass with 2,296 more modules (TypeScript, JSX, CommonJS mixes, symbol merges, random modules) in up to six load modes: this assert does not fire.

@robobun

robobun commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator Author

Results for the orders in the comment above. Each module is loaded with import(). "Both" is this branch (1cf570b) with the source diff of #45705 applied, debug build, plain and with --minify-syntax (the same values in both modes).

module node this branch alone both
if (globalThis) var x = 1; var x = 2; (also else, while, do) 2 in the body 2, from outside undefined 2 and 2
if (globalThis) var x = 1; export var x = 2; 2 in the body 2, from outside undefined 2 and 2
if (globalThis) var x = 1; var x = 2, y = 3; var y = 4; [2,4] in the body [2,4], from outside [undefined,4] [2,4] and [2,4]
for (var i; !i;) i = 1; var i = 0; 0 in the body 0, from outside undefined 0 and 0
globalThis.seen = typeof x; var x = 2; if (globalThis) var x = 1; (seen) undefined undefined undefined

"In the body" is export const inBody = x. "From outside" is export function read() { return x; }.

With both changes every value is right, and no case fails. The 20 tests that #45705 adds to bundler_edgecase.test.ts pass on that tree too.

This branch alone is not enough for a reader outside the module. The var in the body without braces is still a second variable inside the wrapper, and the store that this PR keeps in the wrapper now writes that variable too. So the outer variable stays undefined, where main has the moved value. #45705 has to land before this PR. The PR body says so now.

The tests are in bundler_edgecase.test.ts:

Comment thread src/ast/s.rs Outdated
Comment on lines +290 to +293
!self.origin.is_commonjs_export()
// The kind of a `var` only, not of a function's name: the linker turns
// `export default (function f() {})` into a local that binds `f`.
&& symbol_of(id.get().r#ref).is_none_or(|bound| bound.kind != symbol::Kind::Hoisted)

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.

🔴 If this lands before #45705, a lazily loaded module whose var x is also declared in a braceless body (if (c) var x = 1;, for (var i; ...)) now exports undefined for it; the base exports the value. With the Hoisted check at src/ast/s.rs:293 the top-level var x = 2 becomes x = 2 inside the __esm closure, but the braceless var x stays a closure-local declaration, so the store writes that local and the outer x read by export getters and hoisted functions is never written. Fix: keep the store inside only once every nested var in the module body is turned into an assignment of the outer binding (what #45705 does), or land #45705 first. The author's own ReadFromOutside tests in test/bundler/bundler_edgecase.test.ts:3098 are todo for exactly this. [also at: src/bundler/linker_context/generateCodeForFileInChunkJS.rs:764 - A lazily loaded ES module that declares a var both in a braceless body and at top level now exports undefined for it, where the base bundle exported the right value. generateCodeForFileInChunkJS.rs:762 keeps x = 2 inside the __esm closure, but if (c) var x = 1; is never relocated and stays a closure-local var x, so the store never reaches the outer x.]

Why this was flagged

Input: a module such as if (globalThis) var x = 1; export var x = 2; loaded through require() or import() without splitting, so the linker gives it WrapKind::Esm, and read from outside the closure. maybe_relocate_vars_to_top_level (src/js_parser/fold.rs:59) does nothing for a braceless body because current_scope == module_scope, so the if/for keeps a real var x declaration and that statement goes inside the arrow at generateCodeForFileInChunkJS.rs:826. The top-level var x = 2 has a Hoisted symbol, so value_can_leave_esm_wrapper (src/ast/s.rs:293) returns false and the decl becomes var x; outside plus x = 2 inside. Inside the arrow the nested var x shadows the outer one, so x = 2 writes the closure-local and the outer x stays undefined. On the base the same decl is printed as var x = 2 outside the closure, so outside readers see 2. The PR's tests (test/bundler/bundler_edgecase.test.ts:3098-3103) are marked todo: true; the mitigation is landing #45705 first, which this PR does not enforce.

Verification: test/bundler/bundler_edgecase.test.ts:3098-3107 marks EsmWrapVarStoreAfterBracelessBodyReadFromOutside as todo: true "until #45705"; #45705 is not in this checkout. An SIf/SFor falls to _ => stmt (generateCodeForFileInChunkJS.rs:826) and stays inside the __esm arrow. value_can_leave_esm_wrapper (src/ast/s.rs:293) returns false, so x = 2 writes the local; the outer x stays undefined. On the base, the outside reader got 2.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Right. On this branch alone the reader outside the module gets undefined for these modules, and main gives the moved value.

This PR is a draft now, and it stays one until the relocation is in its base. It adds no guard. While the second var is inside the wrapper, a guard only chooses which reader is wrong. The old output for such a file is wrong for the reader in the module body (export const inBody = x gives 1 on main, node gives 2). The output of this PR is wrong for the reader outside. The parser is the place that leaves the second var, and #45705 fixes it there.

#45705 keeps an exception for a file with a top-level function f and var f (#44001). With both PRs such a script still goes from 2 to undefined for the reader outside. So the base also needs #44001 and the end of that exception.

The description lists the prerequisites, the measured values, and the todo tests that become active then.

The same test as before. The condition has a name now, and each comment
is one line.
@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Update, 2026-10-23. A fuzz report hit this bug again on main 5c6d4e2. I ran a second review of the design against main. This comment records the results.

The reported input

// cfg.mjs
var url = "http://localhost"; export const first = url; var url = "https://prod.example"; export const second = url;
// lazy.mjs
const m = await import("./cfg.mjs"); console.log(m.first, m.second);

bun build lazy.mjs --target=bun --outfile=o.mjs && bun o.mjs prints https://prod.example https://prod.example. node, bun lazy.mjs and a bundle from esbuild 0.28.2 print http://localhost https://prod.example.

The design stays

The review compared the rule of this PR with five other designs: the rule of esbuild (#34934), a position rule, two rules that search for an early read, and no change. It kept the rule of this PR.

A position rule ("a var store stays in the wrapper only if a statement before it stays") is not safer. I ran it in a prototype build of main. Each module below is read through export * from by a static import, in a bundle where an import() makes it a wrapped module.

module node main position rule every var store stays
export var first = [1][0]; export var Registry = class {}; in a "sideEffects": false package, typeof of both number function undefined function undefined undefined undefined undefined
await new Promise(r => setTimeout(r, 5)); export var v = "V"; V V undefined undefined

So each rule that keeps a store in the wrapper shows the two bugs of main that the body lists. Main is wrong for them today if the value is computed: first above is undefined, and v = ["V"][0] in the second module prints undefined.

The import through export * is wrong on main for every computed value

// pkg/package.json: { "name": "pkg", "sideEffects": false, "type": "module" }
// pkg/barrel.mjs
export * from "./lib.mjs";
// pkg/lib.mjs
export var movable = 200;
export var computed = Math.max(500, 1);
export function get() { return computed; }
// entry.mjs
import { movable, computed, get } from "./pkg/barrel.mjs";
console.log(movable, computed, get());
await import("./pkg/lib.mjs");
form node main 5c6d4e2 esbuild 0.28.2
as written 200 500 500 200 undefined undefined undefined undefined undefined
two export * hops 200 500 500 200 undefined undefined undefined undefined undefined
import * as ns, then ns.computed 200 500 500 200 undefined undefined undefined undefined undefined
the lazy edge is a require(), or a lazily loaded module that imports lib.mjs 200 500 500 200 undefined undefined undefined undefined undefined
export { movable, computed, get } from "./lib.mjs" 200 500 500 200 500 500 200 500 500
no "sideEffects" field 200 500 500 200 500 500 200 500 500
the barrel has a second export that the entry imports too 200 500 500 200 500 500 200 500 500

The import binds to lib.mjs directly, so no part of the barrel is live, and the init_lib() call of its export * statement is dropped. I work on a fix for this as a separate PR. It has to merge before this PR.

What this PR needs, in order

  1. The export * fix.
  2. bundler: evaluate a wrapped module's dependencies in source order and await every async one #41601. It conflicts with main in one file, test/bundler/bundler_bytecode_portable.test.ts. Each file under src/ merges without a conflict.
  3. A rebase of the five commits of this PR onto main, with main as the base. On main needs_wrapper_ref does not need the exports_kind parameter: a CommonJS file gets its wrapper symbol before the call (src/js_parser/p.rs:9264).
  4. For a var in a body without braces: js_parser: relocate module-level vars from brace-less bodies for the bundler and the REPL #45705 and js_parser: print a var merged with a top-level function declaration as an assignment #44001. The review proposed a way to not wait for them: a file that keeps such a var inside the wrapper keeps main's output for all of its var stores. That guard is a sketch. Nobody compiled it.

@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

The base branch of this PR (#41601) is merged with main 5c6d4e24cd at 09d9c21d1b. It is mergeable against main again. The merge changed only the libraries.js pins in test/bundler/bundler_bytecode_portable.test.ts on the branch side. This PR needs a merge of its base to pick that up.

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

The fix for the import through export * of a "sideEffects": false file is #46786. It is the first item of "What this PR needs, in order" in the comment above.

One result of that work applies here too. With --splitting, a reader in a chunk that two entry points share does not import the chunk that holds a barrel's init_x() statement. #46786 adds that chunk import for export *. For export { x } from it is still missing on main: a computed value reads undefined there in that layout. I expect the same for a var that this PR keeps inside the wrapper. I did not build this branch to check it.

@robobun

robobun commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

The base branch of this PR (#41601) moved to 2c90d7953b6. It is merged with main 9b8347a24e and is mergeable against main again.

Two changes on the base matter here:

  • The __esmAsync helper and the init_a() && await init_a() guard are removed. A wrapper waits for one async dependency with a bare await init_a(), as on main.
  • InsideWrapperPrefix::append_non_dependency no longer splits a var declaration into a hoisted var ns; and an assignment. A statement after an async dependency stays as written.

This PR needs a merge of its base to pick that up.

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.

2 participants