mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
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:
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)");
|
||||
|
||||
Reference in new issue
Block a user