diff --git a/crates/oxc_minifier/src/peephole/minimize_statements.rs b/crates/oxc_minifier/src/peephole/minimize_statements.rs index 383559aa39..2e82bc4586 100644 --- a/crates/oxc_minifier/src/peephole/minimize_statements.rs +++ b/crates/oxc_minifier/src/peephole/minimize_statements.rs @@ -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`: /// @@ -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 diff --git a/crates/oxc_minifier/tests/peephole/dead_code_elimination.rs b/crates/oxc_minifier/tests/peephole/dead_code_elimination.rs index da0c8ebe4c..e09c948c84 100644 --- a/crates/oxc_minifier/tests/peephole/dead_code_elimination.rs +++ b/crates/oxc_minifier/tests/peephole/dead_code_elimination.rs @@ -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)");