fix(minifier): drop unused-var-init pure IIFEs and preserve annotation for downstream#22349
Conversation
How to use the Graphite Merge QueueAdd either label to this PR to merge it via the merge queue:
You must have a Graphite account in order to use the merge queue. Sign up using this link. An organization admin has enabled the Graphite Merge Queue in this repository. Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue. This stack of pull requests is managed by Graphite. Learn more about stacking. |
Merging this PR will not alter performance
Comparing Footnotes
|
…ropagation Per Codex adversarial review of #22349. The previous gate let any direct `CallExpression`/`NewExpression` body inline because the outer `pure` flag could be transferred. That isn't equivalent to the original IIFE assertion when the body has side-effectful sub-expressions: * Original: `/* @__PURE__ */ (() => foo(bar()))()` — outer pure call with no arguments. Rolldown's `detect_side_effect_of_call_expr` sees zero args, flags zero side effects, drops the whole declaration. * Inlined: `/* @__PURE__ */ foo(bar())` — pure call, but rolldown checks `bar()` as an argument, flags it as side-effectful, keeps the module. Removing the `body_carries_pure` short-circuit means the DCE-mode gate now treats any side-effectful body as a "would lose pure" case. Propagation still fires when the body call is genuinely side-effect-free (e.g. `manual_pure_functions` recognized). Adds regression coverage for `(() => foo(bar()))()` and `(() => new Foo(bar()))()` in exported used positions. Updates the simple-call/new tests to expect the IIFE wrapper (which the final chunk-minify will still inline in full mode). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
This PR is essentially teaching the minifier to drop a |
|
Does this work for the following case? var removeThis = /* @__PURE__ */ (() => stuff())();
if (false) {
console.log(removeThis)
} |
Yes, final output is empty |
…ropagation Per Codex adversarial review of #22349. The previous gate let any direct `CallExpression`/`NewExpression` body inline because the outer `pure` flag could be transferred. That isn't equivalent to the original IIFE assertion when the body has side-effectful sub-expressions: * Original: `/* @__PURE__ */ (() => foo(bar()))()` — outer pure call with no arguments. Rolldown's `detect_side_effect_of_call_expr` sees zero args, flags zero side effects, drops the whole declaration. * Inlined: `/* @__PURE__ */ foo(bar())` — pure call, but rolldown checks `bar()` as an argument, flags it as side-effectful, keeps the module. Removing the `body_carries_pure` short-circuit means the DCE-mode gate now treats any side-effectful body as a "would lose pure" case. Propagation still fires when the body call is genuinely side-effect-free (e.g. `manual_pure_functions` recognized). Adds regression coverage for `(() => foo(bar()))()` and `(() => new Foo(bar()))()` in exported used positions. Updates the simple-call/new tests to expect the IIFE wrapper (which the final chunk-minify will still inline in full mode). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a84936c to
b5c96e2
Compare
Merge activity
|
…n for downstream (#22349) Closes #17480. Supersedes #21526 (gthb) and #22251 (mo-n) — both target the same bug from different angles. This PR takes a single coordinated approach. Closes: #21526 Closes: #22251 ## What A `/* @__PURE__ */`-annotated IIFE assigned to an unused `var`/`let`/`const` was not being dropped, and inlining such an IIFE silently destroyed the annotation that rolldown's tree-shaker relies on. The fix lands three coordinated checks in `substitute_iife_call`, one small swap in `handle_variable_declaration`, and an export-ancestor guard. ### 1. Widened `is_expression_result_unused` `is_expression_result_unused` already short-circuited pure IIFEs to `void 0` for bare expression statements. This PR extends it to also recognize `VariableDeclaratorInit` whose binding has no references and isn't exported. The unused declaration then drops in a single iteration — regardless of body shape (call, new, member access, tagged template, chain, sequence, conditional, etc.). Guards: - `can_remove_unused_declarators` — respects script-mode and direct-eval gates. - `decl.kind().is_using()` — `using` / `await using` run `[Symbol.dispose]` at scope exit, so the init isn't truly unused. - `var_declaration_is_exported` — exported bindings bypass `handle_variable_declaration`, so dropping the init would silently break the export's runtime value. The check looks only at `ctx.ancestors().nth(2)` (the slot directly above `VariableDeclaration`); walking the full chain would over-broaden the guard to function-local vars inside exported functions. ### 2. `iife_inline_would_lose_pure` (DCE-only gate) In DCE-only mode (rolldown's per-module preprocess), refuse to inline a pure IIFE whose body has *any* side effects. The original outer IIFE call has zero arguments, so its `pure` flag covers the body's evaluation as a unit; inlining surfaces sub-effects at the outer call's argument level, where rolldown's side-effect detector flags them regardless of any propagated `pure` flag. Gated on `ctx.state.dce` because in full-minify mode there's no downstream consumer to preserve the annotation for — inline aggressively for smaller output. ### 3. `try_take_iife_body` helper Unifies the three IIFE inline branches (expression body, expression-statement body, return-statement body). Returns `Some(inlined)` after taking the body and stamping `pure = true` on a `CallExpression`/`NewExpression` replacement; returns `None` to bail when `iife_inline_would_lose_pure` says inlining would weaken the assertion — caller leaves the IIFE intact. ### 4. `remove_unused_expression` swap in `handle_variable_declaration` Replace `init.may_have_side_effects(ctx)` with `!Self::remove_unused_expression(&mut init, ctx)`. The smart expression cleanup peels pure-call wrappers and keeps only side-effectful args (`var x = /* @__PURE__ */ foo(a)` → `a;`). ## Relation to #21526 and #22251 | Mechanism | This PR | #21526 (gthb) | #22251 (mo-n) | |---|---|---|---| | Widened unused-var-init drop | ✓ | ✗ | ✓ (as `is_in_unused_variable_declarator`) | | Export-ancestor guard | ✓ | ✗ | ✗ (caught here: exports could be wrongly dropped) | | `remove_unused_expression` swap in `handle_variable_declaration` | ✓ | ✗ | ✓ | | Propagate `pure` onto inlined call/new | ✓ | ✓ (recursive into containers) | ✗ | | Preserve IIFE wrapper when body has any side effect (DCE-only) | ✓ | ✗ | ✗ | | Recursive propagation into sequence/conditional/etc bodies | ✗ | ✓ | ✗ | | TS wrapper transparency in `may_have_side_effects` | ✗ | ✓ | ✗ | Two mechanisms from #21526 are intentionally omitted here: - **Recursive container propagation.** The DCE-only gate preserves the IIFE wrapper for those body shapes, so the outer call's `pure` flag stays visible to downstream tools. Recursive propagation also spreads any developer-lie annotation to inner sub-expressions, which this PR's restraint avoids. - **TS wrapper transparency in `may_have_side_effects`.** Orthogonal; can land separately. If this PR is accepted, #21526 and #22251 should be closed as superseded. ## Coverage | Case | Output | |---|---| | `var x = /* @__PURE__ */ (() => stuff())()` (x unused — original bug) | empty | | `var x = /* @__PURE__ */ (() => g.x)()` (x unused, non-call body) | empty | | `var x = /* @__PURE__ */ foo(a)` (x unused, side-effectful arg) | `a;` | | `var a = /* @__PURE__ */ (() => x)(); console.log(a)` (armano2 #1, full mode) | `var a = x; console.log(a);` | | `(/* @__PURE__ */ (() => !0)() ? () => x() : () => {})()` (armano2 #2) | `x();` | | `export const x = /* @__PURE__ */ (() => _M.exec)();` (lights0123, DCE mode) | preserves IIFE → rolldown can tree-shake | | `export const x = /* @__PURE__ */ (() => foo(bar()))();` (DCE mode, sub-effects) | preserves IIFE → rolldown can tree-shake | | `export function f() { var x = /* @__PURE__ */ (() => foo())(); } f();` | `export function f() {} f();` (function-local var is local, not export) | | `var x = (async () => {})()` / `var x = (function* () {})()` (x unused) | empty (empty-IIFE branch via widened predicate) | ## End-to-end verification with rolldown Locally patched rolldown to use this branch. Added a regression fixture (`tree_shaking/pure_iife_unused_export`) covering five body shapes. **Before this PR** (rolldown bundle for unused-export module): ```js //#region lib.js function maybeExpensive() { ... } function MaybeExpensiveCtor() { ... } const _M = { exec: 1, tag: () => 1 }; maybeExpensive(); new MaybeExpensiveCtor(); _M.exec; _M?.exec; _M.tag`tpl`; //#endregion //#region main.js console.log("hello"); //#endregion ``` **After this PR**: ```js //#region main.js console.log("hello"); //#endregion ``` Two pre-existing rolldown tests (`strict_execution_order/issue_5303`, `concatenate_wrapped_modules`) fail with the local patch — they use `/* #__PURE__ */ (() => globalValue)()` where `globalValue` is set by a sibling module's side effect. The annotation is technically incorrect; the tests previously "worked" only because oxc destroyed the annotation and rolldown defensively wrapped the module. These need a follow-up in rolldown to correct the annotations. ## Snapshots - `minsize.snap` — unchanged. - `allocs_minifier.snap` — `cal.com.tsx` +1 alloc (negligible); `antd.js` +1921 allocs (~0.6% of the 331k baseline). Comes from the new `may_have_side_effects` call in `iife_inline_would_lose_pure` plus the deeper rewrites in `remove_unused_expression`. Trade is correctness/tree-shaking-fidelity for a small intra-pass alloc bump; output size unchanged.
b5c96e2 to
5ac7e79
Compare
### 🚀 Features - bc91a17 codegen: Expose `Codegen::with_source_type` method (#22432) (camc314) ### 🐛 Bug Fixes - 5ac7e79 minifier: Drop unused-var-init pure IIFEs and preserve annotation for downstream (#22349) (Dunqing) - 4ab57eb allocator: Fixed-size allocators use `VirtualAlloc` on Windows (#22124) (overlookmotel) - 66d77eb allocator: Fix segfault on Linux MUSL with fixed-size allocators (#22388) (overlookmotel) - b8fbc1f transformer/object-rest-spread: Correct scope id when moving bindings (#22419) (camc314) - 18edc2c codegen: Keep `Object.defineProperty` property name as plain string in minify (#22400) (Dunqing) - dda33de transformer/explicit-resource-management: Align lexical binding scopes (#22320) (camc314) - 8e79de8 transformer: Preserve for-await statement bodies (#22361) (camc314) - 0cba210 transformer/class: Replace `new.target` in static blocks (#22360) (camc314) - 67ab1c9 transformer/es2018/for-await: Hoist for-await generated bindings (#22355) (camc314) - c3ceb4a transformer/object-rest-spread: Use hoisted scope for `for-of` temp refs (#22347) (camc314) ### ⚡ Performance - 73a9043 allocator/bitset: Avoid temp heap `String` allocation (#22403) (camc314) - 8b2f4f9 transformer/object-rest-spread: Collect `Vec<SymbolId` over `Vec<BindingIdentifier>` (#22418) (camc314) - 83679ea parser: Split TriviaBuilder::handle_token hot/cold paths (#22415) (Boshen) - 2c7d781 codegen: Inline identifier-name accessors (#22411) (Boshen) - 618bc76 diagnostics: Inline `OxcDiagnosticInner` to avoid heap allocation (#22406) (Boshen) - 0b4e158 parser: Reserve cap `2` for sequence expressions vec (#22374) (camc314) - 5f3bdd0 codegen: Add `#[inline]` to `code`, `code_len` (#22373) (camc314) Co-authored-by: overlookmotel <557937+overlookmotel@users.noreply.github.com>
### Root cause
`SideEffectDetector::detect_side_effect_of_expr`'s catch-all arm
collapses any compound expression (`ObjectExpression`,
`ArrayExpression`, `SequenceExpression`,
`Conditional`/`Logical`/`Binary`/`Unary` expressions, `TemplateLiteral`)
into a plain bool via `From<bool> for SideEffectDetail`:
```rust
impl From<bool> for SideEffectDetail {
fn from(value: bool) -> Self {
if value { SideEffectDetail::Unknown } else { SideEffectDetail::empty() }
}
}
```
That conversion can only encode `Unknown` or `empty`. The
`PureAnnotation` and `GlobalVarAccess` metadata bits on a buried call
are silently dropped at the catch-all and never reach the
statement-level intersection that drives
`EcmaViewMeta::ExecutionOrderSensitive`.
Concretely, `export default { foo: /* @__PURE__ */ (() => globalValue)()
}` reaches statement level with `SideEffectDetail::empty()`.
`wrap_modules` (`crates/rolldown/src/stages/link_stage/wrapping.rs`)
takes the `avoid_wrapping` branch, `WrapKind::None` is emitted, and the
module's top-level `var foo_inner_default = ...` runs at chunk top level
— before sibling modules like `setup.js` that the IIFE depends on:
```js
var foo_inner_default = { foo: /* @__PURE__ */ (() => globalValue)() }; // globalValue is undefined here
__esmMin((() => {
globalThis.globalValue = "foo";
// ...
}))();
```
### Why this was latent before oxc 0.131
oxc 0.130's DCE pass unconditionally inlined `/* @__PURE__ */ (() =>
globalValue)()` down to a bare `globalValue` identifier. After inlining,
the detector's `Identifier` arm independently flagged the unresolved
global via `GlobalVarAccess` (a code path separate from
`PureAnnotation`), the module was marked `ExecutionOrderSensitive`, and
wrapping kicked in. Wrap-correctness was accidentally riding on oxc's
IIFE inlining.
oxc 0.131 (oxc-project/oxc#22349) intentionally **preserves**
pure-annotated IIFEs in DCE-only mode so downstream tree-shakers can see
the annotation. That is the right call on oxc's side — but it removes
the inline-to-bare-global behavior rolldown was implicitly leaning on,
exposing this detector gap.
### Changes
- Add explicit arms in `detect_side_effect_of_expr` for the transparent
compound expression types so metadata propagates: `ObjectExpression`,
`ArrayExpression`, `SequenceExpression`, `ConditionalExpression`,
`LogicalExpression`, `BinaryExpression`, `UnaryExpression`,
`TemplateLiteral`.
- Each arm goes through a single helper `fold_compound(expr, children)`
that:
1. Asks oxc's `may_have_side_effects` for the spec-level boolean
(handles ToPrimitive coercion, getter triggers in spread, Symbol checks
in templates, etc.). If oxc says yes, return `Unknown` and stop.
2. Otherwise recurses through the children via rolldown's own detector
to harvest `PureAnnotation` / `GlobalVarAccess` flags.
3. Strips `Unknown` from the harvested result since oxc already
certified the parent has no side effect — only metadata bits should
propagate.
- Peel transparent syntactic wrappers (`(x)`, `x as T`, `x satisfies T`,
`x!`, `<T>x`, `x<T>`) at the top of `detect_side_effect_of_expr` via
`Expression::get_inner_expression()` — they add no runtime semantics, so
a single peel covers all five wrapper variants (and any future ones oxc
adds to that method).
This mirrors the existing gate-and-harvest pattern already used by
`detect_side_effect_of_call_expr` and the `NewExpression` arm.
### Tests
New unit tests in `side_effect_detector::test`:
- `test_pure_annotation_propagates_through_compound_expr` — covers
`PureAnnotation` propagation through
`Object`/`Array`/`Sequence`/`Conditional`/`Logical`/`Binary` (`===`) /
`Unary` (`typeof`) / computed-key / nested compound, plus
`GlobalVarAccess` propagation through compound, plus TS wrapper
passthroughs (`as`, `satisfies`, `!`).
- `test_pure_annotation_not_propagated_through_function_body` — verifies
a pure annotation inside an arrow/function body does **not** propagate
to the statement level (function bodies aren't evaluated at module
init).
- `test_compound_expr_side_effectful_operand_still_unknown` — verifies a
real side effect anywhere in a compound expression still surfaces as
`Unknown`; the new arms don't weaken side-effect detection.
Existing integration suites kept green without snapshot changes:
```sh
cargo test --package rolldown --lib ast_scanner::side_effect_detector::test::
cargo test --package rolldown --test integration -- strict_execution_order # 24 passed
cargo test --package rolldown --test integration -- tree_shaking # 72 passed
```
The two pre-existing integration fixtures called out in the issue
(`function/experimental/strict_execution_order/concatenate_wrapped_modules`
and `.../issue_5303`) pass with their current snapshots — those
snapshots already encode the wrapped behavior, so this PR restores
conformance to them after the oxc 0.131 bump (#9424)
would otherwise break them.
Closes #9425.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: dalaoshu <165626830+shulaoda@users.noreply.github.com>
Fixes upstream rolldown/rolldown#9437. Supersedes the `iife_inline_would_lose_pure` gate introduced in #22349. ## Problem In DCE-only mode (rolldown's per-module preprocess via `Compressor::dead_code_elimination_with_scoping`), `substitute_iife_call` inlines `const x = /* @__PURE__ */ (() => [1, 2, 3])()` down to `const x = [1, 2, 3]`. The previous `iife_inline_would_lose_pure` gate only bailed for side-effecting bodies, so array/object literals and primitive bodies still got inlined — silently dropping the `@__PURE__` annotation that downstream bundlers rely on. ## Framing IIFE body extraction is peephole / strength-reduction, not dead-code elimination. The rewrite removes nothing — the binding is still alive — and doesn't enable subsequent DCE. So it shouldn't run in DCE-only mode at all. Cross-checked against Rollup (`treeshake: true`), esbuild (`--tree-shaking=true`, no minify), Terser (no compress), and SWC (no minify) — all four preserve the IIFE structure in their non-minify modes. The current oxc DCE-only behavior was the outlier; this PR aligns oxc with those tools. ## Fix One line — `try_take_iife_body` bails on `ctx.state.dce` directly. The `iife_inline_would_lose_pure` helper is removed (now trivial). Full-minify mode is untouched: `state.dce` is only `true` for `dead_code_elimination[_with_scoping]` entry points. The DCE-only IIFE rewrites that still fire: dropping pure-annotated IIFEs whose result is unused (via `is_expression_result_unused` → `void 0`) and replacing fully-empty IIFEs with `void 0`. ## Test `preserve_pure_iife_in_used_position_for_downstream_treeshake` is renamed to `preserve_iife_in_dce_mode`, with five non-pure cases added (which the previous gate let through). All 501 `oxc_minifier --test mod` tests pass; `minsize` / `allocs` snapshots unchanged.
Fixes upstream rolldown/rolldown#9437. Supersedes the `iife_inline_would_lose_pure` gate from #22349. ## Bug In DCE-only mode (rolldown's per-module preprocess via `Compressor::dead_code_elimination_with_scoping`), `substitute_iife_call` inlines `const x = /* @__PURE__ */ (() => [1, 2, 3])()` down to `const x = [1, 2, 3]`, dropping the `@__PURE__` annotation downstream tree-shakers rely on. The previous `iife_inline_would_lose_pure` gate only bailed for side-effecting bodies, so array / object / primitive bodies still got inlined. ## Cross-tool comparison Empirical, same input `export const x = /* @__PURE__ */ (() => [1, 2, 3])();`: | Tool / Mode | Result | | ---------------------------------------- | ---------------------- | | Rollup 4.60 `treeshake: true` | preserved | | esbuild 0.27 `--tree-shaking=true` | preserved | | SWC 1.15 default (no minify) | preserved | | Terser 5.46 no `-c` (no compress) | preserved | | Terser 5.46 `-c` (compress / minify) | inlined to `[1, 2, 3]` | | **oxc DCE-only — before this PR** | inlined to `[1, 2, 3]` | | **oxc DCE-only — after this PR** | preserved | | oxc full-minify (unchanged by this PR) | inlined to `[1, 2, 3]` | DCE-only is "tree-shake without minify" — the analogous reference is the "non-minify" rows. oxc DCE-only was the only mode inlining there. ## Fix `try_take_iife_body` bails on `ctx.state.dce` directly. The `iife_inline_would_lose_pure` helper is removed. Full-minify is untouched — `state.dce` is only set to `true` for `dead_code_elimination[_with_scoping]` entry points (exactly two writers in `compressor.rs`). DCE-only IIFE rewrites that **still fire** (real DCE): - Dropping pure-annotated IIFEs whose result is unused — `is_expression_result_unused` → `void 0` - Replacing fully-empty IIFEs with `void 0` - Dropping unused-var pure IIFEs via #22349's widened `is_expression_result_unused` (the original #17480 fix) Covered by `preserve_iife_in_dce_mode` (renamed + extended from `preserve_pure_iife_in_used_position_for_downstream_treeshake`). 501 / 501 `cargo test -p oxc_minifier` pass, idempotent under `--twice`, `minsize` / `allocs` snapshots unchanged.
#22589) ## Why `substitute_iife_call` bailed out as soon as the call had any arguments, so `(() => {})(a)` stayed in the output while esbuild emits `a;`. The existing logic (#22349, closing #17480) only handled the zero-args case. Surfaced by rolldown ↔ esbuild compatibility tests for `dce/dce_of_iife` (rolldown/rolldown#8688): ```js // input (() => {})(keepThisButRemoveTheIIFE); // before (oxc DCE) (() => {})(keepThisButRemoveTheIIFE); // after (matches esbuild) keepThisButRemoveTheIIFE; ``` ## Fix Extend `substitute_iife_call` to drop the wrapper when the body is empty, the callee is neither `async` nor a generator, and every named parameter is a bare `BindingIdentifier` with no default. The arguments become a sequence ending in `void 0` (preserving the call's `undefined` result); `remove_unused_expression` strips the tail at statement position. Argument conversion reuses `fold_arguments_into_needed_expressions`, which also drops pure args for free. Two related cases handled in the same path: - Rest parameter binding to a simple identifier (`((...r) => {})(a)` → `a;`). Deliberately looser than the spec `IsSimpleParameterList` (which forbids any rest): the empty body never observes the collected array. - Spread argument (`(() => {})(...a)` → `[...a];`) — converted to a single-spread array literal that preserves the iterator-protocol invocation. Matches esbuild's `--minify` output. Rejected (no behaviour change): destructured params, parameter defaults, `async` / generator callees, destructured rest bindings — each can have observable effects that the empty body would otherwise elide. New positive + negative cases in `test_fold_iife` (`tests/peephole/remove_unused_expression.rs`); full minifier suite (501/0) and `minsize` snapshots unchanged.
…ldown#9431) ### Root cause `SideEffectDetector::detect_side_effect_of_expr`'s catch-all arm collapses any compound expression (`ObjectExpression`, `ArrayExpression`, `SequenceExpression`, `Conditional`/`Logical`/`Binary`/`Unary` expressions, `TemplateLiteral`) into a plain bool via `From<bool> for SideEffectDetail`: ```rust impl From<bool> for SideEffectDetail { fn from(value: bool) -> Self { if value { SideEffectDetail::Unknown } else { SideEffectDetail::empty() } } } ``` That conversion can only encode `Unknown` or `empty`. The `PureAnnotation` and `GlobalVarAccess` metadata bits on a buried call are silently dropped at the catch-all and never reach the statement-level intersection that drives `EcmaViewMeta::ExecutionOrderSensitive`. Concretely, `export default { foo: /* @__PURE__ */ (() => globalValue)() }` reaches statement level with `SideEffectDetail::empty()`. `wrap_modules` (`crates/rolldown/src/stages/link_stage/wrapping.rs`) takes the `avoid_wrapping` branch, `WrapKind::None` is emitted, and the module's top-level `var foo_inner_default = ...` runs at chunk top level — before sibling modules like `setup.js` that the IIFE depends on: ```js var foo_inner_default = { foo: /* @__PURE__ */ (() => globalValue)() }; // globalValue is undefined here __esmMin((() => { globalThis.globalValue = "foo"; // ... }))(); ``` ### Why this was latent before oxc 0.131 oxc 0.130's DCE pass unconditionally inlined `/* @__PURE__ */ (() => globalValue)()` down to a bare `globalValue` identifier. After inlining, the detector's `Identifier` arm independently flagged the unresolved global via `GlobalVarAccess` (a code path separate from `PureAnnotation`), the module was marked `ExecutionOrderSensitive`, and wrapping kicked in. Wrap-correctness was accidentally riding on oxc's IIFE inlining. oxc 0.131 (oxc-project/oxc#22349) intentionally **preserves** pure-annotated IIFEs in DCE-only mode so downstream tree-shakers can see the annotation. That is the right call on oxc's side — but it removes the inline-to-bare-global behavior rolldown was implicitly leaning on, exposing this detector gap. ### Changes - Add explicit arms in `detect_side_effect_of_expr` for the transparent compound expression types so metadata propagates: `ObjectExpression`, `ArrayExpression`, `SequenceExpression`, `ConditionalExpression`, `LogicalExpression`, `BinaryExpression`, `UnaryExpression`, `TemplateLiteral`. - Each arm goes through a single helper `fold_compound(expr, children)` that: 1. Asks oxc's `may_have_side_effects` for the spec-level boolean (handles ToPrimitive coercion, getter triggers in spread, Symbol checks in templates, etc.). If oxc says yes, return `Unknown` and stop. 2. Otherwise recurses through the children via rolldown's own detector to harvest `PureAnnotation` / `GlobalVarAccess` flags. 3. Strips `Unknown` from the harvested result since oxc already certified the parent has no side effect — only metadata bits should propagate. - Peel transparent syntactic wrappers (`(x)`, `x as T`, `x satisfies T`, `x!`, `<T>x`, `x<T>`) at the top of `detect_side_effect_of_expr` via `Expression::get_inner_expression()` — they add no runtime semantics, so a single peel covers all five wrapper variants (and any future ones oxc adds to that method). This mirrors the existing gate-and-harvest pattern already used by `detect_side_effect_of_call_expr` and the `NewExpression` arm. ### Tests New unit tests in `side_effect_detector::test`: - `test_pure_annotation_propagates_through_compound_expr` — covers `PureAnnotation` propagation through `Object`/`Array`/`Sequence`/`Conditional`/`Logical`/`Binary` (`===`) / `Unary` (`typeof`) / computed-key / nested compound, plus `GlobalVarAccess` propagation through compound, plus TS wrapper passthroughs (`as`, `satisfies`, `!`). - `test_pure_annotation_not_propagated_through_function_body` — verifies a pure annotation inside an arrow/function body does **not** propagate to the statement level (function bodies aren't evaluated at module init). - `test_compound_expr_side_effectful_operand_still_unknown` — verifies a real side effect anywhere in a compound expression still surfaces as `Unknown`; the new arms don't weaken side-effect detection. Existing integration suites kept green without snapshot changes: ```sh cargo test --package rolldown --lib ast_scanner::side_effect_detector::test:: cargo test --package rolldown --test integration -- strict_execution_order # 24 passed cargo test --package rolldown --test integration -- tree_shaking # 72 passed ``` The two pre-existing integration fixtures called out in the issue (`function/experimental/strict_execution_order/concatenate_wrapped_modules` and `.../issue_5303`) pass with their current snapshots — those snapshots already encode the wrapped behavior, so this PR restores conformance to them after the oxc 0.131 bump (rolldown#9424) would otherwise break them. Closes rolldown#9425. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: dalaoshu <165626830+shulaoda@users.noreply.github.com>

Closes #17480.
Supersedes #21526 (gthb) and #22251 (mo-n) — both target the same bug from different angles. This PR takes a single coordinated approach.
Closes: #21526
Closes: #22251
What
A
/* @__PURE__ */-annotated IIFE assigned to an unusedvar/let/constwas not being dropped, and inlining such an IIFE silently destroyed the annotation that rolldown's tree-shaker relies on. The fix lands three coordinated checks insubstitute_iife_call, one small swap inhandle_variable_declaration, and an export-ancestor guard.1. Widened
is_expression_result_unusedis_expression_result_unusedalready short-circuited pure IIFEs tovoid 0for bare expression statements. This PR extends it to also recognizeVariableDeclaratorInitwhose binding has no references and isn't exported. The unused declaration then drops in a single iteration — regardless of body shape (call, new, member access, tagged template, chain, sequence, conditional, etc.).Guards:
can_remove_unused_declarators— respects script-mode and direct-eval gates.decl.kind().is_using()—using/await usingrun[Symbol.dispose]at scope exit, so the init isn't truly unused.var_declaration_is_exported— exported bindings bypasshandle_variable_declaration, so dropping the init would silently break the export's runtime value. The check looks only atctx.ancestors().nth(2)(the slot directly aboveVariableDeclaration); walking the full chain would over-broaden the guard to function-local vars inside exported functions.2.
iife_inline_would_lose_pure(DCE-only gate)In DCE-only mode (rolldown's per-module preprocess), refuse to inline a pure IIFE whose body has any side effects. The original outer IIFE call has zero arguments, so its
pureflag covers the body's evaluation as a unit; inlining surfaces sub-effects at the outer call's argument level, where rolldown's side-effect detector flags them regardless of any propagatedpureflag.Gated on
ctx.state.dcebecause in full-minify mode there's no downstream consumer to preserve the annotation for — inline aggressively for smaller output.3.
try_take_iife_bodyhelperUnifies the three IIFE inline branches (expression body, expression-statement body, return-statement body). Returns
Some(inlined)after taking the body and stampingpure = trueon aCallExpression/NewExpressionreplacement; returnsNoneto bail wheniife_inline_would_lose_puresays inlining would weaken the assertion — caller leaves the IIFE intact.4.
remove_unused_expressionswap inhandle_variable_declarationReplace
init.may_have_side_effects(ctx)with!Self::remove_unused_expression(&mut init, ctx). The smart expression cleanup peels pure-call wrappers and keeps only side-effectful args (var x = /* @__PURE__ */ foo(a)→a;).Relation to #21526 and #22251
is_in_unused_variable_declarator)remove_unused_expressionswap inhandle_variable_declarationpureonto inlined call/newmay_have_side_effectsTwo mechanisms from #21526 are intentionally omitted here:
pureflag stays visible to downstream tools. Recursive propagation also spreads any developer-lie annotation to inner sub-expressions, which this PR's restraint avoids.may_have_side_effects. Orthogonal; can land separately.If this PR is accepted, #21526 and #22251 should be closed as superseded.
Coverage
var x = /* @__PURE__ */ (() => stuff())()(x unused — original bug)var x = /* @__PURE__ */ (() => g.x)()(x unused, non-call body)var x = /* @__PURE__ */ foo(a)(x unused, side-effectful arg)a;var a = /* @__PURE__ */ (() => x)(); console.log(a)(armano2 #1, full mode)var a = x; console.log(a);(/* @__PURE__ */ (() => !0)() ? () => x() : () => {})()(armano2 #2)x();export const x = /* @__PURE__ */ (() => _M.exec)();(lights0123, DCE mode)export const x = /* @__PURE__ */ (() => foo(bar()))();(DCE mode, sub-effects)export function f() { var x = /* @__PURE__ */ (() => foo())(); } f();export function f() {} f();(function-local var is local, not export)var x = (async () => {})()/var x = (function* () {})()(x unused)End-to-end verification with rolldown
Locally patched rolldown to use this branch. Added a regression fixture (
tree_shaking/pure_iife_unused_export) covering five body shapes.Before this PR (rolldown bundle for unused-export module):
After this PR:
Two pre-existing rolldown tests (
strict_execution_order/issue_5303,concatenate_wrapped_modules) fail with the local patch — they use/* #__PURE__ */ (() => globalValue)()whereglobalValueis set by a sibling module's side effect. The annotation is technically incorrect; the tests previously "worked" only because oxc destroyed the annotation and rolldown defensively wrapped the module. These need a follow-up in rolldown to correct the annotations.Snapshots
minsize.snap— unchanged.allocs_minifier.snap—cal.com.tsx+1 alloc (negligible);antd.js+1921 allocs (~0.6% of the 331k baseline). Comes from the newmay_have_side_effectscall iniife_inline_would_lose_pureplus the deeper rewrites inremove_unused_expression. Trade is correctness/tree-shaking-fidelity for a small intra-pass alloc bump; output size unchanged.