Skip to content

fix(minifier): keep unary + when the other operand has side effects - #25645

Merged
sapphi-red merged 9 commits into
oxc-project:mainfrom
mizchi:fix/unary-plus-coercion-order
Aug 19, 2026
Merged

fix(minifier): keep unary + when the other operand has side effects#25645
sapphi-red merged 9 commits into
oxc-project:mainfrom
mizchi:fix/unary-plus-coercion-order

Conversation

@mizchi

@mizchi mizchi commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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:

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.

`+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>
@mizchi
mizchi requested a review from Dunqing as a code owner August 14, 2026 07:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread crates/oxc_minifier/src/peephole/substitute_alternate_syntax.rs
@codspeed-hq

codspeed-hq Bot commented Aug 14, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 39 untouched benchmarks
⏩ 42 skipped benchmarks1


Comparing mizchi:fix/unary-plus-coercion-order (ae237e8) with main (af82a07)2

Open in CodSpeed

Footnotes

  1. 42 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (8e2b3be) during the generation of this report, so af82a07 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

mizchi and others added 2 commits August 14, 2026 16:42
…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>
@mizchi
mizchi requested a review from overlookmotel as a code owner August 14, 2026 07:47
@camc314 camc314 added the A-minifier Area - Minifier label Aug 14, 2026
Comment thread crates/oxc_minifier/tests/peephole/substitute_alternate_syntax.rs Outdated
Comment thread crates/oxc_minifier/src/peephole/substitute_alternate_syntax.rs Outdated
Comment thread crates/oxc_minifier/src/peephole/substitute_alternate_syntax.rs Outdated
mizchi and others added 6 commits August 19, 2026 15:41
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
@sapphi-red
sapphi-red merged commit af82a07 into oxc-project:main Aug 19, 2026
33 of 37 checks passed
@mizchi
mizchi deleted the fix/unary-plus-coercion-order branch August 19, 2026 13:31
graphite-app Bot pushed a commit that referenced this pull request Aug 24, 2026
### 🚀 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-minifier Area - Minifier

3 participants