Repository navigation
Conversation
… 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.
|
Updated 5:05 PM PT - Nov 8th, 2026
❌ @robobun, your commit e438ef5 has some failures in 🧪 To try this PR locally: bunx bun-pr 48251That installs a local version of the PR into your bun-48251 --bun |
|
Status Reproduced on main It prints CI: no failure comes from this change. In build #138894 (head 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. |
…n/9303e486/bundle-top-level-using-scope
… ends with The unbundled program of `using/TopLevelDisposerThrows` ends with exit code 1, because its disposer throws.
…n/9303e486/bundle-top-level-using-scope Also shortens the comments that this branch adds to one line each.
|
Follow-up in d504902 and e438ef5:
|
Stacked on #48250. Draft: should the linker give a module its own scope for a native
using?Problem
bun build --target=bundisposes the top-levelusingof an imported module when the chunk ends. The repro in Notes printsmodule-end render module, and unbundledmodule-end module-disposed render module.process.exit(), the disposer never runs.using/await usingwhen targeting Bun #29538 (1.3.14) the parser keepsusingfor this target (src/bundler/ParseTask.rs:2490). The linker prints all modules without a wrapper into one scope.Fix
scope_top_level_usingjoins the part ranges of such a module. The printer puts its statements, from the firstusingon, in a block.usingprints asusing res2 = res = value, withvar resin front.usingis disposed when its block ends, and the block ends where the module ends.test/js/bun/resolve/lower-using-bun-target.test.ts(28 new cases, 26 fail on bundler: keep a top-levelusingbinding readable outside its__esmwrapper #48250), and the suites of bundler: keep a top-levelusingbinding readable outside its__esmwrapper #48250. Self-reviewed with bundler: keep a top-levelusingbinding readable outside its__esmwrapper #48250: 35 concerns raised, 30 addressed.Background
__esmwrapper: every module that it imports gets one too. Considered loweringusingagain: the bugs of the lowered path come back (js_parser: relocate the module-level declarations of a lowered top-levelusingout of its try #38284).Downsides
undefined. On main it throws.--footer, library builds (--dts), and a module with another file printed inside it.Notes
Repro
dep.jsentry.jsbun entry.jsmodule-end module-disposed render modulebun build ./entry.js --target=nodeorbrowser, then runmodule-end module-disposed render modulebun build ./entry.js --target=bun --outfile=out.js && bun out.js, 1.3.13module-end module-disposed render modulemodule-end render modulemodule-end module-disposed render module--compile,--compile --minifyand--compile --bytecode --format=esmprint the same two lines as--target=bun, before and after.The input of the existing
edgecase/UsingExportDefault(test/bundler/bundler_edgecase.test.ts:2543, expectsDisposing\n1) prints1\nDisposingwith--target=bunon 1.3.14 and on main. It printsDisposing\n1on 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:after:
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 firstusingstay as they are. From the firstusingon:__esmhoist loop.extends, a static block, a computed key) becomesvar K = class K { ... }in its place.usingbinding gets the second symbol of bundler: keep a top-levelusingbinding readable outside its__esmwrapper #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.letandconstdeclarations print asvar, 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
usingto its last statement. It runs after the ranges of every chunk are final and before the renamers.Prints as before
--footer, the footer runs before its disposer: 1.3.13 printsentry-end entry-disposed footer, 1.3.14 to main and this change printentry-end footer entry-disposed.using. A block cannot hold the statements of two ranges. Four layouts do this:require.resolve()of a bundled file (using/TopLevelWithAnotherFileInsidepins it)require()inside a package that the bundler unwraps to ESM, such asreact-dom--splitting, arequire()of a file that is its own chunk--splitting, the class-name object of a CSS module--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 throwsReferenceError, and so does main's bundle. With the block the binding is avarand readsundefined, as it does today in a bundle for node.var K = class K { ... }.usingbinding readable outside its__esmwrapper #48250 lists hold inside the block too. A read before the declaration givesundefined, and a directevalcan assign the binding.The outer binding is a
varand not alet. Withlet 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 withvarand 1.26 to 1.31 G on main (2e8 calls).Not in this change
Bun.Transpiler({ target: "bun", replMode: true })printsusing x = valueasvar x; x = valueand never disposes the resource.usingunder--react-compiler(react-compiler: do not compile a function that declaresusing, orvarin a for-in/of head #48094).usingout of its try #38284, js_parser: split a lifted CommonJS export into a declaration and a store when lowering top-levelusing#45562, js_parser: remove an unusedimport a = b.cwhen a top-levelusingis lowered #47994.Measurements
Linux x64. Release builds with
--lto=offof main7ca0042d3b, of #48250 and of this change on top of it. The branch is now on2596ac5d39, one commit later, and the change is the same.cmp, the set of bundler: keep a top-levelusingbinding readable outside its__esmwrapper #48250).wc -c): the repro 251 to 279 B (178 to 187 B minified). A module with only ausing109 to 135 B (73 to 82 B). A module with anawait using, ausingand classes 589 to 662 B (398 to 423 B). 10,000 modules with oneusingeach 1,434,879 to 1,753,772 B.usingeach: 1.000 G instructions on main, 1.001 G with bundler: keep a top-levelusingbinding readable outside its__esmwrapper #48250, 1.068 G with this change (+6.8%).using, 402 modules: +51,748 of 1,590,462,820 instructions against bundler: keep a top-levelusingbinding readable outside its__esmwrapper #48250 (median of 20 interleaved runs on one core). Two builds of the same commit differ by 67,340.JSLexicalEnvironmentcount 0 in both)..text+5,744 B against bundler: keep a top-levelusingbinding readable outside its__esmwrapper #48250 (107,585,632 to 107,591,376,size -A),.rodata+128 B, file +4,096 B.JavaScriptChunkgoes from 336 to 360 B.using: 4 print ranges become 1 when another file follows it. As the last module it keeps 4, and the output is byte-identical.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(alsoCLI,Cjs,Iife,Minify,Bytecode,NoTreeShaking),using/TopLevelAwaitUsing,using/TopLevelSeveralDeclarationsusing/TopLevelBindingReadOutsideItsBlock(alsoMinify,NoTreeShaking),using/TopLevelDirectEval,using/TopLevelReactCompiler,using/TopLevelClassesusing/TopLevelImportCycle,using/TopLevelReadBeforeItsModuleRuns-bunand-node,using/TopLevelDisposerThrows,using/TopLevelLaterModuleExitsusing/TopLevelSplitting,using/TopLevelEntryImportedByEntry,using/TopLevelLiftedCommonJS,using/TopLevelNotInTheFirstRangeOfItsModule,using/TopLevelLargeModule,using/TopLevelImportAfterUsingNoTreeShaking,using/TopLevelWithAnotherFileInsidea library build prints a module with a top-level using as it didTwo of the 28 pass without this change: the node case of
TopLevelReadBeforeItsModuleRunsand 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 onto2596ac5d39:esbuild/splitting,esbuild/default,bundler_cjs2esm,bundler_dynamic_import_dce,bundler_regressions,bundler_promiseall_deadcodeandregression/issue/11100. Two cases ofesbuild/defaultran 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
usingagain 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-scopesalso prints a module in a block, for shorter names and one small scope for each module. It leaves out a file with a top-levelusing.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:
--react-compiler, a function that the compiler outlines read ausingbinding of the block from outside it (using/TopLevelReactCompiler).--dts) lost its.aot.tsoutput (the library build test).Not done, as in #48250:
--compiletest (2 concerns). With a debug build on my machine,bun build --compilewrites a 1.6 GB file and can run into the 60 s limit of the test harness. The existingcompile/HelloWorldcase does. Checked by hand with release builds.usingshape intest/bundler/splitting-fuzz.ts. A new draw changes the program of every pinned seed.Other designs
__esmwrapper 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 callsinit_x().DisposableStackat chunk scope and adispose()call where the module ends: the chunk scope still holds oneusingfor each module.usingagain for this target: the module becomes one part with no tree shaking of its statements, the__usingand__callDisposehelpers 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)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 1
evidence per changed file