Skip to content

bundler: add every top-level symbol before renaming nested scopes - #41062

Merged
Jarred-Sumner merged 4 commits into
mainfrom
robobun/7b5e4ce6/renamer-two-phase
Aug 31, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
robobun/7b5e4ce6/renamer-two-phase

Conversation

@robobun

@robobun robobun commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • Split the loop into two passes, the same shape as esbuild's renameSymbolsInChunk. Pass one adds every top-level symbol in the chunk to the root scope and collects the nested scopes. Pass two renames the collected nested scopes.
  • Correct because a nested rename consults the parent chain up to the root scope. The check is only sound once the root scope holds every name that stays at the top level. The pass split establishes exactly that.
  • The same deferral covers CommonJS-wrapped module scopes. Before, a local inside one closure could take the name of another module's require_* wrapper registered later (circular requires hit this).
  • Verified: 5 new tests in test/bundler/bundler_edgecase.test.ts under identifiers/, all fail on unfixed bun. Also the full test/bundler/ suite, and the @vercel/otel repro from Bundler identifier rename collides with an existing binding, miscompiling @vercel/otel #41054 now prints registered ok.

Background

Notes
  • Minimal repro (8 lines, fails on 1.4.1): top-level const n, a function with a local let n that calls n2(), and function n2 declared after it. The bundle renames the local to n2 and the call becomes n2(n2).
  • The MinifyRenamer path is slot-based and does not have this problem.
  • The hive pool bitset reset moved with the nested-scope loop. assign_names_recursive_with_number_scope returns every pool slot before it returns, so the reset stays equivalent.
  • entry_point_cjs_export_copies symbols are now added before nested renaming instead of after. Nested locals avoid those names too. Copy names themselves are unchanged: nested renames never write to the root scope.
  • Suites run: test/bundler/ in full (6234 pass). The 9 failures are debug-build-only: bytecode tests that exceed their 5s timeout under a debug ASAN build, and compile/HelloWorldWithProcessVersionsBun, which compares process.versions.bun (1.4.1-debug) against the version with -debug stripped. Both reproduce without this diff.
  • The bytecode portability snapshot moved: the two-pass renamer changes numbered renames inside the acorn, happy-dom, immutable, all.js, and libraries.js fixtures. The change is identical on every platform and profile (verified on linux x64 and windows arm64 against every CI lane's output), which is the sanctioned update case in that file's header.
  • Self-reviewed: 4 concerns raised, 4 addressed (absorb the tests and issue closures from the stale bundler: register every top-level symbol before renaming nested scopes #35663 branch, add CommonJS-wrap coverage, move the test out of bundler_regressions.test.ts, reference the duplicate Bun ESM bundler scope-hoisting bug #30269).

Fixes #41054. Fixes #14588.


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bundler/bundler_bytecode_portable.test.ts

The number renamer added a part's top-level symbols and then renamed
that part's nested scopes at once. A nested local that collides with an
earlier name got a numbered name before a later part's top-level symbol
with that exact name was registered in the root scope. The local then
shadowed the top-level binding at the call sites that reference both,
which miscompiled pre-minified packages such as @vercel/otel.

Collect the nested scopes during the top-level pass and rename them
after every top-level symbol in the chunk is added, the same two-phase
split esbuild uses in renameSymbolsInChunk.

Adds coverage for the nested-let, function-parameter, __esm-wrapped,
and CommonJS-wrapped variants.

Fixes #41054
Fixes #14588
@coderabbitai

coderabbitai Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 92776ccc-19cf-4b8f-89fa-eb835ab07efe

📥 Commits

Reviewing files that changed from the base of the PR and between 27f34fd and ce58921.

📒 Files selected for processing (1)
  • test/bundler/bundler_bytecode_portable.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.


Walkthrough

Changes

The linker now defers nested-scope symbol renaming until it registers all top-level symbols, including CommonJS export copies. Five bundler regression tests cover identifier collisions across nested parameters, ESM wrappers, local bindings, hoisted symbols, and CommonJS wrappers. Portable bytecode snapshots now match the updated generated output.

Suggested reviewers: jarred-sumner

Merge Risk: ⚪ Minimal · up to ce589

The change defers nested-scope renaming until all top-level symbols are registered, addressing a concrete symbol-collision bug. No actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The two-pass renaming change addresses both linked issues [#41054] and [#14588] by preventing nested bindings from colliding with later top-level or outer-scope bindings. The added regression tests co…
Out of Scope Changes check ✅ Passed The code changes, regression tests, and bytecode snapshot updates support the linked objectives. No unrelated changes are evident.
Title check ✅ Passed The title clearly and concisely summarizes the main change: registering all top-level symbols before renaming nested scopes.
Description check ✅ Passed The description explains the problem, root cause, fix, affected behavior, tests, suite results, and snapshot updates. It does not use the exact template headings, but it provides the required change s…
Full details: Linked Issues check

Explanation

The two-pass renaming change addresses both linked issues [#41054] and [#14588] by preventing nested bindings from colliding with later top-level or outer-scope bindings. The added regression tests cover the reported failure modes.

Full details: Description check

Explanation

The description explains the problem, root cause, fix, affected behavior, tests, suite results, and snapshot updates. It does not use the exact template headings, but it provides the required change summary and verification details.


Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. The two-phase split matches esbuild's renameSymbolsInChunk and the reasoning is sound, but since this reorders the non-minify renamer that every un-minified bundle goes through — and moves entry_point_cjs_export_copies registration to before nested renaming — a human familiar with the bundler should also take a look.

What was reviewed:

  • Traced the deferred *const Scope pointers: both the &raw const all_module_scopes[i] and part.scopes entries point into arena storage that nothing mutates between collection and deref, so the later &*scope is sound.
  • Verified the pool-reset claim in src/js_printer/renamer.rs: assign_names_recursive_with_number_scope put()s every slot it took before returning, so resetting per-scope instead of per-part (and now also after CJS module scopes, which previously had no reset) is behaviorally equivalent.
  • Checked that nested renaming never writes to r.root, so registering the CJS export copies before the nested pass only adds names for nested locals to avoid and cannot change the copies' own names.
  • The five itBundled cases cover parameter, let, single-file later-part, __esm-wrap, and CJS-wrap variants and all assert runtime stdout, not just bundle shape.
Extended reasoning...

Overview

This PR fixes a symbol-collision bug in the bundler's non-minify NumberRenamer path (src/bundler/linker_context/renameSymbolsInChunk.rs). The old code interleaved two operations per part: registering that part's top-level declared symbols into the root NumberScope, then immediately renaming the part's nested scopes. Because later parts' (and later files') top-level symbols hadn't been registered yet, a nested local could be number-suffixed into a name that a later top-level symbol keeps, shadowing it at runtime. The fix collects (source_index, *const Scope) pairs during the file/part loop and defers all assign_names_recursive_with_number_scope calls until after every top-level symbol — including entry_point_cjs_export_copies — is in the root scope. This mirrors esbuild's two-phase structure. Five new itBundled regression tests in test/bundler/bundler_edgecase.test.ts cover the reported reproductions (#14588, #30269, #41054) plus the __esm-wrap and CJS-wrap variants.

Security risks

None. This is bundler output-naming logic with no auth, crypto, network, or filesystem-write surface. The unsafe involved is dereferencing raw pointers to arena-allocated Scope nodes that were already being dereferenced in the same function; the change only moves where the deref happens.

Level of scrutiny

Moderate-to-high. The change is small (~50 net lines) and mechanically simple, but it sits on the path every non-minified bundle takes, so a regression would corrupt user output broadly. Two ordering-sensitive details deserve a maintainer glance: (1) entry_point_cjs_export_copies are now added to the root scope before nested renaming instead of after — the PR argues nested renaming never writes to the root scope so copy names are unchanged, and I confirmed assign_names_recursive_with_number_scope only reads the parent chain; (2) the hive-pool bitset reset now runs after every nested scope rather than once per part (and never for CJS). I read renamer.rs and confirmed each call put()s back every slot it get()s, so the reset is a no-op either way.

Other factors

The stored raw pointers are derived from all_module_scopes: &mut [Scope] (via &raw const) and from part.scopes (arena *mut Scope cast to *const); nothing between collection and the second loop reborrows or mutates those places, so provenance holds under Stacked Borrows. Test coverage is good: each case runs the bundle and asserts stdout (so a wrong rename fails at runtime, not just via a string match), and two also assert on bundle text as a stronger guard. No CODEOWNERS entry covers these paths. The bug hunt exited on dry_streak with no findings and no ruled-out candidates. Given the correctness-critical location and the subtle ordering changes, deferring for a human sanity check rather than auto-approving.

Comment thread src/bundler/linker_context/renameSymbolsInChunk.rs Outdated
Comment thread src/bundler/linker_context/renameSymbolsInChunk.rs Outdated
Comment thread src/bundler/linker_context/renameSymbolsInChunk.rs
Comment thread src/bundler/linker_context/renameSymbolsInChunk.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

The two-pass renamer changes numbered renames in bundles where a nested
local collides with a top-level name. The library fixtures move on every
platform at once. Verified locally: the regenerated hashes match what
every CI lane produced.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner
Jarred-Sumner merged commit 1314777 into main Aug 31, 2026
10 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/7b5e4ce6/renamer-two-phase branch August 31, 2026 21:43
Jarred-Sumner added a commit that referenced this pull request Sep 3, 2026
… reference

The number renamer numbered a binding in a nested scope whenever any
enclosing scope, including the chunk's merged top level, had a binding of
the same name. Since #41062 put every file's top-level names in place
before nested scopes are named, `const Model = factory()` next to
`() => { class Model {} }` printed `Model2`, chalk@5's default export got
`.name === "chalk2"`, and `var Foo = class Foo {}` / tsc's
`let User = class User {}` printed `Foo2` / `User2`.

The parser now records, per file, the scope of every identifier reference
(`Ast::scope_uses`, only when bundling without identifier minification).
The renamer keeps a nested binding's name unless the symbol that owns that
name in an enclosing scope is referenced inside the binding's scope, as
the printer will write it (linked imports, CommonJS namespace objects,
`ns.prop` bindings, cross-chunk imports). Reserved, unbound and
eval-pinned names are still always avoided; uses the linker adds, the
parser's generated helpers, `module`/`exports`, and class field
initializers (which decorator lowering can move into the constructor)
count conservatively. Class and function expression names are declared in
their own scope rather than at top level. Minified output is unchanged.
Jarred-Sumner added a commit that referenced this pull request Sep 4, 2026
… reference (#41286)

### What

**1. Nested bindings keep their names.** Non-minified bundles no longer
number a binding in a nested scope just because a top-level symbol in
the chunk (or another enclosing binding) has the same name. A nested
binding keeps its name unless the symbol that owns that name in an
enclosing scope is actually referenced inside the binding's scope — so
`Function#name` / `constructor.name` match unbundled code, Node and
Rollup.

```js
const factory = () => { class Model {} return Model; };
const Model = factory();
console.log(Model.name); // "Model2" before, "Model" now
```

| | before | after |
|---|---|---|
| nested `class Model` + top-level `Model` | `Model2` | `Model` |
| chalk@5 default export `.name` | `chalk2` | `chalk` |
| `var Foo = class Foo {}`, tsc's `let User = class User {}` | `Foo2`,
`User2` | `Foo`, `User` |
| `var fn = function fn() {}` | `fn2` | `fn` |
| nested binding that would capture an outer reference (`import { T as U
}` + `class T { m() { return U } }`, a local `parse` next to
`ns.parse()`, a local `import_x` next to a CommonJS default import) |
renamed | still renamed |

Follow-up to #41062 (that fix stands; #41054 stays fixed).

**2. `bun build` is faster**, mostly on large / many-module ESM inputs.
Linux x64, LTO release builds of 1.4.0, `main` (14ab660) and this
branch, hyperfine on 8 pinned cores, non-minified unless noted:

| | 1.4.0 | main | this PR |
|---|---|---|---|
| `bench/bundle` three.js ×100 (`index.ts`) | 1.83 s | 1.51 s | **1.12
s** |
| `bench/bundle` three.js ×10 (`index10.ts`) | 253 ms | 258 ms | **221
ms** |
| three.js `src/` tree ×100 (~70k modules) | 2.34 s | 2.48 s | **2.03
s** |
| three.js `src/` tree ×10 | 220 ms | 221 ms | **196 ms** |
| vite dep chunk + rollup (3 MB ESM, `--packages=external`) | 95 ms | 97
ms | **86 ms** |
| `typescript.js` as entry, `--format=cjs` | 291 ms | 304 ms | **278
ms** |
| typescript+svelte+react-dom+lodash (CJS-wrapped deps) | 298 ms | 312
ms | 300 ms |
| hono+mongodb+fastify+axios+express | 55 ms | 55 ms | 52 ms |
| three.js ×10 `--minify` (n=30) | 134 ms | 133 ms | 127 ms |
| rolldown-benchmarks `apps/10000` (10k tiny JSX files), `--production
--sourcemap` | 571 ms | 584 ms | 580 ms |
| same, non-minified | 619 ms | 665 ms | 635 ms |

macOS arm64 (`bunx bun-pr 41286` vs 1.4 release): `bench/bundle`
three.js ×100 382 → 257 ms (`--minify` 265 → 256 ms); three.js `src/`
tree ×100 999 → 845 ms (`--minify` 801 → 807 ms). `--minify` builds see
only the parser-side share (the minifier has its own renamer);
CJS-wrapped dependencies print as one closure and see mostly parity;
bundles of thousands of tiny modules are dominated by per-module
resolve/link cost and see little change.

### How

Renamer:
- The parser records `(scope, symbol)` for each identifier reference
into a flat per-file list (`Ast::scope_uses`), with scopes numbered in
visit pre-order so a subtree is a range. Only when bundling without
`--minify-identifiers`; references to unbound globals are skipped.
- `NumberRenamer` keeps the chunk's top-level names as interned ids with
one slot each (scope, count, owner). Each file's nested scopes are named
by a `NestedRenamer` overlaying that table (sparse, undo-logged per
scope) and run as parallel per-file tasks once top-level names are
final. A nested binding may take a name held further out when the holder
is not referenced in its scope subtree.
- Conservative cases: uses the linker adds (`symbol::Use` carries an
"unscoped" bit), parser-generated temporaries/helpers,
`module`/`exports`, TypeScript decorator-metadata type references, and
class field initializers/keys count as referenced across the whole file
/ class body. Class and function *expression* names are declared in
their own scope. The TS namespace closure parameter is now visible to
the renamer.

Parser / linker:
- Scope member tables start as a 4-slot inline block (hashes first,
compared as a group; names compared only on a hash match), then 8, then
a hash table — most scopes never allocate a table, and resolving an
identifier up the scope chain touches one cache line per scope.
- Per-part symbol-use counts are tracked in a dense side table and
turned into the part's map once, instead of a hash probe per identifier
reference.
- Property accesses check a small bit filter before hashing the name
against dot defines.
- `symbols`, `scopes_in_order` and the part list are sized from the
source up front; the bundler's parse workers use one mimalloc heap for
AST nodes, `AstAlloc` spills and the parser arena.
- A file's part range stops growing at `max(largest_source / (2 ×
threads), 128 KB)` of source, so a large unwrapped file prints on
several threads (ranges already join the way files do).
- `CharFreq::scan` (minifier) counts into four interleaved tables.

Minified output can differ from main in which equally-short name a
nested slot gets (scope members now iterate in declaration order, which
changes the creation order of hoisted symbols); sizes move by ±0.001%.
The bytecode-portability snapshot is updated for that and for the
non-minified naming change.

### Tests

`test/bundler/bundler_edgecase.test.ts`:
`identifiers/NestedBindingKeepsNameWhenOuterIsNotReferencedInside`
(fails on main),
`identifiers/NestedBindingRenamedWhenOuterIsReferencedInside`,
`identifiers/NestedBindingRenamedAroundLinkedAndHoistedNames`,
`identifiers/NestedBindingRenamedWhenOuterIsReferencedLater` (capture
cases that must still rename). Two existing expectations updated (`bar2`
→ `bar` in an expected runtime error, `fn2` → `fn` in a snapshot);
`bundler_bytecode_portable` snapshot updated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bundler identifier rename collides with an existing binding, miscompiling @vercel/otel Bun is breaking a file from a dependency

2 participants