Skip to content

bundler: read destructured require() of an unwrapped package from its import namespace - #39184

Open
robobun wants to merge 2 commits into
mainfrom
farm/4f3838f8/unwrap-require-destructuring
Open

robobun wants to merge 2 commits into
mainfrom
farm/4f3838f8/unwrap-require-destructuring

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build (ESM output, the default) of an entry that destructures a require() of a package in the CommonJS unwrap list reads undefined for every property when that package's module stays CommonJS (it assigns module.exports):
    const { react } = require("react"); // node_modules/react/index.js: module.exports = { react: "react" }
    bundles to
    var react = __toESM(require_react(), 1);
    var { react: react2 } = (__toESM(exports, 1));   // `exports` is the wrapper's parameter, unbound here
    Array destructuring throws TypeError: {} is not iterable instead. Same output on 1.4.0 and on main.
  • This is the shape of a real react install: react/index.js is a module.exports = require("./cjs/...") redirect that stays wrapped, so const { useState } = require("react") in a bundled entry gives useState === undefined (verified with a copy of that layout).
  • Cause: transpose_require (src/js_parser/p.rs:1193) returns an E::RequireString marker instead of the namespace identifier whenever the require() is a declaration initializer. visit_decls (src/js_parser/visit/mod.rs:361) only consumes the marker for an identifier binding; for a destructuring binding it survived to the printer. The printer's path for a surviving marker (print_require_or_import_expr with was_unwrapped_require, via LinkerContext::require_or_import_meta_for_source) printed the target's exports_ref whenever the target has FORCE_CJS_TO_ESM. That flag is set at parse time for every file in an unwrapped package (src/js_parser/p.rs:8449) whether or not the file converted, and for a file that did not convert exports_ref is the __commonJS callback's exports parameter.

Fix

  • visit_decls sets is_immediately_assigned_to_decl only when the binding is an identifier, so a destructuring initializer gets the same namespace identifier that require() of an unwrapped package becomes in every other expression position (call argument, assignment right-hand side, require(...).x, ...). The case above now bundles to var { react: react2 } = react;.
  • Why this is correct:
    • The marker exists only for the identifier-binding rewrite in visit_decls (rename the generated import * as ns to the declared name, drop the declaration). Producing it for a binding that rewrite does not handle was the defect; now it is produced exactly when it is consumed.
    • The namespace identifier is what the existing cjs2esm/UnwrappedModuleRequireAssigned test already exercises for the non-declaration positions, and it is right for both target shapes: a wrapped target prints it as __toESM(require_x(), 1), a converted target prints it as the generated namespace object (exports_react), which is what the removed path printed for a converted target.
  • Deleted, because no RequireString with unwrapped_id set reaches the printer any more (second commit, no output change): the was_unwrapped_require argument of print_require_or_import_expr and of the require_or_import_meta_for_source callback (RequireOrImportMetaCallback, RequireOrImportMetaSource, Options::require_or_import_meta_for_source), the RequireOrImportMeta.was_unwrapped_require field, the printer branch that printed meta.exports_ref for it, and the FORCE_CJS_TO_ESM special case in LinkerContext::require_or_import_meta_for_source, which now returns exports_ref only for wrap == Esm. unwrapped_id itself stays (visit_decls reads it); the printer's ERequireString arm now debug_asserts that it is unset, so the whole bundler suite under a debug build checks the invariant the deletion relies on.
  • Verified with test/bundler/bundler_cjs2esm.test.ts:
    • cjs2esm/UnwrappedModuleRequireDestructured (new): object, renamed and defaulted, nested and array destructuring, a destructuring declarator between two identifier declarators in one statement, and one inside a function body, all against packages that stay wrapped. It asserts both packages are still emitted as __commonJS( wrappers and that the destructurings read from an identifier, then runs the bundle. Fails on main on every form (each prints (__toESM(exports, 1))), passes with this change.
    • cjs2esm/UnwrappedModuleRequireDestructuredAndInTry (from js_parser: type RequireString.unwrapped_id as an optional index #39169): its comment described the removed marker path, so it is reworded, and it now also covers a defaulted property and an identifier declarator next to a destructuring one, against a package that does convert; the try/catch require() it already had covers the ordinary RequireString printing that remains. It passes before and after this change.
  • Also ran bundler_cjs2esm, bundler_cjs, bundler_edgecase, bundler_regressions, bundler_jsx, bundler_splitting, bundler_npm (the npm/ReactSSR exact file size is unchanged), esbuild/default, esbuild/splitting, esbuild/importstar, and transpiler/transpiler.test.js with the debug build: no failures. cargo clippy on bun_js_printer and bun_bundler is clean.
  • Not changed here: export const X = require("react") loses its export through the same drop-the-declaration rewrite; that is a separate defect with its own repro and is tracked separately.

Background

  • CommonJS unwrap list: when bundling to ESM, files inside react, react-dom, scheduler, react-is, react-refresh, react-client and react-server (DEFAULT_UNWRAP_COMMONJS_PACKAGES in src/bundler/options.rs) are parsed with exports.x = ... turned into ESM exports, and every require() that resolves into one of those packages (from any file) is turned into an import * as ns from "..." statement plus a reference to ns, so the package tree-shakes. The import statements are emitted from the parser's imports_to_convert_from_require list at the end of the parse.
  • Converted vs wrapped target: a file in one of those packages that only uses exports.x = ... becomes ESM and the linker gives it a namespace object (var exports_react = {}; __export(exports_react, {...})), which is its exports_ref. A file that assigns module.exports cannot be converted and is emitted as var require_react = __commonJS(function(exports, module) {...}); its exports_ref is that callback's exports parameter, which only exists inside the callback. FORCE_CJS_TO_ESM is set on both kinds of file, so it does not tell the two apart.
  • is_immediately_assigned_to_decl / E::RequireString.unwrapped_id: visit_decls passes this flag when visiting a declaration's initializer; transpose_require answers it by returning an E::RequireString whose unwrapped_id indexes the pending import, and visit_decls uses that index to rename the import's namespace to the declared identifier and remove the declaration (const React = require("react") becomes import * as React from "react"). This flag has no other reader.
  • RequireOrImportMeta: what the printer asks the linker for when printing a bundled require()/import() of another file: the file's wrapper function (require_x for CommonJS, init_x for lazily initialized ESM) and, for wrapped ESM, its namespace object, printed as (init_x(), __toCommonJS(exports_x)).

[review] gate passed · iteration 0 · 6 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ 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 (bcab5edce)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [970.45ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [492.60ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [414.55ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [516.94ms]
(pass) bundler > cjs2esm/ExportsFunction [520.00ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [561.26ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [493.23ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [911.48ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [760.75ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [632.75ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned [1147.96ms]
327 |     },
328 |     onAfterBundle: api => {
329 |       const code = api.readFile("out.js");
330 |       expect(code).toContain("var require_react = __commonJS(");
331 |       expect(code).toCon
... (truncated)

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

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [27.27ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [14.97ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [14.82ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [13.20ms]
(pass) bundler > cjs2esm/ExportsFunction [13.95ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [15.07ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [12.97ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [16.86ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [15.28ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [16.68ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned [12.58ms]
327 |     },
328 |     onAfterBundle: api => {
329 |       const code = api.readFile("out.js");
330 |       expect(code).toContain("var require_react = __commonJS(");
331 |       expect(code).toContain("var require_scheduler = __commonJS(");
332 |       expect(code).toMatch(/\{ react: between \} = \w+;/);
                         ^
error: expect(received).toMatch(expect
... (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 (bcab5edce)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [832.08ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [406.44ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [407.36ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [407.23ms]
(pass) bundler > cjs2esm/ExportsFunction [374.57ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [424.60ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [439.04ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [660.31ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [615.42ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [428.53ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned [504.46ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireDestructured [774.47ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireDestructuredAndInTry [431.22ms]
(pass) bundler > cjs2esm/ReactSpecificUnwrapping
... (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     4f39150115
  features     baseline

22 deps, 120 codegen, 1175 objects in 707ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1236] gen ErrorCode+*.h
[2/1236] install /workspace/bun
bun install v1.4.0-canary.1 (eabb96de7)

Checked 107 installs across 153 packages (no changes) [15.00ms]
[3/1236] gen bindgenv2
[4/1236] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[5/1236] gen .bind.ts → GeneratedBindings.cpp
[6/1236] fetch tinycc
[tinycc] up to date
[7/1235] fetch zlib
[zlib] up to date
[8/1235] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[9/1235] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (eabb96de7)

Checked 1 install across 2 packages (no changes) [6.00ms]
[10/1235] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (eabb96de7)

Checked 129 installs across 147 packages (no changes) [7.00ms]

... (truncated)
diff hotspot
src/ast/e.rs                         |  2 +-
 src/bundler/LinkerContext.rs         | 19 ++--------
 src/js_parser/parser.rs              |  3 +-
 src/js_parser/visit/mod.rs           |  6 +++-
 src/js_printer/lib.rs                | 54 ++++++++--------------------
 test/bundler/bundler_cjs2esm.test.ts | 70 ++++++++++++++++++++++++++++++++----
 6 files changed, 88 insertions(+), 66 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                  reads  edits  tests
src/ast/e.rs                              1      2      0
src/bundler/LinkerContext.rs              3      2      0
src/js_parser/parser.rs                   3      3      0
src/js_parser/visit/mod.rs                3      4      0
src/js_printer/lib.rs                     9     14      0
test/bundler/bundler_cjs2esm.test.ts      4      4      0

@coderabbitai

coderabbitai Bot commented Aug 15, 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: 13 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: aafecea8-b2b1-4c31-bdc7-45c050945c3d

📥 Commits

Reviewing files that changed from the base of the PR and between 8c5296a and 4f39150.

📒 Files selected for processing (6)
  • src/ast/e.rs
  • src/bundler/LinkerContext.rs
  • src/js_parser/parser.rs
  • src/js_parser/visit/mod.rs
  • src/js_printer/lib.rs
  • test/bundler/bundler_cjs2esm.test.ts

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

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:38 PM PT - Aug 15th, 2026

@robobun, your commit 4f39150 is building: #99086

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Latest push (4f39150) adds a debug assert pinning the invariant behind the deletion and positive output assertions in the new test, after a second review pass; waiting on CI for that push.

Reproduced on bun 1.4.0 and on a debug build of main with test/bundler/bundler_cjs2esm.test.ts (cjs2esm/UnwrappedModuleRequireDestructured): every destructuring form of require("react") against a module.exports = ... react module bundles to (__toESM(exports, 1)) and reads undefined (array form throws TypeError: {} is not iterable). With this branch the same test prints the expected values.

The second commit deletes the printer/linker was_unwrapped_require path the parser change makes unreachable (named in the description). bundler_cjs2esm, bundler_cjs, bundler_edgecase, bundler_regressions, bundler_jsx, bundler_splitting, bundler_npm, esbuild/default, esbuild/splitting, esbuild/importstar and the transpiler tests pass unchanged with it.

@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.

LGTM — targeted one-line fix at the right layer, with thorough tests.

What was reviewed:

  • Traced is_immediately_assigned_to_decl end-to-end: sole setter in visit_decls, sole reader in visit_expr.rs:2022 → transpose_require (p.rs:1193); the marker it produces is only consumed by the BIdentifier branch that follows, so gating the flag on that binding shape is exactly right.
  • Checked the mixed-declarator case (const before = require(...), { between } = require(...), after = require(...)) — the flag is computed per-declarator, so identifier bindings still take the rename-and-drop path.
  • Tests cover object/array/nested/defaulted destructuring, mixed declarators, function-body scope, and pin the converted-target path so both wrapped and unwrapped targets are exercised.
Extended reasoning...

Overview

The PR fixes a bundler bug where const { x } = require("react") (or any destructuring binding) of a package on the CommonJS unwrap list produced (__toESM(exports, 1)) — a reference to the wrapped module's callback parameter, unbound at the use site — instead of the namespace identifier the generated import * as ns binds. The behavioral change is one line in src/js_parser/visit/mod.rs: is_immediately_assigned_to_decl is now set only when the binding is BIdentifier. A doc comment on the ExprIn field is tightened to match, and two itBundled tests are added.

Security risks

None. This is a bundler code-transformation change with no auth, crypto, filesystem, or network surface.

Level of scrutiny

Medium. The change is a single boolean condition in a well-understood parser path, and the PR description demonstrates a complete trace of the mechanism (marker producer in transpose_require, marker consumer in visit_decls, printer fallback in LinkerContext). I confirmed via grep that the flag has exactly one setter and one consumer chain, so restricting the setter to the shape the consumer handles is provably the whole class. The identifier-binding path (const React = require("react") → rename the import namespace, drop the decl) is unchanged because matches!(.., BIdentifier(_)) is true there.

Other factors

  • Test coverage is strong: the new UnwrappedModuleRequireDestructured test covers six destructuring shapes and asserts both output (not.toMatch(/__toESM\(exports\b/)) and runtime stdout; UnwrappedModuleRequireDestructuredConverted pins the already-working converted-target path to the new code path it now shares. The mixed-declarator case (before, {between}, after in one const) verifies per-declarator flag evaluation.
  • The author reports the broader bundler suites (bundler_cjs2esm, bundler_cjs, bundler_edgecase, bundler_regressions, bundler_jsx, bundler_npm including the ReactSSR byte-size check, esbuild/default, transpiler tests) all pass unchanged.
  • No prior human or bot reviews to address; CI is building.

@robobun
robobun force-pushed the farm/4f3838f8/unwrap-require-destructuring branch from 4e8d3a6 to 6a0d169 Compare August 15, 2026 23:10
Comment thread src/js_parser/parser.rs Outdated
Comment thread src/js_parser/visit/mod.rs Outdated
@robobun
robobun force-pushed the farm/4f3838f8/unwrap-require-destructuring branch from 6a0d169 to a690bbe Compare August 15, 2026 23:14
Comment thread src/js_parser/visit/mod.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 re-reviewed after a690bbe — the two comment-length nits from my earlier pass are addressed and the bug-hunting system found nothing this run. Given this changes bundler output on the React unwrap path and removes the was_unwrapped_require printer/linker fallback, a maintainer look is still worthwhile.

What was reviewed:

  • Traced is_immediately_assigned_to_decl → transpose_require → visit_decls consumer: with the new BIdentifier gate, every ERequireString carrying unwrapped_id is consumed by the identifier-rename branch (should_unwrap_common_js_to_esm() and the prev_require_to_convert_count check both hold whenever should_unwrap_require fired), so none reaches the printer.
  • Checked the removed FORCE_CJS_TO_ESM / was_unwrapped_require branches in LinkerContext::require_or_import_meta_for_source and print_require_or_import_expr — all callers now pass the equivalent of false, so the deletions are behavior-preserving.
  • Confirmed the module.exports = require(...) reader at parse_entry.rs:1475 is unaffected (that path never had is_immediately_assigned_to_decl set).
Extended reasoning...

Overview

The PR fixes destructured require() of a package on the CommonJS unwrap list (react, react-dom, scheduler, …) when the target module stays wrapped. The core fix is one line in src/js_parser/visit/mod.rs: is_immediately_assigned_to_decl is now only set when the declaration binding is a plain identifier, so transpose_require returns the import-namespace identifier (rather than the ERequireString marker) for object/array destructuring. A second commit removes the printer/linker was_unwrapped_require path that this change makes unreachable — the callback signature in RequireOrImportMetaSource/RequireOrImportMetaCallback, the RequireOrImportMeta.was_unwrapped_require field, the FORCE_CJS_TO_ESM special case in LinkerContext::require_or_import_meta_for_source, and the meta.exports_ref printing branch. A one-line doc comment on ExprIn::is_immediately_assigned_to_decl and a new cjs2esm/UnwrappedModuleRequireDestructured bundler test (plus expanded coverage in the existing ...AndInTry test) round it out.

Security risks

None. This is bundler codegen; no untrusted input parsing, auth, crypto, or filesystem write logic is touched.

Level of scrutiny

Moderate-to-high. The core parser change is tiny and clearly correct, but the second commit deletes a fallback path across the printer↔linker callback boundary on the strength of an invariant ("no unwrapped_id reaches the printer any more"). I traced it: unwrapped_id is only set at p.rs:1203 when state.is_require_immediately_assigned_to_decl is true, which after this PR is only true for a top-level require("literal") initializer of a BIdentifier decl; that exact shape is always consumed by the continue 'outer branch in visit_decls (the two extra gates there — should_unwrap_common_js_to_esm() and prev_require_to_convert_count < len — are both implied by should_unwrap_require having fired). in_.is_immediately_assigned_to_decl does not propagate into nested sub-expression visits (p.visit_expr(arg) uses ExprIn::default()), so a nested require() cannot get the marker. The other unwrapped_id reader at parse_entry.rs:1475 is unaffected by this PR. Still, this is the React-unwrap path — the single most common bundling scenario — and the deletion touches three crates, so a maintainer confirming the invariant is worth the extra look.

Other factors

The author addressed my earlier comment-cop note in a690bbe (both new comments are one line each). Test coverage is thorough: object/renamed/defaulted/nested/array destructuring, a destructuring declarator mixed with identifier declarators in one statement, one inside a function body, and one against a package that does convert to ESM; the existing UnwrappedModuleRequireAssigned test already covers the non-declaration expression positions this fix aligns with. The PR description reports passes across bundler_cjs2esm, bundler_cjs, bundler_edgecase, bundler_regressions, bundler_jsx, bundler_splitting, bundler_npm (ReactSSR byte size unchanged), and the esbuild suites.

…ort namespace

When a require() of a package in the CommonJS unwrap list (react,
react-dom, ...) initializes a declaration, the parser returns an
E::RequireString marker so visit_decls can rename the generated import
namespace to the declared identifier and drop the declaration. Only
identifier bindings are handled there; for a destructuring binding the
marker survived to the printer, which printed the target module's own
exports ref. When that module could not be converted to ESM (it assigns
module.exports), that ref is the __commonJS wrapper's local `exports`
parameter, so the destructuring read from an unrelated `exports` binding
and every property came out undefined.

Only set is_immediately_assigned_to_decl for identifier bindings, so a
destructuring initializer gets the namespace identifier that require()
becomes in every other expression position.
With the parser only producing the unwrapped RequireString marker for
identifier bindings, which visit_decls always removes, no RequireString
with unwrapped_id set reaches the printer any more. Remove the flag the
printer threaded through RequireOrImportMeta and its callback, the
printer branch that printed the target's exports_ref for it, and the
FORCE_CJS_TO_ESM special case in
LinkerContext::require_or_import_meta_for_source. The printer now
debug-asserts that invariant where it used to read the marker.
@robobun
robobun force-pushed the farm/4f3838f8/unwrap-require-destructuring branch from a690bbe to 4f39150 Compare August 16, 2026 03:38

@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 earlier comment-length nit was addressed in a690bbe. Because the second commit removes the was_unwrapped_require printing path across the parser/printer/linker boundary based on the invariant that no E::RequireString with unwrapped_id set reaches the printer, a human look at that invariant would still be worthwhile.

What was reviewed:

  • Traced is_immediately_assigned_to_decl → transpose_require → visit_decls consumer: with the BIdentifier gate, the marker is produced exactly when the consumer handles it (continue 'outer).
  • Checked that the removed FORCE_CJS_TO_ESM branch in require_or_import_meta_for_source and the printer's meta.exports_ref fallback have no remaining producer; the new debug_assert!(e.unwrapped_id.is_none()) guards this in debug builds.
  • Confirmed the parse_entry.rs reader of unwrapped_id (the module.exports = require(...) → S::ExportStar rewrite) is unaffected — it's an expression-statement path where is_immediately_assigned_to_decl was already false.
  • New test covers object/array/nested/renamed/defaulted destructuring, mixed declarators, and function-body scope against both wrapped and converted targets.
Extended reasoning...

Overview

The PR fixes bundler output for const { x } = require("react") when react is on the CommonJS-unwrap list but the target module stays wrapped (assigns module.exports). The functional fix is one line in src/js_parser/visit/mod.rs:323 — is_immediately_assigned_to_decl is now only set for identifier bindings, so destructuring initializers get the namespace identifier instead of the E::RequireString marker. The second commit deletes the now-unreachable was_unwrapped_require plumbing across js_printer/lib.rs and bundler/LinkerContext.rs (~55 net lines removed), plus doc-comment updates in parser.rs and ast/e.rs, and adds a comprehensive test in bundler_cjs2esm.test.ts.

Security risks

None. This is bundler code-generation logic with no user-input validation, network, filesystem, or crypto surface. The change narrows when a marker is produced and removes a printing path; no new attack surface.

Level of scrutiny

High — bundler output correctness is production-critical (miscompiled require() of react affects real apps), and the dead-code deletion spans a parser→printer→linker invariant. The one-line fix itself is straightforward and well-scoped; the cross-crate deletion is what warrants a maintainer's confirmation. I traced the invariant end-to-end (transpose_require in p.rs:1193 sets unwrapped_id only when is_require_immediately_assigned_to_decl, which after this PR is only true for BIdentifier bindings, which the visit_decls consumer at mod.rs:359-372 always handles via continue 'outer), and it holds for the direct case. The new debug_assert in the printer would catch any violation in CI.

Other factors

  • CI green on a690bbe (Buildkite #98803) and the description reports the full bundler suite (bundler_cjs2esm, bundler_cjs, bundler_edgecase, bundler_regressions, bundler_jsx, bundler_splitting, bundler_npm including the byte-exact npm/ReactSSR, esbuild/{default,splitting,importstar}, transpiler tests) passes unchanged.
  • The new UnwrappedModuleRequireDestructured test covers the variant matrix per REVIEW.md (object/array/nested/renamed/defaulted destructuring, mixed declarators in one statement, function-body scope, both wrapped and converted targets) and asserts both output shape and runtime behavior.
  • My prior review (comment-cop style nit) was addressed; both flagged comments are now one line and the threads are resolved.
  • The PR description's root-cause analysis is unusually thorough and matches what I found in the code.

Jarred-Sumner pushed a commit that referenced this pull request Sep 3, 2026
…JS at link time (#41237)

### Problem
- With `--splitting`, a cross-chunk `import()` of a module that is
CommonJS at link time resolves to the bare chunk namespace `{ default:
module.exports }`. With npm react-dom 18.3.1, `(await
import("react-dom/client")).createRoot` is `undefined`. Unsplit builds
give the function.
- Cause: the skip at
`src/bundler/linker_context/scanImportsAndExports.rs:1116` drops the
`__toESM` wrap for every `import()` target with `FORCE_CJS_TO_ESM`. Such
a target can still be CommonJS at link time, with the chunk `export
default require_x()`.

### Fix
- Remove the skip. The existing cross-chunk branch then adds `.then((m)
=> __toESM(m.default))` for a CommonJS target, and nothing for an ESM
target.
- Correct because splitting is ESM output only, where `exports_kind ==
Cjs` means the chunk exports only `default: module.exports`. Since
#41231 the skip did nothing else, so ESM targets print the same.
- Verified: three new cases in `test/bundler/bundler_cjs2esm.test.ts`
fail on 1.4.1 and on main, and pass with this change.
- Self-reviewed: 3 concerns raised, 3 addressed. Most of the diff is
re-indentation. Hide whitespace to see the change.

### Background
- Lifting: in an ESM bundle, the parser turns top-level `exports.foo =
...` into ES module exports and sets `FORCE_CJS_TO_ESM`. Every file of
the unwrap list (react, react-dom, ...) gets the flag, lifted or not.
- A flagged file is CommonJS at link time when it assigns
`module.exports`, when a `require()` of it wraps it, or when the target
of its lifted `module.exports = require()` is CommonJS (#41188).
- With code splitting, each `import()` target gets its own entry point
chunk.

<details><summary>Notes</summary>

No issue reports this. It was found during work on the nearby CommonJS
lifting code.

An earlier version of this PR narrowed the skip to `exports_kind !=
Cjs`. After the rebase on #41231, the body of the skip was only
`continue`, so the narrowed skip did nothing. This version removes it.
The self-review found a wrong comment about `FORCE_CJS_TO_ESM` (now
removed with the code). It also asked for a test outside the unwrap list
and for a fuller description.

Real packages (react 18.3.1, react-dom 18.3.1, scheduler 0.23.2), entry:

```js
const { createRoot } = await import("react-dom/client");
const React = await import("react");
const Scheduler = await import("scheduler");
console.log(typeof createRoot, typeof React.useState, typeof Scheduler.unstable_scheduleCallback);
```

| build | 1.4.1 | this branch |
| --- | --- | --- |
| `--splitting`, browser or bun target, development or production |
`undefined undefined undefined` | `function function function` |
| `--splitting --minify`, production | `undefined function undefined` |
`function function function` |
| `--splitting --minify`, development | `undefined undefined undefined`
| `function function function` |
| no `--splitting` | `function function function` | `function function
function` |

`bun run` of the entry prints `function function function`.

Minimal repro in an empty directory. Same output on 1.4.1 and main:

```sh
mkdir -p node_modules/react/cjs
echo '{ "name": "react", "version": "19.0.0", "main": "index.js" }' > node_modules/react/package.json
printf "'use strict';\nif (process.env.NODE_ENV === 'production') {\n  module.exports = require('./cjs/react.production.js');\n} else {\n  module.exports = require('./cjs/react.development.js');\n}\n" > node_modules/react/index.js
printf "'use strict';\nfunction useState(i) { return [i, function () {}]; }\nexports.useState = useState;\nexports.version = '19.0.0';\n" > node_modules/react/cjs/react.production.js
cp node_modules/react/cjs/react.production.js node_modules/react/cjs/react.development.js
printf 'import React from "react";\nconst m = await import("react");\nconsole.log(m.useState(1)[0], m.default === React);\n' > entry.mjs
NODE_ENV=production bun build ./entry.mjs --splitting --target=bun --outdir=out && bun out/entry.js
```

Before: `TypeError: m.useState is not a function`. After: `1 true`. The
importer now prints `await
import("./index-<hash>.js").then((m)=>__toESM(m.default,1))`.

The three new cases, one for each way to be CommonJS at link time:
- `cjs2esm/DynamicImportSplittingOfWrappedCommonJS`: `react/index.js` is
a run-time `if` over two `module.exports = require()` calls, so it is
never lifted.
- `cjs2esm/DynamicImportSplittingOfRewrappedLiftedCommonJS`: the
`ReactSpecificUnwrappingTargetIsCommonJS` fixture from #41188. The
linker wraps `react-dom/index.js` again because `impl.js` assigns
`module.exports = function`. It prints `m.default.version`, not `typeof
m.default`, so it does not depend on #35722.
- `cjs2esm/DynamicImportSplittingOfRequiredLiftedCommonJS`: a user file
with `exports.foo = ...`, outside `node_modules`. The entry `require()`s
it and `import()`s it. Before: `foo undefined true`. The unsplit build
and `bun run` print `foo foo true`.

The `require()` side of the same shape was a regression from #41188
(#41236). #41243 fixed it on main, in the same block. This PR changes
only `import()` records.

Not changed (each reproduces with and without this change):
- `const { useState } = require("react")` throws `ReferenceError:
exports is not defined` in a bundle, split or not. #39184 fixes it.
- With `--splitting`, `import()` of a file that does `export * from
"<cjs>"` reads `undefined` for the names of the CommonJS module. This
happens for any CommonJS package.
- #41231 (merged) made the chunk of a lifted target export its namespace
as `default`. It keeps the skip for a CommonJS target, so it does not
cover this bug. This branch is rebased on it, and its tests pass here.

The unwrap list is `DEFAULT_UNWRAP_COMMONJS_PACKAGES` in
`src/bundler/options.rs`: react, react-dom, scheduler, react-is,
react-refresh, react-client, react-server.

Also checked with the debug build under `--splitting`: `module.exports =
{ ... }`, `module.exports = function`, an importer whose only `__toESM`
use is the `import()` (it gets the runtime import), and a `.js` importer
(`__toESM(m.default)` without the node-mode flag, as on the existing
path). A user file that is only `import()`ed, and a real
`module.exports` file, print the same before and after.

Suites run with the debug build on main 1d1f431 (after #41231 and
#41243), with this version of the fix: bundler_cjs2esm and
bundler_splitting (188 pass), and bundler_cjs,
bundler_dynamic_import_dce, esbuild/splitting (358 pass, 0 fail). Before
those rebases, on main e8c8d81: the same suites plus esbuild/default,
bundler_edgecase, bundler_regressions, bundler_npm,
bundler_compile_splitting, bundler_bun, bundler_browser (904 pass, 0
fail). `cargo clippy -p bun_bundler` is clean.
</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 0 · 2 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/bundler/bundler_cjs2esm.test.ts"
bun test v1.4.1 (a6c4cc2)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [808.95ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [428.20ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [378.09ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [467.65ms]
(pass) bundler > cjs2esm/ExportsFunction [371.77ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [454.02ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [429.96ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPoint [371.66ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPoint [479.66ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPointSplitting [494.23ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireTwoEntryPoints [407.11ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [706.09ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [575
... (truncated)

release without fix: all passed
bun test v1.4.1-canary.1 (b36f032)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [19.49ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [9.57ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [8.62ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [7.87ms]
(pass) bundler > cjs2esm/ExportsFunction [8.03ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [8.04ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [7.78ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPoint [8.72ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPoint [9.61ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPointSplitting [9.77ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireTwoEntryPoints [9.36ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [11.65ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [11.04ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [10.16ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned [9.16ms]
(pass) bundler > cjs2esm/UnwrappedModuleRe
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/bundler/bundler_cjs2esm.test.ts"
bun test v1.4.1 (a6c4cc2)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [835.93ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [485.38ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [375.10ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [342.95ms]
(pass) bundler > cjs2esm/ExportsFunction [429.44ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [433.97ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [347.36ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPoint [436.35ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPoint [424.28ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPointSplitting [513.08ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireTwoEntryPoints [375.98ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [674.86ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [643
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 635ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/1] reconfigure
[1/10] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 244 extern-C blocks audited
[2/10] gen cpp.rs (cppbind)
[2/10] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/wor
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
.../linker_context/scanImportsAndExports.rs        | 134 ++++++++++-----------
 test/bundler/bundler_cjs2esm.test.ts               |  95 +++++++++++++++
 2 files changed, 157 insertions(+), 72 deletions(-)
```

</details>

**gate history** · 3 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                                 reads  edits  tests
src/bundler/linker_context/scanImportsAndExports.rs      8      4     41
test/bundler/bundler_cjs2esm.test.ts                     4      3     40
```

</details>

<!-- robobun:evidence:end -->
@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

#42500 is stacked on this PR. Its first two commits are this branch on top of current main, and a third commit removes two readers of the marker that main gained since (require_namespace_ref, value_is_import_namespace), which the parser change here makes dead.

The fix in #42500 (an ES module installed as react loses all of its exports on a default import) regresses const { version } = require("react") of that ES module without the parser change from this PR. Merge this one first, or merge #42500 and close this one.

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.

1 participant