feat(minifier): fold switch stmt whose parent is not block stmt (#25403)

Move removal of dead code from switch stmts to remove_dead_code, this
change enables processing of switch stmts whose parent is not list of
stmts

current minification processes stmts whose parents are block stmts and
they are last stmt of set block, this change also allows us to remove
empty blocks and last break from switch cases whose parent is not block
stmt

```js
if (a) { b(); if (c) switch (d) { case 1: case 2: break; } c(); }
//            ^~~~~~ due to body not being a block we where not able to process this

// Current:
if (a) { if (b(), c) switch (d) { case 1: case 2: break } c() }
//                   ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ this switch case is not being processed

// This branch:
a && (b(), c && d, c());
//              ^ switch cases can be correctly processed
```

note: this change applies only dce, and dosn't substitute switch stmts
with if'stmts

#### changes in stack:
- https://github.com/oxc-project/oxc/pull/25405 
- https://github.com/oxc-project/oxc/pull/25403 👈
- `main`
This commit is contained in:
Armano authored and GitHub committed 2026-08-23 22:08:10 +08:00
1 parent fd61cd860f
commit b6e9e5efda
5 files changed
+100 -82

No files matched your search

@@ -566,22 +566,6 @@ impl<'a> PeepholeOptimizations {
false
}
fn is_switch_case_removable(stmt: &SwitchCase, allow_break: bool) -> bool {
let is_empty = if stmt.consequent.len() == 1 {
match stmt.consequent.last() {
Some(Statement::EmptyStatement(_)) => true,
Some(Statement::BreakStatement(break_stmt)) => {
allow_break && break_stmt.label.is_none()
}
_ => false,
}
} else {
stmt.consequent.is_empty()
};
is_empty && stmt.test.as_ref().is_none_or(Expression::is_literal)
}
/// Check if a switch case can be inlined by verifying:
/// - The test expression has no side effects
/// - All statements can be safely inlined (no unlabeled breaks)
@@ -616,71 +600,6 @@ impl<'a> PeepholeOptimizations {
ctx.drop_statement(&dropped);
}
// Remove empty case clauses that don't affect behavior.
// Handles fall-through semantics: remove empty cases before default or at end (if no default).
// e.g., `switch(x){ case 0: foo(); break; case 1: default: bar() }`
// => `switch(x){ case 0: foo(); break; default: bar() }`
// https://github.com/evanw/esbuild/commit/add452ed51333953dd38a26f28a775bb220ea2e9
let case_count = switch_stmt.cases.len();
if case_count == 1 {
// Remove sole case if empty and has no side-effect test
if Self::is_switch_case_removable(&switch_stmt.cases[0], true) {
ctx.drop_switch_case(&switch_stmt.cases.pop().unwrap());
}
} else if case_count > 1 {
// Determine the range [0, end] to check for removable cases.
// 1. default exists and is empty: check the full switch.
// 2. default exists and is non-removable and last: check only cases before that default.
// 3. default exists, is non-removable, and is not last: skip this optimization (`end = 0`).
// 4. no default case: check the full switch and allow a trailing unlabeled `break`.
let default_pos = switch_stmt.cases.iter().rposition(SwitchCase::is_default_case);
let (end, allow_break) = if let Some(default_pos) = default_pos {
if Self::is_switch_case_removable(&switch_stmt.cases[default_pos], true) {
(case_count, true)
} else if default_pos == case_count - 1 {
(default_pos, false)
} else {
(0, false)
}
} else {
(case_count, true)
};
if end > 0 {
// Last non-removable case index in [0, end]. Returns None if all cases are removable.
let last_non_removable_case_before_end = switch_stmt.cases[..end]
.iter()
.rposition(|case| !Self::is_switch_case_removable(case, allow_break));
// Calculate the start of the removable suffix.
// 1. next case after last non-removable: remove from pos + 1
// 2. no non-removable case: all cases are removable, start from 0
let start = match last_non_removable_case_before_end {
Some(pos) => pos + 1,
None => 0,
};
// Remove the removable suffix if any
if start < end && default_pos.is_none_or(|pos| pos >= start) {
for removed_case in switch_stmt.cases.drain(start..end) {
ctx.drop_switch_case(&removed_case);
}
}
}
}
if switch_stmt.cases.is_empty() {
let SwitchStatement { span, discriminant, .. } = switch_stmt.unbox();
result.push(Statement::new_expression_statement(span, discriminant, ctx));
return;
} else if let Some(last_case) = switch_stmt.cases.last_mut()
&& let Some(Statement::BreakStatement(last_break)) = last_case.consequent.last()
&& last_break.label.is_none()
{
let dropped = last_case.consequent.pop().unwrap();
ctx.drop_statement(&dropped);
}
if !ctx.is_tree_shake_only()
&& switch_stmt.cases.len() == 1
&& Self::can_switch_case_be_inlined(&switch_stmt.cases[0])
+2
View File
@@ -411,6 +411,7 @@ impl<'a> Traverse<'a> for PeepholeOptimizations {
Statement::ForStatement(_) => Self::try_fold_for(stmt, ctx),
Statement::TryStatement(_) => Self::try_fold_try(stmt, ctx),
Statement::LabeledStatement(_) => Self::try_fold_labeled(stmt, ctx),
Statement::SwitchStatement(_) => Self::try_fold_switch(stmt, ctx),
Statement::FunctionDeclaration(_) => {
Self::remove_unused_function_declaration(stmt, ctx);
}
@@ -451,6 +452,7 @@ impl<'a> Traverse<'a> for PeepholeOptimizations {
}
Statement::TryStatement(_) => Self::try_fold_try(stmt, ctx),
Statement::LabeledStatement(_) => Self::try_fold_labeled(stmt, ctx),
Statement::SwitchStatement(_) => Self::try_fold_switch(stmt, ctx),
Statement::FunctionDeclaration(f) => {
Self::init_function_declaration_symbol_value(f.id.as_ref(), ctx);
Self::remove_unused_function_declaration(stmt, ctx);
@@ -293,6 +293,94 @@ impl<'a> PeepholeOptimizations {
}
}
pub fn try_fold_switch(stmt: &mut Statement<'a>, ctx: &mut TraverseCtx<'a>) {
let Statement::SwitchStatement(switch_stmt) = stmt else { return };
// Remove empty case clauses that don't affect behavior.
// Handles fall-through semantics: remove empty cases before default or at end (if no default).
// e.g., `switch(x){ case 0: foo(); break; case 1: default: bar() }`
// => `switch(x){ case 0: foo(); break; default: bar() }`
// https://github.com/evanw/esbuild/commit/add452ed51333953dd38a26f28a775bb220ea2e9
let case_count = switch_stmt.cases.len();
if case_count == 1 {
// Remove sole case if empty and has no side-effect test
if Self::is_switch_case_removable(&switch_stmt.cases[0], true) {
ctx.drop_switch_case(&switch_stmt.cases.pop().unwrap());
}
} else if case_count > 1 {
// Determine the range [0, end] to check for removable cases.
// 1. default exists and is empty: check the full switch.
// 2. default exists and is non-removable and last: check only cases before that default.
// 3. default exists, is non-removable, and is not last: skip this optimization (`end = 0`).
// 4. no default case: check the full switch and allow a trailing unlabeled `break`.
let default_pos = switch_stmt.cases.iter().rposition(SwitchCase::is_default_case);
let (end, allow_break) = if let Some(default_pos) = default_pos {
if Self::is_switch_case_removable(&switch_stmt.cases[default_pos], true) {
(case_count, true)
} else if default_pos == case_count - 1 {
(default_pos, false)
} else {
(0, false)
}
} else {
(case_count, true)
};
if end > 0 {
// Last non-removable case index in [0, end]. Returns None if all cases are removable.
let last_non_removable_case_before_end = switch_stmt.cases[..end]
.iter()
.rposition(|case| !Self::is_switch_case_removable(case, allow_break));
// Calculate the start of the removable suffix.
// 1. next case after last non-removable: remove from pos + 1
// 2. no non-removable case: all cases are removable, start from 0
let start = match last_non_removable_case_before_end {
Some(pos) => pos + 1,
None => 0,
};
// Remove the removable suffix if any
if start < end && default_pos.is_none_or(|pos| pos >= start) {
for removed_case in switch_stmt.cases.drain(start..end) {
ctx.drop_switch_case(&removed_case);
}
}
}
}
if switch_stmt.cases.is_empty() {
let new_stmt = Statement::new_expression_statement(
switch_stmt.span,
switch_stmt.discriminant.take_in(ctx),
ctx,
);
ctx.replace_statement(stmt, new_stmt);
} else if let Some(last_case) = switch_stmt.cases.last_mut()
&& let Some(Statement::BreakStatement(last_break)) = last_case.consequent.last()
&& last_break.label.is_none()
{
let dropped = last_case.consequent.pop().unwrap();
ctx.drop_statement(&dropped);
}
}
fn is_switch_case_removable(stmt: &SwitchCase, allow_break: bool) -> bool {
let is_empty = if stmt.consequent.len() == 1 {
match stmt.consequent.last() {
Some(Statement::EmptyStatement(_)) => true,
Some(Statement::BreakStatement(break_stmt)) => {
allow_break && break_stmt.label.is_none()
}
_ => false,
}
} else {
stmt.consequent.is_empty()
};
is_empty && stmt.test.as_ref().is_none_or(Expression::is_literal)
}
pub fn try_fold_try(stmt: &mut Statement<'a>, ctx: &mut TraverseCtx<'a>) {
let Statement::TryStatement(s) = stmt else { return };
if let Some(handler) = &s.handler
@@ -347,6 +347,15 @@ fn test_handle_switch_statement() {
); // ;
test("switch (b) { case 2: switch (a) { case 2: foo()}}", "b === 2 && a === 2 && foo();");
test(
"if (a) { b(); if (c) switch (d) { case 1: case 2: break; } c(); }",
"a && (b(), c && d, c());",
);
test(
"if (a) { if (c) switch (b) { case 2: switch (a) { case 2: foo()}}; b() }",
"if (a) { if (c) switch (b) { case 2: a===2 && foo() } b() }",
);
// TODO: expected TDZ issue, when folding if without body https://github.com/oxc-project/oxc/issues/24589
test(
"function f(){ switch (0) { case x: break; } let x = 1; }",
+1 -1
View File
@@ -13,7 +13,7 @@ Original | minified | minified | gzip | gzip | Iterations | Fi
555.77 kB | 262.77 kB | 270.13 kB | 87.67 kB | 90.80 kB | 2 | d3.js
1.01 MB | 436.09 kB | 458.89 kB | 121.80 kB | 126.71 kB | 2 | bundle.min.js
1.01 MB | 436.06 kB | 458.89 kB | 121.79 kB | 126.71 kB | 2 | bundle.min.js
1.25 MB | 634.41 kB | 646.76 kB | 158.45 kB | 163.73 kB | 2 | three.js