Skip to content

fix(minifier): drop unused-var-init pure IIFEs and preserve annotation for downstream#22349

Merged
graphite-app[bot] merged 1 commit into
mainfrom
fix/minifier-pure-iife-unused-init-17480
May 15, 2026
Merged

fix(minifier): drop unused-var-init pure IIFEs and preserve annotation for downstream#22349
graphite-app[bot] merged 1 commit into
mainfrom
fix/minifier-pure-iife-unused-init-17480

Conversation

@Dunqing

@Dunqing Dunqing commented May 12, 2026

Copy link
Copy Markdown
Member

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):

//#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:

//#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.snapcal.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.

@github-actions github-actions Bot added the A-minifier Area - Minifier label May 12, 2026

Dunqing commented May 12, 2026

Copy link
Copy Markdown
Member Author

How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • 0-merge - adds this PR to the back of the merge queue
  • hotfix - for urgent changes, fast-track this PR to the front of 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.

@codspeed-hq

codspeed-hq Bot commented May 12, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 44 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing fix/minifier-pure-iife-unused-init-17480 (b5c96e2) with main (ddb6eed)2

Open in CodSpeed

Footnotes

  1. 7 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 (70a1e2a) during the generation of this report, so ddb6eed was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Dunqing added a commit that referenced this pull request May 12, 2026
…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>
@Dunqing

Dunqing commented May 14, 2026

Copy link
Copy Markdown
Member Author

This PR is essentially teaching the minifier to drop a /* @__PURE__ */ IIFE when its assigned variable isn't used, and to carry that pure annotation onto whatever it inlines into (or just leave the IIFE alone in DCE mode if the body has side effects) so Rolldown's tree-shaker can still see it downstream.

@Dunqing Dunqing requested review from Boshen and sapphi-red May 14, 2026 02:20
@Dunqing Dunqing marked this pull request as ready for review May 14, 2026 02:20
@sapphi-red

Copy link
Copy Markdown
Member

Does this work for the following case?

var removeThis = /* @__PURE__ */ (() => stuff())();

if (false) {
    console.log(removeThis)
}

@Dunqing

Dunqing commented May 14, 2026

Copy link
Copy Markdown
Member Author

Does this work for the following case?

var removeThis = /* @__PURE__ */ (() => stuff())();

if (false) {
    console.log(removeThis)
}

Yes, final output is empty

Comment thread crates/oxc_minifier/tests/peephole/remove_unused_declaration.rs
Dunqing added a commit that referenced this pull request May 15, 2026
…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>
@Dunqing Dunqing force-pushed the fix/minifier-pure-iife-unused-init-17480 branch from a84936c to b5c96e2 Compare May 15, 2026 10:37
@Dunqing Dunqing added the 0-merge Merge with Graphite Merge Queue label May 15, 2026

Dunqing commented May 15, 2026

Copy link
Copy Markdown
Member Author

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.
@graphite-app graphite-app Bot force-pushed the fix/minifier-pure-iife-unused-init-17480 branch from b5c96e2 to 5ac7e79 Compare May 15, 2026 10:41
@graphite-app graphite-app Bot merged commit 5ac7e79 into main May 15, 2026
28 checks passed
@graphite-app graphite-app Bot removed the 0-merge Merge with Graphite Merge Queue label May 15, 2026
@graphite-app graphite-app Bot deleted the fix/minifier-pure-iife-unused-init-17480 branch May 15, 2026 10:45
overlookmotel added a commit that referenced this pull request May 15, 2026
### 🚀 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>
shulaoda added a commit to rolldown/rolldown that referenced this pull request May 18, 2026
### 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>
Dunqing added a commit that referenced this pull request May 19, 2026
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.
graphite-app Bot pushed a commit that referenced this pull request May 19, 2026
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.
graphite-app Bot pushed a commit that referenced this pull request May 25, 2026
#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.
V1OL3TF0X pushed a commit to V1OL3TF0X/rolldown that referenced this pull request May 25, 2026
…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>
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.

minifier: pure annotated iife call on unused variable initializer is not removed

2 participants