Skip to content

fix(minifier): re-evaluate pure/no-side-effects flags after peephole inlining#22595

Merged
graphite-app[bot] merged 1 commit into
mainfrom
fix/minifier-re-eval-pure-in-dce
May 25, 2026
Merged

fix(minifier): re-evaluate pure/no-side-effects flags after peephole inlining#22595
graphite-app[bot] merged 1 commit into
mainfrom
fix/minifier-re-eval-pure-in-dce

Conversation

@Dunqing

@Dunqing Dunqing commented May 19, 2026

Copy link
Copy Markdown
Member

Summary

A single minify pass produced output where a second pass added /* @__PURE__ */ / /* #__NO_SIDE_EFFECTS__ */ annotations the first pass had missed.

Examples (line 2 is what one pass produces; line 3 is what two passes produce, which is what idempotency requires from one pass):

new RegExp('^…$' + '$')                                            // input
new RegExp("^…$")                                                  // single pass (before fix)
/* @__PURE__ */ new RegExp("^…$")                                  // expected
var ab = new ArrayBuffer(1); var dv = new DataView(ab); foo(dv)    // input
var dv = new DataView(/* @__PURE__ */ new ArrayBuffer(1)); foo(dv) // single pass (before fix)
var dv = /* @__PURE__ */ new DataView(/* @__PURE__ */ new ArrayBuffer(1)); foo(dv) // expected

Root cause

Normalize::set_pure_or_no_side_effects_to_new_expr / set_no_side_effects_to_call_expr set the flags by inspecting the callee identity and the current arg shape (e.g. new RegExp(<string literal>) → pure, new DataView(<already-pure NewExpression>) → pure). But Normalize is a one-shot pass that runs once before the peephole fixed-point loop:

// crates/oxc_minifier/src/compressor.rs
Normalize::new(normalize_options).build(program, &mut ctx);
Self::run_in_loop(max_iterations, program, &mut ctx)

The loop then folds string concats and inlines single-use variables, turning opaque args into the literal / already-pure shapes Normalize would have matched on — but only after Normalize has finished. The flag is stale by the time codegen runs.

Fix

Wire the two flag-setters back into PeepholeOptimizations's exit_call_expression / exit_new_expression so they re-evaluate each loop iteration:

  • DCE-only mode: the exit hooks already early-return without doing other substitutions; the flag-setter runs before the early return.
  • Full minify mode: the flag-setter runs after substitute_*, so it sees the post-flatten arg shape.

Both flag-setters already guard with if pure { return; } — they're monotone, so cascading is well-behaved and the loop converges normally.

Promoted set_pure_or_no_side_effects_to_new_expr and set_no_side_effects_to_call_expr to pub(crate) so peephole can call them.

Found by

monitor-oxc's DCE runner: 104 files in the npm-top-3000 corpus failed the idempotency check on this pattern (sentry, vite, svgo, js-yaml, html-minifier-terser, libphonenumber-js, loupe, basic-ftp, is-data-view, stack-utils, …).

Tests

  • dead_code_elimination::pure_comment_re_evaluated_after_string_concat_fold
  • dead_code_elimination::pure_comment_re_evaluated_after_variable_inline
  • inline_single_use_variable::pure_comment_for_pure_global_constructors_after_inline (this one goes through full minify; test_options checks idempotency, so the test fails without the non-DCE arm of the fix)

Verification

  • cargo test -p oxc_minifier → 504 passed (was 501; +3 new), 0 failed
  • cargo test -p oxc_codegen → 105 passed, 0 failed
  • just minsize → snapshot unchanged across all 12 fixtures
  • just allocs → +3 allocs / +6 max-allocs on pdf.mjs (negligible)
  • End-to-end via monitor-oxc DCE runner: 104 → 0 PURE-annotation idempotency failures on the 116,429-file corpus. The 9 remaining DCE failures are unrelated bugs (arrow-alternate comment attachment ×8, __webpack_exports__ unused-variable fixed-point ×1) and need separate fixes.

@Dunqing Dunqing force-pushed the fix/minifier-re-eval-pure-in-dce branch from ae444fb to ce18c47 Compare May 19, 2026 15:11
@github-actions github-actions Bot added the A-minifier Area - Minifier label May 19, 2026
@codspeed-hq

codspeed-hq Bot commented May 19, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 52 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing fix/minifier-re-eval-pure-in-dce (313af83) with main (07afbb6)

Open in CodSpeed

Footnotes

  1. 8 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.

@Dunqing Dunqing force-pushed the fix/minifier-re-eval-pure-in-dce branch 3 times, most recently from 74a0b97 to f86476f Compare May 25, 2026 07:05
@Dunqing Dunqing marked this pull request as ready for review May 25, 2026 07:20
@Dunqing Dunqing added the 0-merge Merge with Graphite Merge Queue label May 25, 2026

Dunqing commented May 25, 2026

Copy link
Copy Markdown
Member Author

Merge activity

…inlining (#22595)

## Summary

A single minify pass produced output where a second pass added `/* @__PURE__ */` / `/* #__NO_SIDE_EFFECTS__ */` annotations the first pass had missed.

Examples (line 2 is what one pass produces; line 3 is what two passes produce, which is what idempotency requires from one pass):

```js
new RegExp('^…$' + '$')                                            // input
new RegExp("^…$")                                                  // single pass (before fix)
/* @__PURE__ */ new RegExp("^…$")                                  // expected
```

```js
var ab = new ArrayBuffer(1); var dv = new DataView(ab); foo(dv)    // input
var dv = new DataView(/* @__PURE__ */ new ArrayBuffer(1)); foo(dv) // single pass (before fix)
var dv = /* @__PURE__ */ new DataView(/* @__PURE__ */ new ArrayBuffer(1)); foo(dv) // expected
```

## Root cause

`Normalize::set_pure_or_no_side_effects_to_new_expr` / `set_no_side_effects_to_call_expr` set the flags by inspecting the callee identity and the *current* arg shape (e.g. `new RegExp(<string literal>)` → pure, `new DataView(<already-pure NewExpression>)` → pure). But `Normalize` is a one-shot pass that runs once *before* the peephole fixed-point loop:

```rust
// crates/oxc_minifier/src/compressor.rs
Normalize::new(normalize_options).build(program, &mut ctx);
Self::run_in_loop(max_iterations, program, &mut ctx)
```

The loop then folds string concats and inlines single-use variables, turning opaque args into the literal / already-pure shapes `Normalize` would have matched on — but only after `Normalize` has finished. The flag is stale by the time codegen runs.

## Fix

Wire the two flag-setters back into `PeepholeOptimizations`'s `exit_call_expression` / `exit_new_expression` so they re-evaluate each loop iteration:

- DCE-only mode: the exit hooks already early-return without doing other substitutions; the flag-setter runs before the early return.
- Full minify mode: the flag-setter runs *after* `substitute_*`, so it sees the post-flatten arg shape.

Both flag-setters already guard with `if pure { return; }` — they're monotone, so cascading is well-behaved and the loop converges normally.

Promoted `set_pure_or_no_side_effects_to_new_expr` and `set_no_side_effects_to_call_expr` to `pub(crate)` so peephole can call them.

## Found by

monitor-oxc's DCE runner: 104 files in the npm-top-3000 corpus failed the idempotency check on this pattern (sentry, vite, svgo, js-yaml, html-minifier-terser, libphonenumber-js, loupe, basic-ftp, is-data-view, stack-utils, …).

## Tests

- `dead_code_elimination::pure_comment_re_evaluated_after_string_concat_fold`
- `dead_code_elimination::pure_comment_re_evaluated_after_variable_inline`
- `inline_single_use_variable::pure_comment_for_pure_global_constructors_after_inline` (this one goes through full minify; `test_options` checks idempotency, so the test fails without the non-DCE arm of the fix)

## Verification

- `cargo test -p oxc_minifier` → 504 passed (was 501; +3 new), 0 failed
- `cargo test -p oxc_codegen` → 105 passed, 0 failed
- `just minsize` → snapshot unchanged across all 12 fixtures
- `just allocs` → +3 allocs / +6 max-allocs on pdf.mjs (negligible)
- End-to-end via monitor-oxc DCE runner: 104 → 0 PURE-annotation idempotency failures on the 116,429-file corpus. The 9 remaining DCE failures are unrelated bugs (arrow-alternate comment attachment ×8, `__webpack_exports__` unused-variable fixed-point ×1) and need separate fixes.
@graphite-app graphite-app Bot force-pushed the fix/minifier-re-eval-pure-in-dce branch from 313af83 to 9ea4d64 Compare May 25, 2026 07:22
@graphite-app graphite-app Bot merged commit 9ea4d64 into main May 25, 2026
29 checks passed
@graphite-app graphite-app Bot removed the 0-merge Merge with Graphite Merge Queue label May 25, 2026
@graphite-app graphite-app Bot deleted the fix/minifier-re-eval-pure-in-dce branch May 25, 2026 07:26
Dunqing added a commit that referenced this pull request May 26, 2026
### 🚀 Features

- e857b0c napi/minify: Expose legalComments option and result (#20370)
(Boshen)
- 661132d parser: More friendly error messages for rest assignment
target and rest binding element (#22719) (sapphi-red)
- ee659b6 transformer/legacy-decorator: Add `strictNullChecks` option
for nullable-union design:type (#22266) (Kyle Cannon)

### 🐛 Bug Fixes

- e1d064e transformer/class-properties: Reparent lifted private method
helpers (#22716) (Cameron)
- 4ac0fca minifier: Preserve `0 && (module.exports = { ... })`
cjs-module-lexer hint (#22729) (Dunqing)
- 40ff611 minifier: Mark peephole loop changed when dropping
dead-after-throw statement (#22722) (Dunqing)
- 2f7b210 codegen: Emit pife-arrow/function leading comments inside the
wrap (#22720) (Dunqing)
- e184f74 parser: Improve invalid `import` property access diagnostic
(#22693) (camc314)
- 7baed9c transformer/private-method: Clear inherited strict flags
(#22508) (camc314)
- a9ad27e parser: Keep annotation comments leading without preceding
newline (#22711) (Dunqing)
- 9ea4d64 minifier: Re-evaluate pure/no-side-effects flags after
peephole inlining (#22595) (Dunqing)
- 07afbb6 minifier: Drop empty-body IIFE wrapper when called with
arguments (#22589) (Dunqing)
- fa7c463 semantic: Correct TS enum member symbol spans (#22689)
(camc314)
- 26b9396 semantic: Resolve parameter decorators outside parameter scope
(#22623) (camc314)
- b284045 parser: Switch to module goal eagerly on `export` (#22684)
(Boshen)
- dfa931d semantic: Propagate unresolved auto-increment enum value
instead of defaulting to 0 (#22646) (Dunqing)
- 69a6ba6 transformer/legacy-decorator: Emit Array for ReadonlyArray<T>
in decorator metadata (#22265) (Kyle Cannon)
- e421ef0 transformer/legacy-decorator: Return runtime binding for
design:type (#22640) (Dunqing)
- d61e1d7 codegen: Preserve verbatim text of pure/no-side-effects
comments (#22525) (Dunqing)
- 702b14e minifier: Preserve IIFE structure in DCE-only mode (#22547)
(Dunqing)
- 917da24 parser: Apply PURE comment through member-access chains
(#22566) (Dunqing)
- a069b1c codegen: Preserve quotes for cjs-module-lexer equality strings
(#22551) (Dunqing)

### ⚡ Performance

- 2f623b0 semantic: Skip unresolved checks for re-exports (#22660)
(camc314)
- 0d9553d semantic: Early-exit `check_object_expression` for objects
with <2 properties (#22668) (Dunqing)
- d721ad9 semantic: Use direct grandparent lookup for TS type parameters
(#22658) (camc314)
- 0aff288 semantic: Reorder numeric literal strict mode checks (#22657)
(camc314)
- 4d5ddb1 semantic: Reorder binding identifier checks (#22656) (camc314)
- e32acd8 semantic: Reorder identifier ambient binding check (#22653)
(camc314)
- 09fe178 semantic: Reorder ident reference strict mode check (#22652)
(camc314)
- 4b6add2 semantic: Avoid duplicate ident clone for bindings (#22663)
(camc314)
- 82f9662 parser: Check identifier kind before context flag (#22662)
(camc314)
- d7cd951 parser: Fast path identifier parsing and inline operator
helpers (#22650) (Boshen)
- 7b84314 semantic: Use direct byte access for numeric leading-zero
check (#22642) (camc314)
- 0345a31 semantic: Pre-size class elements hash map (#22618) (camc314)
- 04d3065 minifier: Drop per-call buffers in try_fold_concat (#22596)
(Dunqing)
- 4f289f1 semantic: Resolve_references_for_current_scope without a temp
Vec (#22599) (Dunqing)
- e862c15 semantic: Avoid heap alloc for var hoist scope ids (#22603)
(Dunqing)
- 8ff8674 semantic: Early return if `excess` is `0` in
`Stats::increase_by` (#22616) (camc314)
- 7a4120e semantic: Pre-reserve unresolved_references using
Stats::references (#22580) (Dunqing)

Co-authored-by: Dunqing <29533304+Dunqing@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-minifier Area - Minifier

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant