Skip to content

bundler: read .default of a split require() whose target is CommonJS at link time - #41243

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/5ede2104/split-require-cjs-default
Sep 3, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
robobun/5ede2104/split-require-cjs-default

Conversation

@robobun

@robobun robobun commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • scan_imports_and_exports sets a new record flag, CROSS_CHUNK_REQUIRE_DEFAULT, on a split require() whose target has exports_kind == Cjs at link time. The printer then prints import.meta.require("./chunk.js").default.
  • Correct because splitting is ESM output only, where a CommonJS entry chunk exports exactly one thing: default: module.exports. A target that stays ESM at link time keeps the bare call and gets its namespace, as before.
  • The split output now matches the unsplit bundle: typeof m, m.version, typeof m.default print object 19.0.0 function in both.
  • Verified: two new cases in test/bundler/bundler_splitting.test.ts (SplitRequireOfRewrappedLiftedCommonJS#41236 fails on main). Also bundler_splitting, bundler_cjs2esm, bundler_cjs, esbuild/splitting, bundler_compile_splitting, bundler_edgecase, bundler_regressions, bundler_bun, all green with the debug build.

Background

  • A split require() (target bun, --splitting) makes its target an entry point with its own chunk and prints the call as import.meta.require(path). The linker decides this at parse time from ExportsKind::Esm.
  • Lifting: in an ESM bundle the parser turns top-level exports.foo = ... or module.exports = require() into ES exports. The lifted file can still become CommonJS at link time, for example when the target of its export star has no static exports.
  • bundler: wrap a split import() with __toESM when the target is CommonJS at link time #41237 fixes the same shape for a split import(). This PR changes only require() records.
Notes

Repro from the issue, in an empty directory:

mkdir -p node_modules/react-dom
printf "console.log('side effect');\nmodule.exports = require('./impl');\n" > node_modules/react-dom/index.js
printf 'module.exports = function render() { return "rendered"; };\nmodule.exports.version = "19.0.0";\n' > node_modules/react-dom/impl.js
printf 'let m;\ntry {\n  m = require("react-dom");\n} catch {}\nconsole.log(m.version);\n' > entry.js
bun build ./entry.js --splitting --target=bun --outdir=out && bun out/entry.js

Main prints side effect then undefined. With this change it prints 19.0.0. The entry chunk now has m = import.meta.require("./index-<hash>.js").default.

module.exports of the re-wrapped lifted file is __toESM(require_impl()), an object with default set to the function. That is the #41188 lift behavior and is the same in the unsplit bundle, so the test reads m.default().

The second test, SplitRequireOfLiftedCommonJSStaysEsm, pins the other side: when ./impl uses exports.x = ... and is lifted too, the chunk stays ESM and the call has no .default.

The alternative, to not split a require() of a lifted file with an export star, would keep the wrapper in the importer's chunk. The link-time check is the smaller change and covers every way a parse-time ESM target becomes CommonJS at link time, not only the lift case.

cargo clippy -p bun_bundler -p bun_js_printer is clean.

…at link time

A split require() loads its target's chunk with import.meta.require().
When the target is CommonJS at link time, the chunk exports only
default: module.exports, so the bare call returned the chunk namespace
instead of module.exports. The linker now flags such records and the
printer reads .default from the call.

Fixes #41236
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 18 days. After that, they cost $0.25 per reviewed file.

Or wait 12 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 73828113-8603-4308-8d97-8f852d031980

📥 Commits

Reviewing files that changed from the base of the PR and between dcd8569 and 751b74e.

📒 Files selected for processing (4)
  • src/ast/import_record.rs
  • src/bundler/linker_context/scanImportsAndExports.rs
  • src/js_printer/lib.rs
  • test/bundler/bundler_splitting.test.ts

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

@github-actions github-actions Bot added the claude label Sep 3, 2026

@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 fix is small and mirrors the existing import() cross-chunk-CJS handling right below it, but since it changes bundler code generation (linker + printer) for split require() output, a human familiar with the splitting/re-wrap semantics should confirm.

What was reviewed:

  • The new flag condition in scan_imports_and_exports — is_external_dyn already implies rec_source_index.is_valid(), and the exports_kind[...] indexing matches the sibling dynamic-import block at lines 1158-1168.
  • Printer placement of .default — WRAP_WITH_TO_ESM is never set for ImportKind::Require in this branch, so there's no suffix-ordering interaction; the outer wrap paren still closes after .default.
  • Bitflag value 1 << 18 is the next free bit after NAMESPACE_UNUSED.
  • Both tests use the existing chunkContaining helper and assert runtime output plus emitted shape; the negative test pins that an ESM-at-link-time target gets no .default.
Extended reasoning...

Overview

This PR fixes a bundler regression introduced by #41188 (the module.exports = require() lift). When --splitting --target=bun splits a require() whose target was lifted to ESM at parse time but re-wrapped as CommonJS at link time, the emitted import.meta.require("./chunk.js") returned the chunk's namespace object { default: module.exports } instead of module.exports. The fix adds a new ImportRecordFlags::CROSS_CHUNK_REQUIRE_DEFAULT bit (1 << 18), sets it in scan_imports_and_exports when a split require() targets a source whose exports_kind is Cjs at link time, and has the printer append .default to the emitted call. Two itBundled tests cover the re-wrapped-CJS case (issue #41236 repro) and the stays-ESM negative case.

Security risks

None. This is bundler output shaping — no user-input parsing, no auth/crypto/permissions, no network. The change only affects what property access is emitted after a synthetic import.meta.require() call in split ESM output.

Level of scrutiny

Moderate. The diff is ~30 lines of production code and is structurally identical to the neighboring dynamic-import handling (lines 1158-1168 in scanImportsAndExports.rs), which already checks the same is_external_dyn && exports_kind == Cjs condition for import(). The redundant rec_source_index.is_valid() guard is harmless (already implied by is_external_dyn per line 1069). In the printer, .default is appended after the call and before the wrap_with_to_esm suffix and outer wrap paren; since WRAP_WITH_TO_ESM is only ever set for kind != Require in this code path, there's no ordering conflict. However, this is still a code-generation change in the bundler's linker/printer, where the correctness of "the chunk's only export is default: module.exports" depends on how CJS entry chunks are emitted under splitting — a reviewer who knows that emission path should confirm the invariant holds for all re-wrap shapes, not just the lifted-module.exports = require() one.

Other factors

Tests are placed correctly in test/bundler/bundler_splitting.test.ts using itBundled, follow existing patterns (chunkContaining helper, onAfterBundle shape assertions, run: for runtime output), and cover both the positive and negative branches of the new condition. The PR description states the broader bundler suites (splitting, cjs2esm, cjs, esbuild/splitting, compile_splitting, edgecase, regressions, bun) pass. No CODEOWNERS cover these files. The bug hunt exited on dry_streak with no findings. Given the change touches core bundler output rather than being a mechanical tweak, deferring for a human sign-off is the safer call.

@robobun

robobun commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

On the invariant that a CommonJS entry chunk exports only default: module.exports: generate_entry_point_tail_js (src/bundler/linker_context/postProcessJSChunk.rs:844) emits exactly export default require_foo(); for every WrapKind::Cjs entry in ESM output, with no other shape. Step 1 of scan_imports_and_exports sets wrap = Cjs for every file with exports_kind == Cjs when the output format is ESM, and splitting is ESM only. So the .default read holds for every way a target becomes CommonJS at link time, not only the lifted module.exports = require() case.

@Jarred-Sumner
Jarred-Sumner merged commit 5d6dc96 into main Sep 3, 2026
10 of 12 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/5ede2104/split-require-cjs-default branch September 3, 2026 04:08
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 -->
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