Files
oxc-project__oxc/tasks/benchmark/benches/pipeline.rs
Dunqing 4eb074e69d 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.
2026-07-02 09:31:28 +00:00

128 lines
4.5 KiB
Rust

use std::path::Path;
use oxc::{
CompilerInterface,
codegen::CodegenOptions,
mangler::MangleOptions,
minifier::CompressOptions,
transformer::TransformOptions,
transformer_plugins::{InjectGlobalVariablesConfig, InjectImport, ReplaceGlobalDefinesConfig},
};
use oxc_benchmark::{BenchmarkId, Criterion, criterion_group, criterion_main};
use oxc_tasks_common::TestFiles;
/// A representative Vite-style define config. Unlike a single dot define, this exercises every
/// `ReplaceGlobalDefines` dispatch path — identifier, dot, specific `import.meta.env.*`, and
/// trailing wildcards — so the benchmark reflects the per-node lookup cost of a real config.
const DEFINES: &[(&str, &str)] = &[
// identifier defines
("__DEV__", "false"),
("__PROD__", "true"),
("__VERSION__", "'1.2.3'"),
("__TEST__", "false"),
("DEBUG", "false"),
// dot defines
("process.env.NODE_ENV", "'production'"),
("process.env.PLATFORM", "'browser'"),
("process.env.VERSION", "'1.2.3'"),
("globalThis.__DEV__", "false"),
// specific `import.meta.env` entries
("import.meta.env.MODE", "'production'"),
("import.meta.env.BASE_URL", "'/'"),
("import.meta.env.PROD", "true"),
("import.meta.env.DEV", "false"),
("import.meta.env.SSR", "false"),
("import.meta.env.VITE_API_URL", "'https://example.com'"),
("import.meta.env.VITE_APP_TITLE", "'app'"),
// trailing wildcards
("import.meta.env.*", "undefined"),
("import.meta.*", "undefined"),
];
/// A [`CompilerInterface`] that runs the complete compilation pipeline:
/// parse -> semantic -> transform -> define -> inject -> minify -> mangle -> codegen.
struct PipelineCompiler {
transform: TransformOptions,
define: ReplaceGlobalDefinesConfig,
inject: InjectGlobalVariablesConfig,
compress: CompressOptions,
mangle: MangleOptions,
codegen: CodegenOptions,
}
impl CompilerInterface for PipelineCompiler {
fn transform_options(&self) -> Option<&TransformOptions> {
Some(&self.transform)
}
fn define_options(&self) -> Option<ReplaceGlobalDefinesConfig> {
Some(self.define.clone())
}
fn inject_options(&self) -> Option<InjectGlobalVariablesConfig> {
Some(self.inject.clone())
}
fn compress_options(&self) -> Option<CompressOptions> {
Some(self.compress.clone())
}
fn mangle_options(&self) -> Option<MangleOptions> {
Some(self.mangle.clone())
}
fn codegen_options(&self) -> Option<CodegenOptions> {
Some(self.codegen.clone())
}
fn check_semantic_error(&self) -> bool {
false
}
}
fn bench_pipeline(criterion: &mut Criterion) {
let mut group = criterion.benchmark_group("pipeline");
for file in TestFiles::minimal().files() {
let id = BenchmarkId::from_parameter(&file.file_name);
let source_text = &file.source_text;
let source_type = file.source_type;
let source_path = Path::new(&file.file_name);
// `compile` creates its own `Allocator` and keeps no state between runs,
// so the compiler and its options can be built once and reused.
let mut compiler = PipelineCompiler {
transform: TransformOptions::from_target("esnext").unwrap(),
define: ReplaceGlobalDefinesConfig::new(DEFINES).unwrap(),
// Common global-variable injections: Node.js polyfills (when bundling for the
// browser) plus a classic-runtime JSX auto-import. Like the defines, these exercise
// the per-reference inject matching rather than a single specifier.
inject: InjectGlobalVariablesConfig::new(vec![
InjectImport::named_specifier("node:buffer", Some("Buffer"), "Buffer"),
InjectImport::default_specifier("node:process", "process"),
InjectImport::named_specifier("node:timers", Some("setImmediate"), "setImmediate"),
InjectImport::named_specifier(
"node:timers",
Some("clearImmediate"),
"clearImmediate",
),
InjectImport::default_specifier("react", "React"),
]),
compress: CompressOptions::smallest(),
mangle: MangleOptions::default(),
codegen: CodegenOptions::default(),
};
group.bench_function(id, |b| {
b.iter(|| {
compiler.compile(source_text, source_type, source_path);
});
});
}
group.finish();
}
criterion_group!(pipeline, bench_pipeline);
criterion_main!(pipeline);