Skip to content

bundler: export the namespace object of a lifted CommonJS chunk as "module.exports" for a split require() - #47193

Draft
robobun wants to merge 14 commits into
mainfrom
robobun/8449ad12/split-require-lifted-module-exports
Draft

robobun wants to merge 14 commits into
mainfrom
robobun/8449ad12/split-require-lifted-module-exports

Conversation

@robobun

@robobun robobun commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #47080. Draft until a maintainer answers the question under Background.

Problem

  • With --splitting --target=bun, require("react-dom/server") in a try body returns the module namespace of a chunk: a read-only object with __esModule: true. bundler: skip the __toCommonJS copy for require() of a lifted CommonJS file #47080 fixes the other modes.
  • Babel interop reads .default as undefined. A write throws TypeError: Attempted to assign to readonly property.
  • Cause: the call is import.meta.require("./chunk.js"), and the chunk of a lifted file exports only its names.

Fix

  • The chunk exports the namespace object of the file as "module.exports". Bun's loader returns that export from require() of an ES module (src/js/builtins/CommonJS.ts, since 1.2.6).
  • The linker marks the file when a split require() does more than read named exports. The printed call does not change.
  • Verified: 27 cases in test/bundler/bundler_splitting.test.ts, 18 fail before. Self-reviewed: 16 concerns on this route, 9 addressed, 7 open (Notes).

Background

  • Question: keep the chunk and add this export, or give a lifted file no chunk, as for other CommonJS targets?
  • No chunk adds no export. A never-called require("react-dom/server") costs 44.0M startup instructions, not 4.0M.
  • Lifting: for react and six more packages, the parser turns exports.x = v into an ES export. A split require() loads its target as a chunk of its own (bundler: with --splitting --target bun, require() of an ES module is a chunk boundary #40519).

Downsides

  • An import() of a marked chunk sees a module.exports key. Bun before 1.2.6 returns the old value.
  • An entry that also imports react statically builds the namespace object at startup: 5.94M to 7.57M instructions, 1,982 B more (React 19.2.0).
  • A tracked method call, require("react").self(), still gets the chunk namespace as this.
Notes

How to read the diff. This branch is #47080 plus one commit. Until #47080 lands, the diff against main shows both. The commit of this PR changes LinkerGraph.rs, postProcessJSChunk.rs, computeCrossChunkDependencies.rs, about 30 lines of LinkerContext.rs and scanImportsAndExports.rs, bundler_splitting.test.ts, one case of bundler_cjs2esm.test.ts and three docs passages.

The question. #47080 leaves --splitting --target=bun at the value of main. Two remedies are built and measured.

never-called, held require("react-dom/server"), --splitting --target=bun --production main no chunk this PR
18.3.1 bytes, all files 81,433 81,559 81,812
18.3.1 startup instructions 4,006,602 (again: 3,999,395) 43,953,363 3,999,275
19.2.0 bytes, all files 191,396 191,487 191,682
19.2.0 startup instructions 4,001,726 (again: 4,000,629) 28,278,696 4,000,629
  • No chunk: a test in find_reachable_files keeps a lifted file in the chunk of its caller, as for any CommonJS target. It adds no export and no runtime floor. It gives up the lazy chunk: the table shows the price. Every file that the target imports also gets a lazy __esm wrapper for the whole build, so another entry point that only imports react paid 4.4% to 61% more startup instructions (1 to 600 importing files). That form was the head f65c7b7 of bundler: skip the __toCommonJS copy for require() of a lifted CommonJS file #47080.
  • This PR: the chunk stays, and the entry files of the third column are byte-identical to main. The price is under "What the export adds".
  • A third form read .default at the call (the route of bundler: read .default of a split require() whose target is CommonJS at link time #41243). The value then belonged to the call, not to the file: require("react").self() === m was false, where main and bun run print true.

How it works

  • Bun's loader returns an export named "module.exports" from require() of an ES module, when the module has one (src/js/builtins/CommonJS.ts, since module pr 2 #18266 in 1.2.6). dynamic_import_dce/SplitRequireKeepsModuleExportsExport pins that rule for a user-written export. Node does the same (v26.3.0).
  • The chunk of a marked file ends with export { $count as count, exports_react as "module.exports" }. exports_react is the namespace object that __exportCjs builds, with a getter and a setter for each name.
  • The mark is js_meta::Flags::needs_module_exports_export. Step 1 of scan_imports_and_exports sets it for the target of a split require(), when require_returns_namespace (from bundler: skip the __toCommonJS copy for require() of a lifted CommonJS file #47080) is true and the call observes its result: it has no tracked use, or it reads default or __esModule.
  • When every call of a file is a tracked read of other names (require("x").a, const { a } = require("x")), the output is the output of main, byte for byte, and the chunk keeps only the names that are read.
  • A file that the linker puts back into __commonJS after step 1 keeps the .default route of bundler: read .default of a split require() whose target is CommonJS at link time #41243. chunk_module_exports_is_namespace tests wrap != Cjs for that reason (splitting/SplitRequireOfLiftedCommonJSThatIsWrappedAgainLater).
  • Both mechanisms stay. The .default route is for a chunk that is CommonJS at link time. A lifted chunk cannot use it, because its default name can be taken by the file.
  • A file can export the name module.exports itself. The namespace object takes the export name, and the file's own value stays a key of the object (...WithModuleExportsName, and ...WithModuleExportsNameAlsoImportedStatically for the cross-chunk import list).
  • A debug_assert! in generate_entry_point_tail_js fails when two items of an export clause have one alias.

What the export adds

  • An import() of a marked chunk sees a module.exports key on its namespace. Node lists that key for a CommonJS module too (count,default,inc,module.exports). bun run does not. A lifted user entry point that another entry point requires gets the same export. The metafile does not list the export. It does not list the default that main adds for an import() of a lifted file either.
  • The value needs a runtime that reads the export. I ran one bundle on thirteen releases. From 1.2.6 on, the call returns the object. On 1.0.0, 1.1.0, 1.2.0 and 1.2.5 it returns the read-only namespace, as the bundle of main does on every release. --compile embeds the current runtime.
  • The namespace object is built where the code of the file is. When an entry point also imports the file statically, that is the startup chunk. React 19.2.0, --production, static import { useState } plus a held require("react") that never runs: 5,936,834 to 7,573,994 startup instructions (main a second time: 5,939,094), and the startup chunk grows from 7,135 to 9,117 B.
  • The mark is set before tree shaking, as the mark for the default of an import() is. A held call that tree shaking removes still marks its target.
  • When the chunk loads: react-dom/server 19.2.0, 33,246,605 to 33,457,813 instructions (+0.6%, 21 interleaved runs). For 18.3.1 the difference is inside the noise. The chunk grows by 379 B (18.3.1) and 286 B (19.2.0).

Limits that stay

  • A tracked method call keeps the value of main: require("react").self() gets the module namespace of the chunk as this. A mark for every split require() would fix it, and would keep every export of the chunk.
  • If code does import() of the chunk, then reads require.cache for it, then calls require(), the call returns the module namespace. The require.cache get trap stores the namespace without a look at the export. The runtime loader is not changed here.
  • ns.__esModule of an import() of the same chunk turns true after the require(), as on main.
  • A require() cycle through the chunk gives the placeholder {} of the runtime to the inner call, as for any chunk on main (splitting/SplitRequireCycleThroughLiftedCommonJS).
  • Bake production cannot load a split require() chunk, on main and with this PR. bake: turn split require off for production builds #46112 (open) turns split require() off there.

Open items before this leaves draft

  • The answer to the question.
  • compile: return module.exports from a run-time require() of an embedded CommonJS entry point #40914 (open) adds x_default as default, x_default as "module.exports" to the same export clause for --compile. I did not build the two together. If both add the name for one chunk, the debug_assert! of this PR fails in a debug build. The second one to land has to give the name one owner.
  • The mark is set in step 1, and step 2 can change the kind of a file after that. Today the wrap != Cjs test covers it. A loop after step 2 that asks the helper again would not depend on that.
  • require_reads_exports_object repeats the name pair of the parser's is_require_marker. One shared helper is better.
  • No case runs --compile --splitting. A manual run on the earlier form printed undefined 42 true, alone and with --format=esm --bytecode. Two cases of bundler_compile_splitting.test.ts that this PR does not touch time out on a local debug build, so I did not add a case there.
  • One shape is not measured: an entry point that uses a runtime helper, with a held call that never runs. The chunk imports __exportCjs, which can move the runtime helpers into a shared chunk.
  • New measurements on the final form (see below).

Where the numbers come from. Release builds of a0cc096 (main on that day) and of an earlier form of this change on it, Linux x64, instructions:u from a perf_event_open counter. The mechanism is the same. Since then the branch got the helper change of #47080 (a lifted re-export of another package), the skip in the cross-chunk import list and the assertion. I did not build a release binary of the final form.

Tests. 27 cases in bundler_splitting.test.ts: 26 new and 1 changed. 18 fail on a release build without the change, 8 are controls, 1 is a todo. cjs2esm/KeptRequireOfLiftedCommonJSWithSplitting-SplitRequire, the control that #47080 adds for this route, now expects undefined ok 42.

  • Changed: splitting/SplitRequireOfLiftedCommonJSStaysEsm (from bundler: read .default of a split require() whose target is CommonJS at link time #41243) keeps its assertions (a chunk, a call with no .default). It now also prints m.__esModule and checks the new export of the chunk.
  • splitting/SplitRequireOfLiftedCommonJSProductionMinified is the real react-dom/index.js shape with all three minify flags and a production NODE_ENV.
  • todo: splitting/SplitRequireOfLiftedReExportOfExternal. A lifted module.exports = require(<external>) as a split chunk has export * inside its wrapper function and fails with Unexpected keyword 'export'. Main has this defect.
  • Debug build: bundler_cjs2esm.test.ts 139 pass, 1 todo. bundler_splitting.test.ts 233 pass, 1 skip, 4 todo, 1 fail. The failure is splitting/BarrelCycleKeepsImportOrder: a method names a later file, a 5 s timeout in a case that this PR does not touch.

Self-review. The review of the earlier form raised 16 concerns about this route.

  • Addressed (9): the inert minChunkSize case, a minified production case, the skip in the cross-chunk import list with a case, labels for the controls and two cases that asserted nothing new, the startup cost with a static import, the mark before tree shaking, the assertion on unique aliases, why both mechanisms stay, the docs sentences as user-visible rules.
  • Open (7): see "Open items" and "Limits that stay": the loop after step 2, the shared helper with the parser, the --compile case, the tracked method call, the helper-chunk shape, the owner of the name with compile: return module.exports from a run-time require() of an embedded CommonJS entry point #40914, one producer for the names that a chunk binds to its namespace object.

…ifted CommonJS file

A `require()` that stays a call (inside `try`/`catch`, or through an
alias of the package) of a CommonJS file that the parser lifted to an
ES module evaluated to `__toCommonJS(exports_x)`: a read-only copy with
an `__esModule` property that the file never set. With `--splitting
--target=bun` the call was `import.meta.require(chunk)`, which returns
the module namespace of the chunk with the same property.

The parser now records when a lifted file is CommonJS to `bun run`
(`NAMESPACE_IS_MODULE_EXPORTS`): a top-level `exports.x = ...` became an
export, or a `require()` became an import and the file is not an ES
module by type. For such a target the linker does not wrap the call in
`__toCommonJS`, and `find_reachable_files` does not split the call. The
call evaluates to `(init_x(), exports_x)`, the namespace object that an
unwrapped `require()` and a default import already use.
…onJS file

`require_returns_namespace` now tests `COMMONJS_LIFTED_TO_ESM` on a file
that is still `ExportsKind::Esm`, and the parser flag
`NAMESPACE_IS_MODULE_EXPORTS` is gone. The step 6 check applies to ESM
output only, because no other format lifts. A cycle of files that
re-export each other counts as lifted CommonJS.

The helper calls `get_redirect_id` for a redirect file, and the debug
assertion sits above the comment of the `.default` arm.

Tests: each case also runs its entry point with `bun run` and compares
the output. New cases cover the re-export file of react-dom, a cycle,
IIFE output, an alias and a second entry point under code splitting,
and a compiled executable. Two `todo` cases record what is still wrong:
a write through a re-export file, and a lifted re-export of an external
package under code splitting.

Docs: the added sentences say only what runs on real React.
…l re-export control

Another entry point that imports a file of the lifted target's import
closure now calls its wrapper in import order. A lifted
`module.exports = require()` of an external package stays a chunk
boundary: with an `import()` of the same file the call loads that chunk.
…mpile file as it is

A file that only calls require() in a package with "type": "module" is an
ES module to Bun. The bundle treats it as a lifted CommonJS file.
… on main

The change now covers a `require()` that stays a call in the same chunk as
its lifted target. Step 6 of `scan_imports_and_exports` adds no
`__toCommonJS` copy there, and the call evaluates to the namespace object
of the file.

- `find_reachable_files` is back at main. With `--splitting --target=bun`
  a lifted file is a chunk again, and the call there keeps the value of
  main.
- `require_returns_namespace` also accepts a lifted CommonJS file of a
  package outside the unwrap list as the target of a lifted
  `module.exports = require()`.
- The cases are in `test/bundler/bundler_cjs2esm.test.ts`.
  `test/bundler/bundler_splitting.test.ts` is the file of main again.
- `matchesBunRun` is an option of the bundler test harness. It replaces the
  two local helpers.
…odule.exports"

With `--splitting --target=bun`, a `require()` of a lifted CommonJS file
that stays a call loads the chunk of the file with `import.meta.require`.
That call returned the module namespace of the chunk, which is read-only
and has `__esModule: true`.

The chunk of such a file now exports its namespace object under the name
"module.exports". The loader of Bun returns that export from `require()`
of an ES module, so the call evaluates to the object. The linker marks the
file in step 1 of `scan_imports_and_exports`, when a split `require()` does
more than read named exports off its result.
@robobun

robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

@robobun

robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft. It waits for a maintainer to choose between the two remedies in the PR body (keep the chunk and export "module.exports", or no chunk for a lifted file). It is stacked on #47080.

Repro. The same call as in #47080, with --splitting --target=bun:

mkdir -p node_modules/react && echo '{}' > package.json
echo '{"name":"react","version":"19.0.0","main":"./index.js"}' > node_modules/react/package.json
printf 'exports.count = 0;\nexports.inc = () => ++exports.count;\n' > node_modules/react/index.js
cat > entry.js <<'EOS'
let m;
try { m = require("react"); } catch {}
let write = "ok";
try { m.count = 41; } catch (e) { write = e.constructor.name; }
console.log(m.__esModule, write, m.inc());
EOS
bun entry.js                                             # undefined ok 42
bun build --splitting --target=bun entry.js --outdir=out && bun out/entry.js
# main: true TypeError 1

CI. Buildkite build 134479 passed. Its six red jobs are the soft-fail test-aot-drawn lanes of the AOT tests, which are also red on main. GitHub Actions cancelled the cargo miri test job at its 6 hour limit. That job runs for hours on other open PRs too.

PR: #47193

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