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:
Dunqing committed 2026-07-15 08:26:29 +00:00
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]