fix(minifier): avoid longer numeric constant folds (#24875)

Constant evaluation can replace a short numeric expression with a longer exact floating-point literal, increasing minified output.

Using the serializer shared by #24876 and optimized by #24889, the minifier compares the folded literal with a conservative lower bound for the original numeric expression before accepting numeric addition, subtraction, and multiplication folds. Parentheses are omitted from the estimate, so an accepted fold cannot make output longer. Measuring the original tree instead of evaluated operand values also preserves profitable outer folds such as `.8` without regressing nested expressions such as `0+(.1+.05)`.

## Example

Input:

```js
console.log(0.1 + 0.05);
console.log(0.1 + 0.2 + 0.1 + 0.4);
console.log(0.1 * 1 + 0.05);
console.log(0.1 * 1 + 0.7);
console.log(0.3 - 0.1);
```

Before:

```js
console.log(.15000000000000002),console.log(.7999999999999999),console.log(.15000000000000002),console.log(.8),console.log(.19999999999999998);
```

After:

```js
console.log(.1+.05),console.log(.7+.1),console.log(.1+.05),console.log(.8),console.log(.3-.1);
```

Verified with the full `oxc_minifier` test suite, minsize and allocation snapshots, Clippy, and formatting.

Closes #24863.

AI assistance: Codex was used to implement and verify this change.
This commit is contained in:
Dunqing committed 2026-07-26 14:12:00 +00:00
1 parent f7971f181f
commit 46183007c2
4 files changed
+122 -25

No files matched your search

@@ -4,6 +4,7 @@ use oxc_ecmascript::{
GlobalContext, ToJsString,
constant_evaluation::{ConstantEvaluation, ConstantValue, DetermineValueType, ValueType},
side_effects::MayHaveSideEffects,
with_number_literal,
};
use oxc_span::{GetSpan, SPAN};
use oxc_syntax::operator::{AssignmentOperator, BinaryOperator, LogicalOperator};
@@ -345,26 +346,38 @@ impl<'a> PeepholeOptimizations {
&& (left.abs() as usize) <= 0xFFFF_FFFF
&& (right.abs() as usize) <= 0xFFFF_FFFF)
})
.filter(|(left, right)| {
Self::folded_numeric_expression_is_shorter(e, left - right) != Some(false)
})
.and_then(|_| ctx.eval_binary(e))
}
BinaryOperator::Multiplication
| BinaryOperator::Exponential
| BinaryOperator::Remainder => Self::extract_numeric_values(e, ctx)
.filter(|(left, right)| {
*left == 0.0
|| left.is_nan()
|| left.is_infinite()
|| *right == 0.0
|| right.is_nan()
|| right.is_infinite()
// Small number multiplication.
|| (e.operator == BinaryOperator::Multiplication
&& left.abs() <= 255.0
&& left.fract() == 0.0
&& right.abs() <= 255.0
&& right.fract() == 0.0)
| BinaryOperator::Remainder => {
let shorter_multiplication = if e.operator == BinaryOperator::Multiplication {
Self::try_fold_shorter_numeric_expression(e, ctx)
} else {
None
};
shorter_multiplication.or_else(|| {
Self::extract_numeric_values(e, ctx)
.filter(|(left, right)| {
*left == 0.0
|| left.is_nan()
|| left.is_infinite()
|| *right == 0.0
|| right.is_nan()
|| right.is_infinite()
// Small number multiplication.
|| (e.operator == BinaryOperator::Multiplication
&& left.abs() <= 255.0
&& left.fract() == 0.0
&& right.abs() <= 255.0
&& right.fract() == 0.0)
})
.and_then(|_| ctx.eval_binary(e))
})
.and_then(|_| ctx.eval_binary(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)),
@@ -412,11 +425,75 @@ impl<'a> PeepholeOptimizations {
count
}
/// Lower bound for the minified size of a numeric expression. Parentheses are deliberately
/// omitted, so accepting a fold based on this count cannot make the output longer.
fn numeric_expression_size_lower_bound(expr: &Expression<'a>) -> Option<usize> {
match expr.get_inner_expression() {
Expression::NumericLiteral(lit) => Self::number_literal_source_len(lit.value),
Expression::UnaryExpression(e)
if matches!(
e.operator,
UnaryOperator::UnaryNegation | UnaryOperator::UnaryPlus
) =>
{
Some(1 + Self::numeric_expression_size_lower_bound(&e.argument)?)
}
Expression::BinaryExpression(e)
if matches!(
e.operator,
BinaryOperator::Addition
| BinaryOperator::Subtraction
| BinaryOperator::Multiplication
| BinaryOperator::Division
| BinaryOperator::Remainder
| BinaryOperator::Exponential
) =>
{
Self::binary_numeric_expression_size_lower_bound(e)
}
_ => None,
}
}
fn binary_numeric_expression_size_lower_bound(e: &BinaryExpression<'a>) -> Option<usize> {
Some(
Self::numeric_expression_size_lower_bound(&e.left)?
+ e.operator.as_str().len()
+ Self::numeric_expression_size_lower_bound(&e.right)?,
)
}
fn number_literal_source_len(value: f64) -> Option<usize> {
value.is_finite().then(|| {
usize::from(value.is_sign_negative()) + with_number_literal(value.abs(), str::len)
})
}
fn try_fold_shorter_numeric_expression(
e: &BinaryExpression<'a>,
ctx: &TraverseCtx<'a>,
) -> Option<Expression<'a>> {
let original_len = Self::binary_numeric_expression_size_lower_bound(e)?;
let result = e.evaluate_value(ctx)?.into_number()?;
(Self::number_literal_source_len(result)? <= original_len)
.then(|| ctx.value_to_expr(e.span, ConstantValue::Number(result)))
}
fn folded_numeric_expression_is_shorter(e: &BinaryExpression<'a>, result: f64) -> Option<bool> {
let original_len = Self::binary_numeric_expression_size_lower_bound(e)?;
Some(Self::number_literal_source_len(result)? <= original_len)
}
// Simplified version of `tryFoldAdd` from closure compiler.
fn try_fold_add(e: &mut BinaryExpression<'a>, ctx: &TraverseCtx<'a>) -> Option<Expression<'a>> {
if !e.may_have_side_effects(ctx)
&& let Some(v) = e.evaluate_value(ctx)
{
if let ConstantValue::Number(result) = &v
&& Self::folded_numeric_expression_is_shorter(e, *result) == Some(false)
{
return None;
}
return Some(ctx.value_to_expr(e.span, v));
}
debug_assert_eq!(e.operator, BinaryOperator::Addition);
@@ -1014,6 +1014,25 @@ fn test_fold_add() {
fold("x = null + 1", "x = 1");
}
#[test]
fn test_fold_numeric_expression_only_if_shorter() {
// https://github.com/oxc-project/oxc/issues/24863
fold_same("0.1 + 0.05");
fold_same("0.7 + 0.1");
fold_same("0.3 - 0.1");
fold("0.1 + (0.2 - 0.1) * 0.5", "0.1 + 0.05");
fold("0.7 + (0.9 - 0.7) * 0.5", "0.8");
fold_same("1e3 + 1e-10");
fold("1e12 + 1e12", "2e12");
fold("1e12 - 1e12", "0");
// Compare against the original expression, not the longer value of a nested operand.
fold_same("0 + (0.1 + 0.05)");
// Do not evaluate across an operand with side effects.
fold_same("f() + 0.05");
}
#[test]
fn test_fold_sub() {
fold("x = 10 - 20", "x = -10");
@@ -1021,8 +1040,9 @@ fn test_fold_sub() {
#[test]
fn test_fold_multiply() {
fold_same("x = 2.25 * 3");
fold("x = 2.25 * 3", "x = 6.75");
fold_same("z = x * y");
fold_same("x = f() * 2");
fold_same("x = y * 5");
fold("x = null * undefined", "x = NaN");
fold("x = null * 1", "x = 0");
@@ -1035,7 +1055,7 @@ fn test_fold_multiply() {
fold("x = 255 * 255", "x = 65025");
fold("x = -255 * 255", "x = -65025");
fold("x = -255 * -255", "x = 65025");
fold_same("x = 256 * 255");
fold("x = 256 * 255", "x = 65280");
}
#[test]
+5 -5
View File
@@ -11,17 +11,17 @@ Original | minified | minified | gzip | gzip | Iterations | Fi
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.98 kB | 90.80 kB | 2 | d3.js
555.77 kB | 267.20 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.25 MB | 642.45 kB | 646.76 kB | 159.37 kB | 163.73 kB | 2 | three.js
1.25 MB | 642.44 kB | 646.76 kB | 159.37 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
3.20 MB | 1.00 MB | 1.01 MB | 322.57 kB | 331.56 kB | 2 | echarts.js
3.20 MB | 1.00 MB | 1.01 MB | 322.56 kB | 331.56 kB | 2 | echarts.js
6.69 MB | 2.12 MB | 2.31 MB | 453.79 kB | 488.28 kB | 5 | antd.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.81 kB | 915.50 kB | 4 | typescript.js
10.95 MB | 3.33 MB | 3.49 MB | 852.84 kB | 915.50 kB | 4 | typescript.js
@@ -49,7 +49,7 @@ antd.js:
sys deallocs: 1038
sys alloc bytes: 29975903 # 29.98 MB
sys peak growth: 19934725 # 19.93 MB
arena allocs: 371546
arena allocs: 371560
arena reallocs: 76271
arena size: 49106184 # 49.11 MB
@@ -71,7 +71,7 @@ kitchen-sink.tsx:
sys deallocs: 1858
sys alloc bytes: 5337950 # 5.34 MB
sys peak growth: 3737991 # 3.74 MB
arena allocs: 71838
arena allocs: 71882
arena reallocs: 12252
arena size: 9559192 # 9.56 MB
arena size: 9560952 # 9.56 MB