fix(minifier): drop unused-var-init pure IIFEs and preserve annotation 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.
This commit is contained in:
Dunqing committed 2026-05-15 10:41:52 +00:00
1 parent 70a1e2a81e
commit 5ac7e79672
8 files changed
+263 -28

No files matched your search

@@ -450,8 +450,10 @@ impl<'a> PeepholeOptimizations {
for mut decl in declarations {
if Self::should_remove_unused_declarator(&decl, ctx) {
ctx.state.changed = true;
if let Some(init) = decl.init.take()
&& init.may_have_side_effects(ctx)
// `init` is `mut` because `remove_unused_expression` rewrites
// it in place (peeling pure-call wrappers, etc).
if let Some(mut init) = decl.init.take()
&& !Self::remove_unused_expression(&mut init, ctx)
{
result.push(ctx.ast.statement_expression(init.span(), init));
}
@@ -4,7 +4,7 @@ use oxc_ast::ast::*;
use oxc_ecmascript::constant_evaluation::{DetermineValueType, ValueType};
impl<'a> PeepholeOptimizations {
fn can_remove_unused_declarators(ctx: &TraverseCtx<'a>) -> bool {
pub(super) fn can_remove_unused_declarators(ctx: &TraverseCtx<'a>) -> bool {
ctx.state.options.unused != CompressOptionsUnused::Keep
&& !Self::keep_top_level_var_in_script_mode(ctx)
&& !ctx.scoping().root_scope_flags().contains_direct_eval()
@@ -1709,9 +1709,51 @@ impl<'a> PeepholeOptimizations {
)
}
/// Checks if the expression result is unused (i.e., in an expression statement context).
/// Whether the expression's result will be discarded — bare expression
/// statement, or the init of a `var`/`let`/`const` whose binding has no
/// references and isn't exported. Used by the IIFE inliner to short-circuit
/// pure-annotated IIFEs to `void 0` so they drop regardless of body shape,
/// and to allow `(async () => {})()` / `(function* () {})()` (whose return
/// value isn't a meaningful result) to collapse in those positions too.
/// Fixes <https://github.com/oxc-project/oxc/issues/17480>.
fn is_expression_result_unused(ctx: &TraverseCtx<'a>) -> bool {
matches!(ctx.parent(), Ancestor::ExpressionStatementExpression(_))
match ctx.parent() {
Ancestor::ExpressionStatementExpression(_) => true,
Ancestor::VariableDeclaratorInit(decl) => {
if !Self::can_remove_unused_declarators(ctx) {
return false;
}
// `using` runs `[Symbol.dispose]` at scope exit.
if decl.kind().is_using() {
return false;
}
let BindingPattern::BindingIdentifier(ident) = decl.id() else {
return false;
};
if !ctx.scoping().symbol_is_unused(ident.symbol_id()) {
return false;
}
!Self::var_declaration_is_exported(ctx)
}
_ => false,
}
}
/// `true` if the `VariableDeclaration` that contains the current expression
/// (entered via `VariableDeclaratorInit`) sits directly under an `export`
/// wrapper. Exports are cross-module reachable, and the inner
/// `VariableDeclaration` never routes through `handle_variable_declaration`
/// — dropping its init would silently break the export's runtime value.
///
/// Checks the exact ancestor slot above `VariableDeclaration` only;
/// walking the full chain would over-broaden the guard to function-local
/// vars inside exported functions.
fn var_declaration_is_exported(ctx: &TraverseCtx<'a>) -> bool {
// Only `ExportNamedDeclaration`'s `declaration` field can hold a
// `VariableDeclaration`. `export default` wraps a function / class /
// expression — never a `VariableDeclaration` — so no arm is needed
// for it.
matches!(ctx.ancestors().nth(2), Some(Ancestor::ExportNamedDeclarationDeclaration(_)))
}
/// Optimizes the usage of Immediately Invoked Function Expressions (IIFEs)
@@ -1753,49 +1795,59 @@ impl<'a> PeepholeOptimizations {
return;
}
let is_pure =
(call_expr.pure && ctx.annotations()) || ctx.manual_pure_functions(&call_expr.callee);
// The callee here is always a function/arrow literal, so
// `manual_pure_functions` (which matches named paths like `styled`)
// can never apply — only an explicit `/* @__PURE__ */` annotation can.
let is_pure = call_expr.pure && ctx.annotations();
if let Expression::ArrowFunctionExpression(f) = &mut call_expr.callee
&& !f.r#async
&& !f.params.has_parameter()
&& f.body.statements.len() == 1
{
if f.expression {
if let Some(expr) = f.get_expression_mut() {
// Replace "(() => foo())()" with "foo()"
let expr = f.get_expression_mut().unwrap();
if is_pure && Self::is_expression_result_unused(ctx) {
*e = ctx.ast.void_0(call_expr.span);
*e = if is_pure && Self::is_expression_result_unused(ctx) {
ctx.ast.void_0(call_expr.span)
} else if let Some(taken) = Self::try_take_iife_body(expr, is_pure, ctx) {
taken
} else {
*e = expr.take_in(ctx.ast);
}
return;
};
ctx.state.changed = true;
return;
}
match &mut f.body.statements[0] {
Statement::ExpressionStatement(expr_stmt) => {
// Replace "(() => { foo() })()" with "(foo(), undefined)"
if is_pure && Self::is_expression_result_unused(ctx) {
*e = ctx.ast.void_0(call_expr.span);
} else {
*e = ctx.ast.expression_sequence(expr_stmt.span, {
*e = if is_pure && Self::is_expression_result_unused(ctx) {
ctx.ast.void_0(call_expr.span)
} else if let Some(taken) =
Self::try_take_iife_body(&mut expr_stmt.expression, is_pure, ctx)
{
ctx.ast.expression_sequence(expr_stmt.span, {
let mut sequence = ctx.ast.vec();
sequence.push(expr_stmt.expression.take_in(ctx.ast));
sequence.push(taken);
sequence.push(ctx.ast.void_0(call_expr.span));
sequence
});
}
})
} else {
return;
};
ctx.state.changed = true;
}
Statement::ReturnStatement(ret_stmt) => {
if let Some(argument) = &mut ret_stmt.argument {
// Replace "(() => { return foo() })()" with "foo()"
if is_pure && Self::is_expression_result_unused(ctx) {
*e = ctx.ast.void_0(call_expr.span);
*e = if is_pure && Self::is_expression_result_unused(ctx) {
ctx.ast.void_0(call_expr.span)
} else if let Some(taken) = Self::try_take_iife_body(argument, is_pure, ctx)
{
taken
} else {
*e = argument.take_in(ctx.ast);
}
return;
};
ctx.state.changed = true;
}
}
@@ -1803,6 +1855,48 @@ impl<'a> PeepholeOptimizations {
}
}
}
/// Take the IIFE body out for inlining, propagating the outer `pure`
/// annotation onto a call/new body. Propagation runs in both DCE and
/// full-minify modes; only the bail-out (via
/// [`Self::iife_inline_would_lose_pure`]) is gated to DCE mode, where
/// preserving the IIFE wrapper matters for downstream tools.
/// Returns `None` to signal the caller should leave the IIFE intact.
fn try_take_iife_body(
body: &mut Expression<'a>,
is_pure: bool,
ctx: &TraverseCtx<'a>,
) -> Option<Expression<'a>> {
if Self::iife_inline_would_lose_pure(is_pure, body, ctx) {
return None;
}
let mut taken = body.take_in(ctx.ast);
if is_pure {
match &mut taken {
Expression::CallExpression(c) => c.pure = true,
Expression::NewExpression(n) => n.pure = true,
_ => {}
}
}
Some(taken)
}
/// Whether inlining the IIFE body would weaken the outer pure assertion.
/// Fires only in DCE-only mode (rolldown's per-module preprocess): the
/// outer call has no arguments, so its `pure` flag covers the entire
/// body. Inlining any side-effectful body surfaces sub-effects at the
/// outer call site, where rolldown's side-effect detector treats them
/// as real side effects regardless of the inlined node's `pure` flag.
fn iife_inline_would_lose_pure(
is_pure: bool,
body: &Expression<'a>,
ctx: &TraverseCtx<'a>,
) -> bool {
if !is_pure || !ctx.state.dce {
return false;
}
body.may_have_side_effects(ctx)
}
}
struct ClassPropertyKeyParent {
+10
View File
@@ -57,6 +57,16 @@ pub(crate) fn test_same_options(source_text: &str, options: &CompressOptions) {
test_options(source_text, source_text, options);
}
#[track_caller]
pub(crate) fn test_smallest(source_text: &str, expected: &str) {
test_options(source_text, expected, &CompressOptions::smallest());
}
#[track_caller]
pub(crate) fn test_same_smallest(source_text: &str) {
test_smallest(source_text, source_text);
}
#[track_caller]
pub(crate) fn test_options_source_type(
source_text: &str,
@@ -546,3 +546,32 @@ fn remove_pure_function_calls() {
test("var foo = function() {}; foo()", "");
test_same("function foo() { bar() } foo()");
}
#[test]
fn preserve_pure_iife_in_used_position_for_downstream_treeshake() {
// https://github.com/oxc-project/oxc/issues/17480
//
// In DCE-only mode the IIFE inliner refuses to inline a
// `@__PURE__`-annotated IIFE when the body can't carry a `pure` flag,
// so the outer call's annotation stays visible to rolldown's
// side-effect detector.
test_same("export const exec = /* @__PURE__ */ (() => _M.exec)();");
test_same("export const x = /* @__PURE__ */ (() => foo()?.bar())();");
test_same("export const x = /* @__PURE__ */ (() => foo`tpl`)();");
test_same("export const x = /* @__PURE__ */ (() => (a(), b()))();");
test_same("export const x = /* @__PURE__ */ (() => c ? a() : b())();");
// Bare-identifier conditional — unknown global reads count as side
// effects in DCE mode, so the IIFE wrapper stays.
test_same("export const x = /* @__PURE__ */ (() => a ? b : c)();");
test_same("export const x = /* @__PURE__ */ (() => foo())();");
test_same("export const x = /* @__PURE__ */ (() => new Foo())();");
// Effectful arg inside the body call — the original IIFE could be dropped
// wholesale; the inlined form would expose `bar()` as a surviving side
// effect of the outer call.
test_same("export const x = /* @__PURE__ */ (() => foo(bar()))();");
test_same("export const x = /* @__PURE__ */ (() => new Foo(bar()))();");
test("export const x = /* @__PURE__ */ (() => 42)();", "export const x = 42;");
}
@@ -2,7 +2,7 @@ use oxc_span::SourceType;
use crate::{
CompressOptions, TreeShakeOptions, test_options, test_options_source_type, test_same_options,
test_same_options_source_type,
test_same_options_source_type, test_same_smallest, test_smallest,
};
#[test]
@@ -56,6 +56,106 @@ fn remove_unused_variable_declaration() {
test_same_options("for (var x = foo; ; );", &options); // can be improved
}
#[test]
fn remove_unused_pure_iife_init() {
// https://github.com/oxc-project/oxc/issues/17480
test_smallest("var x = /* @__PURE__ */ foo()", "");
test_smallest("var x = /* @__PURE__ */ new Foo()", "");
test_smallest("var x = /* @__PURE__ */ foo(a)", "a;");
test_smallest("var x = /* @__PURE__ */ foo(bar())", "bar();");
test_smallest("var x = /* @__PURE__ */ new Foo(bar())", "bar();");
test_smallest("var x = /* @__PURE__ */ foo(/* @__PURE__ */ bar(z))", "z;");
test_smallest("var x = /* @__PURE__ */ (() => foo())()", "");
test_smallest("var x = /* @__PURE__ */ (() => new Foo())()", "");
test_smallest("var x = /* @__PURE__ */ (() => { return foo() })()", "");
test_smallest("var x = /* @__PURE__ */ (() => { foo() })()", "");
test_smallest("var x = /* @__PURE__ */ (() => g.x)()", "");
test_smallest("var x = /* @__PURE__ */ (() => g[k])()", "");
test_smallest("var x = /* @__PURE__ */ (() => foo`tpl`)()", "");
test_smallest("var x = /* @__PURE__ */ (() => [a, b])()", "");
test_smallest("var x = /* @__PURE__ */ (() => ({ a }))()", "");
test_smallest("var x = /* @__PURE__ */ (() => a + b)()", "");
test_smallest("var x = /* @__PURE__ */ (() => `${a}`)()", "");
test_smallest("var x = /* @__PURE__ */ (() => foo()?.bar())()", "");
test_smallest("var x = /* @__PURE__ */ (() => a ? b : c)()", "");
test_smallest("var x = /* @__PURE__ */ (function() { return foo() })()", "");
test_smallest("let x = /* @__PURE__ */ (() => g.x)()", "");
test_smallest("const x = /* @__PURE__ */ (() => g.x)()", "");
test_smallest("var x = /* @__PURE__ */ foo(), y = bar(); use(y);", "var y = bar(); use(y);");
// Referenced bindings keep the declarator — `symbol_is_unused` blocks
// the drop. Propagation still inlines the IIFE body.
test_same_smallest("var x = /* @__PURE__ */ foo(); use(x);");
test_smallest(
"var x = /* @__PURE__ */ (() => foo())(); use(x);",
"var x = /* @__PURE__ */ foo(); use(x);",
);
test_smallest("var x = /* @__PURE__ */ (() => g.x)(); use(x);", "var x = g.x; use(x);");
test_smallest(
"var x = /* @__PURE__ */ (() => { return foo() })(); use(x);",
"var x = /* @__PURE__ */ foo(); use(x);",
);
// Conditional body — propagation only fires on Call/New, so the
// top-level conditional is inlined without an annotation.
test_smallest(
"var x = /* @__PURE__ */ (() => a ? b : c)(); use(x);",
"var x = a ? b : c; use(x);",
);
// Exported bindings are cross-module reachable — the export-ancestor
// check blocks the early drop.
test_smallest("export var x = /* @__PURE__ */ foo()", "export var x = /* @__PURE__ */ foo();");
test_smallest(
"export const x = /* @__PURE__ */ (() => foo())();",
"export const x = /* @__PURE__ */ foo();",
);
test_smallest("export const x = /* @__PURE__ */ (() => g.x)();", "export const x = g.x;");
test_same_smallest("var x = /* @__PURE__ */ foo(); export { x }");
test_smallest("var x = (() => g.x)();", "g.x;");
// `using` runs `[Symbol.dispose]` at scope exit, so the declarator stays.
test_smallest("using x = /* @__PURE__ */ (() => foo())()", "using x = /* @__PURE__ */ foo();");
test_smallest(
"await using x = /* @__PURE__ */ (() => foo())()",
"await using x = /* @__PURE__ */ foo();",
);
// Function-local var inside an exported function — the export ancestor
// walk must not be fooled by `f` being exported. `x` is a local.
test_smallest(
"export function f() { var x = /* @__PURE__ */ (() => foo())(); } f();",
"export function f() {} f();",
);
// Empty async/generator IIFE in unused-var-init position now collapses
// through `is_expression_result_unused` (which the widening newly covers).
test_smallest("var x = (async () => {})()", "");
test_smallest("var x = (function* () {})()", "");
// `can_remove_unused_declarators` blocks top-level `var` drops in script
// mode (the binding is an observable global). The IIFE inlines with
// propagation as in any other position, but the declarator stays.
test_options_source_type(
"var x = /* @__PURE__ */ (() => stuff())()",
"var x = /* @__PURE__ */ stuff();",
SourceType::cjs().with_script(true),
&CompressOptions::smallest(),
);
// Direct eval at the root scope blocks the drop — eval might reference
// the binding even when static analysis sees no use.
test_smallest(
"eval('x'); var x = /* @__PURE__ */ (() => stuff())()",
"eval('x'); var x = /* @__PURE__ */ stuff();",
);
}
#[test]
fn remove_unused_function_declaration() {
let options = CompressOptions::smallest();
@@ -426,7 +426,7 @@ fn test_fold_iife() {
);
test(
"function foo(x) { if (x) { return /* @__PURE__ */ (() => bar())() } return x }",
"function foo(x) { return x && bar() }",
"function foo(x) { return x && /* @__PURE__ */ bar() }",
);
test(
"function foo(x) { if (x) { return /* @__PURE__ */ (() => { return 42 })() } return x }",
@@ -2,13 +2,13 @@ File | File size || Sys allocs | Sys reallocs |
---------------------------------------------------------------------------------------------------------------------------
checker.ts | 2.92 MB || 286 | 37 || 152659 | 28244
cal.com.tsx | 1.06 MB || 16324 | 46 || 37153 | 4570
cal.com.tsx | 1.06 MB || 16324 | 46 || 37154 | 4570
RadixUIAdoptionSection.jsx | 2.52 kB || 29 | 6 || 30 | 6
pdf.mjs | 567.30 kB || 4052 | 417 || 47472 | 7746
antd.js | 6.69 MB || 7702 | 1657 || 331684 | 69349
antd.js | 6.69 MB || 7702 | 1657 || 333605 | 69349
binder.ts | 193.08 kB || 66 | 23 || 7075 | 824