Skip to content

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

Merged
graphite-app[bot] merged 1 commit into
mainfrom
fix/mangler-keep-exports-module
Jul 2, 2026
Merged

feat(mangler): add reserved option for names that must not be mangled#24041
graphite-app[bot] merged 1 commit into
mainfrom
fix/mangler-keep-exports-module

Conversation

@Dunqing

@Dunqing Dunqing commented Jul 2, 2026

Copy link
Copy Markdown
Member

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"). 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, 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)

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, cjs-module-lexer#44); verified that pattern also works appended to fully-mangled output. That could be a future zero-size-cost alternative at the minifier level.

Dunqing commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • 0-merge - adds this PR to the back of the merge queue
  • hotfix - for urgent changes, fast-track this PR to the front of the merge queue

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@github-actions github-actions Bot added the A-minifier Area - Minifier label Jul 2, 2026
@codspeed-hq

codspeed-hq Bot commented Jul 2, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 62 untouched benchmarks
⏩ 9 skipped benchmarks1


Comparing fix/mangler-keep-exports-module (0f2998e) with main (3cad48d)

Open in CodSpeed

Footnotes

  1. 9 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@Dunqing
Dunqing force-pushed the fix/mangler-keep-exports-module branch 2 times, most recently from ce4c225 to faff07d Compare July 2, 2026 06:39
@Dunqing Dunqing changed the title fix(mangler): keep names of exports and module bindings feat(mangler): option to keep exports / module binding names Jul 2, 2026
@Dunqing
Dunqing force-pushed the fix/mangler-keep-exports-module branch from faff07d to e86e7cf Compare July 2, 2026 06:53
@Dunqing Dunqing changed the title feat(mangler): option to keep exports / module binding names feat(mangler): add reserved option for names that must not be mangled Jul 2, 2026
@Dunqing
Dunqing force-pushed the fix/mangler-keep-exports-module branch 3 times, most recently from 48d4623 to 0f2998e Compare July 2, 2026 07:41
@Dunqing
Dunqing marked this pull request as ready for review July 2, 2026 07:52
@Dunqing
Dunqing requested a review from sapphi-red July 2, 2026 08:49
@sapphi-red

sapphi-red commented Jul 2, 2026

Copy link
Copy Markdown
Member

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

I'm not able to reproduce this. terser with these inputs:

function wrapper() {
  function foo(exports) {
      exports.foo = "foo"
  }
  const obj = {}
  foo(obj)
  console.log(obj)
}
wrapper()
{
  module: false,
  compress: {},
  mangle: { reserved: ["exports"] },
  output: {},
  parse: {},
  rename: {},
}

gives me

function wrapper(){const o={};o.foo="foo",console.log(o)}wrapper();

IIUC mangler.reserved only affects the top-level variables.

@Dunqing

Dunqing commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

mangle.reserved works at any depth, not just top level. What you're seeing is the compressor, not the mangler: foo is only called once, so compress inlines it, and the exports parameter is gone before mangling even runs. The o in o.foo="foo" is obj renamed, not exports renamed.

Same input with mangle only:

$ terser --mangle "reserved=['exports']" -- input.js
function wrapper(){function o(exports){exports.foo="foo"}const n={};o(n);console.log(n)}wrapper();

The nested exports param is kept.

It also holds up on a real UMD wrapper even with compress on (--compress --mangle "reserved=['exports','module']"):

!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((e=e||self).mylib={})}(this,function(exports){"use strict";exports.queue=function(e,n){...},...});

The factory is passed as an argument, so it can't be inlined, and import { queue } from that output works in node.

That said, you did find a real limitation: reserved only stops renaming. If the compressor removes the binding completely, it can't help — same in terser. It doesn't matter for monitor-oxc since that only runs the mangler. If we ever want this to survive compression too, the esbuild-style 0 && (module.exports = {...}) annotation would be the way to go, since it survives both renaming and inlining.

@sapphi-red

Copy link
Copy Markdown
Member

Ah, ok. It worked with

function wrapper() {
  function foo(exports) {
      exports.foo = "foo"
  }
  const obj = {}
  foo(obj)
  foo(obj)
  console.log(obj)
}
wrapper()

@Dunqing

Dunqing commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

Thank you for reviewing!

@Dunqing Dunqing added the 0-merge Merge with Graphite Merge Queue label Jul 2, 2026

Dunqing commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

Merge activity

…ed (#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](swc-project/swc#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](evanw/esbuild#960), [cjs-module-lexer#44](nodejs/cjs-module-lexer#44)); verified that pattern also works appended to fully-mangled output. That could be a future zero-size-cost alternative at the minifier level.
@graphite-app
graphite-app Bot force-pushed the fix/mangler-keep-exports-module branch from 0f2998e to 4eb074e Compare July 2, 2026 09:31
@graphite-app
graphite-app Bot merged commit 4eb074e into main Jul 2, 2026
30 checks passed
@graphite-app graphite-app Bot removed the 0-merge Merge with Graphite Merge Queue label Jul 2, 2026
@graphite-app
graphite-app Bot deleted the fix/mangler-keep-exports-module branch July 2, 2026 09:36
Dunqing added a commit to oxc-project/monitor-oxc that referenced this pull request Jul 2, 2026
Node's cjs-module-lexer detects a CommonJS module's named exports by
lexically scanning for `exports.<name> =` / `module.exports` patterns.
UMD / CommonJS wrappers bind these as function parameters, and mangling
renames them, erasing every named export the lexer can see —
`import { queue } from "async"` then fails the runtime test. Renaming is
ecosystem-standard (esbuild / terser / swc do the same), so oxc keeps
default behavior and provides `MangleOptions::reserved`
(oxc-project/oxc#24041, the terser `mangle.reserved` equivalent); pass
`["exports", "module"]` here since this suite imports prebuilt dists
directly.
Dunqing added a commit to oxc-project/monitor-oxc that referenced this pull request Jul 2, 2026
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. UMD / CommonJS wrappers bind `exports` /
`module` as ordinary function parameters, and mangling renames them,
erasing every named export the lexer can see: `import { queue } from
"async"` fails the mangler runtime test ([failing
run](https://github.com/oxc-project/monitor-oxc/actions/runs/28569108563/job/84702922472)).

Renaming these is ecosystem-standard (esbuild / terser / swc all do it
by default), so oxc keeps its default behavior and instead provides
`MangleOptions::reserved` — the terser `mangle.reserved` equivalent,
added in oxc-project/oxc#24041. This suite imports prebuilt dists
directly, so pass `["exports", "module"]`.

Depends on oxc-project/oxc#24041 (merged). The mangler job also needs
oxc-project/oxc#24033 to pass its idempotency phase.
camc314 pushed a commit that referenced this pull request Jul 3, 2026
…ed (#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](swc-project/swc#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](evanw/esbuild#960), [cjs-module-lexer#44](nodejs/cjs-module-lexer#44)); verified that pattern also works appended to fully-mangled output. That could be a future zero-size-cost alternative at the minifier level.
Boshen added a commit that referenced this pull request Jul 6, 2026
### 🚀 Features

- 260425f semantic/examples: Include unresolved references (#24214)
(camc314)
- 2d9b0b3 minifier: Fold boolean-literal ternary branches in value
contexts (#24110) (Dunqing)
- 61fbf10 ast: Implement `ReplaceWith` on all AST types (#24013)
(overlookmotel)
- 7db7a29 allocator: Add `ReplaceWith` trait (#24012) (overlookmotel)
- 4eb074e mangler: Add `reserved` option for names that must not be
mangled (#24041) (Dunqing)
- 2e62012 data_structures: Add `StringExt` trait (#24006)
(overlookmotel)
- 60e7160 minifier: Drop side-effect-free IIFEs whose result is unused
(#23967) (Dunqing)
- 26dd9e2 ast: Add method to widen inherited enum ref to parent ref
(#23961) (overlookmotel)

### 🐛 Bug Fixes

- e8b50ee transformer: Clean up semantics for stripped TypeScript syntax
(#24180) (camc314)
- d966d0b react_compiler: Remove clippy allows (#24168) (Boshen)
- 854ef8d react_compiler: Compile generic functions instead of
over-bailing on type-param hoisting (#24158) (Boshen)
- 093586c react_compiler: Align memoization cache-slot allocation with
Babel (#24157) (Boshen)
- 09c8f59 react_compiler: Normalize snapshot fixture paths (#24142)
(camc314)
- f13df97 react_compiler: Drop stray empty statement from catch bindings
(#24133) (Boshen)
- cb2a505 react_compiler: Codegen destructuring reassignment targets
(#24131) (Boshen)
- b82c394 react_compiler: Propagate codegen invariants instead of
emitting empty bodies (#24128) (Boshen)
- 5771982 react_compiler: Render unchanged programs as source in fixture
snapshots (#24129) (Boshen)
- 4b16e1a transformer/async-to-generator: Preserve direct eval scope
flags (#24136) (camc314)
- 4e9194f react_compiler: Lower `delete obj.prop` to
Property/ComputedDelete (#24123) (Boshen)
- 0b25582 ast: Type binding node `typeAnnotation` as `TSTypeAnnotation |
null` (#23113) (Boshen)
- 018c0e5 transformer: Hoist lowered async declarations (#22770)
(camc314)
- 652fbaf mangler: Keep names of destructured exported bindings (#24036)
(Dunqing)
- e274415 minifier: Don't drop global calls that throw despite pure
arguments (#23917) (Dunqing)
- 59abb30 minifier: Only merge string literals in `try_fold_add` when
the inner operator is `+` (#23622) (Jerry Zhao)

### ⚡ Performance

- c5ca77b transformer: Avoid cloning refresh options (#24191) (camc314)
- bf1a151 react_compiler: Compile out debug printers (#24184) (Boshen)
- abb44a0 transformer: Build fixed object-rest arguments (#24190)
(camc314)
- a4db731 isolated_declarations: Use `ReplaceWith` instead of `TakeIn`
(#24016) (overlookmotel)
- ff10855 transformer: Use `ReplaceWith` instead of `TakeIn` (#24015)
(overlookmotel)
- bd49aff ecmascript: Avoid heap-allocating Math.min/max/imul operands
(#23941) (Lawrence Lin)
- e4b708b react_compiler: Skip compiled files before prefilters (#24171)
(Boshen)
- c59f2fe rust: Return impl ExactSizeIterator from slice-backed
accessors (#24144) (Boshen)
- 5d6d04a codegen: SWAR-skip boring byte runs in sourcemap line/column
scan (#24023) (Boshen)
- a55e0be traverse: Reduce string operations in `get_var_name_from_node`
(#24007) (overlookmotel)
- e6d48e1 transformer/nullish_coalescing: Move cold path into separate
function (#23989) (overlookmotel)
- c4e35b5 transformer/object_rest_spread: Pre-allocate capacity in `Vec`
(#23988) (overlookmotel)
- 527b8e5 transformer/decorators: Narrow type earlier (#23987)
(overlookmotel)

### 📚 Documentation

- 30d17f5 allocator: Clarify docs for `TakeIn::take_in_box` (#24093)
(overlookmotel)
- 675e6a8 ast: Correct doc comment for `PrivateFieldExpression` (#24008)
(overlookmotel)
- e4c30e6 minifier: Explain what `dce` mode means (#23994) (Dunqing)
- 37cbf88 ast_macros: Document fields of `StructDetails` (#23959)
(overlookmotel)
- 4de3e54 ast: Correct doc comment (#23948) (overlookmotel)

Co-authored-by: Boshen <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-minifier Area - Minifier

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants