fix(minifier): mark peephole loop changed when dropping dead-after-throw statement (#22722)

## Summary

In `minimize_statements`, the dead-after-jump branch silently drops statements without flagging the peephole fixed-point loop as having changed. That leaves stale reference counts in `Scoping` until the next iteration kicks in for unrelated reasons — and in some cases, no further iteration ever happens, so the downstream cleanup never runs.

### Repro

```js
export function f() {
  var x = {};
  throw new Error('boom');
  module.exports = x;
}
```

Expected: `export function f() { throw new Error('boom'); }`.
Actual (before fix): `export function f() { var x = {}; throw new Error('boom'); }` — the dead `module.exports = x` is removed, but the now-orphaned `var x = {}` survives because the unused-declarator pass reads stale scope counts.

### Fix

In the dead-stmt-drop branch, set `ctx.state.changed = true` so the next iteration's `LiveUsageCollector` refreshes scope counts and unused-declarator cleanup runs.

To avoid an oscillation where dropping a bare `var x;` and immediately re-hoisting an identical `var x;` (via `KeepVar`) looks like progress, gate the flag on `dead_drop_mutates_ast`: drops of `var <id>[, <id>]*;` (no initializers) are byte-identical no-ops and don't flag.

Two related cases were also fixed in this commit after code review:

1. **Coalescing** — `throw; var x; var y;` → `throw; var x, y;`. Each individual identity-drop looked byte-identical, but the combined re-emit is a real change. Count identity drops; flag changed when more than one was harvested.
2. **Rehoist-then-remove** — when the rehoisted `var x;` is itself unused and `remove_unused_variable_declaration` drops it, the net effect is removing a declaration. Flag changed in that `None` branch.

### Tests

- New `dead_after_throw_drop_triggers_unused_declarator_removal` in `dead_code_elimination.rs`.
- All 505 existing minifier tests pass.

Stacked under #22729.
This commit is contained in:
Dunqing committed 2026-05-26 05:31:29 +00:00
1 parent ebe9373df6
commit 40ff611934
2 files changed
+57 -5

No files matched your search

@@ -15,6 +15,15 @@ use crate::{TraverseCtx, keep_var::KeepVar};
use super::PeepholeOptimizations;
/// `false` when dropping `stmt` produces a byte-identical AST — a `var`
/// with no initializers, which `KeepVar` re-emits unchanged at the end of
/// the block. Flagging such an identity drop as a real change would
/// oscillate the peephole fixed-point loop forever.
fn dead_drop_mutates_ast(stmt: &Statement<'_>) -> bool {
!matches!(stmt, Statement::VariableDeclaration(decl)
if decl.kind.is_var() && decl.declarations.iter().all(|d| d.init.is_none()))
}
impl<'a> PeepholeOptimizations {
/// `mangleStmts`: <https://github.com/evanw/esbuild/blob/v0.24.2/internal/js_ast/js_parser.go#L8788>
///
@@ -37,14 +46,28 @@ impl<'a> PeepholeOptimizations {
let mut old_stmts = stmts.take_in(ctx.ast);
let mut is_control_flow_dead = false;
let mut keep_var = KeepVar::new(ctx.ast);
let mut identity_drops = 0u32;
for i in 0..old_stmts.len() {
let stmt = old_stmts[i].take_in(ctx.ast);
if is_control_flow_dead
&& !stmt.is_module_declaration()
&& !matches!(stmt.as_declaration(), Some(Declaration::FunctionDeclaration(_)))
{
// Harvest any `var` bindings so they re-emit at the end of
// the block (see `keep_var.get_variable_declaration_statement`
// below).
keep_var.visit_statement(&stmt);
continue;
// Re-flag the peephole loop so the next iteration's
// `LiveUsageCollector` refreshes scope counts and the
// unused-declarator pass can remove bindings that this
// drop just orphaned (e.g. `var x = {}` after dropping
// `module.exports = x;`).
if dead_drop_mutates_ast(&stmt) {
ctx.state.changed = true;
} else {
identity_drops += 1;
}
continue; // drop: `stmt` is intentionally not pushed into `stmts`.
}
if Self::minimize_statement(
stmt,
@@ -59,10 +82,23 @@ impl<'a> PeepholeOptimizations {
break;
}
}
if let Some(stmt) = keep_var.get_variable_declaration_statement()
&& let Some(stmt) = Self::remove_unused_variable_declaration(stmt, ctx)
{
stmts.push(stmt);
if let Some(stmt) = keep_var.get_variable_declaration_statement() {
match Self::remove_unused_variable_declaration(stmt, ctx) {
// Multiple identity-dropped `var x;`s coalesce into a single
// `var x, y;`. The individual drops looked byte-identical, but
// the combined re-emit is a real AST change — re-flag so the
// fixed-point loop doesn't terminate one iteration early.
Some(stmt) => {
stmts.push(stmt);
if identity_drops > 1 {
ctx.state.changed = true;
}
}
// The harvested `var` was entirely unused; the net effect of
// drop + re-hoist + remove is removing the declaration, even
// though every individual drop was classified as identity.
None => ctx.state.changed = true,
}
}
// Drop a trailing unconditional jump statement if applicable
@@ -206,6 +206,22 @@ fn dce_var_hoisting() {
);
}
// Dropping a dead-after-throw statement (`module.exports = x`) removes the
// only reference to `x`. Without flagging that as a change, the peephole loop
// terminates before `LiveUsageCollector` refreshes scoping, leaving the
// unused-declarator pass to see a stale reference and keep `var x = {}`.
#[test]
fn dead_after_throw_drop_triggers_unused_declarator_removal() {
test(
"export function f() {
var x = {};
throw new Error('boom');
module.exports = x;
}",
"export function f() { throw new Error('boom'); }",
);
}
#[test]
fn pure_comment_for_pure_global_constructors() {
test("var x = new WeakSet; foo(x)", "var x = /* @__PURE__ */ new WeakSet();\nfoo(x)");