mirror of
https://github.com/aphrody-labs/oxc.git
synced 2026-10-17 12:56:09 +00:00
fix(minifier): eliminate unused IIFE calls with constant arguments (#27434)
Unused calls such as `const f = x => x + 1; f(1);` currently survive as `(x => x + 1)(1)`. Track constant arguments for arrow IIFEs and strict regular-function IIFEs so the existing constant folder and side-effect analysis can remove the call. Parameter constants guide folding without replacing bare parameter reads, avoiding duplicated argument literals. The optimization preserves argument evaluation and respects parameter writes, redeclarations, and direct eval. It conservatively skips defaults, destructuring, rest parameters, and spread arguments, and preserves the existing DCE-only IIFE behavior. Regular functions are excluded when their own name is referenced or when they are non-strict, since recursion and mapped arguments can invalidate the call's parameter values. Regression coverage includes multiple parameters, nested closures, side effects, mixed BigInt arithmetic, argument duplication, recursion, and strict versus sloppy arguments aliasing. Fixes #27379. Validation: - `just fmt` - `just test -p oxc_minifier` — 723 tests and 2 doctests passed; 33 existing tests ignored - `just lint -p oxc_minifier` - `pnpm run build-test` and `pnpm test` in `napi/minify` — 890 tests passed; 14 skipped and 1 todo - `just minsize` — size and allocation snapshots unchanged - 586 runtime equivalence comparisons across 148 inputs through the Node API, with and without mangling, in module and script modes AI disclosure: Developed with OpenAI Codex.
This commit is contained in:
1 parent
17a2acbfbd
commit
f14ead5379
6 files changed
+200
-6
No files matched your search
@@ -10,6 +10,52 @@ use crate::symbol_value::FreshValueKind;
|
||||
use super::PeepholeOptimizations;
|
||||
|
||||
impl<'a> PeepholeOptimizations {
|
||||
/// Seed parameter constants before traversing an immediately invoked function.
|
||||
/// The arguments stay at the call site, preserving their evaluation order.
|
||||
pub fn init_iife_parameter_values(call: &CallExpression<'a>, ctx: &mut TraverseCtx<'a>) {
|
||||
let params = match &call.callee {
|
||||
Expression::ArrowFunctionExpression(arrow) => &arrow.params,
|
||||
Expression::FunctionExpression(function) => {
|
||||
// Sloppy functions expose mapped arguments and caller introspection.
|
||||
// A referenced function name can escape or recurse with other arguments.
|
||||
if !ctx.scoping().scope_flags(function.scope_id()).is_strict_mode()
|
||||
|| function
|
||||
.id
|
||||
.as_ref()
|
||||
.is_some_and(|id| !ctx.scoping().symbol_is_unused(id.symbol_id()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
&function.params
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
if params.items.is_empty()
|
||||
|| params.rest.is_some()
|
||||
|| params
|
||||
.items
|
||||
.iter()
|
||||
.any(|param| !param.pattern.is_binding_identifier() || param.initializer.is_some())
|
||||
|| call.arguments.iter().any(Argument::is_spread)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (param, argument) in params.items.iter().zip(&call.arguments) {
|
||||
let BindingPattern::BindingIdentifier(id) = ¶m.pattern else { unreachable!() };
|
||||
let symbol_id = id.symbol_id();
|
||||
// A body declaration can replace a parameter before its first read.
|
||||
if !ctx.scoping().symbol_redeclarations(symbol_id).is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = argument.to_expression().evaluate_value(ctx) {
|
||||
// `init_value` and its consumers reject direct eval and writes,
|
||||
// including writes from nested closures.
|
||||
ctx.init_value(symbol_id, Some(value), FreshValueKind::None, false, false)
|
||||
.allow_constant_inlining = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_symbol_value(decl: &VariableDeclarator<'a>, ctx: &mut TraverseCtx<'a>) {
|
||||
let Ancestor::VariableDeclarationDeclarations(declaration) = ctx.parent() else {
|
||||
unreachable!();
|
||||
|
||||
@@ -671,6 +671,12 @@ impl<'a> Traverse<'a> for PeepholeOptimizations {
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_call_expression(&mut self, e: &mut CallExpression<'a>, ctx: &mut TraverseCtx<'a>) {
|
||||
if !ctx.is_tree_shake_only() {
|
||||
Self::init_iife_parameter_values(e, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn exit_call_expression(&mut self, e: &mut CallExpression<'a>, ctx: &mut TraverseCtx<'a>) {
|
||||
if !ctx.is_tree_shake_only() {
|
||||
Self::substitute_call_expression(e, ctx);
|
||||
|
||||
@@ -57,8 +57,12 @@ impl<'a> SymbolState<'a> {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn init_value(&mut self, symbol_id: SymbolId, value: SymbolValue<'a>) {
|
||||
self.values[symbol_id] = Some(value);
|
||||
pub fn init_value(
|
||||
&mut self,
|
||||
symbol_id: SymbolId,
|
||||
value: SymbolValue<'a>,
|
||||
) -> &mut SymbolValue<'a> {
|
||||
self.values[symbol_id].insert(value)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
|
||||
@@ -85,6 +85,11 @@ pub struct SymbolValue<'a> {
|
||||
/// `None` when the value is not a constant evaluated value.
|
||||
pub initialized_constant: Option<ConstantValue<'a>>,
|
||||
|
||||
/// Whether bare reads may be replaced with the constant. IIFE parameter
|
||||
/// constants guide folding, but their arguments remain at the call site,
|
||||
/// so replacing bare reads would duplicate the argument's text.
|
||||
pub allow_constant_inlining: bool,
|
||||
|
||||
/// The `initialized_constant` originates from the implicit `undefined` of
|
||||
/// a declaration with no initializer, possibly through direct aliases.
|
||||
/// Textually inlining such a read prints `void 0` — longer than a mangled
|
||||
@@ -125,7 +130,8 @@ impl SymbolValue<'_> {
|
||||
/// [esbuild]: https://github.com/evanw/esbuild/blob/f6058f8364fe7ab91ca57a83e02577ed74c9cae4/internal/js_ast/js_ast.go#L1650-L1685
|
||||
/// [swc]: https://github.com/swc-project/swc/blob/6c778430811853d4feee2ab3af1473669deb7b2a/crates/swc_ecma_minifier/src/compress/optimize/inline.rs#L277-L295
|
||||
pub fn can_inline_initialized_constant(&self) -> bool {
|
||||
if self.references.has_writes() || self.implicit_undefined {
|
||||
if !self.allow_constant_inlining || self.references.has_writes() || self.implicit_undefined
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(constant) = &self.initialized_constant else { return false };
|
||||
|
||||
@@ -313,7 +313,7 @@ impl<'a> TraverseCtx<'a, MinifierState<'a>> {
|
||||
kind: FreshValueKind,
|
||||
falsy_init: bool,
|
||||
implicit_undefined_source: bool,
|
||||
) {
|
||||
) -> &mut SymbolValue<'a> {
|
||||
let mut references = ReferenceCounts::default();
|
||||
for reference in self.scoping().get_resolved_references(symbol_id) {
|
||||
references.record(reference.flags());
|
||||
@@ -365,12 +365,13 @@ impl<'a> TraverseCtx<'a, MinifierState<'a>> {
|
||||
|
||||
let symbol_value = SymbolValue {
|
||||
initialized_constant,
|
||||
allow_constant_inlining: true,
|
||||
implicit_undefined,
|
||||
references,
|
||||
kind: if has_multiple_value_declarations { FreshValueKind::None } else { kind },
|
||||
boolean_falsy,
|
||||
};
|
||||
self.state.symbols.init_value(symbol_id, symbol_value);
|
||||
self.state.symbols.init_value(symbol_id, symbol_value)
|
||||
}
|
||||
|
||||
/// If two expressions are equal.
|
||||
|
||||
@@ -1,9 +1,140 @@
|
||||
use oxc_span::SourceType;
|
||||
|
||||
use crate::{
|
||||
CompressOptions, test_options, test_options_source_type, test_same_options, test_smallest,
|
||||
CompressOptions, test_options, test_options_source_type, test_same_options,
|
||||
test_same_options_source_type, test_smallest,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn inline_constant_iife_arguments() {
|
||||
// https://github.com/oxc-project/oxc/issues/27379
|
||||
test_smallest("const f = x => x + 1; f(1);", "");
|
||||
test_smallest("const foo = x => x + 1; foo(1); export const bar = 1;", "export const bar = 1;");
|
||||
test_smallest("(x => x + 1)(1);", "");
|
||||
test_smallest("((x, y) => x + y)(1, 2);", "");
|
||||
test_smallest("(x => x + 1)(1 + 2);", "");
|
||||
test_smallest("(x => { return x + 1 })(1);", "");
|
||||
test_smallest("(x => x + 'b')('a');", "");
|
||||
test_smallest("(x => x + 1n)(1n);", "");
|
||||
|
||||
// Parameter facts also reach nested closures, respecting shadowing.
|
||||
test_smallest("use((x => () => x + 1)(1));", "use((x => () => 2)(1));");
|
||||
test_smallest("use((x => x + (x => x + 1)(2))(1));", "use((x => x + (x => 3)(2))(1));");
|
||||
// Recording the argument must not duplicate it at a bare parameter read.
|
||||
test_smallest(
|
||||
"(x => effect(x))('a long string with many characters');",
|
||||
"(x => effect(x))('a long string with many characters');",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_iife_arguments_preserves_side_effects() {
|
||||
test_smallest("(x => effect(x + 1))(1);", "(x => effect(2))(1);");
|
||||
test_smallest(
|
||||
"(x => effect(x + 1))((sideEffect(), 1));",
|
||||
"(x => effect(2))((sideEffect(), 1));",
|
||||
);
|
||||
test_smallest("(x => x + 1)(1, sideEffect());", "(x => 2)(1, sideEffect());");
|
||||
|
||||
// The type of a nonconstant argument is unknown; a mixed BigInt addition throws.
|
||||
test_smallest("(x => x + 1)(value);", "(x => x + 1)(value);");
|
||||
test_smallest("(x => x + 1)(1n);", "(x => x + 1)(1n);");
|
||||
test_smallest("(x => x + 1)(Symbol());", "(x => x + 1)(Symbol());");
|
||||
|
||||
let options = CompressOptions::smallest();
|
||||
for source in [
|
||||
"use((x => (x++, () => x))(1));",
|
||||
"use((x => () => x++)(1));",
|
||||
"(x => (eval('x = 2'), effect(x)))(1);",
|
||||
"((x = sideEffect()) => effect(x))(1);",
|
||||
"(({ x }) => effect(x))(1);",
|
||||
"((...x) => effect(x))(1);",
|
||||
"((x, y) => effect(x, y))(...values, 1);",
|
||||
"((x, y) => effect(x, y))(1, ...values);",
|
||||
] {
|
||||
test_same_options(source, &options);
|
||||
}
|
||||
|
||||
// Redeclarations may replace the parameter before an earlier closure reads it.
|
||||
test_smallest(
|
||||
"(x => { function read() { effect(x) } function x() { sideEffect() } read() })(1);",
|
||||
"(x => { function read() { effect(x) } function x() { sideEffect() } read() })(1);",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_constant_function_iife_arguments() {
|
||||
test_smallest("(function (x) { return x + 1 })(1);", "");
|
||||
test_smallest("const f = function (x) { return x + 1 }; f(1);", "");
|
||||
test_smallest("(function unused(x, y) { return x + y })(1, 2);", "");
|
||||
test_smallest("(function (x) { effect(x + 1) })(1);", "(function (x) { effect(2) })(1);");
|
||||
test_smallest(
|
||||
"(function (x) { effect(x + 1) })((sideEffect(), 1));",
|
||||
"(function (x) { effect(2) })((sideEffect(), 1));",
|
||||
);
|
||||
test_smallest(
|
||||
"use((function (x) { return () => x + 1 })(1));",
|
||||
"use((function (x) { return () => 2 })(1));",
|
||||
);
|
||||
test_smallest(
|
||||
"(function (x) { effect(x) })('a long string with many characters');",
|
||||
"(function (x) { effect(x) })('a long string with many characters');",
|
||||
);
|
||||
|
||||
// Explicit strict mode also makes regular script IIFEs eligible.
|
||||
test_options_source_type(
|
||||
"(function (x) { 'use strict'; effect(x + 1) })(1);",
|
||||
"(function (x) { 'use strict'; effect(2) })(1);",
|
||||
SourceType::cjs(),
|
||||
&CompressOptions::smallest(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_function_iife_arguments_preserves_arguments_aliasing() {
|
||||
let options = CompressOptions::smallest();
|
||||
// Sloppy-mode parameters can change through mapped arguments or caller
|
||||
// introspection, without a resolved write to the parameter binding.
|
||||
for source in [
|
||||
"(function (x) { effect(x + 1) })(1);",
|
||||
"(function (x) { arguments[0] = 2, effect(x + 1) })(1);",
|
||||
"(function (x) { use(arguments), effect(x + 1) })(1);",
|
||||
"(function (x) { use(() => (arguments[0] = 2, x + 1)) })(1);",
|
||||
"(function (x) { var arguments; arguments[0] = 2, effect(x + 1) })(1);",
|
||||
"(function (x, x) { effect(x + 1) })(1, value);",
|
||||
"(function (x) { mutateCaller(), effect(x + 1) })(1);",
|
||||
] {
|
||||
test_same_options_source_type(source, SourceType::cjs(), &options);
|
||||
}
|
||||
|
||||
// Strict arguments objects do not alias the parameters, even in closures.
|
||||
test_smallest(
|
||||
"(function (x) { arguments[0] = 2, effect(x + 1) })(1);",
|
||||
"(function (x) { arguments[0] = 2, effect(2) })(1);",
|
||||
);
|
||||
test_smallest(
|
||||
"use((function (x) { return () => (arguments[0] = 2, x + 1) })(1));",
|
||||
"use((function (x) { return () => (arguments[0] = 2, 2) })(1));",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_function_iife_arguments_preserves_reentry_and_writes() {
|
||||
let options = CompressOptions::smallest();
|
||||
// The function's own name may be used to call it with different arguments.
|
||||
for source in [
|
||||
"(function f(x) { x && f(0), effect(x + 1) })(1);",
|
||||
"use((function f(x) { return () => (use(f), x + 1) })(1));",
|
||||
"(function (x) { use(() => x++), effect(x + 1) })(1);",
|
||||
"(function (x) { eval('x = 2'), effect(x + 1) })(1);",
|
||||
"(function (x) { function x() {} effect(x + 1) })(1);",
|
||||
"(function (x) { return x + 1 })(1n);",
|
||||
"(function (x) { return x + 1 })(value);",
|
||||
] {
|
||||
test_same_options(source, &options);
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/oxc-project/oxc/issues/24531
|
||||
// A `var` assigned only inside a conditional holds its hoisted `undefined`
|
||||
// on the untaken path; single-statement block flattening produces a
|
||||
|
||||
Reference in new issue
Block a user