mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
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:
1 parent
07afbb6581
commit
9ea4d64cf4
6 files changed
+46
-18
No files matched your search
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user