feat(minifier): add new IsTerminated trait and use it in exit-point optimization (#24950)

Added a new IsTerminated trait and wired it into handle_if_statement in
minimize-exit-points.

before this, we only treated direct jump statements as terminating. Now
we also handle nested terminated branches, so cases like nested if/else
termination optimize correctly.

for now this is only used in this one optimization path, but we should
reuse IsTerminated in more places (e.g. switch-case and other
exit-point/termination optimizations).
This commit is contained in:
Armano authored and GitHub committed 2026-07-27 16:03:47 +08:00
1 parent ae1a39b71c
commit b31713a693
7 files changed
+123 -102

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
use oxc_allocator::ArenaVec;
use oxc_ast::ast::{BlockStatement, IfStatement, Statement, TryStatement};
/// A statement that never completes normally — a direct jump, a kept block
/// ending in a jump, an if/else where every branch jumps, or a try/catch where
/// every path jumps — makes the rest of the list unreachable.
pub trait IsTerminated {
fn is_terminated(&self) -> bool;
}
impl IsTerminated for Statement<'_> {
fn is_terminated(&self) -> bool {
match self {
Statement::IfStatement(stmt) => stmt.is_terminated(),
Statement::BlockStatement(stmt) => stmt.is_terminated(),
Statement::TryStatement(stmt) => stmt.is_terminated(),
_ => self.is_jump_statement(),
}
}
}
impl IsTerminated for IfStatement<'_> {
fn is_terminated(&self) -> bool {
self.consequent.is_terminated() && self.alternate.is_terminated()
}
}
impl IsTerminated for TryStatement<'_> {
fn is_terminated(&self) -> bool {
// A finalizer that aborts overrides however the other blocks complete.
// Otherwise the try block must abort, and so must the catch block when
// present (an exception thrown before the try block's jump lands there).
self.finalizer.as_ref().is_some_and(|f| f.is_terminated())
|| (self.block.is_terminated()
&& self.handler.as_ref().is_none_or(|h| h.body.is_terminated()))
}
}
impl IsTerminated for BlockStatement<'_> {
fn is_terminated(&self) -> bool {
self.body.is_terminated()
}
}
impl IsTerminated for ArenaVec<'_, Statement<'_>> {
fn is_terminated(&self) -> bool {
// A minimized dead zone keeps only hoisting survivors after the jump:
// `function` declarations and the initializer-less `var` stub
// re-emitted by `KeepVar`. Their bindings initialize at scope entry
// and nothing after an aborting statement ever runs, so skip them
// from the back before testing how the block terminates.
self.iter()
.rev()
.find(|stmt| match stmt {
Statement::FunctionDeclaration(_) => false,
Statement::VariableDeclaration(decl) => {
!(decl.kind.is_var() && decl.declarations.iter().all(|d| d.init.is_none()))
}
_ => true,
})
.is_some_and(Statement::is_terminated)
}
}
impl<T: IsTerminated> IsTerminated for Option<T> {
fn is_terminated(&self) -> bool {
self.as_ref().is_some_and(IsTerminated::is_terminated)
}
}
+1
View File
@@ -47,6 +47,7 @@
mod compression_pass;
mod compressor;
pub(crate) mod generated;
mod is_terminated;
mod keep_var;
mod minifier_traverse;
mod options;
@@ -11,7 +11,7 @@ use oxc_ecmascript::{
use oxc_semantic::ScopeFlags;
use oxc_span::{ContentEq, GetSpan, GetSpanMut, SPAN};
use crate::{TraverseCtx, keep_var::KeepVar};
use crate::{TraverseCtx, is_terminated::IsTerminated, keep_var::KeepVar};
use super::PeepholeOptimizations;
@@ -80,9 +80,7 @@ impl<'a> PeepholeOptimizations {
// kept block ending in a jump, an if/else or try/catch where
// every branch jumps — makes the rest of the list unreachable.
// https://github.com/rolldown/rolldown/issues/10184
if !is_control_flow_dead
&& stmts.last().is_some_and(Self::statement_never_completes_normally)
{
if !is_control_flow_dead && stmts.last().is_some_and(Statement::is_terminated) {
is_control_flow_dead = true;
}
}
@@ -435,65 +433,6 @@ impl<'a> PeepholeOptimizations {
false
}
/// Whether control never falls through to the statement after this one in
/// the same statement list (terser's `aborts`).
///
/// Any abrupt completion qualifies, whatever its target: a `break` or
/// `continue` transfers control past the end of the enclosing statement
/// list, never to the next statement in it.
///
/// Conservative on purpose:
/// - A labeled statement completes normally when its body breaks out of
/// its own label (`a: { break a; }`), so it never counts.
/// - Loops and switches count as completing normally; `while (true)`
/// without `break` is handled by loop-specific folds instead.
fn statement_never_completes_normally(stmt: &Statement<'a>) -> bool {
match stmt {
Statement::ReturnStatement(_)
| Statement::ThrowStatement(_)
| Statement::BreakStatement(_)
| Statement::ContinueStatement(_) => true,
Statement::BlockStatement(block) => Self::block_never_completes_normally(block),
Statement::IfStatement(if_stmt) => {
if_stmt.alternate.as_ref().is_some_and(Self::statement_never_completes_normally)
&& Self::statement_never_completes_normally(&if_stmt.consequent)
}
Statement::TryStatement(try_stmt) => {
// A finalizer that aborts overrides however the other
// blocks complete. Otherwise the try block must abort, and
// so must the catch block when present (an exception
// thrown before the try block's jump lands there).
try_stmt.finalizer.as_ref().is_some_and(|f| Self::block_never_completes_normally(f))
|| (Self::block_never_completes_normally(&try_stmt.block)
&& try_stmt
.handler
.as_ref()
.is_none_or(|h| Self::block_never_completes_normally(&h.body)))
}
_ => false,
}
}
fn block_never_completes_normally(block: &BlockStatement<'a>) -> bool {
// A minimized dead zone keeps only hoisting survivors after the jump:
// `function` declarations and the initializer-less `var` stub
// re-emitted by `KeepVar`. Their bindings initialize at scope entry
// and nothing after an aborting statement ever runs, so skip them
// from the back before testing how the block completes.
block
.body
.iter()
.rev()
.find(|stmt| match stmt {
Statement::FunctionDeclaration(_) => false,
Statement::VariableDeclaration(decl) => {
!(decl.kind.is_var() && decl.declarations.iter().all(|d| d.init.is_none()))
}
_ => true,
})
.is_some_and(Self::statement_never_completes_normally)
}
/// For variable declarations:
/// * merge with the previous variable declarator if their kinds are the same
/// * remove the variable declarator if it is unused
@@ -1003,27 +942,27 @@ impl<'a> PeepholeOptimizations {
return ControlFlow::Break(());
}
}
}
if if_stmt.alternate.is_some() {
// "if (a) return b; else if (c) return d; else return e;" => "if (a) return b; if (c) return d; return e;"
result.push(Statement::IfStatement(if_stmt));
loop {
if let Some(Statement::IfStatement(if_stmt)) = result.last_mut()
&& if_stmt.consequent.is_jump_statement()
&& let Some(stmt) = if_stmt.alternate.take()
{
if let Statement::BlockStatement(block_stmt) = stmt {
Self::handle_block(result, block_stmt, ctx);
} else {
result.push(stmt);
ctx.notice_change();
}
continue;
if if_stmt.alternate.is_some() && if_stmt.consequent.is_terminated() {
// "if (a) return b; else if (c) return d; else return e;" => "if (a) return b; if (c) return d; return e;"
result.push(Statement::IfStatement(if_stmt));
loop {
if let Some(Statement::IfStatement(if_stmt)) = result.last_mut()
&& if_stmt.consequent.is_terminated()
&& let Some(stmt) = if_stmt.alternate.take()
{
if let Statement::BlockStatement(block_stmt) = stmt {
Self::handle_block(result, block_stmt, ctx);
} else {
result.push(stmt);
ctx.notice_change();
}
break;
continue;
}
return ControlFlow::Continue(());
break;
}
return ControlFlow::Continue(());
}
}
@@ -39,6 +39,14 @@ fn test_function_return_optimization() {
"function f(){if(a()){b()}else{c();return;}}",
"function f(){if(a())b();else{c();return}}",
); // function f(){a()?b():c()}
test(
"function f(){ if(a()) { if (a) { return } throw a; } else return 2; }",
"function f(){ if(a()) { if (a) return; throw a } return 2 }",
);
test(
"function f(){if(a()){if(b()){d();return;}else{return;}}else{return;} c();}",
"function f(){if(a()){if(b()){d();return}return}}",
); // function f(){a()&&b()&&d()}
test("function f(){if(a()){b();return;}else;}", "function f(){if(a()){b();return}}"); // function f(){a()&&b()}
test("function f(){if(a()){return;}else{return;} return;}", "function f(){a();}");
test("function f(){if(a()){return;}else{return;} b();}", "function f(){a()}");
@@ -108,6 +116,10 @@ fn test_while_continue_optimization() {
test("while(true){if(a()){b();continue;}else;}", "for (;;) if(a()){b();continue;}"); // for(;;)a()&&b();
test("while(true){if(a()){continue;}else{continue;} continue;}", "for(;;)a();");
test("while(true){if(a()){continue;}else{continue;} b();}", "for(;;)a();");
test(
"while(true){d();if(a()){continue;}else if(b()){c();continue;}else{continue;}}",
"for(;;)if(d(),!a()&&b()){c();continue}",
); // for(;;)d(),!a()&&b()&&c();
test("while(true)while(a())continue;", "for(;;)for(;a();)continue;"); // for(;;)for(;a(););
test("while(true)for(x in a())continue", "for(;;)for(x in a())continue;"); // for(;;)for(x in a());
@@ -184,7 +184,7 @@ fn test_handle_switch_statement() {
); // a === 3 && b && c();
test(
"switch (a) { case 3: { if(b) {c(); break;} else { d(); break;} }}",
"switch (a) { case 3: if(b) {c(); break;} else { d(); break;} }",
"switch (a) { case 3: if(b) {c(); break;} d(); }",
); // if (a === 3) b ? c() : d();
test("switch (a) { case 3: { for (;;) break } }", "if (a === 3) for (;;) break;");
test("switch (a) { case 3: { for (b of c) break; } }", "if (a === 3) for (b of c) break;");
+9 -9
View File
@@ -3,25 +3,25 @@ Original | minified | minified | gzip | gzip | Iterations | Fi
-------------------------------------------------------------------------------------
72.14 kB | 23.11 kB | 23.70 kB | 8.34 kB | 8.54 kB | 2 | react.development.js
173.90 kB | 59.35 kB | 59.82 kB | 19.15 kB | 19.33 kB | 2 | moment.js
173.90 kB | 59.34 kB | 59.82 kB | 19.15 kB | 19.33 kB | 2 | moment.js
287.63 kB | 89.24 kB | 90.07 kB | 30.91 kB | 31.95 kB | 2 | jquery.js
287.63 kB | 89.19 kB | 90.07 kB | 30.90 kB | 31.95 kB | 2 | jquery.js
342.15 kB | 116.98 kB | 118.14 kB | 43.19 kB | 44.37 kB | 2 | vue.js
342.15 kB | 116.92 kB | 118.14 kB | 43.17 kB | 44.37 kB | 2 | vue.js
544.10 kB | 70.91 kB | 72.48 kB | 25.73 kB | 26.20 kB | 2 | lodash.js
555.77 kB | 267.20 kB | 270.13 kB | 87.99 kB | 90.80 kB | 2 | d3.js
555.77 kB | 267.19 kB | 270.13 kB | 87.99 kB | 90.80 kB | 2 | d3.js
1.01 MB | 439.26 kB | 458.89 kB | 122.03 kB | 126.71 kB | 2 | bundle.min.js
1.01 MB | 439.20 kB | 458.89 kB | 122.02 kB | 126.71 kB | 2 | bundle.min.js
1.25 MB | 642.44 kB | 646.76 kB | 159.37 kB | 163.73 kB | 2 | three.js
1.25 MB | 642.41 kB | 646.76 kB | 159.36 kB | 163.73 kB | 2 | three.js
2.14 MB | 710.90 kB | 724.14 kB | 160.38 kB | 181.07 kB | 2 | victory.js
2.14 MB | 710.74 kB | 724.14 kB | 160.33 kB | 181.07 kB | 2 | victory.js
3.20 MB | 1.00 MB | 1.01 MB | 322.56 kB | 331.56 kB | 2 | echarts.js
3.20 MB | 1.00 MB | 1.01 MB | 322.55 kB | 331.56 kB | 2 | echarts.js
6.69 MB | 2.12 MB | 2.31 MB | 453.80 kB | 488.28 kB | 5 | antd.js
10.95 MB | 3.33 MB | 3.49 MB | 852.84 kB | 915.50 kB | 4 | typescript.js
10.95 MB | 3.33 MB | 3.49 MB | 852.67 kB | 915.50 kB | 4 | typescript.js
@@ -5,9 +5,9 @@ checker.ts:
sys deallocs: 152
sys alloc bytes: 15030872 # 15.03 MB
sys peak growth: 10203896 # 10.20 MB
arena allocs: 152956
arena reallocs: 28386
arena size: 19332672 # 19.33 MB
arena allocs: 153112
arena reallocs: 28468
arena size: 19341336 # 19.34 MB
App.tsx:
file size: 415342 # 415.34 kB
@@ -16,8 +16,8 @@ App.tsx:
sys deallocs: 63
sys alloc bytes: 2136669 # 2.14 MB
sys peak growth: 1620605 # 1.62 MB
arena allocs: 17035
arena reallocs: 2714
arena allocs: 17049
arena reallocs: 2721
arena size: 2916824 # 2.92 MB
RadixUIAdoptionSection.jsx:
@@ -38,8 +38,8 @@ pdf.mjs:
sys deallocs: 2467
sys alloc bytes: 5348219 # 5.35 MB
sys peak growth: 3707391 # 3.71 MB
arena allocs: 47436
arena reallocs: 7715
arena allocs: 47442
arena reallocs: 7718
arena size: 6374320 # 6.37 MB
antd.js:
@@ -49,9 +49,9 @@ antd.js:
sys deallocs: 1038
sys alloc bytes: 29975903 # 29.98 MB
sys peak growth: 19934725 # 19.93 MB
arena allocs: 371560
arena reallocs: 76271
arena size: 49106184 # 49.11 MB
arena allocs: 371599
arena reallocs: 76289
arena size: 49108736 # 49.11 MB
binder.ts:
file size: 193077 # 193.08 kB
@@ -60,8 +60,8 @@ binder.ts:
sys deallocs: 33
sys alloc bytes: 963614 # 963.61 kB
sys peak growth: 658042 # 658.04 kB
arena allocs: 7078
arena reallocs: 841
arena allocs: 7079
arena reallocs: 842
arena size: 1169840 # 1.17 MB
kitchen-sink.tsx: