Conversation
Scope hoisting rewrites module-scope const/let to var so the declaration can be split from its initializer inside an __esm/__commonJS wrapper. For --format=esm the chunk has no wrapper, so those vars sit at the true top level. Loaded as a module that is fine, but loaded as a classic <script> a top-level var becomes a globalThis property, and when the name collides with a host global the bundle silently overwrites it. Chart.js ships 'const getComputedStyle = el => el.ownerDocument.defaultView.getComputedStyle(el, null)' which, once hoisted to 'var getComputedStyle', overwrites window.getComputedStyle and recurses into itself through defaultView. Reserve any declared module-scope name that appears in the pure-global identifier table so the number renamer suffixes the binding (e.g. getComputedStyle2). Unbound references to the real global keep the name. Scoped to format=esm; iife/cjs outputs are wrapped and unaffected. Fixes #14110
WalkthroughChangesESM global reservation
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
I didn't find implementation bugs, but this is a behavioral change to default bundler output that a maintainer should sign off on: every ESM bundle with a module-level binding matching one of the ~640 names in defines_table (export class Response, export const fetch, export function alert, const Map = …, etc.) will now have that binding suffixed. It also intentionally diverges from esbuild's ESM behavior. Whether that trade-off is right — vs. documenting that --format=esm output must be loaded as a module, or gating this behind a flag — is a design call.
What was reviewed:
- The reserved-name loop mirrors
compute_reserved_names_for_scope's member walk with the inverted kind predicate, andreserved_namesfeeds both the number and minify renamers. is_pure_global_identifieris a thin wrapper over the existing generated lookup; thepubvisibility is needed for the cross-crate call frombun_bundler.- Searched bundler snapshots for top-level
var <global>collisions that this would have flipped — none found.
Extended reasoning...
Overview
The PR reserves any declared top-level name in an ESM chunk that appears in the parser's ~640-entry pure-global table, so the number/minify renamer suffixes the binding (var getComputedStyle2). It adds is_pure_global_identifier as a public wrapper in js_parser::defines_table and three itBundled cases covering the Chart.js repro, an unbound-reference control, and an iife-format control.
Design trade-off
The failing scenario is loading --format=esm output as a classic <script>. That's already only partially supported — an ESM bundle whose entry has exports emits top-level export {…}, which is a syntax error in a classic script — so the fix targets a narrow use case (export-less ESM bundles loaded as classic scripts). The change it makes is broad: any module-level binding named Response, fetch, Map, Error, URL, document, etc. is now renamed in every ESM bundle regardless of how it's loaded. The PR description notes esbuild has the same behavior and does not rename; this deliberately diverges. That's a reasonable choice, but it's a default-output design decision that should get a maintainer's eyes rather than a bot approval.
Level of scrutiny
Bundler renamer output is user-visible and covered by many snapshot tests; a change here touches every ESM build. I grepped bundler .snap files and test/bundler for top-level var <known-global> patterns and found none, so existing snapshots likely don't flip, but the blast radius still warrants human review.
Other factors
Implementation looks correct: the new loop reads scope.members the same way compute_reserved_names_for_scope does, guards on kind != Unbound && !must_not_be_renamed(), and .put(.., 1) is idempotent with the existing reservation for unbound refs of the same name (so the typeof fetch control case works). reserved_names is consumed by both MinifyRenamer::init and NumberRenamer::init, so the fix applies with and without --minify-identifiers. No memory-safety or SB concerns beyond what the surrounding code already documents. No prior review comments on the PR.
|
Updated 12:14 PM PT - Jul 25th, 2026
✅ @robobun, your commit 8c233459cbf7937bd1ffb81dd387dc2453d97d2f passed in 🧪 To try this PR locally: bunx bun-pr 35617That installs a local version of the PR into your bun-35617 --bun |
Address review: gate on target==Browser (Bun/Node load ESM in module scope so the hazard is impossible), skip WrapKind::Cjs modules (their declarations stay inside the __commonJS closure), and restrict the predicate to Hoisted/HoistedFunction/GeneratorOrAsyncFunction/Constant/ Other. Class declarations and import bindings stay lexical at the chunk top level and cannot clobber globalThis in a classic script, so excluding them preserves .name and the external-import alias.
There was a problem hiding this comment.
Prior inline feedback (add TsEnum/TsNamespace to the matches!, trim the bug-history comment) was addressed in 8c23345; no new issues found this pass. Deferring to a maintainer because this is a deliberate bundler-output policy change — it diverges from esbuild by proactively renaming any browser-ESM top-level var/function/const/let whose name is in the ~600-entry pure-global table, which changes .name for those bindings and is a call someone should sign off on.
What was reviewed:
- Reserved-name loop placement (runs before both
MinifyRenamerandNumberRenamerinit, so both paths honor it) and theWrapKind::Cjsskip. - Symbol-kind gate against the lowering paths that emit chunk-top-level
var; the remainingClass-in-WrapKind::Esmgap is acknowledged with a stated trade-off. - Test coverage pins the negative contract:
target=bun|node,format=iife, CJS-wrapped modules,class, and externalimportaliases keep their original names.
Extended reasoning...
Overview
The PR adds a pass in rename_symbols_in_chunk that, for --format=esm --target=browser chunks, walks each non-CJS-wrapped module scope and reserves any declared top-level name that appears in the parser's pure-global identifier table (defines_table::is_pure_global_identifier). The number/minify renamer then suffixes the binding so a scope-hoisted var getComputedStyle becomes var getComputedStyle2, preventing it from clobbering window.getComputedStyle when the ESM output is loaded via a classic <script> tag. A one-line is_pure_global_identifier wrapper is added in defines_table.rs, and six itBundled cases are added covering the fix and its gates.
Security risks
None. This is bundler symbol-renaming logic operating on already-parsed ASTs; no new I/O, no untrusted-input parsing, no privilege boundaries.
Level of scrutiny
Medium-high. The mechanism is small and localized, but it changes emitted output for the default --target=browser --format=esm configuration whenever a module-scope binding shares a name with any of the several hundred globals in the pure-global table (Map, URL, fetch, Response, DOM/CSSOM constructors, etc.). That's an intentional divergence from esbuild, which the PR description calls out. The gating (browser-only, ESM-only, VarScoped-kind-only, skip WrapKind::Cjs) is careful and well-tested, but whether this is the right layer for the fix versus, say, recommending --format=iife for classic-script consumers, is a product/design call.
Other factors
- I previously flagged two issues inline; both are resolved (
TsEnum | TsNamespaceadded to thematches!; the Chart.js/#14110 bug-history parenthetical removed and the comment compressed to one line). The author leftClassout of thematches!with an explicit trade-off argument — including it would rename movableclass Response {}inWrapKind::Nonemodules and break.constructor.name, which the Scoping test now pins. - The reserved-names loop runs before both renamer constructors, so
--minify-identifierspicks up the reservation too (verified by readingMinifyRenamer::init— it takesreserved_namesby value at that point). - Tests exercise the runtime failure via indirect
eval(matching classic-script scope), assert the property-access.defaultView.getComputedStyleis untouched, and pin thattarget=bun|nodeandformat=iifeoutput is byte-identical to before. The negative-contract coverage is solid. - One user-visible side effect worth a maintainer's eye:
export function URL() {}in a browser-ESM bundle now has.name === "URL2". That's inherent to the approach and probably fine, but it's the kind of thing a human should ratify.
Repro
Loaded via
<script src="out.js">(classic script, notype="module"),var getComputedStyleat the top level becomes a property ofwindow,so
defaultView.getComputedStyleresolves back to the var and thefunction recurses into itself:
Chart.js ships exactly this helper; bundling it with Bun's default
--format=esmand loading the result in a classic script tag breaks atruntime. The same output works when loaded as a module, and esbuild
--format=esmproduces the samevarand fails the same way (esbuildonly avoids it by defaulting to iife).
Cause
select_local_kindin the parser rewrites module-scopeconst/lettovarduring bundling so the declaration can be hoisted out of an__esm/__commonJSwrapper. In ESM format the chunk has no wrapper, sothe
varsits at the true top level.The renamer only reserves names for unbound identifier references. Since
.defaultView.getComputedStyleis a property access and no baregetComputedStylereference appears anywhere in the bundle, the name isnever reserved and the declared symbol keeps it.
Fix
While building the reserved-name set, walk each module scope once more
and reserve any declared top-level name that is in the pure-global
identifier table (
defines_table). The number renamer then suffixes thebinding (
var getComputedStyle2) and all references follow; the.defaultView.getComputedStyleproperty access is untouched, so theoutput works whether loaded as a module or a classic script.
Scope is kept tight so nothing changes for code that cannot hit the
hazard:
--format=esm --target=browser(Bun/Node load ESM in modulescope, where
varnever reachesglobalThis, so--target=bun|nodeand
--compileare untouched);WrapKind::Cjsmodules are skipped because their declarations stayinside the
__commonJSclosure;(
var/function/async function/function*) are considered;classandimportbindings stay lexical at the chunk top level andcannot clobber
globalThis, soclass Response {}keeps its.nameand
import { Response } from "pkg"keeps its alias.Verification
edgecase/EsmTopLevelVarShadowsHostGlobalbundles the Chart.js-shapedhelper and evaluates the output via indirect
eval(classic-scriptscope). On
mainit overflows; with the fix it calls the hostgetComputedStyleonce.EsmTopLevelVarShadowsHostGlobalScopingpins the gates:class Response {}keepsclass Response, an externalimport { Response }keeps the bare alias, and a CJS-wrapped module keeps
{ URL, fetch }shorthand.
EsmTopLevelVarShadowsHostGlobalTarget_{bun,node}confirmthat
--target=bun|nodeleavevar getComputedStyle/var nameunchanged, and
IifeTopLevelVarShadowsHostGlobalconfirms iife outputkeeps the name (the IIFE already scopes it).
Fixes #14110
no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bundler_edgecase.test.ts