Skip to content

bundler: keep import() / require() locals that must not read the export - #42473

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/89bbca00/keep-dynamic-import-locals
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/89bbca00/keep-dynamic-import-locals

Conversation

@robobun

@robobun robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Since bundler: read import() / require() exports without a namespace object #41186, bun build binds a local from import() / require() to the export in four shapes that need the local (1.4.0 is correct). Example: const { a, ...rest } = require("./m"); const { a: x } = rest; gives x the value of a.
  • Shapes: a nested var that names a .then parameter, a pattern off a {...rest} copy or off c ? require(x) : null, a read that runs before the declaration.
  • is_item (src/js_parser/parse/parse_entry.rs:1318) and note_destructured_locals (src/js_parser/p.rs:1191) check only the local's own symbol and reads above the declaration.

Fix

  • The parser flags a symbol that another symbol links to (IS_LINK_TARGET). parse_entry then checks both ends of a link.
  • A pattern off a {...rest} copy makes no import items. is_item rejects a record in dynamic_import_needs_object (a conditional local).
  • If code below a const / let can run first (a function declared below it that earlier code refers to, a later case), a read there keeps the local. Top-level declarations print as var and are exempt.
  • Verified: test/bundler/bundler_dynamic_import_dce.test.ts (8 new cases, 28 of 32 variants fail on 1.4.3), 13 more suites. Self-reviewed: 3 changes asked, 3 made.

Background

  • An import item is a symbol that the linker binds to another module's export. The printer prints the export and drops the local from its pattern.
  • A nested var that names a parameter is the same variable. hoist_symbols links its symbol to the parameter's, which gets no link itself.
  • dynamic_import_needs_object holds the import records whose local can hold no namespace.
Notes

No user reported these. A comparison of bundled output with unbundled runs found them. 1.4.1 and 1.4.2 have them. "1.4.3" below is 1.4.3-canary.1+4ff919377.

Repro. bun build ./entry.js --target=bun --outdir out && bun out/entry.js, compared with bun run entry.js. It also reproduces with --minify, --format=cjs, --target=node and --target=browser.

shape unbundled 1.4.3 bundle
import("./m.js").then(({ a }) => { if (retry) { var a = "fallback"; } console.log(a); }) A undefined
import("./m.js").then(ns => { for (var ns of [{ a: "last" }]) {} console.log(ns.a); }) last A
const { a, ...rest } = require("./m.js"); const { a: x, b } = rest; console.log(a, x, b); A undefined B A A B
const ns = c ? require("./a.js") : null; try { const { x } = ns; … } catch … with c false threw TypeError got ax, from a module that did not load
get() above const { a } = require("./m.js"), with function get() { return a; } below it ReferenceError undefined
switch (k) { case 0: const { a } = require("./m.js"); return a; case 1: return a; } with k = 1 ReferenceError A, and m.js never runs

Variant matrix. 72 variants of the four shapes: each statement that can hold a nested var, the await import() / require() / .then / Promise.all sources, let, a generator, typeof, nested and reversed ternaries, a rest of a rest, a case test, a function declared in a case. Each one was built with --target=bun, --minify, --format=cjs, --target=node, --target=browser and --minify-syntax, and compared with the unbundled run. On 1.4.3, 211 of the first 383 builds differ. On this branch none of 423 differ (5 differ only in how console.log prints a namespace object).

The read-before-declaration rule. note_destructured_locals already keeps a local that has a read above its declaration, "since that read must not see the export". Code below the declaration can also run first. There are two ways:

  • A function declaration below it in the same scope, which the scope creates at entry. It can only run early if some code above the declaration refers to a function declared below it. can_read_local_before_init checks that: a member of the current scope that is a function, is declared after the local, and has a use (or an inexact use count, for { var get; get(); } merged into function get).
  • A later case of the same switch, or its test. All cases share one scope, and the switch can jump past the declaration. A declaration inside case 0: { … } is not affected.

When one of the two holds, watch_early_reads records the locals that the declaration binds. s_function and s_switch take watched_reads_in_scope() before each function declared in that scope and before each case, and call keep_locals_read_since() after it. A destructured local with a new use leaves dynamic_import_destructured_locals. A namespace local with a new ns.a item read gets its records into dynamic_import_needs_object, so its items print ns.a, the same as for c ? require(x) : null. The rule does not follow calls: once some function declared below is referred to early, a read in any function declared below counts.

What the rule costs. It changes output only when code that can run early reads the local. These layouts keep the #41186 output (zod 4, const { z } = await import("zod"), bytes unminified / minified):

layout main this branch
no helper 140,354 / 69,339 same
log("start") above, function log below, log does not read z 140,420 / 69,380 same
the same with const zod = await import("zod") and zod.z 140,420 / 69,386 same
const { z } in an unbraced case, default: does not read z 140,457 / 69,399 same
process.on("exit", report) above, function report below reads z 140,448 / 69,397 757,285 / 461,122

The last row is the cost. There report can run before the await finishes. That read throws in the source, and main reads the export of a module that is still loading. A top-level declaration is exempt, because select_local_kind prints it as var: 1.4.0 already returned undefined for an early read there, also with a CommonJS importee that never binds. So module.exports = fn above a top-level require() keeps the #41186 output. The scan reads the members of one scope, only for a declarator that import() / require() tracking matched.

Not covered: --minify-syntax in a switch case. With --minify-syntax (also bun run), case 0: const ns = require("./m.js"); return ns.a; case 1: return ns.a; still loses the declaration. Single-use substitution runs when one case body ends, before the other cases are visited, and moves the initializer into return. case 1 then reads a free ns. That is older than #41186 and is tracked in #18477, with #30936 and #40791 open for it. ReadInLaterSwitchCase gives each local two uses in its own case, so the substitution does not apply, and it checks for before initialization in the message.

IS_LINK_TARGET. #41158 (open) adds a flag with the same meaning on another bit. This one has the same name and is set at the same three places (hoist_symbols twice, declare_symbol on ReplaceWithNew), so that PR can reuse it. Only the first place changes what this PR's tests see. The other two keep the name true.

is_item and records.len() == 1. register_dynamic_import_namespace_local_multi puts every record of a local with more than one record into dynamic_import_needs_object. The new check covers that case, so it replaces the old one.

switch_scope replaces is_inside_switch. Both would be set and restored together, so the break check now reads switch_scope.is_none().

.then parameter defaults. .then(({ b = a, a }) => …) reads a before the pattern binds it. The tracking runs before the parameter is visited, so a had no use yet and was bound. Now a default value that is not a primitive literal stops the pattern's names from being items (binding_default_can_read_names). Promise.all([…]).then(([x = ns.a, ns]) => …) is not tracked at all.

Self-review. Asked before merge: do not claim the switch shape closed under --minify-syntax and make the test say why it throws (done, above). State the cost on correct code (an earlier version of this branch kept every local of a declaration once any function below it was referred to early. That cost zod 140 KB to 757 KB in the log("start") layout. The watch replaced it, and the table above is the cost now). Name the flag for its invariant and fold the three has_link() checks (done). Follow-ups done here: the .then default value, the merged var get. Not done: one shared predicate for every "can this namespace local bind" check, and a checked-in matrix with a Node oracle.

Each clause is needed. I removed each of the 23 conditions that the fix adds, one at a time, rebuilt, and ran the new tests. Each removal makes at least one new test fail.

No change on real packages. I bundled rollup, express, fastify, hono, axios, mongodb, pg, lodash, acorn, react-dom, svelte, undici, happy-dom, immutable and the corpus of bundler_bytecode_portable.test.ts with main and with this branch (46 outputs, 39 MB, minified and not). The outputs are byte-identical.

Not changed. A sloppy-mode { function a() {} } that names a parameter is merged into the parameter, as in esbuild. The flag makes such a parameter stay a local too.

Suites run with the debug build: bundler_dynamic_import_dce (306 pass), bundler_promiseall_deadcode, bundler_minify, bundler_cjs2esm, bundler_edgecase, bundler_barrel, bundler_splitting, esbuild/dce, esbuild/importstar, esbuild/splitting, esbuild/default, transpiler/transpiler, transpiler/react-compiler, regression/issue/cyclic-imports-async-bundler. bundler_bytecode_portable times out on my machine in a debug build (its bun build --bytecode steps take longer than the test allows). Its bundler inputs are part of the byte-identical comparison above.


[human-review] gate passed · iteration 0 · 8 files touched

fails on main (without fix)
ASAN without fix: 28 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_dynamic_import_dce.test.ts
bun test v1.4.3 (4ff919377)

test/bundler/bundler_dynamic_import_dce.test.ts:
(pass) bundler > dynamic_import_dce/AwaitDestructure [1460.71ms]
(pass) bundler > dynamic_import_dce/AwaitDestructureAlias [653.99ms]
(pass) bundler > dynamic_import_dce/AwaitDot [828.32ms]
(pass) bundler > dynamic_import_dce/AwaitIndex [667.28ms]
(pass) bundler > dynamic_import_dce/LetBinding [656.12ms]
(pass) bundler > dynamic_import_dce/TwoSitesUnion [706.23ms]
(pass) bundler > dynamic_import_dce/BailoutRest [757.13ms]
(pass) bundler > dynamic_import_dce/BailoutDefault [1050.46ms]
(pass) bundler > dynamic_import_dce/BailoutComputed [1056.61ms]
(pass) bundler > dynamic_import_dce/SplittingNarrowedExports [771.77ms]
(pass) bundler > dynamic_import_dce/SplittingTwoImportersUnion [1450.98ms]
(pass) bundler > dynamic_import_dce/SplittingEscapeKeepsAll [649.43ms]
(pass) bundler > dynamic_import_dce/SplittingAwaitDot [654.54ms]
(pass) bundler > dynamic_import_dce/SplittingThenDestructure [890.77ms]
(pass) bundler > d
... (truncated)

release without fix: 28 FAILED
bun test v1.4.3-canary.1 (4ff919377)

test/bundler/bundler_dynamic_import_dce.test.ts:
(pass) bundler > dynamic_import_dce/AwaitDestructure [170.73ms]
(pass) bundler > dynamic_import_dce/AwaitDestructureAlias [38.83ms]
(pass) bundler > dynamic_import_dce/AwaitDot [27.97ms]
(pass) bundler > dynamic_import_dce/AwaitIndex [43.29ms]
(pass) bundler > dynamic_import_dce/LetBinding [24.80ms]
(pass) bundler > dynamic_import_dce/TwoSitesUnion [24.29ms]
(pass) bundler > dynamic_import_dce/BailoutRest [26.16ms]
(pass) bundler > dynamic_import_dce/BailoutDefault [41.73ms]
(pass) bundler > dynamic_import_dce/BailoutComputed [18.82ms]
(pass) bundler > dynamic_import_dce/SplittingNarrowedExports [24.03ms]
(pass) bundler > dynamic_import_dce/SplittingTwoImportersUnion [35.37ms]
(pass) bundler > dynamic_import_dce/SplittingEscapeKeepsAll [97.31ms]
(pass) bundler > dynamic_import_dce/SplittingAwaitDot [21.13ms]
(pass) bundler > dynamic_import_dce/SplittingThenDestructure [21.16ms]
(pass) bundler > dynamic_import_dce/SplittingPromiseAllDestructure [23.71ms]
(pass) bundler > dynamic_import_dce/SplittingPromiseAllThenDestructure [21.87ms]
(pass) bundler > dynamic_import_dce/SplittingPro
... (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/bundler/bundler_dynamic_import_dce.test.ts
bun test v1.4.3 (4ff919377)

test/bundler/bundler_dynamic_import_dce.test.ts:
(pass) bundler > dynamic_import_dce/AwaitDestructure [1713.31ms]
(pass) bundler > dynamic_import_dce/AwaitDestructureAlias [830.07ms]
(pass) bundler > dynamic_import_dce/AwaitDot [765.57ms]
(pass) bundler > dynamic_import_dce/AwaitIndex [514.92ms]
(pass) bundler > dynamic_import_dce/LetBinding [619.80ms]
(pass) bundler > dynamic_import_dce/TwoSitesUnion [595.37ms]
(pass) bundler > dynamic_import_dce/BailoutRest [814.22ms]
(pass) bundler > dynamic_import_dce/BailoutDefault [651.17ms]
(pass) bundler > dynamic_import_dce/BailoutComputed [757.54ms]
(pass) bundler > dynamic_import_dce/SplittingNarrowedExports [684.32ms]
(pass) bundler > dynamic_import_dce/SplittingTwoImportersUnion [1199.03ms]
(pass) bundler > dynamic_import_dce/SplittingEscapeKeepsAll [611.99ms]
(pass) bundler > dynamic_import_dce/SplittingAwaitDot [742.69ms]
(pass) bundler > dynamic_import_dce/SplittingThenDestructure [746.07ms]
(pass) bundler > dyn
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     48362b59da
  features     baseline

23 deps, 131 codegen, 1172 objects in 1161ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.3-canary.1 (4ff919377)

Checked 22 installs across 61 packages (no changes) [75.00ms]
[2/1244] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (4ff919377)

Checked 1 install across 2 packages (no changes) [2.00ms]
[3/1244] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (4ff919377)

Checked 111 installs across 104 packages (no changes) [11.00ms]
[4/1244] gen ErrorCode+*.h
[5/1244] fetch tinycc
[tinycc] up to date
[6/1243] gen bindgenv2
[7/1243] gen node-fallbacks/react-refresh.js
Bundled 1 module in 69ms

  react-refresh.js  4.81 KB  (entry point)

[8/1243] esbuild bun-error

  ../../build/release/codegen/bun-error/index.js       34.9kb
  ../../build/release/codegen/bun-error/bun-error.css  12.8kb

⚡ Done in 80ms
[9/1243] fetch libjpeg-turbo
[libjpeg-turbo]
... (truncated)
diff hotspot
src/ast/symbol.rs                               |  14 +
 src/js_parser/p.rs                              | 136 ++++++++-
 src/js_parser/parse/parse_entry.rs              |  29 +-
 src/js_parser/parser.rs                         |   3 +-
 src/js_parser/visit/mod.rs                      |  19 +-
 src/js_parser/visit/visit_expr.rs               |  12 +-
 src/js_parser/visit/visit_stmt.rs               |  13 +-
 test/bundler/bundler_dynamic_import_dce.test.ts | 350 ++++++++++++++++++++++++
 8 files changed, 551 insertions(+), 25 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                             reads  edits  tests
src/ast/symbol.rs                                    2      3     26
src/js_parser/p.rs                                   9      9     26
src/js_parser/parse/parse_entry.rs                   3      5     26
src/js_parser/parser.rs                              1      2     26
src/js_parser/visit/mod.rs                           5     10     26
src/js_parser/visit/visit_expr.rs                    3      1     26
src/js_parser/visit/visit_stmt.rs                    4      7     26
test/bundler/bundler_dynamic_import_dce.test.ts      4      4     26

Since #41186 the bundler binds a local from import() / require() to the
export it names. Four shapes bound a local that must stay a local:

- A `var` in a nested scope that names a `.then` parameter. hoist_symbols
  links the `var` symbol to the parameter symbol, so the parameter's own
  symbol has no link. The parser now sets IS_LINK_TARGET on a symbol that
  another symbol links to, and parse_entry asks use_count_is_exact(),
  which covers both ends of a link.
- A pattern off a `{...rest}` copy. The copy does not have the names that
  the pattern beside it took, so the pattern makes no import items.
- A pattern off `c ? require(x) : null`. The local can be null, so the
  pattern must run. is_item now rejects a record that needs the namespace
  object.
- A read that runs before the declaration: in a function declared below
  it that earlier code already refers to, in a later case of the same
  switch, or in a default value of a `.then` parameter. That read throws.
  The parser watches the locals of such a declaration, and one that the
  early code reads is not bound. A top-level declaration prints as `var`
  and is exempt.
@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 8 days. After that, they cost $0.25 per reviewed file.

Or wait 8 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 7cde47b8-25f5-4ad7-80d9-396c5f079a5d

📥 Commits

Reviewing files that changed from the base of the PR and between b993710 and 48362b5.

📒 Files selected for processing (8)
  • src/ast/symbol.rs
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • src/js_parser/parser.rs
  • src/js_parser/visit/mod.rs
  • src/js_parser/visit/visit_expr.rs
  • src/js_parser/visit/visit_stmt.rs
  • test/bundler/bundler_dynamic_import_dce.test.ts

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

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: this PR is ready for review (#42473).

How I reproduced it: for each entry in the table under Notes, I ran bun build ./entry.js --target=bun --outdir out && bun out/entry.js with 1.4.3-canary.1+4ff919377 and compared the output with bun run entry.js. All of them differ on that build, and on --minify, --format=cjs, --target=node and --target=browser. With this branch the outputs match. The new cases in test/bundler/bundler_dynamic_import_dce.test.ts run the same comparison: 28 of the 32 new test variants fail on that build, and all pass on this branch.

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:32 AM PT - Sep 12th, 2026

✅ @robobun, your commit 48362b59da82e40238646badcb46c7cf22600564 passed in Build #114717! 🎉


🧪   To try this PR locally:

bunx bun-pr 42473

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

bun-42473 --bun

@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 and didn't find bugs. Given it reworks the dynamic-import DCE gating in the parser visit pass and swaps two shared conditions (is_inside_switch → switch_scope, records.len() == 1 → dynamic_import_needs_object), a human familiar with #41186 should also look.

What was reviewed:

  • is_inside_switch removal — grepped src/, no remaining readers; the one other consumer (break-outside-switch) reads switch_scope.is_none() at the same set/restore points.
  • records.len() == 1 → dynamic_import_needs_object gate — confirmed register_dynamic_import_namespace_local_multi inserts every record when len() > 1, so the new check subsumes the old and only adds the new conditional/early-read cases.
  • Hot-path cost — watched_reads_in_scope() allocates per function-decl/case but iterates dynamic_import_early_read_watch, which stays empty unless a tracked declarator hits can_read_local_before_init; files with no dynamic-import tracking pay an empty-vec collect.
  • IS_LINK_TARGET — set at all three link.set() merge sites (two in hoist_symbols, one in declare_symbol ReplaceWithNew); use_count_is_exact() folds the prior has_link()/must_not_be_renamed() checks plus the new flag.
Extended reasoning...

Overview

This PR hardens the bundler's dynamic-import DCE (introduced in #41186) so locals bound from import()/require() are not rewritten to direct export references when the read can observe TDZ, a {...rest} copy missing keys, or a conditional/null value. It touches src/ast/symbol.rs (new IS_LINK_TARGET flag + use_count_is_exact()), src/js_parser/p.rs (~130 new lines: early-read watch state + five helpers + three set_is_link_target calls at merge sites), src/js_parser/parse/parse_entry.rs (folds link/rename checks into use_count_is_exact() and swaps the records.len() == 1 gate for dynamic_import_needs_object), src/js_parser/parser.rs (is_inside_switch: bool → switch_scope: Option<StoreRef<Scope>>), the visit pass (visit/mod.rs, visit_expr.rs, visit_stmt.rs), and adds ~350 lines of itElides cases to test/bundler/bundler_dynamic_import_dce.test.ts.

Security risks

None. This is bundler output-correctness work with no auth, crypto, network, or filesystem surface. The failure mode of a bug here is miscompiled bundled output (wrong runtime value or a lost TDZ throw), not a security exposure.

Level of scrutiny

High. The change sits in the parser visit hot path and alters two shared conditions that other code depends on: FnOrArrowDataVisit::is_inside_switch is replaced wholesale, and the is_item gate in parse_entry.rs changes from a structural records.len() == 1 check to a set-membership check. Per REVIEW.md, refactors are guilty until proven behavior-preserving — I verified the is_inside_switch replacement has no orphaned readers and the dynamic_import_needs_object gate strictly subsumes the old one (every multi-record local is inserted at registration). The new per-declarator/per-case work is gated so files without tracked dynamic imports pay effectively nothing. Still, the interaction between watch_early_reads, bindable_reads (which counts import-item use estimates), and keep_locals_read_since across nested scopes and revisits is subtle enough that someone who owns the #41186 machinery should confirm the demotion rules match intent.

Other factors

Test coverage is strong: eight new itElides scenarios exercise nested-var merges across every hosting statement form, {...rest} re-destructure, conditional-null locals, hoisted-function TDZ reads (including through a merged var and an indirect call chain), later-case reads, .then parameter defaults, and a negative case proving unrelated hoisted functions and top-level var-printed declarations keep the #41186 output. The PR description documents a 72-variant × 6-mode matrix run and names the known --minify-syntax gap it does not close (#18477). No prior review activity on the PR. dynamic_import_early_read_watch is never pruned on scope exit, but entries are filtered by current_scope at read time and the vec is bounded by tracked-declarator count, so that's not a concern.

@robobun

robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

The review found no bugs, so the branch does not change.

Two notes on its points:

  • Hot path: when dynamic_import_early_read_watch is empty, watched_reads_in_scope() collects an empty Vec, which does not allocate. A file with no tracked import() / require() declarator in a scope that can run code early never gets an entry.
  • The list is never pruned. An entry only matches while the parser visits the scope that declared it, and the list is per file, so it cannot grow past the number of tracked declarators.

CI: Buildkite build 114717 passed. The claude-find-issues job ("Find duplicate PRs") failed in its own setup before it looked at the diff. It is not a result of this change.

I agree that someone who knows #41186 should check the two rules that decide when a local stays a local: keep_locals_read_since in src/js_parser/p.rs, and the is_item condition in src/js_parser/parse/parse_entry.rs. The Notes in the description give the reasoning and the measured cost for each.

@robobun

robobun commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #45016 replaces this. It rebuilds the link flag and the {...rest} gate from this PR on main (this branch has conflicted since #42569 took the flag bit), and reads the flag where a local is registered. The read-before-declaration rule and the .then default rule are not in #45016: the first one costs correct code (the zod table above). Both shapes are kept as todo cases in the test file of #45016, and the repros and the cost table stay here for that work.

@robobun robobun closed this Oct 12, 2026
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