Skip to content

minify: lower if statements to conditionals and logical expressions - #34826

Closed
robobun wants to merge 2 commits into
mainfrom
farm/e811b004/minify-more
Closed

robobun wants to merge 2 commits into
mainfrom
farm/e811b004/minify-more

Conversation

@robobun

@robobun robobun commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds the esbuild-style mangleIf pass to --minify-syntax. When both branches of an if are expression statements (or both return, or both throw), the whole statement is rewritten to the shorter expression form.

// before                            // after
if (a) b(); else c();                a ? b() : c();
if (a) b();                          a && b();
if (!a) b();                         a || b();
if (!a) b(); else c();               a ? c() : b();
if (a) return x; else return y;      return a ? x : y;
if (a) throw x; else throw y;        throw a ? x : y;
if (a) {} else b();                  a || b();
if (sideEffect()) {}                 sideEffect();

Branches that contain declarations, labels, or jumps (var/let/break/continue/loop bodies that can't collapse to a single expression) are left as if statements, and the transform is gated entirely on minify_syntax so unminified output is unchanged.

Because the branch bodies have already been visited by the time mangle_if runs, multi-statement blocks like if (a) { x(); y(); } else { z(); w(); } have already been comma-joined into a single SExpr and also collapse (a ? (x(), y()) : (z(), w())).

Size impact

bun build --minify on react-dom 18.3.1 cjs/react-dom.development.js:

bytes
before 318570
after 315424

node --check accepts the output; the compile/ReactSSR+{cjs,esm}+minify tests (which actually render) pass.

How did you verify your code works?

  • New minify/IfStatementMangling asserts the output shape for each rewrite and that var/break branches are kept as-is.
  • New minify/IfStatementManglingSemantics runs every rewritten form with both truthy and falsy inputs and checks the observed side effects match.
  • New minify/IfStatementManglingOffWithoutMinifySyntax checks nothing changes without minifySyntax.
  • Updated the edgecase/EmitInvalidSourceMap2 mapping snapshot: the __toESM runtime helper contains if statements that now minify further, shifting the first column in the generated map. The validity assertions in that test still pass; only the exact VLQ string moved.
  • Added allow-ref-lazy-initialization-with-logical to the React Compiler MINIFY_SYNTAX_DIVERGENCE table: @validateRefAccessDuringRender only whitelists the lazy-init pattern when it sees an if statement, so after the rewrite it rejects the fixture. This is the same class of visit-order interaction already tracked there.
  • bun bd test test/bundler/ sweep: 3925 pass, 0 fail.

Follow-ups I'll send separately (per review feedback): while to for(;;), a ? true : false to !!a, computed-string property keys, trailing return; removal, shorter number printing.


no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bundler_edgecase.test.ts test/bundler/bundler_npm.test.ts

Fixes #4211

When --minify-syntax is enabled, rewrite if statements whose branches
are expression statements, returns, or throws into the equivalent
shorter form:

  if (a) b(); else c();      => a ? b() : c();
  if (a) b();                => a && b();
  if (!a) b();               => a || b();
  if (!a) b; else c;         => a ? c : b   (branch swap)
  if (a) return b; else return c;  => return a ? b : c;
  if (a) throw b; else throw c;    => throw a ? b : c;
  if (a) {} else b();        => a || b();
  if (a()) {}                => a();

Branches containing declarations or jumps (var/let/break/continue/for)
are left as if statements.

react-dom.development.js shrinks by ~1% (318570 -> 315424 bytes) with
no runtime behavior change.
@coderabbitai

coderabbitai Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f8b12b58-c916-44cf-a9cc-55970a0faf7e

📥 Commits

Reviewing files that changed from the base of the PR and between 1f71ec3 and 2b90cf2.

📒 Files selected for processing (5)
  • src/js_parser/visit/visit_stmt.rs
  • test/bundler/bundler_edgecase.test.ts
  • test/bundler/bundler_minify.test.ts
  • test/bundler/bundler_npm.test.ts
  • test/bundler/transpiler/react-compiler-fixtures.test.ts

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

@robobun

robobun commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:20 AM PT - Jul 20th, 2026

✅ @robobun, your commit 2b90cf2768e7665370b72cac421fdc2862e93cdb passed in Build #76284! 🎉


🧪   To try this PR locally:

bunx bun-pr 34826

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

bun-34826 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Replace if/else-if/else with ternary statements when minified #4211 - Directly implements the requested feature: converting if/else-if/else chains to ternary expressions during minification

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #4211

🤖 Generated with Claude Code

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

No bugs found, but this is a minifier codegen change that reshapes every if in every --minify-syntax bundle and has already surfaced one downstream-tool interaction (React Compiler's @validateRefAccessDuringRender), so it's worth a human look.

What was reviewed:

  • Traced each rewrite arm (empty-yes swap, !a swap, SExpr/SReturn/SThrow collapse) for semantic equivalence — all check out, including the fall-through where data is mutated in place but mangle_if returns None (e.g. if (!a) {var x} else {var y} → if (a) {var y} else {var x} still pushed as an if).
  • Confirmed the pass is gated inside the existing minify_syntax block and that {} reaches is_empty_stmt as SEmpty via stmts_to_single_stmt.
  • Checked function-declaration bodies (SFunction), break/var branches, and nested if all fall through untouched.
Extended reasoning...

Overview

Adds an esbuild-style mangle_if pass to src/js_parser/visit/visit_stmt.rs: when both branches of an if reduce to SExpr/SReturn/SThrow (or one branch is empty), the statement is rewritten to a conditional / logical expression. ~160 net lines in the parser plus three new bundler tests, one source-map snapshot update, and one React Compiler divergence-table entry.

Security risks

None. Pure AST-to-AST rewrite in the minifier; no I/O, no untrusted-length arithmetic, no allocation changes.

Level of scrutiny

Medium-high. The logic is straightforward and closely mirrors esbuild's mangleIf, but it changes the printed shape of every if statement in every minified bundle. The PR itself demonstrates one class of downstream fallout (React Compiler's ref-lazy-init whitelist only recognises the if-statement form), and other tools that pattern-match on output shape could be similarly affected. That's a product/compat call, not a correctness bug, so a maintainer should sign off.

Other factors

  • I traced the in-place mutation of data when mangle_if returns None (the !a swap and the empty-yes → not(test) swap): the resulting if is still semantically equivalent, so pushing *stmt afterward is safe.
  • Verified via stmts_to_single_stmt that an empty {} branch reaches mangle_if as SEmpty, so is_empty_stmt doesn't need an SBlock arm.
  • Test coverage is solid: shape assertions, a runtime side-effect test over both truthy/falsy inputs, a minify-off negative, and explicit var/break keep-as-is cases. The full test/bundler/ sweep passes per the description.

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

No bugs found, but this adds ~160 lines of new minifier AST transforms in the parser hot path with wide-reaching output changes (every minified build), so it's worth a human look.

What was reviewed:

  • Traced each mangle_if rewrite (empty-branch swap via .not(), !-unwrap for ||/?: polarity, mixed return;/return x → void 0, unary-but-not-! tests) — all preserve evaluation order and short-circuit semantics.
  • Confirmed the transform is gated on minify_syntax and falls through to the original if for non-expression branches (var/break kept as-is per test).
  • Checked the bundler_npm.test.ts column/filesize snapshot deltas and the new React Compiler MINIFY_SYNTAX_DIVERGENCE entry are consistent with the shorter output.
Extended reasoning...

Overview

Adds an esbuild-style mangle_if pass to src/js_parser/visit/visit_stmt.rs that rewrites if statements into conditional/logical expressions under --minify-syntax. Replaces the previous ~24-line stub (which only dropped fully-empty ifs) with ~160 lines covering: both-branch SExpr/SReturn/SThrow → ternary, single-branch SExpr → &&/||, empty-branch elimination via simplify_unused_expr, and !-test polarity swaps. Three new bundler tests (shape, runtime semantics, off-without-minify), plus snapshot updates in bundler_edgecase.test.ts (VLQ mapping), bundler_npm.test.ts (ReactSSR sourcemap columns + exact filesize), and a new MINIFY_SYNTAX_DIVERGENCE entry in the React Compiler fixture harness.

Security risks

None. Pure AST-to-AST rewrite of already-visited statements; no I/O, no untrusted-length arithmetic, no allocation from user-controlled sizes.

Level of scrutiny

High. The JS parser/minifier is a critical hot path — every bun build --minify output changes shape. The transform must be semantically identical to the input for all inputs (short-circuit, side-effect ordering, return/throw value semantics), and a subtle bug here would ship broken minified bundles. The tests are good (both shape assertions and a runtime semantics test that exercises truthy/falsy inputs), and the ReactSSR test still renders correctly, but the surface area is large enough that a maintainer should confirm the esbuild parity scope and the React Compiler divergence trade-off are acceptable.

Other factors

  • The removed code was a limited can_remove_test check; the new is_empty_stmt + simplify_unused_expr path subsumes it (verified the emptyBoth(a){a()} test covers the side-effecting-test case).
  • The .not() helper simplifies double-negation, so if (!a) {} else b() correctly becomes a && b() rather than !!a && b(); the emptyYes test only covers the un-negated direction.
  • The React Compiler fixture divergence (@validateRefAccessDuringRender no longer recognizes the lazy-init pattern after if→&& rewrite) is a real minify-vs-compiler ordering interaction; it's tracked in the same table as the existing constant-folding divergence, but a maintainer should confirm this is the intended policy.
  • bundler_npm.test.ts has expectExactFilesize and hard-coded sourcemap columns — these are brittle by design and the deltas (−1600 bytes, columns shifted left) are consistent with shorter output, but I did not independently reproduce them.

robobun added a commit that referenced this pull request Sep 2, 2026
Ported from the test added in #34826, which this PR supersedes. Each
rewritten `if` form is checked for shape, then the bundle runs with
both inputs and the observed side effects are compared with the
original statement. `blocks` covers comma-joined branches, `retMixed`
covers `if (a) return; else return x` at the end of a function body,
and `emptyBoth` keeps the side effect of the test when both branches
are empty.
@robobun

robobun commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #41159, which ports the full set of esbuild statement-level passes (including this mangleIf rewrite) on top of current main. This branch conflicts with main and its scope is covered there. The runtime test from this PR is ported to #41159 as minify/IfStatementMangling, and #41159 now carries Fixes #4211.

@robobun robobun closed this Sep 2, 2026
robobun added a commit that referenced this pull request Sep 2, 2026
Ported from the test added in #34826, which this PR supersedes. Each
rewritten `if` form is checked for shape, then the bundle runs with
both inputs and the observed side effects are compared with the
original statement. `blocks` covers comma-joined branches, `retMixed`
covers `if (a) return; else return x` at the end of a function body,
and `emptyBoth` keeps the side effect of the test when both branches
are empty.
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.

Replace if/else-if/else with ternary statements when minified

1 participant