Skip to content

bundler: keep a let/var initialized by an unwrapped require() when it is rebound later - #39254

Open
robobun wants to merge 6 commits into
mainfrom
farm/ce73f36e/unwrap-require-rebound-decl
Open

robobun wants to merge 6 commits into
mainfrom
farm/ce73f36e/unwrap-require-rebound-decl

Conversation

@robobun

@robobun robobun commented Aug 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build (ESM output, the default) loses later assignments to a let/var that was initialized by a require() of a package on the CommonJS unwrap list (react, react-dom, scheduler, ...) when the required module converts to ESM (it uses exports.x = ..., not module.exports = ...). Same on 1.4.0 and main:
    let r = require("scheduler");   // node_modules/scheduler/index.js: exports.sched = "sched"
    console.log(r.sched);           // sched
    r = { sched: "replaced" };
    console.log(r.sched);           // unbundled: replaced      bundled: sched
    bundles to
    console.log($sched);
    exports_scheduler = { sched: "replaced" };
    console.log($sched);
  • Every way of rebinding the variable is affected: r = ..., r += ..., r++, [r] = ..., ({ r } = ...), for (r of ...), an assignment inside a function, an assignment that precedes the declaration (var hoisting), and a declaration that itself reaches module scope by being hoisted out of a block. Declaring the variable a second time (var r = require("react"); ... var r = {...}, or a var r in a nested block) bundles to code that throws TypeError: undefined is not an object (evaluating 'exports_react.react'): the package's namespace object is never emitted, but the redeclared variable has been renamed to it.
  • This is the shape old Rollup builds emit for a default import of React: var React = require('react'); React = React && React.hasOwnProperty('default') ? React['default'] : React;.
  • Cause: visit_decls (src/js_parser/visit/mod.rs) visits every declarator's initializer with is_immediately_assigned_to_decl; transpose_require (src/js_parser/p.rs) answers it with an E::RequireString marker, and visit_decls consumes the marker by renaming the pending import * as ns to the declared variable and deleting the declarator. From then on r.sched is rewritten into a named import of sched and r itself is the import namespace. That treats the variable as an immutable alias of the module, which holds for const and for a variable nothing else touches, but not for one the file assigns to or declares again. When visit_decls runs, the rest of the file has not been visited yet, so it cannot tell the two apart from what it has seen. (When the target stays a CommonJS wrapper the namespace happens to print as var r = __toESM(require_x(), 1), a real variable, which is why only converted targets show it.)

Fix

  • While parsing, the parser records what the file rebinds:
    • the target of every assignment and update expression (new_expr, using the same binary_assign_target / unary_assign_target classifiers as the visit pass, looking through destructuring patterns) and the head of every for (x in/of ...) (parse_stmt), as (scope it was parsed in, name) in unresolved_rebound_targets. Identifiers are not bound to symbols until the visit pass, so this is all that is known at that point;
    • every redeclaration, as the symbols involved, in rebound_refs: a second declaration in the same scope (declare_symbol, which also covers a var sharing a parameter's name, since parameters are copied into the body scope) and a nested var hoisted into a scope that already has the name (hoist_symbols).
  • When visit_decls meets the marker for a non-const declarator it calls binding_is_rebound, which first binds the recorded targets by walking each one's scope chain (hoisting has run by then, so a var declared later or inside a block is found where it ends up) and adds the symbols they hit to rebound_refs, then asks whether the declarator's own symbol is in the set. If it is, the declarator is kept and its initializer becomes the import's namespace, which is what transpose_require returns for a require() in any other expression position: var r = exports_scheduler; for a converted target, var ns = __toESM(require_x(), 1); var r = ns; for a wrapped one, with r.sched left as a property read of the variable. Otherwise the declarator takes the unchanged replace-with-import path.
  • The import-emitting loop in parse_entry.rs now debug_assert!s that no namespace symbol was assigned to. A fresh namespace never is, so this only fires if visit_decls replaced a variable that the visit pass later saw assigned, i.e. if the recording above ever misses a form of assignment (redeclaration has no symbol flag to check).
  • Why this is correct:
    • Recording happens for the whole file before anything is visited, so the answer does not depend on where the rebinding sits relative to the declaration. Binding the targets is deferred to the first question because that is the first point at which the scope tree is final; an assignment whose name resolves to nothing (an undeclared global) is dropped, since it cannot be one of this file's declarations.
    • Resolution is by symbol, so a same-named parameter, local, block binding, arrow default, or a var hoisted into some other function does not count. That matters because minified CommonJS looks exactly like that (var e=require("react") at the top, e reused as a local in every function): a first version of this change keyed the set by bare name and therefore kept all of react in such bundles; cjs2esm/UnwrappedModuleRequireNotReboundIsTreeShaken fails on that version. With this version a tsdx-shaped consumer of the real react 18.3.1 bundles byte-for-byte the same as on 1.4.0 (1093 bytes minified, no namespace object), and npm/ReactSSR (byte positions in the real react-dom 18.3.1 output, whose own var React = require('react') / var Scheduler = require('scheduler') depend on this rewrite) is unchanged.
    • const declarators skip the question: they cannot be assigned or redeclared, and this keeps the TypeScript-emitted const react_1 = require("react") shape from binding the file's targets at all.
    • The check sits where the marker is consumed rather than where the flag is passed (bundler: read destructured require() of an unwrapped package from its import namespace #39184 and bundler: keep exported and using declarations initialized by an unwrapped require() #39244 gate the flag) because binding the recorded targets costs time proportional to the file's assignments; doing it at the flag would do that work in every file with a let/var, doing it here confines it to files that actually have such a declarator. The marker does not survive either way, it is replaced by the namespace identifier before the declarator continues down the normal path.
    • Cost: in ESM bundling, one 16-byte push per identifier that is an assignment or update target (member targets such as exports.x = ... push nothing), freed when the targets are bound or when the parser is dropped; two hash inserts per redeclaration; the binding walk only in files with a non-const unwrapped require() declarator. Outside ESM bundling nothing is recorded: record_rebound_target is an inlined flag test, and in the optimized assembly the new_expr match leaves code only in the E::Binary / E::Unary constructor paths (a range check on the operator, then the flag test for assignment operators only), nothing in any other instantiation. P grows by an empty RefMap and an empty Vec.
  • Verified with test/bundler/bundler_cjs2esm.test.ts:
    • cjs2esm/UnwrappedModuleRequireRebound: the forms listed above against a converted package, plus one against a wrapped package, checked against what the entry prints unbundled. 14 of its 19 lines are wrong on the current release, including the declaration hoisted out of a block.
    • cjs2esm/UnwrappedModuleRequireRedeclared: same-scope redeclaration and a hoisted block-level var; the current release's bundle throws. Removing only the hoist_symbols hook fails exactly the second case, removing only the for-in/of hook fails exactly the for lines of the first test.
    • cjs2esm/UnwrappedModuleRequireNotReboundIsTreeShaken (passes on main, fails on the name-keyed version): a top-level var, a let inside a function and a const all still become the import (dce pins that the package's unused export is gone) while the same name is assigned as a parameter, assigned after being hoisted out of a for inside another function, redeclared over a parameter, assigned as a block-level let, and used as an arrow default.
    • Also ran bundler_cjs, bundler_edgecase, bundler_regressions, bundler_jsx, bundler_npm, esbuild/default, esbuild/importstar, esbuild/dce, esbuild/ts and transpiler/transpiler.test.js with the debug build (so with the new assertion armed): no failures.
  • Related, each fixing a different symptom of the same rewrite: bundler: read destructured require() of an unwrapped package from its import namespace #39184 (destructuring declarators), bundler: keep exported and using declarations initialized by an unwrapped require() #39244 (exported and using declarators). Both change which declarators get the marker; this one changes what happens to a marker for an identifier declarator, so the three compose. bundler: keep exported and using declarations initialized by an unwrapped require() #39244 keeps a local was_const in visit_decls, which is what this change reads.

Background

  • CommonJS unwrap list: when the output format is ESM, files inside the packages in DEFAULT_UNWRAP_COMMONJS_PACKAGES (src/bundler/options.rs) are parsed with exports.x = ... turned into ESM exports, and every require() that resolves into one of them, from any file, becomes an import * as ns from "..." statement (emitted at the end of the parse from imports_to_convert_from_require) plus a reference to ns. ns.x is then rewritten into a named import of x, so a file that only reads properties never needs the namespace object and the package tree-shakes. esbuild has no equivalent: there require() of CommonJS stays a call to the __commonJS wrapper, and the namespace machinery this reuses only ever applies to real import * as ns bindings, which cannot be assigned to (that is a bundling error).
  • Converted vs wrapped target: a required file that only assigns exports.x converts to ESM, and its namespace prints as the namespace object the linker generates for it (exports_scheduler), shared by every importer. A file that assigns module.exports stays a CommonJS wrapper, and each importer gets its own var ns = __toESM(require_x(), 1).
  • is_immediately_assigned_to_decl / E::RequireString.unwrapped_id: the handshake between visit_decls and transpose_require. The flag asks for the marker; the marker's unwrapped_id indexes the pending import; visit_decls uses it to rename the import's namespace to the declared variable and drop the declarator, which is how const React = require("react") becomes import * as React from "react".
  • Parse pass vs visit pass: the parser first builds the whole file's AST (identifiers are stored as names) while declaring each scope's symbols into Scope::members; hoist_symbols then moves nested vars up into their function or module scope; only the visit pass binds identifiers to symbols and performs rewrites such as this one, in source order. Walking Scope::parent and checking members is the same lookup the visit pass's find_symbol performs, which is why binding the recorded targets after hoisting reproduces what the visit pass will bind them to.
  • Symbol::has_been_assigned_to: a flag the visit pass sets on a symbol when it visits an assignment to it (HMR uses it to decide which exports need live bindings). It is final once the visit pass is over, which is when the import-emitting loop runs, so it can double-check the decisions made earlier.
Earlier revision of this PR

The first revision recorded bare names (rebound_names: HashMap<&[u8], ()>) and compared the declarator's name against them. That is correct but over-approximate: an assignment to a same-named variable in any scope disabled the rewrite, and since minified CommonJS reuses the same short names in every function, it kept all of react in bundles that 1.4.0 tree-shakes. The current revision records scopes alongside the names and binds them to symbols instead, and adds the tree-shaking test cases that distinguish the two.


[review] gate passed · iteration 1 · 5 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/bundler/bundler_cjs2esm.test.ts"
ninja: Entering directory `/workspace/bun/build/debug'
[1/172] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 240 extern-C blocks audited
[2/172] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 8 sinks, 96 exported symbols
Generating /workspace/bun/build/debug/codegen/JSSink.lut.h from /workspace/bun/build/debug/codegen/JSSink.lut.txt
[3/172] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 field
... (truncated)

release without fix: 2 FAILED
bun test v1.4.0-canary.1 (eabb96de7)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [21.84ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [14.49ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [12.75ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [14.63ms]
(pass) bundler > cjs2esm/ExportsFunction [14.62ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [15.61ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [14.80ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [17.71ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [15.46ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [13.94ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned [17.27ms]
runtime failed file: /tmp/bun-build-tests/bun-Hna8UT/cjs2esm/UnwrappedModuleRequireRebound/out.js
stdout output:
react
react
react
c before
react
react
react
react react
string react
number react
react
react
react
undefined k
react
key react
react
dom
n
---
expected stdout:
react
a
b
c before
react
c after
d
react e
string undefined
number undefined
h
i
j
1 k
l
key unde
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/bundler/bundler_cjs2esm.test.ts"
bun test v1.4.0 (f81643299)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [1016.25ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [577.19ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [444.94ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [413.08ms]
(pass) bundler > cjs2esm/ExportsFunction [422.65ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [472.95ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [421.11ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [610.88ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [626.87ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [504.92ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned [1031.23ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireRebound [614.39ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireRedeclared [445.64ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireNotReboundIsTr
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     5dbd732c3d
  features     baseline

22 deps, 123 codegen, 1176 objects in 961ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] install /workspace/bun
bun install v1.4.0-canary.1 (eabb96de7)

Checked 107 installs across 153 packages (no changes) [17.00ms]
[2/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (eabb96de7)

Checked 1 install across 2 packages (no changes) [3.00ms]
[3/1238] gen bindgenv2
[4/1238] gen ErrorCode+*.h
[5/1238] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (eabb96de7)

Checked 129 installs across 147 packages (no changes) [17.00ms]
[6/1238] fetch zlib
[zlib] up to date
[7/1238] gen .bind.ts → GeneratedBindings.cpp
[8/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[9/1238] fetch tinycc
[tinycc] up to date
[10/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
... (truncated)
diff hotspot
src/js_parser/p.rs                   |  77 ++++++++++++
 src/js_parser/parse/parse_entry.rs   |   4 +
 src/js_parser/parse/parse_stmt.rs    |   4 +
 src/js_parser/visit/mod.rs           |  18 ++-
 test/bundler/bundler_cjs2esm.test.ts | 223 +++++++++++++++++++++++++++++++++++
 5 files changed, 322 insertions(+), 4 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                  reads  edits  tests
src/js_parser/p.rs                       26     25      0
src/js_parser/parse/parse_entry.rs        5      3      0
src/js_parser/parse/parse_stmt.rs         1      1      0
src/js_parser/visit/mod.rs                6      5      0
test/bundler/bundler_cjs2esm.test.ts      4      7      0

… is rebound later

When bundling to ESM, visit_decls turns any identifier declaration whose
initializer is a require() of a package on the CommonJS unwrap list into
the import binding itself, so later assignments to the variable went to
the import namespace while reads of its properties had already been bound
to the package's exports. Redeclaring the variable produced a bundle that
throws.

The parser now records, during the parse pass, every name the file
assigns to (plain, compound and update assignments, destructuring
patterns, for-in/of heads) or declares more than once, and visit_decls
keeps such a declaration as a variable initialized from the import's
namespace, as require() in any other expression position already is.
Declarations that are never rebound, and const declarations, are still
replaced by the import, so the React packages bundle exactly as before.
@robobun

robobun commented Aug 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: CI green on 5dbd732 (Buildkite build 99258), ready for review.

Reproduced on 1.4.0 and main by bundling an entry that does let r = require("scheduler"); r = {...}; r.sched against a scheduler whose index.js uses exports.sched = ... (the bundle keeps printing the original export), and var r = require("react"); var r = {...} (the bundle throws TypeError: undefined is not an object (evaluating 'exports_react.react')).

Tests, all in test/bundler/bundler_cjs2esm.test.ts: cjs2esm/UnwrappedModuleRequireRebound and cjs2esm/UnwrappedModuleRequireRedeclared fail on the release build and pass with this branch; cjs2esm/UnwrappedModuleRequireNotReboundIsTreeShaken pins that declarations which are never rebound still become the import, including when the same name is assigned or redeclared in other scopes, as minified code does.

Rework: the first revision matched rebindings by bare name, which would have stopped minified consumers (var e=require("react") with e reused as a local elsewhere) from tree-shaking react; the current revision resolves the recorded assignments through the scope tree instead, so those bundle exactly as on 1.4.0. Details in the description.

@coderabbitai

coderabbitai Bot commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4cc7b565-8716-4218-b259-0fb1814f6e15

📥 Commits

Reviewing files that changed from the base of the PR and between 22494bc and 5dbd732.

📒 Files selected for processing (5)
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • src/js_parser/parse/parse_stmt.rs
  • src/js_parser/visit/mod.rs
  • test/bundler/bundler_cjs2esm.test.ts

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

@alii

alii commented Aug 16, 2026

Copy link
Copy Markdown
Member

@robobun why doesnt esbuild have this problem?

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Because esbuild never performs the rewrite this bug lives in. When esbuild bundles CommonJS, require("react") stays a call to the __commonJS wrapper (require_react()), so var React = require("react") remains an ordinary variable holding that call's result, and assigning to it later works like any other variable. esbuild has no concept of turning a require() into an import.

That rewrite is Bun-specific: for the packages in DEFAULT_UNWRAP_COMMONJS_PACKAGES (src/bundler/options.rs, the React family), an ESM build turns require("react") into import * as ns from "react", and when the require() initializes a declaration it makes the declared variable the import namespace itself, so that React.useState becomes a named import and React tree-shakes (06dab24, then #3292). From there it reuses the machinery esbuild applies to real import * as ns statements (importItemsForNamespace; our maybe_rewrite_property_access is a port of it). In esbuild that machinery is sound because an import binding cannot be rebound: assigning to one is a hard error when bundling (Cannot assign to import "x", which we ported as well, in handle_identifier). That check keys off the symbol being an import, and a var initialized by require() is a plain variable symbol, so it never fires here; and since the input is valid CommonJS, rejecting the assignment the way esbuild rejects it for an import is not an option either. So the fix has to be to find out up front whether the variable is ever rebound and only alias it to the import when it is not, which is what this PR does.

@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 and found no bugs. Because it hooks the hot new_expr path (with a monomorphization claim about the added match compiling away), adds name-keyed over-approximation state to the parser, and changes when the cjs2esm require-unwrap tree-shaking optimization is disabled, a maintainer look at the design trade-off would be worthwhile.

What was reviewed:

  • record_rebound_target handles identifiers, arrays, objects, and spreads; member targets fall through to _ and are not hashed.
  • The visit_decls is_rebound branch replaces the marker with deferred.namespace.ref_ and falls through (no continue 'outer), so visit_decl still runs on the kept declarator.
  • record_redeclared_name in declare_symbol fires on entry.found_existing in the current scope only, so a same-named let in an inner scope does not count (verified by the tree-shaking test's let top = "shadow"); the hoist_symbols hook covers hoisted var collisions.
  • All recording is gated on should_unwrap_common_js_to_esm(), so the map stays empty outside ESM bundling.
Extended reasoning...

Overview

This PR fixes a bundler correctness bug where let/var x = require("react") (for packages on the CommonJS unwrap list) is rewritten into an import namespace binding even when x is later reassigned, causing the reassignment to be lost or the bundle to throw. It adds a rebound_names: HashMap<&[u8], ()> to P, populated during the parse pass from four sites: assignment/update targets in new_expr, for-in/of loop heads in parse_stmt, same-scope redeclarations in declare_symbol, and hoisted-var collisions in hoist_symbols. visit_decls then consults it: for a non-const declarator whose name is in the set, it keeps the declarator and initializes it from the deferred import's namespace instead of replacing the binding with the import.

Three new itBundled tests cover 18 rebinding forms, redeclaration (same-scope and hoisted), and a negative test pinning that non-rebound var/let/const still become the import and tree-shake.

Security risks

None. This is bundler output-shape logic; no untrusted input parsing beyond what the JS parser already does, no filesystem/network/auth surface.

Level of scrutiny

High. new_expr is called for every expression built during parsing across the entire bundler, and the PR relies on a compiler-optimization claim ("T fixes the variant at each inlined call site, so this compiles to nothing except where a binary or unary expression is built") for its cost model. That claim is plausible given #[inline] on new_expr and the generic T: IntoExprData, but it depends on Expr::init inlining and LLVM propagating the discriminant — not verified here, and definitely not true in debug builds. The name-based over-approximation is deliberately conservative (assignment to a same-named variable in any scope disables the rewrite for every let/var of that name), which the description argues only costs tree-shaking, never correctness; that trade-off, and whether the declare_symbol/hoist_symbols hooks over-fire in practice (TS overloads, function redeclarations, catch bindings), is a design call for a maintainer.

Other factors

  • The description is unusually thorough and cites the npm/ReactSSR byte-position test as unchanged, plus eight other bundler suites passing.
  • The three new tests exercise each recording site independently (the description states removing only the hoist_symbols or for-in/of hook fails exactly the corresponding case).
  • HashMap<&[u8], ()> with .contains(&name) matches the existing bun_collections::HashMap::contains signature; load_name_from_ref returns &'a [u8] so the lifetime works.
  • The change composes with #39184 and #39244 per the description; was_const is already threaded into visit_decls from #39244.

Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/visit/mod.rs Outdated
@robobun

robobun commented Aug 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:04 AM PT - Aug 16th, 2026

@robobun, your commit 5dbd732 is building: #99258

Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated

@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 and didn't find any bugs. Because it hooks new_expr (every expression construction) and declare_symbol/hoist_symbols, and encodes a design choice (name-keyed over-approximation rather than symbol-keyed tracking), a human sign-off on the approach and the hot-path cost would still be worthwhile.

Checked: the is_rebound fallback in visit_decls reuses the namespace_ref that transpose_require already record_usage'd and inserted into import_items_for_namespace, matching the non-decl expression path — no missing/extra usage count. The !was_const guard means a same-named assignment in another scope cannot disable the rewrite for a const (pinned by NotReboundIsTreeShaken). The for-in/of hook only fires on the expression-init arm, so for (let x of ...) isn't recorded (correct — that's a fresh binding).

Extended reasoning...

Overview

The PR fixes a bundler correctness bug where a let/var initialized by require() of a package on the CommonJS-unwrap list is aliased to the import namespace even when the file later reassigns or redeclares it, so subsequent reads keep seeing the original module instead of the new value (and redeclaration produces a bundle that throws). It adds a rebound_names set on P, populated during the parse pass from four recording sites: the EBinary/EUnary arm of new_expr (via binary_assign_target/unary_assign_target), the expression-init branch of for (x in/of ...) in t_for, the found_existing branch of declare_symbol, and the ancestor-scope collision branch of hoist_symbols. visit_decls consults the set for non-const declarators carrying the unwrapped_id marker and, if the name is present, keeps the declarator with the deferred namespace ref as its initializer instead of dropping it and renaming the pending import.

Security risks

None. This is bundler output-shape logic; no untrusted input parsing beyond what the JS parser already does, no auth/crypto/fs/network.

Level of scrutiny

High. new_expr is called for every expression the parser and visitor construct, so any per-call cost here is multiplied across every file bundled or transpiled. The PR description reports an assembly-level check that the added match compiles to a range check on the op enum inside the EBinary/EUnary constructor sites and nothing elsewhere, gated by the should_unwrap_common_js_to_esm() flag before any hashing — plausible given the code shape, but I can't verify the emitted assembly. declare_symbol and hoist_symbols are also on every declaration; the added call there is a single flag test in the common (unwrap-off) case. The name-keyed over-approximation is a conscious design trade-off (parse pass hasn't bound refs to symbols yet); it only ever falls back to a correct-but-less-tree-shakable output, and the PR pins that the important var React = require('react') shape inside the React packages themselves is unaffected. That trade-off, and whether the hot-path cost is acceptable, is the kind of call a maintainer should confirm.

Other factors

Test coverage is thorough: UnwrappedModuleRequireRebound exercises plain/compound assignment, pre/post-increment, array/object/rest destructuring targets, for-in/for-of, an assignment inside a nested function, an assignment before the hoisted var, and a wrapped (module.exports) target as a control; UnwrappedModuleRequireRedeclared covers same-scope and hoisted-block redeclaration; UnwrappedModuleRequireNotReboundIsTreeShaken uses dce: true to pin that non-rebound var/let and a const whose name is assigned in another scope still take the alias path. The comment-cop bot flagged long comments in earlier commits; those threads are all resolved and the current diff has one-line comments. alii asked a clarifying question that was answered; no explicit approval or change request from them yet.

…name

Keying the rebound set by bare name disabled the require() unwrap for any
file that assigns to a same-named variable in another scope, which is the
normal shape of minified CommonJS (var e = require("react") at the top,
e reused as a local everywhere else) and kept all of react in such
bundles. Record assignment targets as (scope, name) while parsing and
bind them against the scope tree the first time an unwrapped require()
binding asks, after hoisting; record redeclarations as the symbols
involved. A debug assertion checks that a variable turned into its
import was never assigned to.
Comment thread src/js_parser/p.rs Outdated

@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. Because it adds new cross-pass state to the parser (unresolved_rebound_targets / rebound_refs) and hooks into new_expr, declare_symbol, and hoist_symbols, a human look at the design would still be worthwhile.

Checked that the rebound path in visit_decls reuses the namespace ref whose usage transpose_require already recorded, so use counts stay balanced.
Checked that declare_symbol's MR::Forbidden arm returns before record_redeclared, so genuine redeclaration errors aren't recorded.
Checked that hoist_symbols and declare_symbol over-recording (e.g. private get/set pairs, hoist-into-let collisions) only costs an optimization, never correctness.
The debug_assert on has_been_assigned_to for a user-written const x = require(...); x = 1 was examined and ruled out.

Extended reasoning...

Overview

This PR fixes a bundler correctness bug in the CommonJS-unwrap path: when let/var x = require("react") is later reassigned or redeclared, the current bundler still aliases x to the import namespace and rewrites x.prop to named imports, so the reassignment is lost (or the bundle throws on redeclaration). The fix records, during the parse pass, every assignment/update target and every redeclaration, then in visit_decls resolves those against the scope tree and keeps the declarator as a real variable (initialized from the namespace) when its symbol is in the rebound set. Changes span p.rs (new rebound_refs / unresolved_rebound_targets fields, record_rebound_target / record_redeclared / binding_is_rebound, hooks in new_expr / declare_symbol / hoist_symbols), visit/mod.rs (the is_rebound branch), parse_stmt.rs (for-in/of head), parse_entry.rs (a debug assertion), and three new bundler tests.

Security risks

None. This is bundler AST-transform logic with no I/O, auth, or untrusted-input parsing beyond what the parser already handles.

Level of scrutiny

High. new_expr is called for every constructed expression in every parsed file; declare_symbol and hoist_symbols are core to scope construction. The recording is gated on should_unwrap_common_js_to_esm() (an inlined flag test), and the PR description argues the compiled code leaves work only in the assignment-operator arms of EBinary/EUnary, but the placement of a persistent match inside new_expr and the choice to defer scope resolution to first use are design decisions a maintainer should confirm. The first revision of this PR was already reworked once (name-keyed → scope-resolved) after it regressed react tree-shaking, which underlines that the interaction with minified consumers is subtle.

Other factors

  • Tests are thorough: a positive test covering 15+ rebinding forms (assignment, compound, ++/--, array/object/spread destructuring, for-in/of, hoisted-var-in-block, cross-function), a redeclaration test, and a negative test pinning that same-named locals/parameters/block-lets in other scopes do not disable the rewrite (dce: true verifies tree-shaking still works). The evidence gate confirms the new tests fail on release main and pass on the branch, and the wider bundler suites were run under the debug assertion.
  • I traced the is_rebound branch: transpose_require already calls record_usage(namespace_ref) and inserts namespace_ref into import_items_for_namespace before returning the marker, so replacing the marker with EIdentifier(namespace_ref) here yields a correctly-counted reference, and property reads of the user variable stay as property reads (its ref is not in import_items_for_namespace).
  • record_redeclared fires broadly (private get/set pairs, function overloads, hoist collisions) but is gated on the unwrap flag and only over-approximates "rebound", which can only skip an optimization, not miscompile.
  • The comment-cop bot flagged long comments six times; all were resolved by the author trimming to one-liners in follow-up commits.
  • No CODEOWNERS-gated paths appear to be involved beyond normal parser ownership.

This branch has not been deployed

No deployments
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.

2 participants