Skip to content

bundler: print a scope-hoisted module with a top-level using inside its own block - #48251

Draft
robobun wants to merge 4 commits into
robobun/9303e486/esm-wrapper-using-bindingfrom
robobun/9303e486/bundle-top-level-using-scope
Draft

robobun wants to merge 4 commits into
robobun/9303e486/esm-wrapper-using-bindingfrom
robobun/9303e486/bundle-top-level-using-scope

Conversation

@robobun

@robobun robobun commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #48250. Draft: should the linker give a module its own scope for a native using?

Problem

  • bun build --target=bun disposes the top-level using of an imported module when the chunk ends. The repro in Notes prints module-end render module, and unbundled module-end module-disposed render module.
  • If a later module calls process.exit(), the disposer never runs.
  • Since Don't lower using / await using when targeting Bun #29538 (1.3.14) the parser keeps using for this target (src/bundler/ParseTask.rs:2490). The linker prints all modules without a wrapper into one scope.

Fix

Background

Downsides

  • A module with a block prints 26 to 73 B more. Bundling 10,000 such modules takes 6.8% more instructions. The binary grows by 4,096 B.
  • A read of such a binding before its module runs gives undefined. On main it throws.
  • Still late: the last module under --footer, library builds (--dts), and a module with another file printed inside it.
Notes

Repro

dep.js

export const log = [];
using res = { [Symbol.dispose]() { log.push("module-disposed"); } };
export function render() { log.push("render"); return "module"; }
log.push("module-end");

entry.js

import { log, render } from "./dep.js";
log.push(render());
console.log(log.join(" "));
prints
bun entry.js module-end module-disposed render module
bun build ./entry.js --target=node or browser, then run module-end module-disposed render module
bun build ./entry.js --target=bun --outfile=out.js && bun out.js, 1.3.13 module-end module-disposed render module
the same, 1.3.14 to main module-end render module
the same, this change module-end module-disposed render module

--compile, --compile --minify and --compile --bytecode --format=esm print the same two lines as --target=bun, before and after.

The input of the existing edgecase/UsingExportDefault (test/bundler/bundler_edgecase.test.ts:2543, expects Disposing\n1) prints 1\nDisposing with --target=bun on 1.3.14 and on main. It prints Disposing\n1 on 1.3.13 and with this change.

No user reported this. It was found by a read of the code. esbuild 0.28.2 has the same late disposal with --target=esnext.

Output for dep.js, before:

var log = [];
using res = { [Symbol.dispose]() {
  log.push("module-disposed");
} };
function render() {
  log.push("render");
  return "module";
}
log.push("module-end");

after:

var log = [];
function render() {
  log.push("render");
  return "module";
}
var res;
{
  using res2 = res = { [Symbol.dispose]() {
    log.push("module-disposed");
  } };
  log.push("module-end");
}

What gets a block

A file without a wrapper that has a live top-level using, when another file prints after it in the chunk. Statements in front of the first using stay as they are. From the first using on:

  • A function declaration, and a class that runs no code where it is defined, print in front of the block. This is the rule of the __esm hoist loop.
  • A class that runs code (extends, a static block, a computed key) becomes var K = class K { ... } in its place.
  • Every using binding gets the second symbol of bundler: keep a top-level using binding readable outside its __esm wrapper #48250, with no test of who reads it. A part does not always record what its own statements read: the React Compiler puts the functions that it outlines in a part of their own.
  • Other let and const declarations print as var, as they already do in a bundle.

The block needs the module in one part range, so the step joins the ranges of the file from its first using to its last statement. It runs after the ranges of every chunk are final and before the renamers.

Prints as before

  • The last module of a chunk. The scope of the chunk ends where that module ends. With --footer, the footer runs before its disposer: 1.3.13 prints entry-end entry-disposed footer, 1.3.14 to main and this change print entry-end footer entry-disposed.
  • A module with a part range of another file after its first using. A block cannot hold the statements of two ranges. Four layouts do this:
    • require.resolve() of a bundled file (using/TopLevelWithAnotherFileInside pins it)
    • a require() inside a package that the bundler unwraps to ESM, such as react-dom
    • with --splitting, a require() of a file that is its own chunk
    • with --splitting, the class-name object of a CSS module
  • A library build (--dts). It also writes each output file with its types (.aot.ts), from the declarations as they are written. A test pins it.

For each of these the output is byte-identical to main's.

Differences from the unbundled program

  • using/TopLevelReadBeforeItsModuleRuns: in an import cycle, another module reads the binding before this module runs. The unbundled program throws ReferenceError, and so does main's bundle. With the block the binding is a var and reads undefined, as it does today in a bundle for node.
  • The same holds for a class that runs code and prints as var K = class K { ... }.
  • The differences that bundler: keep a top-level using binding readable outside its __esm wrapper #48250 lists hold inside the block too. A read before the declaration gives undefined, and a direct eval can assign the binding.

The outer binding is a var and not a let. With let res; { res = value } JavaScriptCore sees two writes and does not treat the binding as a constant. Reads of it in hot code took 1.78 G instructions against 1.31 G with var and 1.26 to 1.31 G on main (2e8 calls).

Not in this change

Measurements

Linux x64. Release builds with --lto=off of main 7ca0042d3b, of #48250 and of this change on top of it. The branch is now on 2596ac5d39, one commit later, and the change is the same.

Tests

The new cases are in describe("top-level using in a bundle for bun"), after the cases of #48250. Each case also runs the files unbundled. That run must end with the expected exit code and print the expected string.

  • using/TopLevelDisposedAtModuleEnd (also CLI, Cjs, Iife, Minify, Bytecode, NoTreeShaking), using/TopLevelAwaitUsing, using/TopLevelSeveralDeclarations
  • readers outside the block: using/TopLevelBindingReadOutsideItsBlock (also Minify, NoTreeShaking), using/TopLevelDirectEval, using/TopLevelReactCompiler, using/TopLevelClasses
  • order: using/TopLevelImportCycle, using/TopLevelReadBeforeItsModuleRuns-bun and -node, using/TopLevelDisposerThrows, using/TopLevelLaterModuleExits
  • layouts: using/TopLevelSplitting, using/TopLevelEntryImportedByEntry, using/TopLevelLiftedCommonJS, using/TopLevelNotInTheFirstRangeOfItsModule, using/TopLevelLargeModule, using/TopLevelImportAfterUsingNoTreeShaking, using/TopLevelWithAnotherFileInside
  • a library build prints a module with a top-level using as it did

Two of the 28 pass without this change: the node case of TopLevelReadBeforeItsModuleRuns and the library build. They pin output that must not change.

Other suites, on a debug build of this branch: test/bundler/bundler_edgecase.test.ts (197 pass), bundler_splitting (209 pass), bundler_minify (67 pass), bundler_jsx (65 pass). Before the rebase onto 2596ac5d39: esbuild/splitting, esbuild/default, bundler_cjs2esm, bundler_dynamic_import_dce, bundler_regressions, bundler_promiseall_deadcode and regression/issue/11100. Two cases of esbuild/default ran into the 5 s limit there, and also on main.

The question

This change makes the linker give one module a scope of its own, for one kind of statement. The other answers are to keep the late disposal, as esbuild does, or to lower using again when bundling for this target. If one of those is the answer, close this PR. #48250 does not depend on it.

The unmerged branch claude/optimize-module-scopes also prints a module in a block, for shorter names and one small scope for each module. It leaves out a file with a top-level using.

Self-review

The review read one commit that had #48250 and this change together. It raised 35 concerns. It found two regressions in the first version of this half. Both are fixed and have a test:

  • With --react-compiler, a function that the compiler outlines read a using binding of the block from outside it (using/TopLevelReactCompiler).
  • A library build (--dts) lost its .aot.ts output (the library build test).

Not done, as in #48250:

  • A --compile test (2 concerns). With a debug build on my machine, bun build --compile writes a 1.6 GB file and can run into the 60 s limit of the test harness. The existing compile/HelloWorld case does. Checked by hand with release builds.
  • Tests for three of the four layouts that print as before. One case covers the branch that they share.
  • A using shape in test/bundler/splitting-fuzz.ts. A new draw changes the program of every pinned seed.
  • A tracking issue.

Other designs

  • An __esm wrapper for the module: DependencyWrapper::wrap (src/bundler/linker_context/scanImportsAndExports.rs:1544) then wraps every file that the module imports, and the body runs where the importer calls init_x().
  • A DisposableStack at chunk scope and a dispose() call where the module ends: the chunk scope still holds one using for each module.
  • Lowering using again for this target: the module becomes one part with no tree shaking of its statements, the __using and __callDispose helpers come back, and so do the open bugs of the lowered path.

[human-review] gate passed · iteration 1 · 7 files touched

fails on main (without fix)
ASAN without fix: 26 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/resolve/lower-using-bun-target.test.ts
bun test v1.4.4 (367d939d9)

test/js/bun/resolve/lower-using-bun-target.test.ts:
(pass) using / await using is not lowered when targeting bun > single-use using declaration is not inlined away [7.20ms]
(pass) using / await using is not lowered when targeting bun > single-use using declaration disposes at runtime [287.38ms]
(pass) using / await using is not lowered when targeting bun > Bun.Transpiler passes using / await using through for target=bun [7.43ms]
(pass) using / await using is not lowered when targeting bun > Bun.Transpiler still lowers using / await using for target=browser [7.26ms]
(pass) using / await using is not lowered when targeting bun > Bun.Transpiler still lowers using / await using for target=node [6.29ms]
(pass) using / await using is not lowered when targeting bun > bun build --target=bun passes using / await using through [429.15ms]
(pass) using / await using is not lowered when targeting bun > bun build --target=browser still lowers using / await using [460.80ms
... (truncated)

release without fix: 26 FAILED
bun test v1.4.4-canary.1 (04ae1f8d8)

test/js/bun/resolve/lower-using-bun-target.test.ts:
(pass) using / await using is not lowered when targeting bun > single-use using declaration is not inlined away [1.15ms]
(pass) using / await using is not lowered when targeting bun > single-use using declaration disposes at runtime [9.38ms]
(pass) using / await using is not lowered when targeting bun > Bun.Transpiler passes using / await using through for target=bun [0.49ms]
(pass) using / await using is not lowered when targeting bun > Bun.Transpiler still lowers using / await using for target=browser [0.28ms]
(pass) using / await using is not lowered when targeting bun > Bun.Transpiler still lowers using / await using for target=node [0.22ms]
(pass) using / await using is not lowered when targeting bun > bun build --target=bun passes using / await using through [26.03ms]
(pass) using / await using is not lowered when targeting bun > bun build --target=browser still lowers using / await using [22.23ms]
(pass) using / await using is not lowered when targeting bun > top-level using in a lazily-wrapped ESM module still disposes when bundled [34.32ms]
(pass) using / await using i
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/resolve/lower-using-bun-target.test.ts
bun test v1.4.4 (367d939d9)

test/js/bun/resolve/lower-using-bun-target.test.ts:
(pass) using / await using is not lowered when targeting bun > single-use using declaration is not inlined away [7.59ms]
(pass) using / await using is not lowered when targeting bun > single-use using declaration disposes at runtime [254.96ms]
(pass) using / await using is not lowered when targeting bun > Bun.Transpiler passes using / await using through for target=bun [8.77ms]
(pass) using / await using is not lowered when targeting bun > Bun.Transpiler still lowers using / await using for target=browser [7.89ms]
(pass) using / await using is not lowered when targeting bun > Bun.Transpiler still lowers using / await using for target=node [6.43ms]
(pass) using / await using is not lowered when targeting bun > bun build --target=bun passes using / await using through [260.17ms]
(pass) using / await using is not lowered when targeting bun > bun build --target=browser still lowers using / await using [347.68ms
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 2886ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/119] build.rs build_script_build
[2/119] rustc bun_safety 
[3/119] rustc bun_zlib_sys 
[4/119] rustc bun_output 
[5/119] rustc bun_perf 
[6/119] rustc bun_boringssl_sys 
[7/119] rustc bun_brotli 
[8/119] rustc bun_cares_sys 
[9/119] rustc bun_ptr 
[10/119] rustc bun_zstd 
[11/119] rustc bun_base64 
[12/119] rustc bun_picohttp 
[13/119] rustc bun_clap 
[14/119] rustc bun_lsquic_sys 
[15/119] rustc bun_text_diff 
[16/119] rustc bun_errno 
[17/119] rustc bun_valkey 
[18/119] rustc bun_tcc_sys 
[19/119] rustc bun_paths 
[20/119] rustc bun_shell_parser 
[21/119] rustc bun_collections 
[22/119] rustc bun_zlib 
[23/119] rustc bun_core 
[24/119] rustc bun_url 
[25/119] rustc bun_semver 
[26/119] rustc bun_analytics 
[27/119] rustc bun_png 
[28/119] rustc bun_tar 
[29/119] rustc bun_http_types 
[30/119] rustc bun_which 
[31/119] rustc bun_node_path 
[32/119] rustc bun_dns 
[33/119] rustc bun_archive 
[34/119] rustc bun_boringssl 
[35/119] rustc bun_spawn_sys 
[36/119] rustc bun_sha_hmac 
[37/119] rustc bun_thre
... (truncated)
diff hotspot
src/bundler/Chunk.rs                               |   2 +
 src/bundler/LinkerContext.rs                       |   3 +
 src/bundler/bundle_v2.rs                           |   1 +
 src/bundler/linker_context/README.md               |  10 +-
 .../linker_context/generateCodeForFileInChunkJS.rs |  12 +
 src/bundler/linker_context/scopeTopLevelUsing.rs   | 280 ++++++++--
 test/js/bun/resolve/lower-using-bun-target.test.ts | 618 ++++++++++++++++++++-
 7 files changed, 881 insertions(+), 45 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                                      reads  edits  tests
src/bundler/Chunk.rs                                          0      0     41
src/bundler/LinkerContext.rs                                  1      0     41
src/bundler/bundle_v2.rs                                      0      0     40
src/bundler/linker_context/README.md                          0      0     41
…/bundler/linker_context/generateCodeForFileInChunkJS.rs      3      0     41
src/bundler/linker_context/scopeTopLevelUsing.rs              1      3     41
test/js/bun/resolve/lower-using-bun-target.test.ts            3      4     40

… its own block

With `--target=bun` the parser keeps `using` as written. An unwrapped
module prints into the one scope of its chunk, so its top-level `using`
was disposed when the chunk ended, not when the module ended.

The link step `scope_top_level_using` joins the part ranges of such a
module from its first `using` to its last statement, and the printer
puts them in a block. Function declarations and classes that run no
code stay in front of the block. Each `using` binding becomes a `var`
of the chunk: `var res; { using res2 = res = value; }`.

The last module of a chunk, a module with a range of another file after
its `using`, and a library build (`--dts`) print as before.
@github-actions github-actions Bot added the claude label Nov 8, 2026
@robobun

robobun commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:05 PM PT - Nov 8th, 2026

❌ @robobun, your commit e438ef5 has some failures in Build #138894 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 48251

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

bun-48251 --bun

@robobun

robobun commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main 7ca0042d3b and on 1.3.14 with the two files in the description:

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

It prints module-end render module. 1.3.13, bun entry.js, and this change print module-end module-disposed render module.

CI: no failure comes from this change. In build #138894 (head e438ef5bb3) 184 of 188 jobs passed. The 4 others are the macOS x64 test shards. They did not get an agent in four attempts, and the build ended red for that reason alone. The tests that failed once and passed on a retry are outside this change.

This PR is a draft on top of #48250. It waits for a maintainer's answer to the question in the first line of the description.

… ends with

The unbundled program of `using/TopLevelDisposerThrows` ends with exit
code 1, because its disposer throws.
Comment thread src/bundler/Chunk.rs Resolved
…n/9303e486/bundle-top-level-using-scope

Also shortens the comments that this branch adds to one line each.
@robobun

robobun commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up in d504902 and e438ef5:

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