mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user