Skip to content

bundler: trim empty else {} left behind by DCE#30275

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/d4fed240/dce-empty-else-trim
May 6, 2026
Merged

bundler: trim empty else {} left behind by DCE#30275
Jarred-Sumner merged 1 commit into
mainfrom
farm/d4fed240/dce-empty-else-trim

Conversation

@robobun

@robobun robobun commented May 5, 2026

Copy link
Copy Markdown
Collaborator

Problem

bun build --define Bun.env.NODE_ENV="'production'" test.ts on:

if ("foo" === "foo") {
  console.log("success!");
} else {
  console.log("fail!");
}

produced:

if (true) {
  console.log("success!");
} else {}

The constant-folded test expression and the pruned dead branch are correct, but the empty else {} remnant is left behind. The --define docs guide also promised full collapse to just console.log("success!"); — which only ever happened with --minify-syntax.

Cause

visitStmt's s_if handler had an "else trim" gated on minify_syntax:

// Trim unnecessary "else" clauses
if (p.options.features.minify_syntax) {
    if (data.no != null and @as(Stmt.Tag, data.no.?.data) == .s_empty) {
        data.no = null;
    }
}

Two issues:

  1. Gated on minify_syntax, even though the thing it's cleaning up (an else body emptied by DCE statement-pruning) is produced by DCE itself.
  2. Checked for .s_empty, but visitSingleStmtBlock only converts an empty block to .s_empty when minify_syntax is on — otherwise the pruned body stays as an .s_block with stmts.len == 0.

The scaffolding collapse (if (true) { A }A) intentionally stays behind --minify-syntax since tests (e.g. transpiler.test.js's DCE block, "if (undefined) { let y = Math.random(); }" → "if (undefined) {}") lock that contract in, matching esbuild. This change is strictly about cleaning up the ugly empty-else remnant.

Fix

Gate the else-trim on dead_code_elimination and treat an .s_block with zero stmts as equivalent to .s_empty:

if (p.options.features.dead_code_elimination) {
    if (data.no) |no_stmt| {
        const no_is_empty = switch (no_stmt.data) {
            .s_empty => true,
            .s_block => |block| block.stmts.len == 0,
            else => false,
        };
        if (no_is_empty) {
            data.no = null;
        }
    }
}

Also updates the define-constant docs page so the promised before/after output matches what the bundler actually emits, and points users to --minify-syntax / --minify for the full scaffolding collapse shown as the final step.

Verification

test/bundler/bundler_edgecase.test.ts's DCEEmptyElseTrimmed#30271 asserts the output of the repro contains neither fail nor else. It fails on main (output is if (true) { console.log("success"); } else {}) and passes on this branch (if (true) { console.log("success"); }).

Existing DCE tests (bundler_edgecase.test.ts's DCE*, transpiler.test.js's DCE block, esbuild/dce.test.ts, esbuild/default.test.ts, esbuild/ts.test.ts, bundler_minify.test.ts) all pass — no test depended on an else {} remnant surviving.

Fixes #30271

@github-actions github-actions Bot added the claude label May 5, 2026
@robobun

robobun commented May 5, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:54 PM PT - May 5th, 2026

@robobun, your commit ef598cb has 3 failures in Build #51866 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30275

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

bun-30275 --bun

@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 57ff5033-9e4e-416d-9291-914ef55b74f9

📥 Commits

Reviewing files that changed from the base of the PR and between dae5236 and ef598cb.

📒 Files selected for processing (4)
  • docs/guides/runtime/define-constant.mdx
  • src/js_parser/ast/visitStmt.zig
  • src/js_printer/js_printer.zig
  • test/bundler/bundler_edgecase.test.ts

Walkthrough

If/else pruning now removes empty else clauses when dead-code elimination or minify-syntax makes the else body empty. The printer treats labeled statements as transparent for ambiguous-else detection. Tests and docs were added/updated to cover these behaviors and the linked #30271 scenario.

Changes

Dead Code Elimination & Ambiguous-Else Handling

Layer / File(s) Summary
Core DCE
src/js_parser/ast/visitStmt.zig
After visiting an else branch, data.no is nulled when dead_code_elimination or minify_syntax is enabled and the visited else is empty (either .s_empty or an empty .s_block).
Ambiguous-Else Wrapping
src/js_printer/js_printer.zig
wrapToAvoidAmbiguousElse now descends through Stmt.Data.s_label, treating labeled statements as transparent when checking for ambiguous else attachment.
Behavioral Tests
test/bundler/bundler_edgecase.test.ts
Added edgecase/DCEEmptyElseTrimmed#30271 (ensures pruned else content and empty else {} remnants are removed) and edgecase/DCEEmptyElseTrimmedLabeledDanglingElse#30271 (verifies trimming an inner empty else in labeled code does not change dangling else binding).
Documentation
docs/guides/runtime/define-constant.mdx
Example updated to show the else branch dropped when a condition is constant-true; documents --minify-syntax (and --minify) collapsing surrounding if scaffolding and updates the bun build example.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and specifically describes the main change: trimming empty else blocks left behind by dead-code elimination.
Description check ✅ Passed The PR description comprehensively addresses both template sections: clearly explains the problem and provides detailed verification steps with specific test examples.
Linked Issues check ✅ Passed The PR directly addresses issue #30271 by fixing the DCE regression where empty else blocks remain after dead-code elimination, restoring proper behavior described in the documentation.
Out of Scope Changes check ✅ Passed All changes (compiler logic, documentation updates, and tests) are directly related to fixing the empty else trimming issue specified in #30271.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

Comment thread src/js_parser/ast/visitStmt.zig Outdated
Comment thread src/js_parser/ast/visitStmt.zig Outdated

@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 — both prior concerns (.s_label in wrapToAvoidAmbiguousElse, and the dead_code_elimination or minify_syntax gate) are addressed, with a regression test for the dangling-else case.

Extended reasoning...

Overview

This PR fixes #30271 by trimming empty else {} remnants left behind after DCE prunes a dead branch body. It touches:

  • src/js_parser/ast/visitStmt.zig: widens the else-trim gate from minify_syntax to dead_code_elimination or minify_syntax and matches both .s_empty and zero-stmt .s_block.
  • src/js_printer/js_printer.zig: adds .s_label traversal to wrapToAvoidAmbiguousElse so that trimming an inner labeled if's empty else cannot cause a dangling-else miscompilation.
  • test/bundler/bundler_edgecase.test.ts: two new tests — one for the basic empty-else trim, one for the labeled dangling-else guard.
  • docs/guides/runtime/define-constant.mdx: aligns the documented before/after output with what the bundler actually emits and points to --minify-syntax for full scaffolding collapse.

Follow-up on prior review

My previous review flagged two issues; commit dae5236 addresses both:

  • 🔴 dangling-else via labeled if: fixed by adding .s_label => |current| { s = &current.stmt.data; } to wrapToAvoidAmbiguousElse (verified at js_printer.zig:5145-5147), matching esbuild. The new DCEEmptyElseTrimmedLabeledDanglingElse#30271 test exercises exactly the failing input and asserts outer-else is printed.
  • 🟡 deadCodeElimination: false, minify: { syntax: true } regression: fixed by gating on the union dead_code_elimination or minify_syntax, with the rationale captured in the code comment.

Security risks

None. This is a transpiler/bundler output-shaping change with no auth, crypto, network, or filesystem implications.

Level of scrutiny

Moderate — bundler correctness changes can silently miscompile user code, which is why the dangling-else interaction mattered. With the printer fix and runtime-checked regression test in place, the only remaining behavior change is dropping a semantically empty else clause, which is safe given the printer now correctly guards the ambiguous-else case (including through labels). The minify_syntax path was already exercising this trim before; this PR just enables it for the default DCE config too.

Other factors

The change is small (~20 LOC of logic), well-explained, follows the existing esbuild-derived pattern, and has targeted tests covering both the happy path and the edge case I raised. The docs update is a straightforward accuracy fix.

@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 — both issues from the prior review (dangling-else via .s_label and the minify_syntax-only gate regression) are fixed in dae5236 with regression tests; the Windows HTTP test timeout in CI is unrelated.

Extended reasoning...

Overview

Four files touched: src/js_parser/ast/visitStmt.zig widens the empty-else trim to fire under dead_code_elimination or minify_syntax and to recognize zero-length .s_block as empty; src/js_printer/js_printer.zig adds the missing .s_label arm to wrapToAvoidAmbiguousElse so trimming an inner else can't produce a dangling-else miscompilation; test/bundler/bundler_edgecase.test.ts adds two regression tests (one asserting no else in output, one asserting runtime stdout outer-else for the labeled dangling-else case); docs/guides/runtime/define-constant.mdx is updated to match actual default-config output.

Security risks

None. Pure code-generation/printing change in the bundler with no auth, crypto, FS, or network surface.

Level of scrutiny

Bundler output correctness is production-critical, so I gave this a careful pass on the first revision and flagged the .s_label dangling-else miscompilation and the minify_syntax-gate regression. Both were fixed exactly as suggested in dae5236 and both now have dedicated tests — the dangling-else one runs the bundle and asserts stdout, which is the strongest kind of guard. The remaining logic change is ~15 lines, mirrors esbuild's SLabel handling, and the bug-hunting pass on the latest revision found nothing.

Other factors

No CODEOWNERS cover these paths. All prior inline comments are resolved. The single CI failure (test-http-should-emit-close-when-connection-is-aborted.ts timeout on Windows) is unrelated to bundler/DCE changes.

`bun build --define Bun.env.NODE_ENV="'production'" test.ts` on:

```ts
if ("foo" === "foo") {
  console.log("success!");
} else {
  console.log("fail!");
}
```

produced:

```js
if (true) {
  console.log("success!");
} else {}
```

The constant-folded test expression and the pruned dead branch are correct,
but the empty `else {}` remnant was left behind. The [docs guide for
`--define`](https://bun.com/docs/guides/runtime/define-constant) also
promised full collapse to just `console.log("success!");` — which only
ever happened with `--minify-syntax`.

`visitStmt`'s `s_if` handler had an 'else trim' gated on `minify_syntax`
that also only matched `.s_empty`. But `visitSingleStmtBlock` only
converts an empty block to `.s_empty` when `minify_syntax` is on —
otherwise the pruned body stays as an `.s_block` with `stmts.len == 0`.
So under default config the trim never fired.

Gate the else-trim on `(dead_code_elimination or minify_syntax)` and
treat an `.s_block` with zero stmts as equivalent to `.s_empty`. The
scaffolding collapse (`if (true) { A }` -> `A`) intentionally stays
behind `--minify-syntax` since tests in `transpiler.test.js`'s DCE block
lock that contract in, matching esbuild. This change is strictly about
cleaning up the empty-else remnant.

The printer's `wrapToAvoidAmbiguousElse` didn't traverse `.s_label`, so
trimming the inner `else {}` from `if (a) L: if (b) c(); else {} else d();`
would produce `if (a) L: if (b) c(); else d();` — the `else` re-binds to
the inner `if` and `d()` no longer runs when `a` is falsy. Before the
trim change, the inner `else {}` survived under default config and
shielded the outer `else`. Now that the trim runs under DCE (default), a
labeled inner `if` with an empty/DCE'd `else` would silently miscompile.
Add the `.s_label` arm to match esbuild.

Also updates the define-constant docs page so the promised before/after
output matches what the bundler actually emits, and points users to
`--minify-syntax` / `--minify` for the full scaffolding collapse shown as
the final step.

`test/bundler/bundler_edgecase.test.ts`'s `DCEEmptyElseTrimmed#30271`
asserts the output of the repro contains neither `fail` nor `else`.
`DCEEmptyElseTrimmedLabeledDanglingElse#30271` builds the
labeled-inner-if case and asserts runtime stdout is `outer-else`. Both
fail on main and pass on this branch.

Fixes #30271
@robobun robobun force-pushed the farm/d4fed240/dce-empty-else-trim branch from 447e846 to ef598cb Compare May 5, 2026 19:23
@Jarred-Sumner Jarred-Sumner merged commit a28a3ad into main May 6, 2026
69 of 74 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/d4fed240/dce-empty-else-trim branch May 6, 2026 00:51
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
## Problem

`bun build --define Bun.env.NODE_ENV="'production'" test.ts` on:

```ts
if ("foo" === "foo") {
  console.log("success!");
} else {
  console.log("fail!");
}
```

produced:

```js
if (true) {
  console.log("success!");
} else {}
```

The constant-folded test expression and the pruned dead branch are
correct, but the empty `else {}` remnant is left behind. The [`--define`
docs guide](https://bun.com/docs/guides/runtime/define-constant) also
promised full collapse to just `console.log("success!");` — which only
ever happened with `--minify-syntax`.

## Cause

`visitStmt`'s `s_if` handler had an "else trim" gated on
`minify_syntax`:

```zig
// Trim unnecessary "else" clauses
if (p.options.features.minify_syntax) {
    if (data.no != null and @as(Stmt.Tag, data.no.?.data) == .s_empty) {
        data.no = null;
    }
}
```

Two issues:

1. Gated on `minify_syntax`, even though the thing it's cleaning up (an
`else` body emptied by DCE statement-pruning) is produced by DCE itself.
2. Checked for `.s_empty`, but `visitSingleStmtBlock` only converts an
empty block to `.s_empty` when `minify_syntax` is on — otherwise the
pruned body stays as an `.s_block` with `stmts.len == 0`.

The scaffolding collapse (`if (true) { A }` → `A`) intentionally stays
behind `--minify-syntax` since tests (e.g. `transpiler.test.js`'s DCE
block, `"if (undefined) { let y = Math.random(); }" → "if (undefined)
{}"`) lock that contract in, matching esbuild. This change is strictly
about cleaning up the ugly empty-else remnant.

## Fix

Gate the else-trim on `dead_code_elimination` and treat an `.s_block`
with zero stmts as equivalent to `.s_empty`:

```zig
if (p.options.features.dead_code_elimination) {
    if (data.no) |no_stmt| {
        const no_is_empty = switch (no_stmt.data) {
            .s_empty => true,
            .s_block => |block| block.stmts.len == 0,
            else => false,
        };
        if (no_is_empty) {
            data.no = null;
        }
    }
}
```

Also updates the define-constant docs page so the promised before/after
output matches what the bundler actually emits, and points users to
`--minify-syntax` / `--minify` for the full scaffolding collapse shown
as the final step.

## Verification

`test/bundler/bundler_edgecase.test.ts`'s `DCEEmptyElseTrimmed#30271`
asserts the output of the repro contains neither `fail` nor `else`. It
fails on `main` (output is `if (true) { console.log("success"); } else
{}`) and passes on this branch (`if (true) { console.log("success");
}`).

Existing DCE tests (`bundler_edgecase.test.ts`'s `DCE*`,
`transpiler.test.js`'s DCE block, `esbuild/dce.test.ts`,
`esbuild/default.test.ts`, `esbuild/ts.test.ts`,
`bundler_minify.test.ts`) all pass — no test depended on an `else {}`
remnant surviving.

Fixes oven-sh#30271
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.

Bun Build: Dead code elimination regression

2 participants