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:
Dunqing committed 2026-10-12 08:04:49 +00:00
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;
}
+41 -24
View File
@@ -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);
}
}
+2 -3
View File
@@ -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))
}
+70 -2
View File
@@ -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(
+90
View File
@@ -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]]);
});
}
});