Files
oxc-project__oxc/tasks/minsize/minsize.snap
Kotaro Chikuba af82a0707a fix(minifier): keep unary + when the other operand has side effects (#25645)
`substitute_unary_plus` removes a `+` when the enclosing binary operator
will do the `ToNumber` conversion anyway. The existing doc comment
reasons carefully about `ToNumber` vs `ToNumeric`, but not about *when*
each runs.

`+a` converts `a` while evaluating `+a`, before the right operand is
evaluated at all. `a - n` converts it in
`ApplyStringOrNumericBinaryOperator`, after `n` has been evaluated.
Dropping the `+` therefore moves the conversion across `n`, and a right
operand with a side effect can change what the conversion sees:

```js
var xs = [];
console.log((+xs) - (xs.push(1), 0)); // 0, converted while `xs` was still empty

// oxc emitted:
console.log(xs - (xs.push(1), 0));    // 1, converted after `xs` grew
```

This PR requires the right operand to be free of side effects before
dropping a `+` in the left position. It cannot observe the conversion
either: under the "Coercion Methods Are Pure" assumption the conversion
runs no user code, so there is nothing to observe.

The right position (`n - +a`) is left as is: `n` is evaluated first
either way, and its `ToNumeric` is a no-op because the existing guard
already established that `n` is a number, so nothing runs between
evaluating `a` and converting it.

`cargo minsize` moves the typescript.js gzip figure down by 10 bytes;
every minified size is identical.

The reproduction was found by the minifier fuzzer (#25594) at seed
12397.

### Differential testing

Every case below was run through oxc and then executed in Node against
the unminified source, comparing the result, the mutated state, and the
error constructor *and message*.

| shape | cases | value worlds | runs |
| --- | ---: | ---: | ---: |
| operator × 37 hand-written right operands | 814 | 41 | 33,374 |
| right operand reading a closure variable the conversion may write |
374 | 41 | 15,334 |
| argument shape (`+a.v`, `+f()`, `+(seq(), a)`, `+(+a)`, getter) ×
nesting | 3,168 | 41 | 129,888 |
| random expressions, 3 seeds, depth 2-3 | 7,500 | 41 | 307,500 |

The worlds cover `[]`, `{ valueOf: () => 10n }`, `Symbol()`,
`Object.create(null)`, objects with getters, `Proxy`, and the numeric
edge cases.

No difference survives, other than one that predates this pass and is
discussed in the doc comment: for a `BigInt` argument both forms throw a
`TypeError`, but `+a` says "Cannot convert a BigInt value to a number"
while `a - 1` says "Cannot mix BigInt and other types". The error kind
never changes.

---

Written with AI assistance (Claude Code). Reviewed by me, and the
before/after behaviour was checked against Node.js.

---------

Signed-off-by: Kotaro Chikuba <miz404@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:38:27 +09:00

28 lines
1.3 KiB
Plaintext

| Oxc props | ESBuild | Oxc props | ESBuild |
Original | minified | minified | gzip | gzip | Iterations | File
-------------------------------------------------------------------------------------
72.14 kB | 22.48 kB | 23.70 kB | 8.20 kB | 8.54 kB | 2 | react.development.js
173.90 kB | 55.53 kB | 59.82 kB | 18.78 kB | 19.33 kB | 2 | moment.js
287.63 kB | 89.02 kB | 90.07 kB | 30.87 kB | 31.95 kB | 2 | jquery.js
342.15 kB | 114.85 kB | 118.14 kB | 42.73 kB | 44.37 kB | 2 | vue.js
544.10 kB | 69.88 kB | 72.48 kB | 25.59 kB | 26.20 kB | 2 | lodash.js
555.77 kB | 262.77 kB | 270.13 kB | 87.67 kB | 90.80 kB | 2 | d3.js
1.01 MB | 436.09 kB | 458.89 kB | 121.80 kB | 126.71 kB | 2 | bundle.min.js
1.25 MB | 634.41 kB | 646.76 kB | 158.45 kB | 163.73 kB | 2 | three.js
2.14 MB | 707.71 kB | 724.14 kB | 160.11 kB | 181.07 kB | 2 | victory.js
3.20 MB | 959.19 kB | 1.01 MB | 317.26 kB | 331.56 kB | 2 | echarts.js
6.69 MB | 2.12 MB | 2.31 MB | 453.48 kB | 488.28 kB | 5 | antd.js
10.95 MB | 3.31 MB | 3.49 MB | 850.30 kB | 915.50 kB | 4 | typescript.js