Repository navigation
Conversation
…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.
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 definedThe tests are in |
Collaborator
Author
|
Updated 12:43 PM PT - Oct 19th, 2026
✅ @robobun, your commit e495b704eb8dfb8b2ca1f7a067741a3c2f6b2402 passed in 🧪 To try this PR locally: bunx bun-pr 46072That installs a local version of the PR into your bun-46072 --bun |
This was referenced Oct 19, 2026
bundler: skip __toESM for an unwrapped require() of a package that keeps its CommonJS wrapper
#46215
Open
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
const { useState } = require("react")throwsReferenceError: exports is not definedwhen the required file keeps its CommonJS wrapper, andReferenceError: __require is not definedwhenreactis external.using x = require("react")loses its declaration. An export of a TypeScript namespace isundefined.visit_decls(src/js_parser/visit/mod.rs) consumes the marker oftranspose_requireonly for an identifier. The printer prints a marker that is left.Fix
visit_declsreplaces the marker with the namespace identifier for a pattern,usingand a namespace export. Every other position already has that value.visit_decls. Its printer path, linker flag and four checks are deleted.test/bundler/bundler_cjs2esm.test.ts(17 new tests, 16 failures on main) and all 107 bundler test files.Background
require()ofreactand 6 more packages becomesimport * as ns. A declaratorx = require()makesxthat namespace and goes.Downsides
using x = require("react")throws aTypeError, as Node does. Main deleted the declaration.module.exports:defaultis the module object.--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 CommonJSi18nentry 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 readsmodule.exports. The unwrap list and the fold are Bun's.Repros (
bun build entry.js --outfile out.mjs, thennode out.mjs)const { useState, version } = require("react")(React 18.3.1 or 19.3.1)var { useState, version } = __toESM((exports));thenReferenceError: exports is not defined--external react, or--target=bun --packages=externalvar { useState, version } = __require("react");thenReferenceError: __require is not definedexportsand that another file loads withrequire()undefined: the bareexportsis the object of that file's own wrapper--production, React 19.3.1const { unstable_now } = require("scheduler"),const { createRoot } = require("react-dom/client")(18.3.1)--productionconst { renderToString } = require("react-dom/server")--target=bun --packages=externalReferenceError: __require is not defined{ using x = require("react"); }TypeError: Object not disposablenamespace NS { export const N = require("react") }in a.tsfileNS.NisundefinedNS.Nis the namespaceMain fails when the file that the
require()resolves to keeps its__commonJSwrapper in that build (the marker prints that file'sexports, which is not in scope), or when the package is external. Flags matter only through that: React 19.3.1 with--productionloses the wrapper and runs on main.The change.
transpose_require(src/js_parser/p.rs) returnsE::RequireStringwithunwrapped_idfor arequire()that is the whole initializer of a declarator.visit_declsthen 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 withE::Identifierof the namespace when the binding is a pattern, when the declaration isusingorawait 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_requiremints. 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. Sovisit_declssetsreact_compiler_must_keep_bodywhen 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 undeclaredreactand threw aReferenceError, where main ran. The test has that shape, a.mapcallback, an inner function declaration and a module variable with the name of the namespace, for the browser and the bun target, with and withoutminifyIdentifiers.What stays as on main
cjs2esm/UnwrappedRequireAliasBindsDirectlypasses on main and here.export const x = require("react")still loses its export: a named import fails withNo matching export, andrequire(),import(),export *and an entry point get a bundle without it. The fix isimport * as xwithexport { x }. It waits for bundler: keep a barrel's import record when a statement of the barrel reads a binding from it #45538: the barrel optimization drops an import that the barrel file itself reads. An exported pattern (export const { a } = require("react")) is fixed here.let react = require("react"); react = __toESM(react), the CommonJS output of rolldown) is still folded.require("@tanstack/react-query")with--external reactstill throws at load. bundler: do not fold an unwrapped require() declaration when its variable is rebound #45343 is for that, with a second declaration of the variable, a directeval, and avarin a block that does not run.const { useState, version } = require("react")is 9,478 B (main: 9,480 B) against 927 B forReact.useStateandReact.version(React 19.3.1,--production). That is the cost of an object pattern over an import namespace:import * as R from "react"; const { useState, version } = Rhas the same size..tsfile:ReferenceError. js_parser: roll back a removed statement's tracked uses along with its use counts #45345 owns it.var x = require("react"); function x() {}is aSyntaxError. js_parser: print a var merged with a top-level function declaration as an assignment #44001 owns it.require("react/package.json")withreactexternal prints an import with no JSON attribute, which Node rejects.--react-compiler: a variable of a component that is the namespace and that a closure reads as a value throwsReferenceError: Local2 is not defined, and a closure with a barerequire("react")throwsReferenceError: react is not defined. Tworequire()calls whose namespaces have one name (require("react/x"),require("scheduler/x")) on the two paths of a branch give the first module on both (react-compiler: do not fold a local that holds a different module on each path #42376). A fix that tells the compiler that the namespace is a binding of the module has to land after react-compiler: do not fold a local that holds a different module on each path #42376.Downsides in numbers
const fs = require("fs"); const { default: d, useState, ...rest } = require("react"),--target=node --external react. Unbundled, and in the bundle of main (= __require("react")),disundefinedandresthas 45 keys. In the bundle of this PR (= react, the import namespace),dis the module object under Node and under Bun, and under Noderestalso hasmodule.exports. Names that Node cannot detect in a CommonJS file areundefinedunder Node: a stub withmodule.exports = api.default || api(the layout ofreactup to 16.12) printsfunction function 16.12.0-stubwith main andfunction undefined undefinedwith this PR. Main's__requirewas defined only when another run-timerequire()was in the bundle. Without one main throws.const R = require("react")already reads the import namespace on main.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_requireinjs_printer(RequireOrImportMeta,print_require_or_import_expr, the callback type and its thunk) and inLinkerContext::require_or_import_meta_for_source.conditional_namespace_records,require_namespace_ref, thesplit_requireblock ofvisit_decls, andvalue_is_import_namespaceof the linker. A marker never reaches them now.bun_core::GenericIndexOptional::is_someandis_none: the printer was the last caller.E::RequireString::unwrapped_idstays. It carries the index fromtranspose_requiretovisit_decls.Weighed
is_immediately_assigned_to_declonly for an identifier, as bundler: read destructured require() of an unwrapped package from its import namespace #39184 had it): built and measured, +12 instructions for each declarator with an initializer, because the flag stops being a constant. The replacement runs only for a declarator whose initializer was unwrapped.transpose_requirereturns the identifier always, andvisit_declstests the shape of the initializer. It changestranspose_require,e_call,TransposeStateand the size ofExprIn, and a merge with Source positions: coverage, stack frames, call sites and source maps agree with the source #45677 does not compile. It can follow as a change with no effect on output.Measurements. Release builds (linux x64) of main ad4089b and of this branch.
NODE_ENVdevelopment and production, plain and--minify, with and without--sourcemap=linked,--target=node). 96 are byte-identical to main. 16 are the entry withconst { useState, createElement, version } = require("react"): main's bundle throws aReferenceErrorin 12 of them. All 112 bundles of this PR run.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 plainx = require("react").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%.size, +2,048 B).Relation to other open PRs
x.aitself grows (864 B to 9,481 B for a small library with React 19.3.1,--production).transpose_require,e_callorTransposeState.conditional_namespace_records. bundler: keep a read off a require() / import() local a property access until the linker binds it #45296 has a conflict inLinkerContext.rs.react/index.jsfor a build that definesNODE_ENV. The tests here pin the wrapper that they depend on (cjs2esm: { unhandled }), so they show it.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 ofbundler_bytecode_portablefail on both in the container I used. Main fails 16 more: 15 of the 17 new tests andcjs2esm/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
usingand 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 ofmodule.exports.[human-review] gate passed · iteration 0 · 7 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file