mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
feat(minifier): fold arithmetic over undefined and null operands (#24485)
`extract_numeric_values` required two numeric literals, so arithmetic over values with no literal form never folded: `Infinity` and `NaN` identifiers normalize to non-finite numeric literals and fold, but `undefined` normalizes to `void 0` — a unary expression — so `void 0 * 2` stayed as-is, eight characters where `NaN` is three. The same gate kept tracked constants from folding: `let a; console.log(a * 2)` kept the read (and with it the declaration) alive where `console.log(NaN)` is smaller and lets the binding die. terser folds all of these; esbuild folds none. Surfaced by the review question on #24452. The extraction now falls back to the constant evaluator, which applies ToNumber (`undefined` → NaN, `null` → 0, `'2'` → 2) and refuses side-effectful operands; each arm's small-result size filters are unchanged. A cheap operand-kind gate keeps the fallback away from allocation-prone operands (a bigint literal heap-allocates only for ToNumber to bail; a call expression attempts string-method folds) — with it, the allocation snapshots are byte-identical to main. Addition already behaved this way (`void 0 + 1` folded), so this aligns the gated arms with it. Widening NaN-producing folds exposed a pre-existing miscompile that had to be fixed first: a NaN numeric literal prints as the identifier `NaN`, so `function f() { let NaN = 1; return 0 / 0; }` minified to `return NaN` — returning 1. `eval_binary` now bails when `NaN` / `Infinity` is shadowed at the fold site; bailing rather than emitting `0 / 0` keeps the fixed-point loop convergent, since replacing `0 / 0` with itself would set the changed flag on every pass. For non-binary producers (e.g. `parseInt` folds), `value_to_expr` materializes non-finite constants in shadowed scopes as `0/0` / `1/0` / `-1/0`, which the division fold then leaves alone. ## Example ```js let a; console.log(a * 2); console.log(void 0 * 2); ``` Before: ```js let a; console.log(a * 2), console.log(void 0 * 2); ``` After: ```js console.log(NaN), console.log(NaN); ``` Verified with the minifier unit suite (555 passing; the only expectation changes are the closure-compiler ports this enables: the commented-out `x = null * 1` → `x = 0` family now passes and `x = null ** 0` folds to `x = 1`), `--twice` idempotency, and byte-identical minsize and allocation snapshots. Fixes #24474 Implemented with AI assistance (Claude Code); design, review, and verification driven by the author per the repo AI-usage policy.
This commit is contained in:
1 parent
23d9c307c7
commit
4d0c601aaf
3 files changed
+121
-15
No files matched your search
@@ -281,13 +281,43 @@ impl<'a> PeepholeOptimizations {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_numeric_values(e: &BinaryExpression<'a>) -> Option<(f64, f64)> {
|
||||
fn extract_numeric_values(
|
||||
e: &BinaryExpression<'a>,
|
||||
ctx: &TraverseCtx<'a>,
|
||||
) -> Option<(f64, f64)> {
|
||||
if let (Expression::NumericLiteral(left), Expression::NumericLiteral(right)) =
|
||||
(&e.left, &e.right)
|
||||
{
|
||||
return Some((left.value, right.value));
|
||||
}
|
||||
None
|
||||
// `undefined` has no literal form (`void 0` is a unary expression) and
|
||||
// a tracked-constant read is an identifier, so fall back to the
|
||||
// evaluator, which also applies ToNumber (`undefined` → NaN, `null` →
|
||||
// 0, `'2'` → 2) and refuses operands with side effects.
|
||||
if !Self::is_cheap_to_number_operand(&e.left) || !Self::is_cheap_to_number_operand(&e.right)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let left = e.left.get_side_free_number_value(ctx)?;
|
||||
let right = e.right.get_side_free_number_value(ctx)?;
|
||||
Some((left, right))
|
||||
}
|
||||
|
||||
/// Operand shapes whose ToNumber evaluation is allocation-free. Evaluating
|
||||
/// the rest is wasted work here: a `BigIntLiteral` heap-allocates a bigint
|
||||
/// only for ToNumber to bail, a `CallExpression` attempts string-method
|
||||
/// folds that build strings, and neither can pass the small-result size
|
||||
/// filters anyway.
|
||||
fn is_cheap_to_number_operand(e: &Expression<'a>) -> bool {
|
||||
matches!(
|
||||
e.get_inner_expression(),
|
||||
Expression::NumericLiteral(_)
|
||||
| Expression::StringLiteral(_)
|
||||
| Expression::BooleanLiteral(_)
|
||||
| Expression::NullLiteral(_)
|
||||
| Expression::Identifier(_)
|
||||
| Expression::UnaryExpression(_)
|
||||
)
|
||||
}
|
||||
|
||||
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
@@ -315,7 +345,7 @@ impl<'a> PeepholeOptimizations {
|
||||
BinaryOperator::Addition => Self::try_fold_add(e, ctx),
|
||||
BinaryOperator::Subtraction => {
|
||||
// Subtraction of small-ish integers can definitely be folded without issues
|
||||
Self::extract_numeric_values(e)
|
||||
Self::extract_numeric_values(e, ctx)
|
||||
.filter(|(left, right)| {
|
||||
left.is_nan()
|
||||
|| left.is_finite()
|
||||
@@ -330,7 +360,7 @@ impl<'a> PeepholeOptimizations {
|
||||
}
|
||||
BinaryOperator::Multiplication
|
||||
| BinaryOperator::Exponential
|
||||
| BinaryOperator::Remainder => Self::extract_numeric_values(e)
|
||||
| BinaryOperator::Remainder => Self::extract_numeric_values(e, ctx)
|
||||
.filter(|(left, right)| {
|
||||
*left == 0.0
|
||||
|| left.is_nan()
|
||||
@@ -346,11 +376,11 @@ impl<'a> PeepholeOptimizations {
|
||||
&& right.fract() == 0.0)
|
||||
})
|
||||
.and_then(|_| ctx.eval_binary(e)),
|
||||
BinaryOperator::Division => Self::extract_numeric_values(e)
|
||||
BinaryOperator::Division => Self::extract_numeric_values(e, ctx)
|
||||
.filter(|(_, right)| *right == 0.0 || right.is_nan() || right.is_infinite())
|
||||
.and_then(|_| ctx.eval_binary(e)),
|
||||
BinaryOperator::ShiftLeft => {
|
||||
Self::extract_numeric_values(e).and_then(|(left, right)| {
|
||||
Self::extract_numeric_values(e, ctx).and_then(|(left, right)| {
|
||||
let result = e.evaluate_value(ctx)?.into_number()?;
|
||||
let left_len = Self::approximate_printed_int_char_count(left);
|
||||
let right_len = Self::approximate_printed_int_char_count(right);
|
||||
@@ -360,7 +390,7 @@ impl<'a> PeepholeOptimizations {
|
||||
})
|
||||
}
|
||||
BinaryOperator::ShiftRightZeroFill => {
|
||||
Self::extract_numeric_values(e).and_then(|(left, right)| {
|
||||
Self::extract_numeric_values(e, ctx).and_then(|(left, right)| {
|
||||
let result = e.evaluate_value(ctx)?.into_number()?;
|
||||
let left_len = Self::approximate_printed_int_char_count(left);
|
||||
let right_len = Self::approximate_printed_int_char_count(right);
|
||||
|
||||
@@ -4,8 +4,7 @@ use oxc_compat::ESFeature;
|
||||
use oxc_ecmascript::{
|
||||
GlobalContext,
|
||||
constant_evaluation::{
|
||||
ConstantEvaluation, ConstantEvaluationCtx, ConstantValue, ValueType,
|
||||
binary_operation_evaluate_value,
|
||||
ConstantEvaluationCtx, ConstantValue, ValueType, binary_operation_evaluate_value,
|
||||
},
|
||||
side_effects::{
|
||||
MayHaveSideEffects, MayHaveSideEffectsContext, PropertyReadSideEffects, is_pure_function,
|
||||
@@ -189,7 +188,18 @@ impl<'a> TraverseCtx<'a, MinifierState<'a>> {
|
||||
if e.may_have_side_effects(self) {
|
||||
None
|
||||
} else {
|
||||
e.evaluate_value(self).map(|v| self.value_to_expr(e.span, v))
|
||||
let v = self.eval_binary_operation(e.operator, &e.left, &e.right)?;
|
||||
// Bail instead of materializing the shadow-safe division form:
|
||||
// replacing `0 / 0` with `0 / 0` would set the changed flag on
|
||||
// every pass and the fixed-point loop would never converge. The
|
||||
// other `eval_binary_operation` callers fold bitwise operators
|
||||
// only, whose results are always finite, so the guard lives here.
|
||||
if let ConstantValue::Number(n) = &v
|
||||
&& self.non_finite_global_shadowed(*n)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(self.value_to_expr(e.span, v))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +212,41 @@ impl<'a> TraverseCtx<'a, MinifierState<'a>> {
|
||||
binary_operation_evaluate_value(operator, left, right, self)
|
||||
}
|
||||
|
||||
/// Whether materializing `n` prints a global name (`NaN`, `Infinity`)
|
||||
/// that a binding in the current scope chain captures: in
|
||||
/// `function f() { let NaN = 1; return 0 / 0; }`, folding the division
|
||||
/// to a NaN literal makes `f` return `1`.
|
||||
fn non_finite_global_shadowed(&self, n: f64) -> bool {
|
||||
let name = if n.is_nan() {
|
||||
"NaN"
|
||||
} else if n.is_infinite() {
|
||||
"Infinity"
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
self.scoping().find_binding(self.scoping.current_scope_id(), name.into()).is_some()
|
||||
}
|
||||
|
||||
/// `NaN` → `0 / 0`, `Infinity` → `1 / 0`, `-Infinity` → `-1 / 0`: the
|
||||
/// same value with no capturable name attached. Used when a non-finite
|
||||
/// constant must be materialized where its global name is shadowed.
|
||||
fn non_finite_to_division_expr(&self, span: Span, n: f64) -> Expression<'a> {
|
||||
let numerator = if n.is_nan() { 0.0 } else { 1.0 };
|
||||
let mut left =
|
||||
Expression::new_numeric_literal(span, numerator, None, NumberBase::Decimal, self);
|
||||
if n == f64::NEG_INFINITY {
|
||||
left = Expression::new_unary_expression(span, UnaryOperator::UnaryNegation, left, self);
|
||||
}
|
||||
let right = Expression::new_numeric_literal(span, 0.0, None, NumberBase::Decimal, self);
|
||||
Expression::new_binary_expression(span, left, BinaryOperator::Division, right, self)
|
||||
}
|
||||
|
||||
pub fn value_to_expr(&self, span: Span, value: ConstantValue<'a>) -> Expression<'a> {
|
||||
match value {
|
||||
ConstantValue::Number(n) => {
|
||||
if !n.is_finite() && self.non_finite_global_shadowed(n) {
|
||||
return self.non_finite_to_division_expr(span, n);
|
||||
}
|
||||
let number_base =
|
||||
if is_exact_int64(n) { NumberBase::Decimal } else { NumberBase::Float };
|
||||
Expression::new_numeric_literal(span, n, None, number_base, self)
|
||||
|
||||
@@ -1024,10 +1024,10 @@ fn test_fold_multiply() {
|
||||
fold_same("x = 2.25 * 3");
|
||||
fold_same("z = x * y");
|
||||
fold_same("x = y * 5");
|
||||
// test("x = null * undefined", "x = NaN");
|
||||
// test("x = null * 1", "x = 0");
|
||||
// test("x = (null - 1) * 2", "x = -2");
|
||||
// test("x = (null + 1) * 2", "x = 2");
|
||||
fold("x = null * undefined", "x = NaN");
|
||||
fold("x = null * 1", "x = 0");
|
||||
fold("x = (null - 1) * 2", "x = -2");
|
||||
fold("x = (null + 1) * 2", "x = 2");
|
||||
// test("x = y + (z * 24 * 60 * 60 * 1000)", "x = y + z * 864E5");
|
||||
fold("x = y + (z & 24 & 60 & 60 & 1000)", "x = y + (z & 8)");
|
||||
fold("x = -1 * -1", "x = 1");
|
||||
@@ -1065,7 +1065,41 @@ fn test_fold_exponential() {
|
||||
fold_same("x = 3 ** -1");
|
||||
fold_same("x = (-1) ** 0.5");
|
||||
fold("x = (-0) ** 3", "x = -0");
|
||||
fold_same("x = null ** 0");
|
||||
fold("x = null ** 0", "x = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fold_arithmetic_undefined_null_operands() {
|
||||
// `undefined` has no literal form (it prints as `void 0`), so it never
|
||||
// satisfied the two-numeric-literals extraction; these folds see through
|
||||
// ToNumber(undefined) = NaN / ToNumber(null) = 0 instead. terser folds
|
||||
// all of these.
|
||||
fold("x = void 0 * 2", "x = NaN");
|
||||
fold("x = void 0 - 1", "x = NaN");
|
||||
fold("x = 2 / void 0", "x = NaN");
|
||||
fold("x = void 0 % 2", "x = NaN");
|
||||
fold("x = (void 0) ** 2", "x = NaN");
|
||||
fold("x = null * 2", "x = 0");
|
||||
fold("x = '2' * '3'", "x = 6");
|
||||
fold("x = true * 5", "x = 5");
|
||||
// A tracked constant resolves through the same evaluator path, so the
|
||||
// implicit undefined of `let a;` folds without being textually inlined.
|
||||
test("let a; NOOP(a * 2)", "let a; NOOP(NaN)");
|
||||
// An operand with side effects must not fold even though ToNumber of
|
||||
// the other side is known.
|
||||
fold_same("x = f() * 0");
|
||||
// Mixing BigInt and Number throws at runtime; ToNumber of a BigInt bails.
|
||||
fold_same("x = 1n * 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fold_non_finite_result_with_shadowed_global() {
|
||||
// A NaN result is materialized as a numeric literal that codegen prints
|
||||
// as the identifier `NaN`; a local `let NaN` binding captures it and
|
||||
// changes what the function returns. Same shape for `Infinity`. The fold
|
||||
// must bail when the corresponding global name is shadowed.
|
||||
test_same("function f() {\n\tlet NaN = 1;\n\treturn 0 / 0;\n}");
|
||||
test_same("function f() {\n\tlet Infinity = 1;\n\treturn 1 / 0;\n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user