mirror of
https://github.com/aphrody-labs/oxc.git
synced 2026-10-17 12:56:09 +00:00
fix(minifier): preserve parameter writes through mapped arguments (#27494)
In a non-strict function with a simple parameter list, `arguments[i]` aliases parameter `i`. When a `var` or function declaration redeclares the parameter, the minifier tracks it like an ordinary local and removes or inlines writes that are still visible through `arguments`.
Parameters of these functions are now marked as observable, so unused-code removal and single-use inlining keep their writes. The marking does not depend on the `unused` option, because single-use inlining also runs with `compress: { unused: false }`.
Script sources now always carry this observability state. Without it, `unused: false` inlined a root-level `var` nested in a statement: `try { var x = foo(); throw x } catch {}` became `try { throw foo(); } catch {}`, which removes a global that later scripts can read.
The guard is conservative: it covers every sloppy function with a simple parameter list, whether or not it uses `arguments`. #27525 narrows it to functions that can reach their `arguments` object.
## Example
```js
(function (a) {
var a;
console.log(a = "foo", arguments[0]);
})("bar");
```
This prints `foo foo`. Before, the minified code printed `foo bar`:
```js
(function(a) {
console.log("foo", arguments[0]);
})("bar");
```
After, the write is kept:
```js
(function(a) {
var a;
console.log(a = "foo", arguments[0]);
})("bar");
```
The Rust regressions cover Script and CommonJS with unused-code removal on and off. The NAPI test runs the source and the minified output in `node:vm` and compares what they print. `just minsize` leaves the size and allocation snapshots unchanged.
Related to #27473.
AI disclosure: Developed with AI assistance, including Claude Code.
This commit is contained in:
1 parent
39e6d91b62
commit
1e4c6ee776
6 files changed
+218
-30
No files matched your search
@@ -52,7 +52,8 @@ impl<'a> PeepholeOptimizations {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Covers exports, Script-root bindings, Annex B aliases, and `using`.
|
||||
// Covers exports, Script-root bindings, mapped arguments, Annex B
|
||||
// aliases, and `using`.
|
||||
if ctx.state.symbols.is_implicitly_observable(symbol_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@
|
||||
//! 3. **Implicit observability** is stable metadata for bindings whose runtime
|
||||
//! value can be observed without a resolved reference in this AST. A symbol
|
||||
//! here may have any number of references; the bit records that an observer
|
||||
//! remains even if every reference disappears. The four channels are module
|
||||
//! exports, Script-root bindings, Annex B aliases, and `using` disposal. This
|
||||
//! metadata protects every count-based removal consumer, not only
|
||||
//! declaration removal.
|
||||
//! remains even if every reference disappears. The five channels are module
|
||||
//! exports, Script-root bindings, mapped arguments, Annex B aliases, and
|
||||
//! `using` disposal. This metadata protects every count-based removal
|
||||
//! consumer, not only declaration removal.
|
||||
//! 4. **Analysis runs after scoping is flushed.** Every result is derived from
|
||||
//! the settled resolved-reference lists, so AST rewrites need no parallel
|
||||
//! collection hooks or behind-the-cursor repair log.
|
||||
@@ -95,7 +95,7 @@ use oxc_allocator::{Allocator, ArenaVec, BitSet, GetAllocator};
|
||||
use oxc_ast::ast::*;
|
||||
#[cfg(debug_assertions)]
|
||||
use oxc_ast_visit::{VisitJs, walk_js::walk_function};
|
||||
use oxc_ecmascript::BoundNames;
|
||||
use oxc_ecmascript::{BoundNames, IsSimpleParameterList};
|
||||
use oxc_semantic::Scoping;
|
||||
use oxc_span::SourceType;
|
||||
use oxc_syntax::{reference::ReferenceId, scope::ScopeId, symbol::SymbolId};
|
||||
@@ -104,22 +104,28 @@ use crate::{CompressOptions, CompressOptionsUnused, TraverseCtx};
|
||||
|
||||
/// Stable program-wide symbol facts plus the optional recursive-function graph.
|
||||
///
|
||||
/// This is always present for ES modules because exported-binding implicit
|
||||
/// observability must protect count-based optimizations even when recursive
|
||||
/// function removal is disabled. For CommonJS and Script sources it exists
|
||||
/// whenever unused-declaration removal is enabled, and a `using` declaration
|
||||
/// creates it lazily even otherwise, because disposal observes its binding
|
||||
/// without an ordinary reference.
|
||||
/// This is always present for ES modules and Script sources because the
|
||||
/// implicit observability of exported and Script-root bindings must protect
|
||||
/// count-based optimizations even when recursive function removal is
|
||||
/// disabled. For CommonJS sources it exists whenever unused-declaration
|
||||
/// removal is enabled. Otherwise a `using` declaration or a sloppy function
|
||||
/// with a simple parameter list creates it lazily, because disposal and
|
||||
/// mapped arguments observe those bindings without an ordinary reference.
|
||||
pub struct SymbolLiveness<'a> {
|
||||
/// Bindings with a runtime observer independent of resolved references:
|
||||
/// module exports, Script roots, Annex B aliases, and `using` disposal.
|
||||
/// module exports, Script roots, mapped arguments, Annex B aliases, and
|
||||
/// `using` disposal.
|
||||
implicitly_observable: BitSet<'a>,
|
||||
recursive_functions: Option<FunctionGraph<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> SymbolLiveness<'a> {
|
||||
/// Return `None` when the configuration needs no symbol liveness: a
|
||||
/// non-module source with unused-declaration removal disabled.
|
||||
/// Return `None` only for a CommonJS source with unused-declaration
|
||||
/// removal disabled. Its fresh state has nothing seeded, so Normalize
|
||||
/// creates it when it finds a `using` binding or a parameter exposed
|
||||
/// through mapped arguments. A Script source always has the state because
|
||||
/// later scripts observe its root bindings regardless of the `unused`
|
||||
/// option.
|
||||
pub fn new_if_enabled(
|
||||
source_type: SourceType,
|
||||
options: &CompressOptions,
|
||||
@@ -127,7 +133,7 @@ impl<'a> SymbolLiveness<'a> {
|
||||
allocator: &'a Allocator,
|
||||
) -> Option<Self> {
|
||||
let recursive_functions_enabled = options.unused != CompressOptionsUnused::Keep;
|
||||
if !source_type.is_module() && !recursive_functions_enabled {
|
||||
if !source_type.is_module() && !source_type.is_script() && !recursive_functions_enabled {
|
||||
return None;
|
||||
}
|
||||
Some(Self::new(source_type, scoping, allocator))
|
||||
@@ -453,19 +459,30 @@ impl<'a> GraphScratch<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize hook: register a function declaration as a potential graph
|
||||
/// candidate.
|
||||
/// Normalize hook: record parameters exposed through mapped arguments and
|
||||
/// register function declarations as potential graph candidates.
|
||||
pub fn register_function(function: &Function<'_>, ctx: &mut TraverseCtx<'_>) {
|
||||
if !function.is_declaration() {
|
||||
return;
|
||||
}
|
||||
if ctx.options().unused == CompressOptionsUnused::Keep {
|
||||
return;
|
||||
}
|
||||
let recursive_functions_enabled = ctx.options().unused != CompressOptionsUnused::Keep;
|
||||
let source_type = ctx.source_type();
|
||||
let allocator = ctx.allocator();
|
||||
let TraverseCtx { state, scoping, .. } = ctx;
|
||||
if let Some(liveness) = state.symbols.liveness_mut() {
|
||||
// Sloppy functions with simple parameter lists alias their parameters
|
||||
// through `arguments`, even when a parameter has no resolved reads.
|
||||
// A `var` or function redeclaration must not make its writes removable.
|
||||
// Single-use substitution runs even when unused declarations are kept, so
|
||||
// the parameters are marked regardless of the `unused` option.
|
||||
if !scoping.scoping().scope_flags(function.scope_id()).is_strict_mode()
|
||||
&& function.params.is_simple_parameter_list()
|
||||
{
|
||||
let liveness = state
|
||||
.symbols
|
||||
.ensure_liveness(|| SymbolLiveness::new(source_type, scoping.scoping(), allocator));
|
||||
liveness.mark_bound_names(&*function.params);
|
||||
}
|
||||
if recursive_functions_enabled
|
||||
&& function.is_declaration()
|
||||
&& let Some(liveness) = state.symbols.liveness_mut()
|
||||
{
|
||||
liveness.register_function(function, source_type, scoping.scoping(), allocator);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +100,8 @@ impl<'a> SymbolState<'a> {
|
||||
|
||||
/// Whether runtime semantics have an implicit observation channel that
|
||||
/// remains even if every resolved reference disappears from the current
|
||||
/// AST. Returns `false` when liveness state is absent; optimization
|
||||
/// consumers may interpret that result only in configurations where
|
||||
/// absence is safe.
|
||||
/// AST. Returns `false` when liveness state is absent, which is only the
|
||||
/// case when Normalize recorded no such binding.
|
||||
pub fn is_implicitly_observable(&self, symbol_id: SymbolId) -> bool {
|
||||
self.liveness.as_ref().is_some_and(|liveness| liveness.is_implicitly_observable(symbol_id))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use oxc_span::SourceType;
|
||||
|
||||
use crate::{
|
||||
CompressOptions, test_options, test_options_source_type, test_same_options,
|
||||
test_same_options_source_type, test_smallest,
|
||||
CompressOptions, CompressOptionsUnused, test_options, test_options_source_type,
|
||||
test_same_options, test_same_options_source_type, test_smallest,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -118,6 +118,74 @@ fn inline_function_iife_arguments_preserves_arguments_aliasing() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_writes_preserve_mapped_arguments() {
|
||||
// https://github.com/oxc-project/oxc/issues/27473
|
||||
let options = CompressOptions::smallest();
|
||||
for source_type in [SourceType::script(), SourceType::cjs()] {
|
||||
test_same_options_source_type(
|
||||
"(function (a) { console.log(a = 'foo', arguments[0]) })('bar');",
|
||||
source_type,
|
||||
&options,
|
||||
);
|
||||
// Redeclarations create symbol-value metadata, but the parameter is
|
||||
// still observable through the mapped arguments object.
|
||||
for source in [
|
||||
"(function (a) { var a; console.log(a = 'foo', arguments[0]) })('bar');",
|
||||
"(function (a) { var a = 'baz'; console.log(a = 'foo', arguments[0]) })('bar');",
|
||||
"(function (a) { function a() {} console.log(a = 'foo', arguments[0]) })('bar');",
|
||||
"(function (a) { var a = 'foo'; console.log(arguments[0]) })('bar');",
|
||||
"(function (a, b) { var a, b; console.log(a = 'foo', b = 2, arguments[0], arguments[1]) })('bar', 1);",
|
||||
"(function (a, a) { var a; console.log(a = 'foo', arguments[0], arguments[1]) })('bar', 'baz');",
|
||||
] {
|
||||
test_same_options_source_type(source, source_type, &options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_writes_preserve_mapped_arguments_when_keeping_unused() {
|
||||
// Single-use substitution still runs when unused declarations are kept.
|
||||
let options =
|
||||
CompressOptions { unused: CompressOptionsUnused::Keep, ..CompressOptions::smallest() };
|
||||
for source_type in [SourceType::script(), SourceType::cjs()] {
|
||||
for source in [
|
||||
"function f(a) { var a = function() {}; return [arguments, a]; } use(f);",
|
||||
"function s3(a) { var a = arguments; return a; } use(s3);",
|
||||
"(function (a) { var a = 'foo'; console.log(arguments[0]) })('bar');",
|
||||
] {
|
||||
test_same_options_source_type(source, source_type, &options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmapped_parameters_allow_unused_writes() {
|
||||
let options = CompressOptions::smallest();
|
||||
test_options_source_type(
|
||||
"(function (a) { 'use strict'; var a; console.log(a = 'foo', arguments[0]) })('bar');",
|
||||
"(function (a) { 'use strict'; console.log('foo', arguments[0]) })('bar');",
|
||||
SourceType::cjs(),
|
||||
&options,
|
||||
);
|
||||
test_options_source_type(
|
||||
"(function (a = 'baz') { var a; console.log(a = 'foo', arguments[0]) })('bar');",
|
||||
"(function (a = 'baz') { console.log('foo', arguments[0]) })('bar');",
|
||||
SourceType::cjs(),
|
||||
&options,
|
||||
);
|
||||
test_options_source_type(
|
||||
"(function (outer) { (a => { var a; console.log(a = 'foo', arguments[0]) })('bar') })('outer');",
|
||||
"(function (outer) { (a => { console.log('foo', arguments[0]) })('bar') })('outer');",
|
||||
SourceType::cjs(),
|
||||
&options,
|
||||
);
|
||||
test_smallest(
|
||||
"(function (a) { var a; console.log(a = 'foo', arguments[0]) })('bar');",
|
||||
"(function (a) { console.log('foo', arguments[0]) })('bar');",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_function_iife_arguments_preserves_reentry_and_writes() {
|
||||
let options = CompressOptions::smallest();
|
||||
|
||||
@@ -494,6 +494,19 @@ fn keep_exposed_variables() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_script_root_var_in_nested_statement() {
|
||||
// A `var` nested in a statement still declares a Script-root binding that
|
||||
// later scripts can read.
|
||||
test_script_same("try { var x = foo(); throw x } catch {}");
|
||||
test_script_same("l: { var x = foo(); if (x) break l; bar() }");
|
||||
// The result does not depend on a function that the same run removes.
|
||||
test_script(
|
||||
"(function (a) {})(1); try { var x = foo(); throw x } catch {}",
|
||||
"try { var x = foo(); throw x } catch {}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_names() {
|
||||
test(
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { minifySync } from "../index";
|
||||
|
||||
function captureOutput(code: string): unknown[][] {
|
||||
const output: unknown[][] = [];
|
||||
runInNewContext(code, { console: { log: (...args: unknown[]) => output.push(args) } });
|
||||
return output;
|
||||
}
|
||||
|
||||
describe("mapped arguments", () => {
|
||||
// https://github.com/oxc-project/oxc/issues/27473
|
||||
const cases = [
|
||||
["direct write", "(function(a) { console.log(a = 'foo', arguments[0]); })('bar');"],
|
||||
["var redeclaration", "(function(a) { var a; console.log(a = 'foo', arguments[0]); })('bar');"],
|
||||
[
|
||||
"initialized var redeclaration",
|
||||
"(function(a) { var a = 'baz'; console.log(a = 'foo', arguments[0]); })('bar');",
|
||||
],
|
||||
[
|
||||
"function redeclaration",
|
||||
"(function(a) { function a() {} console.log(a = 'foo', arguments[0]); })('bar');",
|
||||
],
|
||||
[
|
||||
"arguments alias",
|
||||
"(function(a) { var a; const args = arguments; console.log(a = 'foo', args[0]); })('bar');",
|
||||
],
|
||||
[
|
||||
"captured write",
|
||||
"(function(a) { var a; (() => a = 'foo')(); console.log('foo', arguments[0]); })('bar');",
|
||||
],
|
||||
[
|
||||
"captured write from strict scope",
|
||||
"(function(a) { var a; (function() { 'use strict'; a = 'foo'; })(); console.log('foo', arguments[0]); })('bar');",
|
||||
],
|
||||
[
|
||||
"initializer write",
|
||||
"(function(a) { var a = 'foo'; console.log('foo', arguments[0]); })('bar');",
|
||||
],
|
||||
];
|
||||
|
||||
const returnedArgumentsCode = `
|
||||
function f(a) {
|
||||
var a = function() {};
|
||||
return [arguments, a];
|
||||
}
|
||||
const result = f(1);
|
||||
console.log(typeof result[0][0], result[0][0] === result[1]);
|
||||
`;
|
||||
|
||||
for (const mangle of [false, true]) {
|
||||
for (const filename of ["input.js", "input.cjs"]) {
|
||||
// Single-use substitution still runs with `unused: false`.
|
||||
for (const compress of [true, { unused: false }]) {
|
||||
const label = `${filename}, mangle=${mangle}, compress=${JSON.stringify(compress)}`;
|
||||
|
||||
it.each(cases)(`preserves %s in ${label}`, (_name, code) => {
|
||||
const result = minifySync(filename, code, { mangle, compress });
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(captureOutput(code)).toEqual([["foo", "foo"]]);
|
||||
expect(captureOutput(result.code)).toEqual([["foo", "foo"]]);
|
||||
});
|
||||
|
||||
it(`preserves returned arguments in ${label}`, () => {
|
||||
const result = minifySync(filename, returnedArgumentsCode, { mangle, compress });
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(captureOutput(returnedArgumentsCode)).toEqual([["function", true]]);
|
||||
expect(captureOutput(result.code)).toEqual([["function", true]]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it.each(cases)(`preserves strict %s, mangle=${mangle}`, (_name, code) => {
|
||||
const result = minifySync("input.js", code, { module: true, mangle });
|
||||
expect(result.errors).toEqual([]);
|
||||
// These examples have no imports or exports; strict script execution
|
||||
// reproduces their module-mode arguments semantics.
|
||||
expect(captureOutput(`"use strict"; ${code}`)).toEqual([["foo", "bar"]]);
|
||||
expect(captureOutput(`"use strict"; ${result.code}`)).toEqual([["foo", "bar"]]);
|
||||
});
|
||||
|
||||
it(`preserves strict returned arguments, mangle=${mangle}`, () => {
|
||||
const result = minifySync("input.js", returnedArgumentsCode, { module: true, mangle });
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(captureOutput(`"use strict"; ${returnedArgumentsCode}`)).toEqual([["number", false]]);
|
||||
expect(captureOutput(`"use strict"; ${result.code}`)).toEqual([["number", false]]);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user