feat(mangler): add reserved option for names that must not be mangled (#24041)

Adds `MangleOptions::reserved` — equivalent to terser / uglify-js / swc `mangle.reserved`: names in the set are never used as mangled names, and bindings that already carry them keep them. Default empty; default output is byte-identical to main (minsize snapshot unchanged).

## Motivation

Node's cjs-module-lexer detects a CommonJS module's named exports by **lexically** scanning for `exports.<name> =` / `module.exports` token patterns — no scope analysis ([its README documents renamed identifiers as "DETECTS: NO EXPORTS"](https://github.com/nodejs/cjs-module-lexer)). UMD / CommonJS wrappers bind `exports` / `module` as ordinary function parameters, and the mangler renames them, erasing every named export the lexer can see.

Surfaced by the monitor-oxc mangler runtime test ([failing run](https://github.com/oxc-project/monitor-oxc/actions/runs/28569108563/job/84702922472), previously masked by #24033 / #24036): `archiver` does `import { queue } from "async"`, and `async`'s dist is a UMD bundle — mangled, the factory parameter `exports` became `e` and node fails with `Named export 'queue' not found`. With `reserved: ["exports", "module"]` the output imports correctly (verified end-to-end in node, including running a queue task).

## Why an opt-in list instead of always keeping these names

Renaming them is ecosystem-standard. Verified empirically — minifying the same UMD sample and importing it in node:

| minifier | renames `exports` param? | `import { queue }` from its output | escape hatch |
| --- | --- | --- | --- |
| terser 5.48 | yes | fails | `mangle.reserved` (verified working) |
| uglify-js 3.19 | yes | fails | `reserved` (verified working) |
| esbuild 0.28 `--minify` | yes | fails | none for identifier mangling |
| swc | yes | fails | terser-style `mangle.reserved` ([swc#3424](https://github.com/swc-project/swc/issues/3424)) |

An earlier revision of this PR kept `exports` / `module` unconditionally; the Minification Size check showed the real cost: ~1% minified on UMD-heavy files (d3: +3.3 kB from ~590 `exports` references, flipping it to *larger than esbuild*), because the point is keeping the literal 7-char token at every export site. No other minifier pays this by default — and rolldown bundles bind `exports` / `module` in every CJS interop wrapper, where node's lexer never looks. So oxc gets the same escape hatch as terser/swc, with the same name.

`MangleOptions` loses `Copy` (it now holds the set); construction sites switch to `..MangleOptions::default()` / `clone()`. Exposed in `oxc-minify` NAPI options (`mangle.reserved: string[]`) and the mangler example (`--reserve-exports`).

monitor-oxc will pass `["exports", "module"]` for its mangler runtime test (its corpus imports prebuilt CJS/UMD dists directly).

For reference, esbuild solves this for its *own* node-platform CJS bundles differently — a dead-code annotation `0 && (module.exports = { a, b })` that cjs-module-lexer recognizes ([esbuild#960](https://github.com/evanw/esbuild/issues/960), [cjs-module-lexer#44](https://github.com/nodejs/cjs-module-lexer/issues/44)); verified that pattern also works appended to fully-mangled output. That could be a future zero-size-cost alternative at the minifier level.
This commit is contained in:
Dunqing committed 2026-07-02 09:31:28 +00:00
1 parent 01c8775a24
commit 4eb074e69d
10 files changed
+172 -38

No files matched your search

Generated
+1
View File
@@ -2180,6 +2180,7 @@ dependencies = [
"oxc_parser",
"oxc_sourcemap",
"oxc_span",
"oxc_str",
]
[[package]]
+41 -7
View File
@@ -20,7 +20,7 @@ mod keep_names;
pub use keep_names::MangleOptionsKeepNames;
#[derive(Default, Debug, Clone, Copy)]
#[derive(Default, Debug, Clone)]
pub struct MangleOptions {
/// Pass true to mangle names declared in the top level scope.
///
@@ -33,6 +33,19 @@ pub struct MangleOptions {
/// Keep function / class names
pub keep_names: MangleOptionsKeepNames,
/// Names that bindings must not be renamed to, and that bindings already
/// carrying them keep. Equivalent to terser / swc `mangle.reserved`.
///
/// The main use case is `["exports", "module"]` when minifying prebuilt
/// CommonJS / UMD files that Node consumers `import` directly: Node's
/// cjs-module-lexer detects a CommonJS module's named exports by lexically
/// scanning for `exports.<name> =` / `module.exports` token patterns with no
/// scope analysis, so renaming an `exports` / `module` wrapper parameter
/// erases every named export it can see.
///
/// Default: empty.
pub reserved: FxHashSet<CompactStr>,
/// Use more readable mangled names
/// (e.g. `slot_0`, `slot_1`, `slot_2`, ...) for debugging.
///
@@ -41,7 +54,7 @@ pub struct MangleOptions {
}
impl MangleOptions {
fn top_level(self, source_type: SourceType) -> bool {
fn top_level(&self, source_type: SourceType) -> bool {
self.top_level.unwrap_or(source_type.is_module() || source_type.is_commonjs())
}
}
@@ -376,7 +389,7 @@ impl<'t> Mangler<'t> {
// ── Phase 1: collect constraints — names we must not reuse or shadow. ──
let constraints =
Constraints::collect(allocator, scoping, ast_nodes, program, self.options);
Constraints::collect(allocator, scoping, ast_nodes, program, &self.options);
// ── Phase 2: assign slots — give bindings that can share a name the same slot. ──
let slots = SlotAssignment::compute(allocator, scoping, ast_nodes, &constraints);
// ── Phase 3: rank slots by reference frequency (hottest first). ──
@@ -477,6 +490,9 @@ impl<'t> SlotFrequency<'t> {
struct Constraints<'a, 's> {
/// Whether top-level (module / CommonJS) bindings may be mangled at all.
top_level: bool,
/// User-reserved names — never used as mangled names, and bindings carrying
/// them keep them (see [`MangleOptions::reserved`]).
reserved: &'s FxHashSet<CompactStr>,
/// Names of top-level exports — kept when `top_level` so importers still resolve.
exported_names: ArenaHashSet<'a, Str<'a>>,
exported_symbols: Option<BitSet<'a>>,
@@ -511,7 +527,7 @@ impl<'a, 's> Constraints<'a, 's> {
scoping: &'s Scoping,
ast_nodes: &AstNodes,
program: &'a Program<'a>,
options: MangleOptions,
options: &'s MangleOptions,
) -> Self {
let top_level = options.top_level(program.source_type);
let (exported_names, exported_symbols) = if top_level && program.source_type.is_module() {
@@ -521,7 +537,25 @@ impl<'a, 's> Constraints<'a, 's> {
};
let (keep_name_names, keep_name_symbols) =
collect_keep_name_symbols(options.keep_names, allocator, scoping, ast_nodes);
Self { top_level, exported_names, exported_symbols, keep_name_names, keep_name_symbols }
Self {
top_level,
reserved: &options.reserved,
exported_names,
exported_symbols,
keep_name_names,
keep_name_symbols,
}
}
/// Whether a binding with this name must keep it.
///
/// `inline(always)`: called per symbol in `SlotRanking::tally`'s hot loop — the
/// empty-`reserved` fast path must compile down to the plain `is_special_name`
/// check plus one predictable branch.
#[expect(clippy::inline_always, reason = "hot path")]
#[inline(always)]
fn is_kept_name(&self, name: &str) -> bool {
is_special_name(name) || (!self.reserved.is_empty() && self.reserved.contains(name))
}
}
@@ -724,7 +758,7 @@ impl<'a> SlotRanking<'a> {
if scoping.scope_flags(symbol_scope_id).contains_direct_eval() {
continue;
}
if is_special_name(scoping.symbol_name(symbol_id)) {
if constraints.is_kept_name(scoping.symbol_name(symbol_id)) {
continue;
}
if keep_name_symbols.is_some_and(|keep| keep.has_bit(symbol_id.index())) {
@@ -762,7 +796,7 @@ impl<'a, const CAPACITY: usize> NameTable<'a, CAPACITY> {
let root_bindings = scoping.get_bindings(scoping.root_scope_id());
let is_reserved = |name: &str| {
oxc_syntax::keyword::is_reserved_keyword(name)
|| is_special_name(name)
|| constraints.is_kept_name(name)
|| root_unresolved_references.contains_key(name)
|| (root_bindings.contains_key(name)
&& (!constraints.top_level || constraints.exported_names.contains(name)))
+9 -1
View File
@@ -14,6 +14,7 @@
//! ## Options
//!
//! - `--keep-names`: Preserve function and class names
//! - `--reserve-exports`: Reserve `exports` / `module` binding names (cjs-module-lexer)
//! - `--debug`: Enable debug output
//! - `--twice`: Test idempotency by running twice
@@ -25,6 +26,7 @@ use oxc_mangler::{MangleOptions, MangleOptionsKeepNames, Mangler};
use oxc_parser::Parser;
use oxc_span::SourceType;
use pico_args::Arguments;
use rustc_hash::FxHashSet;
// Instruction:
// create a `test.js`,
@@ -34,6 +36,7 @@ fn main() -> std::io::Result<()> {
let mut args = Arguments::from_env();
let keep_names = args.contains("--keep-names");
let reserve_exports = args.contains("--reserve-exports");
let debug = args.contains("--debug");
let twice = args.contains("--twice");
let name = args.free_from_str().unwrap_or_else(|_| "test.js".to_string());
@@ -45,9 +48,14 @@ fn main() -> std::io::Result<()> {
let options = MangleOptions {
top_level: None,
keep_names: MangleOptionsKeepNames { function: keep_names, class: keep_names },
reserved: if reserve_exports {
["exports", "module"].into_iter().map(Into::into).collect()
} else {
FxHashSet::default()
},
debug,
};
let printed = mangler(&source_text, source_type, options);
let printed = mangler(&source_text, source_type, options.clone());
println!("{printed}");
if twice {
+92 -27
View File
@@ -8,14 +8,14 @@ use oxc_span::SourceType;
fn mangle_with_source_type(
source_text: &str,
options: MangleOptions,
options: &MangleOptions,
source_type: SourceType,
) -> String {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source_text, source_type).parse();
assert!(ret.diagnostics.is_empty(), "Parser errors: {:?}", ret.diagnostics);
let program = ret.program;
let mangler_return = Mangler::new().with_options(options).build(&program);
let mangler_return = Mangler::new().with_options(options.clone()).build(&program);
Codegen::new()
.with_scoping(Some(mangler_return.scoping))
.with_private_member_mappings(Some(mangler_return.class_private_mappings))
@@ -23,15 +23,15 @@ fn mangle_with_source_type(
.code
}
fn mangle(source_text: &str, options: MangleOptions) -> String {
fn mangle(source_text: &str, options: &MangleOptions) -> String {
mangle_with_source_type(source_text, options, SourceType::mjs().with_unambiguous(true))
}
fn mangle_script(source_text: &str, options: MangleOptions) -> String {
fn mangle_script(source_text: &str, options: &MangleOptions) -> String {
mangle_with_source_type(source_text, options, SourceType::script())
}
fn test(source_text: &str, expected: &str, options: MangleOptions) {
fn test(source_text: &str, expected: &str, options: &MangleOptions) {
let mangled = mangle(source_text, options);
let expected = {
let allocator = Allocator::default();
@@ -52,12 +52,12 @@ fn direct_eval() {
// Symbols in scopes with direct eval should NOT be mangled
let source_text = "function foo() { let NO_MANGLE; eval('') }";
let mangled = mangle(source_text, options);
let mangled = mangle(source_text, &options);
assert_eq!(mangled, "function foo() {\n\tlet NO_MANGLE;\n\teval(\"\");\n}\n");
// Nested direct eval: parent scope also should not mangle
let source_text = "function foo() { let NO_MANGLE; function bar() { eval('') } }";
let mangled = mangle(source_text, options);
let mangled = mangle(source_text, &options);
assert_eq!(
mangled,
"function foo() {\n\tlet NO_MANGLE;\n\tfunction bar() {\n\t\teval(\"\");\n\t}\n}\n"
@@ -66,43 +66,43 @@ fn direct_eval() {
// Sibling scope without direct eval should be mangled
let source_text =
"function foo() { let NO_MANGLE; eval('') } function bar() { let SHOULD_MANGLE; }";
let mangled = mangle(source_text, options);
let mangled = mangle(source_text, &options);
// SHOULD_MANGLE gets mangled (to some short name), NO_MANGLE stays as is
assert!(mangled.contains("NO_MANGLE"));
assert!(!mangled.contains("SHOULD_MANGLE"));
// Child function scope without direct eval CAN be mangled (eval in parent cannot access child function locals)
let source_text = "function foo() { eval(''); function bar() { let CAN_MANGLE; } }";
let mangled = mangle(source_text, options);
let mangled = mangle(source_text, &options);
assert!(!mangled.contains("CAN_MANGLE"));
// Indirect eval should still allow mangling
let source_text = "function foo() { let SHOULD_MANGLE; (0, eval)('') }";
let mangled = mangle(source_text, options);
let mangled = mangle(source_text, &options);
assert!(!mangled.contains("SHOULD_MANGLE"));
test(
r#"var e = () => {}; var foo = (bar) => e(bar); var pt = (() => { eval("") })();"#,
r#"var e = () => {}; var foo = (t) => e(t); var pt = (() => { eval(""); })();"#,
MangleOptions::default(),
&MangleOptions::default(),
);
test(
r#"var e = () => {}; var foo = (bar) => e(bar); var pt = (() => { eval("") })();"#,
r#"var e = () => {}; var foo = (t) => e(t); var pt = (() => { eval(""); })();"#,
MangleOptions { top_level: Some(true), ..MangleOptions::default() },
&MangleOptions { top_level: Some(true), ..MangleOptions::default() },
);
test(
r"function outer() { let e = 1; eval(''); function inner() { let longNameToMangle = 2; console.log(e); } }",
r#"function outer() { let e = 1; eval(""); function inner() { let t = 2; console.log(e); } }"#,
MangleOptions::default(),
&MangleOptions::default(),
);
test(
r"function evalScope() { let x = 1; eval(''); } function siblingScope() { let longName = 2; console.log(longName); }",
r#"function evalScope() {let x = 1; eval(""); } function siblingScope() { let e = 2; console.log(e); }"#,
MangleOptions::default(),
&MangleOptions::default(),
);
}
@@ -165,12 +165,12 @@ fn mangler() {
let mut snapshot = String::new();
cases.into_iter().fold(&mut snapshot, |w, case| {
let options = MangleOptions::default();
write!(w, "{case}\n{}\n", mangle(case, options)).unwrap();
write!(w, "{case}\n{}\n", mangle(case, &options)).unwrap();
w
});
top_level_cases.into_iter().fold(&mut snapshot, |w, case| {
let options = MangleOptions { top_level: Some(true), ..MangleOptions::default() };
write!(w, "{case}\n{}\n", mangle(case, options)).unwrap();
write!(w, "{case}\n{}\n", mangle(case, &options)).unwrap();
w
});
keep_name_cases.into_iter().fold(&mut snapshot, |w, case| {
@@ -178,7 +178,7 @@ fn mangler() {
keep_names: MangleOptionsKeepNames::all_true(),
..MangleOptions::default()
};
write!(w, "{case}\n{}\n", mangle(case, options)).unwrap();
write!(w, "{case}\n{}\n", mangle(case, &options)).unwrap();
w
});
@@ -210,7 +210,7 @@ fn private_member_mangling() {
let mut snapshot = String::new();
cases.into_iter().fold(&mut snapshot, |w, case| {
let options = MangleOptions::default();
write!(w, "{case}\n{}\n", mangle(case, options)).unwrap();
write!(w, "{case}\n{}\n", mangle(case, &options)).unwrap();
w
});
@@ -230,21 +230,21 @@ fn function_expression_name_shadowed() {
test(
"function _() { var x; var f = function foo() { var foo = x; } }",
"function _() { var e; var t = function t() { var t = e; } }",
options,
&options,
);
// Parameter shadow.
test(
"function _() { var x; (function foo(foo) { foo + x })() }",
"function _() { var e; (function t(t) { t + e; })(); }",
options,
&options,
);
// `var` inside an `else` block — still hoists through the block scope to the fn-expr scope.
test(
"function _() { var x; var f = function foo() { if (x) {} else { var foo = x; } } }",
"function _() { var e; var t = function t() { if (e) {} else { var t = e; } } }",
options,
&options,
);
}
@@ -267,8 +267,8 @@ fn shadowed_fn_expr_mangle_is_idempotent() {
];
for case in cases {
let pass1 = mangle(case, options);
let pass2 = mangle(&pass1, options);
let pass1 = mangle(case, &options);
let pass2 = mangle(&pass1, &options);
assert_eq!(
pass1, pass2,
"\nIdempotency failure for:\n{case}\nPass 1:\n{pass1}\nPass 2:\n{pass2}"
@@ -289,7 +289,7 @@ fn destructured_exports_keep_names() {
export const { tokenize, parse, find } = syntax;",
"import e from 's';
export const { tokenize, parse, find } = e;",
options,
&options,
);
test(
@@ -297,7 +297,7 @@ fn destructured_exports_keep_names() {
export const [first, second, ...rest] = syntax;",
"import e from 's';
export const [first, second, ...rest] = e;",
options,
&options,
);
// Renamed destructuring and nested patterns: only the bound names are exported.
@@ -306,7 +306,72 @@ fn destructured_exports_keep_names() {
export const { a: renamed, b: { nested = 1 } } = syntax;",
"import e from 's';
export const { a: renamed, b: { nested = 1 } } = e;",
options,
&options,
);
}
/// With `reserved: ["exports", "module"]`, those bindings keep their names. Node's
/// cjs-module-lexer detects a CommonJS module's named exports by lexically scanning
/// for `exports.<name> =` / `module.exports` token patterns — it does no scope
/// analysis. UMD bundles bind `exports` / `module` as wrapper-function parameters
/// (e.g. `async`'s `factory(exports)`); renaming the parameter erases every named
/// export, breaking `import { queue } from "async"` at runtime in Node. `reserved` is
/// empty by default, matching esbuild / terser / swc, which also rename these.
#[test]
fn reserved_names() {
let options = MangleOptions {
reserved: ["exports", "module"].into_iter().map(Into::into).collect(),
..MangleOptions::default()
};
// UMD factory parameter named `exports`.
test(
"(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined'
? factory(exports)
: factory((global.lib = {}));
})(this, function (exports) {
function queue(worker) { return worker; }
exports.queue = queue;
});",
"(function (e, t) {
typeof exports === 'object' && typeof module !== 'undefined'
? t(exports)
: t((e.lib = {}));
})(this, function (exports) {
function t(e) { return e; }
exports.queue = t;
});",
&options,
);
// CommonJS wrapper parameters named `module` and `exports`.
test(
"(function (module, exports) {
function helper(value) { return value; }
module.exports.helper = helper;
exports.other = helper;
})(m, m.exports);",
"(function (module, exports) {
function t(e) { return e; }
module.exports.helper = t;
exports.other = t;
})(m, m.exports);",
&options,
);
// Off by default: `exports` is renamed like any other binding (matching
// esbuild / terser / swc), trading Node named-import detection for size.
test(
"(function (factory) { factory(exports); })(function (exports) {
function queue(worker) { return worker; }
exports.queue = queue;
});",
"(function (e) { e(exports); })(function (e) {
function t(e) { return e; }
e.queue = t;
});",
&MangleOptions::default(),
);
}
@@ -338,7 +403,7 @@ fn annex_b_block_scoped_function() {
let mut snapshot = String::new();
cases.into_iter().fold(&mut snapshot, |w, case| {
let options = MangleOptions::default();
write!(w, "{case}\n{}\n", mangle_script(case, options)).unwrap();
write!(w, "{case}\n{}\n", mangle_script(case, &options)).unwrap();
w
});
+1
View File
@@ -25,6 +25,7 @@ doctest = false
oxc_allocator = { workspace = true }
oxc_codegen = { workspace = true, features = ["sourcemap"] }
oxc_compat = { workspace = true }
oxc_str = { workspace = true }
oxc_diagnostics = { workspace = true }
oxc_minifier = { workspace = true }
oxc_napi = { workspace = true }
+11
View File
@@ -137,6 +137,17 @@ export interface MangleOptions {
* @default false
*/
keepNames?: boolean | MangleOptionsKeepNames
/**
* Names that bindings must not be renamed to, and that bindings already
* carrying them keep. Equivalent to terser's `mangle.reserved`.
*
* Pass `['exports', 'module']` when minifying prebuilt CommonJS / UMD files
* that Node consumers `import` directly, so Node's cjs-module-lexer can still
* detect the mangled module's named exports.
*
* @default []
*/
reserved?: Array<string>
/** Debug mangled names. */
debug?: boolean
}
+14
View File
@@ -2,6 +2,7 @@ use napi::Either;
use napi_derive::napi;
use oxc_compat::EngineTargets;
use oxc_str::CompactStr;
#[napi(object)]
pub struct TreeShakeOptions {
@@ -221,6 +222,16 @@ pub struct MangleOptions {
/// @default false
pub keep_names: Option<Either<bool, MangleOptionsKeepNames>>,
/// Names that bindings must not be renamed to, and that bindings already
/// carrying them keep. Equivalent to terser's `mangle.reserved`.
///
/// Pass `['exports', 'module']` when minifying prebuilt CommonJS / UMD files
/// that Node consumers `import` directly, so Node's cjs-module-lexer can still
/// detect the mangled module's named exports.
///
/// @default []
pub reserved: Option<Vec<String>>,
/// Debug mangled names.
pub debug: Option<bool>,
}
@@ -236,6 +247,9 @@ impl From<&MangleOptions> for oxc_minifier::MangleOptions {
Some(Either::B(o)) => oxc_minifier::MangleOptionsKeepNames::from(o),
None => default.keep_names,
},
reserved: o.reserved.as_ref().map_or(default.reserved, |names| {
names.iter().map(|name| CompactStr::from(name.as_str())).collect()
}),
debug: o.debug.unwrap_or(default.debug),
}
}
+1 -1
View File
@@ -342,7 +342,7 @@ impl Oxc {
options.mangle.map(|o| MangleOptions {
top_level: Some(o.top_level),
keep_names: MangleOptionsKeepNames { function: o.keep_names, class: o.keep_names },
debug: false,
..MangleOptions::default()
})
} else {
None
+1 -1
View File
@@ -120,7 +120,7 @@ fn bench_mangler(criterion: &mut Criterion) {
.with_options(MangleOptions {
top_level: None,
keep_names: MangleOptionsKeepNames::all_true(),
debug: false,
..MangleOptions::default()
})
.build_with_semantic(&mut semantic, &program);
});
+1 -1
View File
@@ -68,7 +68,7 @@ impl CompilerInterface for PipelineCompiler {
}
fn mangle_options(&self) -> Option<MangleOptions> {
Some(self.mangle)
Some(self.mangle.clone())
}
fn codegen_options(&self) -> Option<CodegenOptions> {