feat(minifier): drop side-effect-free IIFEs whose result is unused (#23967)

## Summary

- `CallExpression::may_have_side_effects` now looks through an IIFE — a call whose callee is a function/arrow literal: it is side-effect-free when its arguments, parameters, and every body statement are. Previously every such call was assumed effectful, so the minifier kept dead wrappers like `var unused = (function () { function test() {} return test })()`. Closes #23777.
- This is a plain improvement to side-effect detection in `oxc_ecmascript` — no DCE awareness there. The "preserve IIFE structure in DCE-only mode" policy (match Rollup / esbuild `--tree-shaking=true`, see `preserve_iife_in_dce_mode`) lives in the minifier, in one helper (`has_side_effects_or_preserved_iife`) shared by every unused-removal site that can face an IIFE call, so only full minification actually drops it.
- Conservative — skips `async`/generator callees (different invocation semantics) and any non-identifier parameter (defaults run user code, destructuring reads properties / drives the iterator protocol).
- Covered by `remove_unused_expression.rs::test_remove_side_effect_free_iife` (full-mode positive + negative) and `dead_code_elimination.rs::preserve_iife_in_dce_mode` (DCE-mode preservation: bare, unused-binding, sequence, and `for`-init positions). `minsize` unchanged, `allocs` slightly down.

## Example

```js
// input
var unused = (function () {
  function test() {}
  return test
})()
```

```js
// full minify, before — kept as a bare expression statement
(function () {
  function test() {}
  return test;
})();

// full minify, after
// (empty)
```

In dce-only / tree-shaking mode the IIFE structure is preserved (matches Rollup / esbuild `--tree-shaking=true`).
This commit is contained in:
Dunqing committed 2026-07-01 03:36:07 +00:00
1 parent 903ab6eee1
commit 60e716087d
10 files changed
+142 -17

No files matched your search

@@ -530,6 +530,43 @@ fn get_array_minimum_length(arr: &ArrayExpression) -> usize {
.sum()
}
/// Whether invoking an IIFE — `(function () { ... })(args)` / `(() => { ...
/// })(args)` — may have side effects. `None` when the callee is not a plain
/// function/arrow literal, so the caller falls through to its other checks.
///
/// Calling it binds the parameters and runs the body once; the result is the
/// caller's concern, not a side effect. So the call is side-effect-free when
/// its arguments, parameters, and every body statement are. Conservative
/// (`Some(true)`) for an `async`/generator callee, whose invocation differs,
/// and for parameters that aren't bare identifiers (defaults and destructuring
/// run user code when bound).
fn iife_call_may_have_side_effects<'a>(
call: &CallExpression<'a>,
ctx: &impl MayHaveSideEffectsContext<'a>,
) -> Option<bool> {
let (params, body) = match &call.callee {
Expression::FunctionExpression(f) if !f.r#async && !f.generator => {
(&f.params, f.body.as_deref()?)
}
Expression::ArrowFunctionExpression(f) if !f.r#async => (&f.params, &*f.body),
_ => return None,
};
// Arguments are evaluated before the call; binding the parameters must run
// no user code — every one a bare identifier with no default (a rest
// binding to an identifier is fine, it just collects an array).
let params_simple = params
.items
.iter()
.all(|item| item.pattern.is_binding_identifier() && item.initializer.is_none())
&& params.rest.as_ref().is_none_or(|r| r.rest.argument.is_binding_identifier());
if !params_simple || call.arguments.iter().any(|arg| arg.may_have_side_effects(ctx)) {
return Some(true);
}
Some(body.statements.iter().any(|stmt| stmt.may_have_side_effects(ctx)))
}
// `PF` in <https://github.com/rollup/rollup/blob/master/src/ast/nodes/shared/knownGlobals.ts>
impl<'a> MayHaveSideEffects<'a> for CallExpression<'a> {
fn may_have_side_effects(&self, ctx: &impl MayHaveSideEffectsContext<'a>) -> bool {
@@ -537,6 +574,12 @@ impl<'a> MayHaveSideEffects<'a> for CallExpression<'a> {
return self.arguments.iter().any(|e| e.may_have_side_effects(ctx));
}
// An IIFE is side-effect-free when its arguments, parameters, and body
// are. (A `/* @__PURE__ */` IIFE was already handled above.)
if let Some(side_effects) = iife_call_may_have_side_effects(self, ctx) {
return side_effects;
}
if let Expression::Identifier(ident) = &self.callee
&& ctx.is_global_reference(ident)
{
@@ -1000,7 +1000,10 @@ impl<'a> PeepholeOptimizations {
let old_len = var_decl.declarations.len();
var_decl.declarations.retain_mut(|decl| {
let should_keep = !Self::should_remove_unused_declarator(decl, ctx)
|| decl.init.as_ref().is_some_and(|init| init.may_have_side_effects(ctx));
|| decl
.init
.as_ref()
.is_some_and(|init| Self::has_side_effects_or_preserved_iife(init, ctx));
if !should_keep {
// Same leak hazard as `remove_unused_variable_declaration`:
// the `retain` silently drops the declarator, so its refs
@@ -674,7 +674,28 @@ impl<'a> PeepholeOptimizations {
return false;
}
!e.may_have_side_effects(ctx)
!Self::has_side_effects_or_preserved_iife(e, ctx)
}
/// `Expression::may_have_side_effects`, except that in DCE-only mode an IIFE
/// call (`(function () {...})()` / `(() => {...})()`) is reported as
/// effectful so its structure survives — matching Rollup / esbuild
/// tree-shaking (see `preserve_iife_in_dce_mode`). Full minification still
/// drops a pure-bodied IIFE, just as it inlines IIFE bodies. Every
/// unused-removal site that can face an IIFE call routes through here, so
/// the policy lives in one place.
pub(super) fn has_side_effects_or_preserved_iife(
e: &Expression<'a>,
ctx: &TraverseCtx<'a>,
) -> bool {
// Check the cheap DCE-preservation case first: in DCE-only mode an IIFE
// call is always kept, so its (potentially deep) body walk is skipped.
if ctx.state.dce
&& matches!(e, Expression::CallExpression(call) if call.callee.is_function())
{
return true;
}
e.may_have_side_effects(ctx)
}
pub fn fold_arguments_into_needed_expressions(
@@ -122,7 +122,12 @@ mod join_vars {
#[test]
fn test_arrow_function() {
test("(() => { let x = 1; let y = 2; x + y; })()", "(() => { let x = 1, y = 2; })()");
// Assigned to a binding so the side-effect-free arrow isn't dropped as
// dead code; the join still applies to the arrow body.
test(
"var f = () => { let x = 1; let y = 2; x + y; }",
"var f = () => { let x = 1, y = 2; }",
);
// do not redeclare function parameters
// incompatible with strict mode
@@ -618,6 +618,20 @@ fn preserve_iife_in_dce_mode() {
test_same("export const x = (() => 42)();");
test_same("export const x = (() => { foo(); })();");
test_same("export const x = (() => { return foo(); })();");
// A non-pure IIFE with a provably side-effect-free body is also preserved
// in DCE-only mode even when its result is unused. The body analysis that
// proves it droppable runs only under full minification.
// https://github.com/oxc-project/oxc/issues/23777
test_same("(function () { function t() {} return t })();");
test_same("(function () { return 1 })();");
// The unused binding is still dropped, but its side-effect-free IIFE
// initializer survives as a statement (structure preserved).
test("var u = (function () { return 1 })();", "(function () { return 1 })();");
// Nested in a sequence — still preserved (routes through the same path).
test_same("(function () { return 1 })(), foo();");
// `for`-init declarator removal must preserve the IIFE too.
test_same("for (var u = (function () { return 1 })(); cond;) bar();");
}
#[test]
@@ -56,11 +56,11 @@ fn drop_console() {
let options = CompressOptions { drop_console: true, ..default_options() };
test_options("console.log()", "", &options);
test_options("(() => console.log())()", "", &options);
test_options(
"(() => { try { return console.log() } catch {} })()",
"(() => { try { return } catch {} })()",
&options,
);
// After `console.log()` is dropped the IIFE body is side-effect-free, so
// the whole dead call is removed. An empty result still proves the
// return-position `console.*` call was dropped (otherwise the call would
// keep the IIFE alive).
test_options("(() => { try { return console.log() } catch {} })()", "", &options);
}
// Same leak class as `test_void_ident_does_not_leak_reference`: dropped
@@ -178,7 +178,9 @@ fn remove_unreachable() {
test("while(true) { throw a; unreachable;}", "for(;;) throw a");
test("while(true) { return a; unreachable;}", "for(;;) return a");
test("(function () { return; var a })()", "(function () { return; var a })()");
// A kept function declaration (not a dead IIFE) so the unreachable `var a`
// after `return` is preserved under `unused: Keep`.
test("function f() { return; var a }", "function f() { return; var a }");
test_unused("(function () { return; var a })()", "");
}
@@ -450,7 +450,7 @@ fn test_fold_iife() {
test("(() => a())()", "a();");
test("(() => { a() })()", "a();");
test("(() => { return a() })()", "a();");
test_same("(a => {})()");
test("(a => {})()", "");
test_same("((a = foo()) => {})()");
test_same("(a => { a() })()");
test("((...a) => {})()", "");
@@ -536,6 +536,43 @@ fn test_fold_iife() {
test("(function() { 'use strict' })(a)", "a;");
}
#[test]
fn test_remove_side_effect_free_iife() {
// https://github.com/oxc-project/oxc/issues/23777
// Calling a function/arrow literal in place runs its body once; when the
// body (and the args + params) are side-effect-free, the whole call is too,
// so a discarded result drops entirely — even with a non-trivial body.
test("(function () { function test() {} return test })()", "");
test("(function () { return 1 })()", "");
test("(function () { var x = 1; return x })()", "");
test("(function () { let a = 1, b = 2; return a + b })()", "");
test("(function () { return new.target })()", "");
test("(function () { if (1) { return 2 } else { return 3 } })()", "");
test("(function foo() { return foo })()", "");
// Args that are themselves side-effect-free drop with the call.
test("(function (a, b) { return a })(1, 2)", "");
test("(function (...rest) { return rest })()", "");
// The exact issue reproduction: an unused `var` initialized by the IIFE.
let remove = CompressOptions { unused: CompressOptionsUnused::Remove, ..default_options() };
test_options("var unused = (function () { function test() {} return test })()", "", &remove);
// Negative cases — the call has real side effects and must be kept.
test_same("(function () { sideEffect() })()"); // global call in body
test_same("(function () { return sideEffect() })()"); // global call in return
test_same("(function () { globalRead })()"); // global read can throw ReferenceError
test_same("(function () { throw 1 })()"); // throws
test_same("(function () { for (;;) sideEffect() })()"); // loop body
test_same("(function (a) { return a })(sideEffect())"); // side-effect-bearing argument
test_same("(function (a = sideEffect()) { })()"); // param default runs user code
test_same("(function ({ x }) { })(obj)"); // destructuring param reads properties
test_same("(function* () { return 1 })()"); // generator: kept conservatively
test_same("(async function () { return 1 })()"); // async: kept conservatively
// `this` / `arguments` reads are kept conservatively: the shared
// side-effect analysis treats them as potentially effectful.
test_same("(function () { return this })()");
test_same("(function () { return arguments })()");
}
#[test]
fn no_side_effects() {
fn check(source_text: &str) {
@@ -43,8 +43,9 @@ fn test_undefined() {
test("for (undefined in {}) {}", "for(undefined in {});");
test("undefined++;", "undefined++");
test("undefined += undefined;", "undefined+=void 0");
// shadowed
test_same("(function(undefined) { let x = typeof undefined; })()");
// shadowed: a parameter named `undefined` must not be substituted with
// `void 0`. `foo()` keeps the IIFE from being dropped as dead code.
test_same("(function(undefined) { foo(typeof undefined); })()");
// destructuring throw error side effect
test_same("var {} = void 0");
test_same("var [] = void 0");
@@ -788,10 +789,9 @@ fn optional_catch_binding() {
// var inside a function does NOT interact with the catch parameter;
// var doesn't hoist out of functions, so the catch param can be removed.
test(
"try { foo } catch(e) { (function() { var e = 2 })() }",
"try { foo } catch { (function() { var e = 2;})();}",
);
// The side-effect-free IIFE itself is then dropped; the empty `catch`
// still proves the param was safely removed.
test("try { foo } catch(e) { (function() { var e = 2 })() }", "try { foo } catch {}");
test(
"try { foo } catch(e) { function f() { var e = 2 } }",
"try { foo } catch { function f() { var e = 2 } }",
@@ -12,5 +12,5 @@ antd.js | 6.69 MB || 1040 | 56 |
binder.ts | 193.08 kB || 41 | 1 || 7083 | 824
kitchen-sink.tsx | 732.90 kB || 2576 | 174 || 90610 | 15841
kitchen-sink.tsx | 732.90 kB || 2576 | 174 || 90546 | 15840