Skip to content

bundler: read the import namespace for a destructured require() of an unwrapped package - #46072

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/b309f053/unwrap-require-destructured-pattern
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/b309f053/unwrap-require-destructured-pattern

Conversation

@robobun

@robobun robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • In an ESM bundle, const { useState } = require("react") throws ReferenceError: exports is not defined when the required file keeps its CommonJS wrapper, and ReferenceError: __require is not defined when react is external.
  • using x = require("react") loses its declaration. An export of a TypeScript namespace is undefined.
  • Cause: visit_decls (src/js_parser/visit/mod.rs) consumes the marker of transpose_require only for an identifier. The printer prints a marker that is left.

Fix

  • visit_decls replaces the marker with the namespace identifier for a pattern, using and a namespace export. Every other position already has that value.
  • The marker no longer leaves visit_decls. Its printer path, linker flag and four checks are deleted.
  • Verified: test/bundler/bundler_cjs2esm.test.ts (17 new tests, 16 failures on main) and all 107 bundler test files.
  • Self-reviewed: 35 concerns raised, 34 addressed (Notes).

Background

Downsides

  • using x = require("react") throws a TypeError, as Node does. Main deleted the declaration.
  • A pattern over an external package reads the import namespace, not module.exports: default is the module object.
  • With --react-compiler, a component that holds such a pattern is not compiled. Main compiled it.
Notes

No GitHub issue exists for this. The shapes fail on 1.4.2 and on main. Two tools print the pattern: React Compiler 1.0 starts a CommonJS file that it compiles with const { c: _c } = require("react/compiler-runtime"), and the CommonJS i18n entry of react-aria uses it. I found no report from a user of either.

esbuild does not have this bug because it does not unwrap: a require() of a CommonJS package stays a call of its wrapper (require_react()), so a pattern reads module.exports. The unwrap list and the fold are Bun's.

Repros (bun build entry.js --outfile out.mjs, then node out.mjs)

Entry Flags main this PR
const { useState, version } = require("react") (React 18.3.1 or 19.3.1) none var { useState, version } = __toESM((exports)); then ReferenceError: exports is not defined runs
the same --external react, or --target=bun --packages=external var { useState, version } = __require("react"); then ReferenceError: __require is not defined runs
the same, in a file that assigns exports and that another file loads with require() none the names are undefined: the bare exports is the object of that file's own wrapper runs
the same --production, React 19.3.1 runs runs
const { unstable_now } = require("scheduler"), const { createRoot } = require("react-dom/client") (18.3.1) --production throws at load runs
const { renderToString } = require("react-dom/server") --target=bun --packages=external ReferenceError: __require is not defined runs
{ using x = require("react"); } none prints nothing: the declaration is deleted TypeError: Object not disposable
namespace NS { export const N = require("react") } in a .ts file any NS.N is undefined NS.N is the namespace

Main fails when the file that the require() resolves to keeps its __commonJS wrapper in that build (the marker prints that file's exports, which is not in scope), or when the package is external. Flags matter only through that: React 19.3.1 with --production loses the wrapper and runs on main.

The change. transpose_require (src/js_parser/p.rs) returns E::RequireString with unwrapped_id for a require() that is the whole initializer of a declarator. visit_decls then makes an identifier binding the namespace of the import and drops the declarator. For any other binding the marker stayed in the AST. Now the same block replaces it with E::Identifier of the namespace when the binding is a pattern, when the declaration is using or await using, and when it is an export inside a TypeScript namespace. The block runs only for a declarator whose initializer was unwrapped.

React Compiler. The namespace is a symbol that transpose_require mints. It is in no scope. The compiler declares each symbol that is not a binding of the module again, as a local of the function that it compiles. So visit_decls sets react_compiler_must_keep_body when it replaces a marker inside a function that is a candidate, and that function is not handed to the compiler. Without the flag, const has = () => { const { version } = require("react"); return !!version; }; if (has()) ... in a component compiled to a read of an undeclared react and threw a ReferenceError, where main ran. The test has that shape, a .map callback, an inner function declaration and a module variable with the name of the namespace, for the browser and the bun target, with and without minifyIdentifiers.

What stays as on main

Downsides in numbers

  • External package: React 19.3.1, entry const fs = require("fs"); const { default: d, useState, ...rest } = require("react"), --target=node --external react. Unbundled, and in the bundle of main (= __require("react")), d is undefined and rest has 45 keys. In the bundle of this PR (= react, the import namespace), d is the module object under Node and under Bun, and under Node rest also has module.exports. Names that Node cannot detect in a CommonJS file are undefined under Node: a stub with module.exports = api.default || api (the layout of react up to 16.12) prints function function 16.12.0-stub with main and function undefined undefined with this PR. Main's __require was defined only when another run-time require() was in the bundle. Without one main throws. const R = require("react") already reads the import namespace on main.
  • React Compiler: a component with const { version } = require("react") in its body, or in a function inside it, ran on main when the package converts to ES exports in that build (React 19.3.1 with --production), and the component was compiled. Here it runs and is not compiled, so it is not memoized. When the package keeps its wrapper (a development build), main printed __toESM((exports)) in the compiled body. A pattern at the top level of the file is not affected.

Deleted, with their last callers

  • was_unwrapped_require in js_printer (RequireOrImportMeta, print_require_or_import_expr, the callback type and its thunk) and in LinkerContext::require_or_import_meta_for_source.
  • The checks for a marker in conditional_namespace_records, require_namespace_ref, the split_require block of visit_decls, and value_is_import_namespace of the linker. A marker never reaches them now.
  • bun_core::GenericIndexOptional::is_some and is_none: the printer was the last caller.
  • E::RequireString::unwrapped_id stays. It carries the index from transpose_require to visit_decls.

Weighed

Measurements. Release builds (linux x64) of main ad4089b and of this branch.

  • Real React: 112 builds (react and react-dom 18.3.1 and 19.3.1, 7 entries, NODE_ENV development and production, plain and --minify, with and without --sourcemap=linked, --target=node). 96 are byte-identical to main. 16 are the entry with const { useState, createElement, version } = require("react"): main's bundle throws a ReferenceError in 12 of them. All 112 bundles of this PR run.
  • Byte-identical to main: require("@tanstack/react-query") --production (68,315 B), require("@reduxjs/toolkit/query/react") --target=node --production (118,359 B), three.js (334,189 B), a React Compiler component (225,557 B), and each probe of a plain x = require("react").
  • Instructions (user space, perf_event_open, minimum of 9 runs), main to PR. One file of 200,000 items in 2,000 functions: declarations 1,135.64M to 1,136.54M (+0.08%, +0.06% in two more runs), calls 808.72M to 808.34M, member assignments 953.14M to 952.70M, expression statements 646.08M to 646.45M (+0.06%). Two builds of one commit differ by up to 0.06% on these files. Bundles of real packages (three.js, react-dom client and server, Redux Toolkit with react-query) differ by -0.73% to +0.17%, where two runs of one binary differ by up to 0.9%.
  • Release binary text: 106,771,236 to 106,773,284 B (size, +2,048 B).
  • valgrind, perf and bloaty are not installed in the container I used, so there is no per-crate size split.

Relation to other open PRs

Suites run. Release builds of main and of this PR, linux x64: all 107 test files under test/bundler. This PR has no failure that main does not have: 2 tests of bundler_bytecode_portable fail on both in the container I used. Main fails 16 more: 15 of the 17 new tests and cjs2esm/UnwrappedModuleRequireDestructuredAndInTry, whose assertion on the printed pattern is exact now (main prints (exports_react) there). 2 new tests pass on main as guards. Debug build, with assertions: bundler_cjs2esm (130 pass).

Not taken from the self-review: a second PR for the using and TypeScript namespace cases. They are two operands of the same condition, and each has a test. The numbers for an external package under Node are from runs by hand: the bundler tests run their output in Bun, which reads every key of module.exports.


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

fails on main (without fix)
ASAN without fix: 16 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"
rustup spent 10s installing the pinned toolchain (nightly-2026-09-15); it was missing or incomplete on this machine
bun test v1.4.4 (367d939d9)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [1647.66ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [635.59ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [628.34ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [444.70ms]
(pass) bundler > cjs2esm/ExportsFunction [508.93ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [410.60ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [541.97ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPoint [614.40ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPoint [657.01ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPointSplitting [705.62ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireTwoEntryPoints [580.56ms]
(pass) bundler > cjs2esm/
... (truncated)

release without fix: 1 FAILED
bun test v1.4.4-canary.1 (e495b704e)

test/bundler/bundler_cjs2esm.test.ts:
(fail) bundler > cjs2esm/ModuleExportsFunction [5004.02ms]
  ^ this test timed out after 5000ms.
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [460.69ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [39.88ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [39.94ms]
(pass) bundler > cjs2esm/ExportsFunction [37.86ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [29.86ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [32.57ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPoint [37.40ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPoint [34.25ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPointSplitting [42.23ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireTwoEntryPoints [39.43ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [37.54ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [38.09ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [30.09ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned 
... (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/pr_gate.xml" "test/bundler/bundler_cjs2esm.test.ts"
bun test v1.4.4 (367d939d9)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [1147.43ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [631.84ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [509.69ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [546.70ms]
(pass) bundler > cjs2esm/ExportsFunction [576.22ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [574.12ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [586.79ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPoint [836.93ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPoint [783.80ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPointSplitting [742.00ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireTwoEntryPoints [826.62ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [1054.04ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [1
... (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     e495b704eb
  features     lto, baseline

23 deps, 204 codegen, 1207 objects in 7846ms

ninja: Entering directory `/workspace/bun/build/release'
[1/4] fetch rust-argon2
[rust-argon2] up to date
[2/4] fetch lolhtml
[lolhtml] up to date
[2/4] cargo plan → /workspace/bun/build/release/rust-target/plan.json
268 units: 194 lib, 16 proc-macro (host), 20 custom-build (host), 16 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[1/1558] rustc unicode_ident 
[2/1558] gen generated_host_exports.rs
generated_host_exports.rs: 114 exports (host=5, lazy=4, generic=105, rust=0); 247 extern-C blocks audited
[3/1558] rustc heck 
[4/1558] rustc unicode_xid 
[5/1558] install /workspace/bun
bun install v1.4.4-canary.1 (e495b704e)

Checked 22 installs across 44 packages (no changes) [399.00ms]
[6/1558] rustc build_script_build 
[7/1558] rustc build_script_build 
[8/1558] rustc build_script_build 
[9/1558] rustc build_script_build 
[10/1558] rustc build_script_build 
[11/1558] rustc build_s
... (truncated)
diff hotspot
src/bun_core/util.rs                 |  12 -
 src/bundler/LinkerContext.rs         |  22 +-
 src/js_parser/p.rs                   |   9 +-
 src/js_parser/react_compiler_host.rs |   1 +
 src/js_parser/visit/mod.rs           |  39 +--
 src/js_printer/lib.rs                |  52 +---
 test/bundler/bundler_cjs2esm.test.ts | 568 ++++++++++++++++++++++++++++++++++-
 7 files changed, 607 insertions(+), 96 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                  reads  edits  tests
src/bun_core/util.rs                      0      0    190
src/bundler/LinkerContext.rs              2      1    192
src/js_parser/p.rs                        4      2    197
src/js_parser/react_compiler_host.rs      0      0    190
src/js_parser/visit/mod.rs                2      0    190
src/js_printer/lib.rs                     1      0    190
test/bundler/bundler_cjs2esm.test.ts      7      1    189

…comes its import

`transpose_require` returns a marker for a `require()` that initializes a
declarator, and `visit_decls` turns the declarator into the import. Only
an identifier binding consumed the marker. A pattern left it for the
printer, which printed `__toESM((exports))` or a call of `__require` that
no one defines. A `using` declaration and an export of a TypeScript
namespace were deleted with the declarator.

`visit_decls` now replaces the marker with the namespace identifier when
the declarator is a pattern, a `using` declaration or an export of a
TypeScript namespace. That is the value a `require()` in any other
position already has.

The namespace is a symbol that no scope declares. The React Compiler
would declare it again as a local of the function it compiles, so a
function that holds such a declarator is not handed to the compiler
(`react_compiler_must_keep_body`).

The marker never leaves `visit_decls` now, so the `was_unwrapped_require`
printer path, its linker flag and the four checks for a marker are
removed, with `GenericIndexOptional::is_some` and `is_none`, which have
no caller left.
@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Status: draft until CI has run on all platforms.

How I reproduced it (1.4.2 and main give the wrong result, this branch gives the right one):

mkdir repro && cd repro && bun add react@19
echo 'const { useState, version } = require("react"); console.log(typeof useState, version);' > entry.js

bun build entry.js --outfile=out.mjs && node out.mjs
# this branch: function 19.3.1
# main: ReferenceError: exports is not defined in ES module scope
#       (the bundle has `var { useState, version } = __toESM((exports));`)

bun build entry.js --external react --outfile=out2.mjs && node out2.mjs
# this branch: function 19.3.1
# main: ReferenceError: __require is not defined

The tests are in test/bundler/bundler_cjs2esm.test.ts (cjs2esm/UnwrappedRequire*): 17 new tests and one tightened assertion. 16 of them fail without the change in src/.

@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:43 PM PT - Oct 19th, 2026

✅ @robobun, your commit e495b704eb8dfb8b2ca1f7a067741a3c2f6b2402 passed in Build #128937! 🎉


🧪   To try this PR locally:

bunx bun-pr 46072

That installs a local version of the PR into your bun-46072 executable, so you can run:

bun-46072 --bun

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