Skip to content

js_parser: mangle switch case bodies after every case is visited - #40791

Open
robobun wants to merge 1 commit into
mainfrom
farm/bb00899d/switch-case-single-use-inline
Open

robobun wants to merge 1 commit into
mainfrom
farm/bb00899d/switch-case-single-use-inline

Conversation

@robobun

@robobun robobun commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With minify-syntax on (bun file.js, bun build --minify-syntax), a let/const declared in one case clause and used in a later clause is inlined into its first use and the declaration is deleted. case 1: const tag = {}; use(tag); case 2: return tag; becomes case 1: use({}); case 2: return tag;.
  • The cause is the single-use inliner at the tail of visit_stmts (src/js_parser/visit/mod.rs). It trusts use_count_estimate, but s_switch (src/js_parser/visit/visit_stmt.rs) calls visit_stmts once per case body, so uses in later cases are not counted yet. esbuild has the same bug.

Fix

  • Move the minify-syntax tail of visit_stmts into mangle_stmts. visit_stmts skips it for StmtsKind::SwitchStmt. s_switch runs it on each case body after every case is visited, when every use in the switch scope is counted.
  • Case bodies keep the same output as any other block: a single use in the same clause is still inlined, and statement merging is unchanged.
  • RuntimeTranspilerCache::EXPECTED_VERSION goes to 28 so cached output from the old inliner is not reused.
  • Verified: test/bundler/bundler_minify.test.ts (two new tests, both fail on 1.4.1). The other suites run are listed in the notes.

Background

Notes

Repro from the report, run as bun sw.mjs (node prints ["object","s1!"]):

function f(k) {
  switch (k) {
    case 1: const tag = { id: 1 }; use(tag);
    case 2: return typeof tag;
  }
}
function g() { switch (1) { case 1: let s = "s1"; use(s.length); default: { return s + "!"; } } }
function use(v) { return v; }
let r; try { r = g(); } catch (e) { r = e.constructor.name; }
console.log(JSON.stringify([f(1), r]));

1.4.1 prints ["undefined","ReferenceError"]. With this change it prints ["object","s1!"].

Other shapes checked against node with the fixed build (all match): a Duff-style decoder that pushes onto an array declared in the first case (1.4.1 throws acc is not defined), a declaration read only by a closure in a later case, a let reassigned in the default clause, a nested switch sharing a declaration with the outer case, and a declaration used as a later case value. Case values are visited in the same loop as the bodies, so a declaration used in a later case t: expression had the same bug.

Why defer instead of guarding: a guard on kind == SwitchStmt (or on is_inside_switch, as in #30936) would stop inlining case 1: const x = foo(); return x.y; shapes, which are common in unbraced case clauses. Deferring the whole mangle pass keeps the output identical to a braced block. bun build --minify-syntax output for a file with mixed same-case and cross-case declarations is byte-identical between 1.4.1 and this build except for the declarations that must now stay.

esbuild 0.21.5 and 0.25.1 produce the same wrong output for the repro. The current esbuild mangleStmts still runs per case body.

Suites run with the debug build, all green: test/bundler/bundler_minify.test.ts, bundler_edgecase, bundler_regressions, bundler_cjs2esm, bundler_minify_symbol_for, transpiler_constant_fold_eqeq, esbuild/{dce,default,ts,lower,extra}, transpiler/transpiler.test.js, transpiler/runtime-transpiler, cli/run/transpiler-cache. cargo clippy -p bun_js_parser is clean. cargo fmt --check and prettier pass.


[review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_minify.test.ts
bun test v1.4.1 (65362b53b)

test/bundler/bundler_minify.test.ts:
(pass) bundler > minify/DirectEvalKeepsTopLevelNamesOfWrappedFile [802.38ms]
(pass) bundler > minify/TemplateStringFolding [175.59ms]
(pass) bundler > minify/StringAdditionFolding [111.38ms]
(pass) bundler > minify/FunctionExpressionRemoveName [113.69ms]
(pass) bundler > minify/KeepNamesPreservesNames [94.40ms]
(pass) bundler > minify/KeepNamesWithMinifyIdentifiers [85.26ms]
(pass) bundler > minify/PrivateIdentifiersNameCollision [1142.83ms]
(pass) bundler > minify/MergeAdjacentVars [358.93ms]
(pass) bundler > minify/UnusedCommaAndStrictEqChains [457.79ms]
(pass) bundler > minify/Infinity [112.05ms]
(pass) bundler > minify+whitespace/Infinity [98.19ms]
(pass) bundler > minify/NumericPropertyKeysPrintedAsComputed [431.10ms]
(pass) bundler > minify/SameTargetDestructuringAfterOtherDecl [436.20ms]
(pass) bundler > minify/SameTargetDestructuringMultipleRuns [351.68ms]
(pass) bundler > minify/SameTargetDestructuringStopsWhenTargetRebound [3
... (truncated)

release without fix: 7 FAILED
bun test v1.4.1-canary.1 (65362b53b)

test/bundler/bundler_minify.test.ts:
(pass) bundler > minify/DirectEvalKeepsTopLevelNamesOfWrappedFile [21.53ms]
(pass) bundler > minify/TemplateStringFolding [3.69ms]
(pass) bundler > minify/StringAdditionFolding [3.95ms]
(pass) bundler > minify/FunctionExpressionRemoveName [3.56ms]
(pass) bundler > minify/KeepNamesPreservesNames [3.38ms]
(pass) bundler > minify/KeepNamesWithMinifyIdentifiers [2.90ms]
(pass) bundler > minify/PrivateIdentifiersNameCollision [14.81ms]
(pass) bundler > minify/MergeAdjacentVars [10.22ms]
(pass) bundler > minify/UnusedCommaAndStrictEqChains [9.29ms]
(pass) bundler > minify/Infinity [3.14ms]
(pass) bundler > minify+whitespace/Infinity [2.78ms]
(pass) bundler > minify/NumericPropertyKeysPrintedAsComputed [9.17ms]
361 |       `,
362 |     },
363 |     minifySyntax: true,
364 |     onAfterBundle(api) {
365 |       api.expectFile("/out.js").toContain("{ random: Math_random, random: Math_random2 } = Math");
366 |       api.expectFile("/out.js").toContain("{ cos: Math_cos, sin: Math_sin } = Math");
                                      ^
error: expect(received).toContain(expected)

Expected to contain: "{ 
... (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/mechgate.xml" test/bundler/bundler_minify.test.ts
bun test v1.4.1 (65362b53b)

test/bundler/bundler_minify.test.ts:
(pass) bundler > minify/DirectEvalKeepsTopLevelNamesOfWrappedFile [876.55ms]
(pass) bundler > minify/TemplateStringFolding [175.25ms]
(pass) bundler > minify/StringAdditionFolding [118.77ms]
(pass) bundler > minify/FunctionExpressionRemoveName [100.84ms]
(pass) bundler > minify/KeepNamesPreservesNames [98.12ms]
(pass) bundler > minify/KeepNamesWithMinifyIdentifiers [82.30ms]
(pass) bundler > minify/PrivateIdentifiersNameCollision [1207.93ms]
(pass) bundler > minify/MergeAdjacentVars [365.53ms]
(pass) bundler > minify/UnusedCommaAndStrictEqChains [502.12ms]
(pass) bundler > minify/Infinity [129.52ms]
(pass) bundler > minify+whitespace/Infinity [96.99ms]
(pass) bundler > minify/NumericPropertyKeysPrintedAsComputed [385.92ms]
(pass) bundler > minify/SameTargetDestructuringAfterOtherDecl [422.95ms]
(pass) bundler > minify/SameTargetDestructuringMultipleRuns [344.71ms]
(pass) bundler > minify/SameTargetDestructuringStopsWhenTargetRebound [3
... (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     06d24c5627
  features     baseline

23 deps, 131 codegen, 1172 objects in 670ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.1-canary.1 (65362b53b)

Checked 26 installs across 63 packages (no changes) [8.00ms]
[2/1244] gen bindgenv2
[3/1244] install /workspace/bun/packages/bun-error
bun install v1.4.1-canary.1 (65362b53b)

Checked 1 install across 2 packages (no changes) [2.00ms]
[4/1244] gen .bind.ts → GeneratedBindings.cpp
[5/1244] gen ErrorCode+*.h
[6/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1217] fetch tinycc
[tinycc] up to date
[8/1216] install /workspace/bun/src/node-fallbacks
bun install v1.4.1-canary.1 (65362b53b)

Checked 111 installs across 104 packages (no changes) [5.00ms]
[9/1216] fetch zlib
[zlib] up to date
[10/1216] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[11
... (truncated)
diff hotspot
src/js_parser/visit/mod.rs          | 22 +++++++++-
 src/js_parser/visit/visit_stmt.rs   | 11 +++++
 src/jsc/RuntimeTranspilerCache.rs   |  4 +-
 test/bundler/bundler_minify.test.ts | 82 +++++++++++++++++++++++++++++++++++++
 4 files changed, 116 insertions(+), 3 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                 reads  edits  tests
src/js_parser/visit/mod.rs               7      4      0
src/js_parser/visit/visit_stmt.rs        1      2      0
src/jsc/RuntimeTranspilerCache.rs        3      1      0
test/bundler/bundler_minify.test.ts      4      4      0

The single-use let/const inliner at the tail of visit_stmts trusts
use_count_estimate. A switch case body is one slice of the switch block
scope, so a declaration in one case can still be used by a later case
that has not been visited yet. The inliner saw a count of 1 and deleted
the declaration, which left the later use dangling.

Move the minify-syntax tail of visit_stmts into mangle_stmts. visit_stmts
skips it for StmtsKind::SwitchStmt and s_switch runs it on each case body
once all cases are visited. Case bodies keep the same inlining and
statement merging as any other block.

Bump the runtime transpiler cache version so cached output from the old
inliner is not reused.
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

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

Or wait 34 minutes for your next included review.

View limit details

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 268a5478-94a4-4532-a26c-bc3410a2782a

📥 Commits

Reviewing files that changed from the base of the PR and between fde8b45 and 06d24c5.

📒 Files selected for processing (4)
  • src/js_parser/visit/mod.rs
  • src/js_parser/visit/visit_stmt.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • test/bundler/bundler_minify.test.ts

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

@robobun

robobun commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 06d24c5: 180 of 181 jobs pass. The one red lane is darwin x64, where test/js/web/url/url.test.ts fails on the Unicode 16 IDNA check (new URL("http://foo\u180E:80/") throws Invalid URL). That failure is also on main and does not involve the parser. The remaining entries in the report passed on retry or when run alone. No bundler, transpiler, or minify test failed. Ready for review.

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

LGTM — clean fix that defers the mangle pass until use counts are final, rather than disabling it.

What was reviewed:

  • mangle_stmts is a pure extract of the old visit_stmts tail; body is unchanged, and it runs in the same switch block scope (before pop_scope), so current_scope().parent/contains_direct_eval see the same state — actually strictly better, since a direct eval in a later case now correctly suppresses removal in earlier cases too.
  • is_inside_switch is restored before the new mangle loop, but that flag is only read during s_break visiting, not by mangle_stmts, so no behavior change.
  • Test capture list checks out (the leading "v" is the function capture(v) definition matching captureFile's indexOf scan); only still inlines to v + 1, cross-case decls stay. Cache version bump is required and present.
Extended reasoning...

Overview

This PR fixes a minify-syntax correctness bug where let/const declarations in one switch case clause were being inlined and deleted before later clauses (which share the same block scope) had their uses counted. The fix extracts the mangle tail of visit_stmts into a new mangle_stmts() method, has visit_stmts skip mangling for StmtsKind::SwitchStmt, and has s_switch invoke mangle_stmts on each case body only after all cases have been visited. Files touched: src/js_parser/visit/mod.rs (extract method, ~20 lines net), src/js_parser/visit/visit_stmt.rs (10-line loop after the visit loop), src/jsc/RuntimeTranspilerCache.rs (version bump 27→28), and test/bundler/bundler_minify.test.ts (two new tests).

Security risks

None. This is a minifier/transpiler correctness fix with no auth, crypto, network, or filesystem surface. The only user-facing effect is that previously-broken switch fall-through code now runs correctly instead of throwing ReferenceError.

Level of scrutiny

Moderate — the JS parser's visit/mangle pass runs on every file Bun transpiles or bundles, so a regression here would be wide-blast. However, the change is deliberately conservative: mangle_stmts is a verbatim extract of the existing tail (the let p = self; alias keeps the body byte-identical), and it is called from s_switch while still inside the pushed switch block scope, so current_scope().parent.is_some() and contains_direct_eval observe the same values they did before. Deferring the pass until all cases are visited only gives the inliner more complete use_count_estimate data, never less — it cannot cause a new incorrect inlining, only prevent premature ones. I checked that is_inside_switch (restored before the new loop) is not read by anything mangle_stmts reaches; its only reader is the break-outside-loop diagnostic during visiting.

Other factors

Tests are well-designed per REVIEW.md: the itBundled case asserts both the negative contract (tag, s, n survive as identifiers via capture) and the positive contract (only still inlines to v + 1 in the same clause), plus a run.stdout check for end-to-end semantics; the second test exercises the runtime transpiler via bun -e with bunExe()/bunEnv, drains pipes concurrently, and asserts stdout/stderr before exit code. The transpiler cache version bump ensures stale broken output is invalidated. No CODEOWNERS cover these paths. The PR description notes an alternative open PR (#30936) that guards on is_inside_switch instead — this PR's defer approach is strictly better since it preserves same-clause inlining, and that claim is covered by the sameClause/v + 1 capture assertion.

@robobun

robobun commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

The review above asks for no changes. One detail it points out is worth stating plainly: mangle_stmts now runs after every case is visited, so a direct eval in a later case also blocks inlining in earlier cases. Before this change, an earlier case was mangled before the later case was visited. No open review threads. The diff is unchanged since 06d24c5 and ready for a maintainer.

robobun added a commit that referenced this pull request Sep 2, 2026
`RuntimeFeatures.minify_syntax_statements` replaces the
`minify_syntax && (bundle || transform_only)` predicate. The bundler's
parse task derives it from `minify_syntax`, and `bun build --no-bundle`
sets it through the transpiler options. `Bun.Transpiler`, `bun pm diff`
and the runtime transpiler leave it off, so their output is unchanged
from main. The `===` to `==`, `a === null || a === void 0`, logical
chain and `simplify_boolean` rewrites move behind the same flag.

The switch case bodies are mangled from `s_switch` once every case is
visited, so the single-use inliner sees the uses in later cases (same
fix as #40791). The runtime transpiler cache version goes to 29 for it.

A tagged template whose tag was a conditional keeps `this` unbound:
`(a ? o.m : o.m)\`x\`` prints as `(0, o.m)\`x\``.
@robobun

robobun commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

#41159 moves the same visit_stmts tail into src/js_parser/visit/mangle.rs and carries this fix (the StmtsKind::SwitchStmt deferral, the per-case mangle from s_switch, both tests, and the cache version bump). Either PR can land first: the other rebases onto it.

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