fix(minifier): re-evaluate pure/no-side-effects flags after peephole 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.
This commit is contained in:
Dunqing committed 2026-05-25 07:22:11 +00:00
1 parent 07afbb6581
commit 9ea4d64cf4
6 files changed
+46 -18

No files matched your search

+10 -8
View File
@@ -422,19 +422,21 @@ impl<'a> Traverse<'a> for PeepholeOptimizations {
}
fn exit_call_expression(&mut self, e: &mut CallExpression<'a>, ctx: &mut TraverseCtx<'a>) {
if ctx.state.dce {
return;
if !ctx.state.dce {
Self::substitute_call_expression(e, ctx);
Self::remove_empty_spread_arguments(&mut e.arguments);
}
Self::substitute_call_expression(e, ctx);
Self::remove_empty_spread_arguments(&mut e.arguments);
// Re-evaluate each iteration: peephole folding/inlining may expose a
// pure-eligible arg shape that `Normalize`'s one-shot pass missed.
Normalize::set_no_side_effects_to_call_expr(e, ctx);
}
fn exit_new_expression(&mut self, e: &mut NewExpression<'a>, ctx: &mut TraverseCtx<'a>) {
if ctx.state.dce {
return;
if !ctx.state.dce {
Self::substitute_new_expression(e, ctx);
Self::remove_empty_spread_arguments(&mut e.arguments);
}
Self::substitute_new_expression(e, ctx);
Self::remove_empty_spread_arguments(&mut e.arguments);
Normalize::set_pure_or_no_side_effects_to_new_expr(e, ctx);
}
fn exit_object_property(&mut self, prop: &mut ObjectProperty<'a>, ctx: &mut TraverseCtx<'a>) {
@@ -270,7 +270,10 @@ impl<'a> Normalize {
Some(ctx.ast.nan(ident.span))
}
fn set_no_side_effects_to_call_expr(call_expr: &mut CallExpression<'a>, ctx: &TraverseCtx<'a>) {
pub(crate) fn set_no_side_effects_to_call_expr(
call_expr: &mut CallExpression<'a>,
ctx: &TraverseCtx<'a>,
) {
if call_expr.pure {
return;
}
@@ -287,7 +290,7 @@ impl<'a> Normalize {
/// Set `pure` on side effect free `new Expr()`s.
/// `PC` or `PC_WITH_ARRAY` in <https://github.com/rollup/rollup/blob/v4.42.0/src/ast/nodes/shared/knownGlobals.ts>
fn set_pure_or_no_side_effects_to_new_expr(
pub(crate) fn set_pure_or_no_side_effects_to_new_expr(
new_expr: &mut NewExpression<'a>,
ctx: &TraverseCtx<'a>,
) {
@@ -217,6 +217,24 @@ fn pure_comment_for_pure_global_constructors() {
test("var x = new WeakSet([]); foo(x)", "var x = /* @__PURE__ */ new WeakSet([]);\nfoo(x)");
}
// `Normalize` sets pure flags before the peephole loop runs, so it misses
// args that the loop later folds/inlines into pure-eligible shapes.
#[test]
fn pure_comment_re_evaluated_after_string_concat_fold() {
test(
"var r = new RegExp('foo' + 'bar'); foo(r)",
"var r = /* @__PURE__ */ new RegExp(\"foobar\");\nfoo(r)",
);
}
#[test]
fn pure_comment_re_evaluated_after_variable_inline() {
test(
"export function f() { var ab = new ArrayBuffer(1); var dv = new DataView(ab); foo(dv); }",
"export function f() {\n\tvar dv = /* @__PURE__ */ new DataView(/* @__PURE__ */ new ArrayBuffer(1));\n\tfoo(dv);\n}",
);
}
#[test]
fn fold_number_nan() {
test("foo(Number.NaN)", "foo(NaN)");
@@ -439,3 +439,14 @@ fn integration() {
",
);
}
// Inlining happens in the loop, after `Normalize`'s pure-flag pass, so the
// outer constructor's flag needs the loop's re-evaluation hook.
// `test_options` checks idempotency, so this fails without it.
#[test]
fn pure_comment_for_pure_global_constructors_after_inline() {
test(
"export function f() { var ab = new ArrayBuffer(1); var dv = new DataView(ab); foo(dv); }",
"export function f() { var dv = /* @__PURE__ */ new DataView(/* @__PURE__ */ new ArrayBuffer(1)); foo(dv); }",
);
}
@@ -2,13 +2,7 @@ commit: 78fc3f8e
minifier_test262 Summary:
AST Parsed : 44752/44752 (100.00%)
Positive Passed: 44745/44752 (99.98%)
Compress: tasks/coverage/test262/test/intl402/Temporal/PlainDate/prototype/toLocaleString/lone-options-accepted.js
Compress: tasks/coverage/test262/test/intl402/Temporal/PlainMonthDay/prototype/toLocaleString/lone-options-accepted.js
Compress: tasks/coverage/test262/test/intl402/Temporal/PlainYearMonth/prototype/toLocaleString/lone-options-accepted.js
Positive Passed: 44748/44752 (99.99%)
Compress: tasks/coverage/test262/test/language/statements/class/elements/private-accessor-is-visible-in-computed-properties.js
Compress: tasks/coverage/test262/test/language/statements/class/elements/private-field-is-visible-in-computed-properties.js
@@ -6,7 +6,7 @@ App.tsx | 415.34 kB || 119 | 13 |
RadixUIAdoptionSection.jsx | 2.52 kB || 24 | 0 || 31 | 6
pdf.mjs | 567.30 kB || 3926 | 393 || 47465 | 7740
pdf.mjs | 567.30 kB || 3929 | 399 || 47465 | 7740
antd.js | 6.69 MB || 3084 | 53 || 336992 | 69349