perf(minifier): replace expressions without take_in dummies (#25836)

`replace_expression` currently makes callers extract the old value before constructing its replacement, leaving an arena-backed dummy node behind.

Add a closure-based replacement path that owns the old expression and records the AST mutation. Its dropped-subtree contract remains explicit: callers report discarded subtrees, while values moved into the replacement stay live. Convert the logical-not unwrapping sites whose discarded wrappers contain no references.

This is the foundation of a three-PR Graphite stack. The tracked allocation fixtures record 256 fewer arena allocations and 2,256 fewer arena bytes. Minsize output is unchanged.

Validated with `cargo test -p oxc_minifier`, `just minsize`, `cargo clippy -p oxc_minifier -- -D warnings`, `just fmt`, and `git diff --check`.

AI assistance was used for research, implementation, review, and testing. I reviewed and take responsibility for the changes.
This commit is contained in:
Dunqing committed 2026-08-18 06:57:08 +00:00
1 parent 241c559b83
commit 673b04bc46
3 files changed
+44 -20

No files matched your search

@@ -1,4 +1,3 @@
use oxc_allocator::TakeIn;
use oxc_ast::ast::*;
use oxc_ecmascript::constant_evaluation::DetermineValueType;
use oxc_span::GetSpan;
@@ -32,8 +31,14 @@ impl<'a> PeepholeOptimizations {
Expression::UnaryExpression(e)
if e.operator.is_not() && e.argument.value_type(ctx).is_boolean() =>
{
let new_expr = e.argument.take_in(ctx);
ctx.replace_expression(expr, new_expr);
// Both discarded `!` wrappers contain no references.
ctx.replace_expression_with(expr, |old, _| {
let Expression::UnaryExpression(outer) = old else { unreachable!() };
let Expression::UnaryExpression(inner) = outer.unbox().argument else {
unreachable!()
};
inner.unbox().argument
});
}
// `!(a == b)` => `a != b`
// `!(a != b)` => `a == b`
@@ -41,8 +46,7 @@ impl<'a> PeepholeOptimizations {
// `!(a !== b)` => `a === b`
Expression::BinaryExpression(binary_expr) if binary_expr.operator.is_equality() => {
binary_expr.operator = binary_expr.operator.equality_inverse_operator().unwrap();
let new_expr = e.argument.take_in(ctx);
ctx.replace_expression(expr, new_expr);
ctx.replace_expression_with(expr, Self::unwrap_not);
}
// `!(a == b || c == d)` => `a != b && c != d`
// `!(a == b && c == d)` => `a != b || c != d`
@@ -56,23 +60,27 @@ impl<'a> PeepholeOptimizations {
if Self::de_morgan_paren_delta(logical_expr).is_some_and(|delta| delta <= 0) =>
{
Self::de_morgan_invert_logical(logical_expr);
let new_expr = e.argument.take_in(ctx);
ctx.replace_expression(expr, new_expr);
ctx.replace_expression_with(expr, Self::unwrap_not);
}
// "!(a, b)" => "a, !b"
Expression::SequenceExpression(sequence_expr) => {
if let Some(last_expr) = sequence_expr.expressions.last_mut() {
let new_last =
Self::minimize_not(last_expr.span(), last_expr.take_in(ctx), ctx);
ctx.replace_expression(last_expr, new_last);
let new_expr = e.argument.take_in(ctx);
ctx.replace_expression(expr, new_expr);
ctx.replace_expression_with(last_expr, |old, ctx| {
Self::minimize_not(old.span(), old, ctx)
});
ctx.replace_expression_with(expr, Self::unwrap_not);
}
}
_ => {}
}
}
/// Unwrap `!x` into `x`. The discarded `!` wrapper contains no references.
fn unwrap_not(old: Expression<'a>, _ctx: &mut TraverseCtx<'a>) -> Expression<'a> {
let Expression::UnaryExpression(e) = old else { unreachable!() };
e.unbox().argument
}
/// Character delta from parentheses added or removed by De Morgan's law
/// (flipping `&&` <-> `||` changes which nested operands need parens), or
/// `None` if some operand cannot invert its operator in place.
@@ -1,4 +1,4 @@
use oxc_allocator::GetAllocator;
use oxc_allocator::{GetAllocator, ReplaceWith};
use oxc_ast::ast::*;
use oxc_compat::{ESFeature, EngineTargets};
use oxc_ecmascript::{
@@ -452,6 +452,22 @@ impl<'a> TraverseCtx<'a, MinifierState<'a>> {
self.state.record_ast_change();
}
/// Replace an expression slot with a value built from the owned old expression.
///
/// Prefer this over `take_in` followed by [`Self::replace_expression`]. It avoids
/// leaving a dummy node in the arena. The closure must report any discarded
/// subtrees through the `drop_*` helpers; values moved into its result are not dropped.
#[inline]
pub fn replace_expression_with(
&mut self,
slot: &mut Expression<'a>,
replacer: impl FnOnce(Expression<'a>, &mut Self) -> Expression<'a>,
) {
let ctx = &mut *self;
slot.replace_with(|old| replacer(old, ctx));
self.state.record_ast_change();
}
/// Replace a statement slot. Marks the pass as having mutated the AST.
#[inline]
pub fn replace_statement(&mut self, slot: &mut Statement<'a>, new: Statement<'a>) {
@@ -5,7 +5,7 @@ checker.ts:
sys deallocs: 150
sys alloc bytes: 14996600 # 15.00 MB
sys peak growth: 10169560 # 10.17 MB
arena allocs: 148426
arena allocs: 148403
arena reallocs: 28468
arena size: 19204712 # 19.20 MB
@@ -16,7 +16,7 @@ App.tsx:
sys deallocs: 61
sys alloc bytes: 2128555 # 2.13 MB
sys peak growth: 1612427 # 1.61 MB
arena allocs: 16046
arena allocs: 16038
arena reallocs: 2721
arena size: 2887488 # 2.89 MB
@@ -38,7 +38,7 @@ pdf.mjs:
sys deallocs: 2427
sys alloc bytes: 5334247 # 5.33 MB
sys peak growth: 3693355 # 3.69 MB
arena allocs: 45881
arena allocs: 45817
arena reallocs: 7718
arena size: 6326224 # 6.33 MB
@@ -49,9 +49,9 @@ antd.js:
sys deallocs: 1038
sys alloc bytes: 29976031 # 29.98 MB
sys peak growth: 19934725 # 19.93 MB
arena allocs: 371736
arena allocs: 371595
arena reallocs: 76289
arena size: 49111064 # 49.11 MB
arena size: 49108808 # 49.11 MB
binder.ts:
file size: 193077 # 193.08 kB
@@ -60,7 +60,7 @@ binder.ts:
sys deallocs: 33
sys alloc bytes: 963008 # 963.01 kB
sys peak growth: 657404 # 657.40 kB
arena allocs: 7032
arena allocs: 7030
arena reallocs: 842
arena size: 1167360 # 1.17 MB
@@ -71,6 +71,6 @@ kitchen-sink.tsx:
sys deallocs: 1854
sys alloc bytes: 5307512 # 5.31 MB
sys peak growth: 3707425 # 3.71 MB
arena allocs: 66770
arena allocs: 66752
arena reallocs: 12252
arena size: 9424360 # 9.42 MB