fix(minifier): keep unary + when the other operand has side effects - #25645
Conversation
`+a` converts `a` while evaluating `+a`, before the right operand of the enclosing binary expression is evaluated. `a - n` converts it in `ApplyStringOrNumericBinaryOperator`, after `n` has been evaluated. Removing the `+` therefore moves the conversion across `n`, and a side effect in `n` can observe the difference: ```js var xs = []; (+xs) - (xs.push(1), 0); // 0, converted while `xs` was still empty xs - (xs.push(1), 0); // 1, converted after `xs` grew ``` Require the right operand to be side-effect free before dropping a `+` in the left position. The right position is unaffected: the left operand is evaluated first either way, and its `ToNumeric` is a no-op because it is already known to be a number, so nothing runs in between. Found by the minifier fuzzer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 77603e4e86
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Merging this PR will not alter performance
Comparing Footnotes
|
…cross
Requiring the right operand to be side-effect free is not enough. It is
evaluated between the argument of `+` and the conversion the operator performs
on it, so it can also *observe* that conversion:
```js
let x = 0, a = { valueOf() { x = 1; return 2 } };
(+a) - (x ? 1 : 0); // 1, `x` read after `valueOf` set it
a - (x ? 1 : 0); // 2, `x` read before
```
Require its value to be known at compile time, which rules out both directions.
Every case the pass compressed before is a literal on the right, so nothing is
lost in practice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: 翠 <green@sapphi.red> Signed-off-by: Kotaro Chikuba <miz404@gmail.com>
Co-authored-by: 翠 <green@sapphi.red> Signed-off-by: Kotaro Chikuba <miz404@gmail.com>
…on-order # Conflicts: # tasks/minsize/minsize.snap # tasks/track_memory_allocations/allocs_minifier.yaml
The compile-time-value guard also ruled out a right operand that observes the conversion, which the "Coercion Methods Are Pure" assumption already says cannot happen: converting the left argument runs no user code, so nothing is there to observe. Requiring the right operand to be free of side effects is enough to keep it from affecting the conversion. Drops the `valueOf` example from the docs and the test for the same reason - it only misbehaves in a world the minifier does not model.
…nto fix/unary-plus-coercion-order # Conflicts: # crates/oxc_minifier/src/peephole/substitute_alternate_syntax.rs
### 🚀 Features - b6e9e5e minifier: Fold switch stmt whose parent is not block stmt (#25403) (Armano) ### 🐛 Bug Fixes - 8a9bdbd estree: Include decorators in `FormalParameterRest ` spans (#26021) (camc314) - 6f0c7cf minifier: Avoid merging `if` to `for` in sloppy mode when containing function declaration (#25638) (sapphi-red) - 2cde1f6 rust: Address nightly deprecations (#25998) (Boshen) - 6c5ad1b codegen: Only add mapping names for `PrivateIdentifier`s which have changed (#25958) (overlookmotel) - 2dad1e0 parser: Track irregular line terminators in trivia (#25947) (camc314) - 58ba651 minifier: Avoid merging `if` to `for` when the body contains a function declaration (#25637) (sapphi-red) - a185ac3 codegen: Print `#` before private identifier in TS signature key (#25938) (overlookmotel) - af82a07 minifier: Keep unary `+` when the other operand has side effects (#25645) (Kotaro Chikuba) ### ⚡ Performance - 53f6270 packages/codegen: Eagerly initialize `mapNames` (#25979) (overlookmotel) - 5b43c60 packages/codegen: Split recording mappings into multiple functions (#25978) (overlookmotel) - cc947b5 packages/codegen: Reduce property lookups (#25977) (overlookmotel) - c22a9cc packages/codegen: Combine condition checks (#25976) (overlookmotel) - 215a6a5 packages/codegen: Add functions for writing/marking unnamed mappings (#25975) (overlookmotel) - fe444cc codegen: Avoid allocations for single comment anchors (#25598) (camc314) - a0aee81 minifier: Do not rebuild expression when substituting void to null (#25964) (Armano) - 1004439 packages/codegen: Merge `mark` and `write` calls in `printImportAttributes` (#25972) (overlookmotel) - 94c8a34 packages/codegen: Remove `node` param from `printString` and `printNonNegativeFloat` (#25971) (overlookmotel) - 7e2b4b3 packages/codegen: `printNonNegativeFloat` write mapping directly (#25968) (overlookmotel) - f09c8ab minifier: Avoid creation of unnecessary conditional expressions (#25931) (Armano) - 53ff080 packages/codegen: Remove `lastWasPostfixClose` (#25888) (overlookmotel) ### 📚 Documentation - 07de646 packages/codegen: Fix JSDoc comments on `write*` methods (#25966) (overlookmotel) - 3b09c04 packages/codegen: Clarify comment (#25959) (overlookmotel)
substitute_unary_plusremoves a+when the enclosing binary operator will do theToNumberconversion anyway. The existing doc comment reasons carefully aboutToNumbervsToNumeric, but not about when each runs.+aconvertsawhile evaluating+a, before the right operand is evaluated at all.a - nconverts it inApplyStringOrNumericBinaryOperator, afternhas been evaluated. Dropping the+therefore moves the conversion acrossn, and a right operand with a side effect can change what the conversion sees: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:nis evaluated first either way, and itsToNumericis a no-op because the existing guard already established thatnis a number, so nothing runs between evaluatingaand converting it.cargo minsizemoves 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.
+a.v,+f(),+(seq(), a),+(+a), getter) × nestingThe 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
BigIntargument both forms throw aTypeError, but+asays "Cannot convert a BigInt value to a number" whilea - 1says "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.