Skip to content

bundler: wrap a split import() with __toESM when the target is CommonJS at link time - #41237

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/f0abc94a/splitting-import-cjs-at-link-time
Sep 3, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
robobun/f0abc94a/splitting-import-cjs-at-link-time

Conversation

@robobun

@robobun robobun commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator

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 bundler: give a split import() of a lifted CommonJS module its namespace as default #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 (bundler: lift module.exports = require() after side effects in unwrapped packages to export * #41188).
  • With code splitting, each import() target gets its own entry point chunk.
Notes

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:

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:

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:

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):

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.


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

fails on main (without fix)
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 (a6c4cc276)

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 (b36f032e7)

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)
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.1 (a6c4cc276)

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)
diff hotspot
.../linker_context/scanImportsAndExports.rs        | 134 ++++++++++-----------
 test/bundler/bundler_cjs2esm.test.ts               |  95 +++++++++++++++
 2 files changed, 157 insertions(+), 72 deletions(-)

gate history · 3 passed · 0 rejected · iteration 0

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

@robobun

robobun commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: rebased on main (1d1f431), after #41231 and #41243. No conflicts. Waiting for CI.

@coderabbitai

coderabbitai Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 699a28bd-ce9a-4e17-9e54-fd509ba2dbca

📥 Commits

Reviewing files that changed from the base of the PR and between 1d1f431 and 74e951b.

📒 Files selected for processing (2)
  • src/bundler/linker_context/scanImportsAndExports.rs
  • test/bundler/bundler_cjs2esm.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


Walkthrough

Changes

The linker now applies standard CommonJS interop handling to external dynamic imports of FORCE_CJS_TO_ESM modules. New splitting tests cover __toESM wrapping, rewrapped CommonJS exports, side effects, and matching require() and import() namespaces.

CommonJS dynamic import interop

Layer / File(s) Summary
Linker interop processing
src/bundler/linker_context/scanImportsAndExports.rs
External dynamic imports use runtime require counting, cross-chunk CommonJS default handling, and conditional __toESM wrapping.
Split bundle validation
test/bundler/bundler_cjs2esm.test.ts
Splitting tests verify wrapped modules, rewrapped CommonJS modules, and equivalent require() and dynamic import() results.

Suggested reviewers: sosukesuzuki, jarred-sumner

Merge Risk: ⚪ Minimal · up to 74e95

Split dynamic imports of CommonJS modules now preserve named exports and default namespace behavior instead of returning an unusable bare chunk namespace. The covered bundler scenarios pass with no remaining concrete merge-blocking risk.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the main change: applying __toESM to split dynamic imports whose targets are CommonJS at link time.
Description check ✅ Passed The description explains the problem, cause, fix, scope, regression tests, verification results, and excluded changes. It does not use the template headings exactly, but it provides the required infor…
Full details: Description check

Explanation

The description explains the problem, cause, fix, scope, regression tests, verification results, and excluded changes. It does not use the template headings exactly, but it provides the required information and is complete.


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

@robobun

robobun commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:22 PM PT - Sep 2nd, 2026

@robobun, your commit 74e951b is building: #109627

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflicts

@robobun
robobun force-pushed the robobun/f0abc94a/splitting-import-cjs-at-link-time branch from 3b23e6d to b55d5a5 Compare September 3, 2026 03:44
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs
Comment thread src/bundler/linker_context/scanImportsAndExports.rs
Comment thread src/bundler/linker_context/scanImportsAndExports.rs
@robobun

robobun commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed. The branch is rebased on main (dcd8569), and the PR merges cleanly.

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

### Problem
- With `--splitting --target=bun`, a split `require()` of a file that is
CommonJS at link time returns the chunk namespace `{ default:
module.exports }` and not `module.exports`. In the repro of #41236,
`require("react-dom").version` is `undefined`. With npm react-dom 18.3.1
in development, `createPortal` is `undefined`.
- The cause: since #41188 the parser lifts `module.exports =
require("./impl")` into `export * from "./impl"`, so the file is ESM at
parse time and `ReachableFileVisitor` splits the `require()`
(`src/bundler/bundle_v2.rs:1998`). The linker then wraps the file again
because `./impl` is CommonJS (`scanImportsAndExports.rs:422`). Its chunk
is `export default require_x()`, and `import.meta.require()` of that
chunk returns the namespace. This is a regression on main only. No
release has it.

### 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.
- #41237 fixes the same shape for a split `import()`. This PR changes
only `require()` records.

<details><summary>Notes</summary>

Repro from the issue, in an empty directory:

```sh
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.
</details>

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

Code review found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflicts

…JS at link time

With code splitting, a cross-chunk import() skipped the __toESM wrap
when the target had FORCE_CJS_TO_ESM. The parser sets that flag on each
file in the unwrap list and on each file whose exports.foo = ... it
lifted. Such a file can still be CommonJS at link time: a require() of
it wraps it, it assigns module.exports, or the target of its lifted
module.exports = require() is CommonJS. Its chunk is then
`export default require_x()`, so the importer saw the bare chunk
namespace and named exports were undefined.

Remove the skip. Since #41231 it did nothing else. The branch below
already wraps a cross-chunk import() when the target is CommonJS at
link time, and leaves every other target alone.
@robobun
robobun force-pushed the robobun/f0abc94a/splitting-import-cjs-at-link-time branch from b36f032 to 74e951b Compare September 3, 2026 04:22
Comment thread src/bundler/linker_context/scanImportsAndExports.rs
@robobun

robobun commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed again. The branch is rebased on main (1d1f431), and the PR merges cleanly.

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

Code review found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner
Jarred-Sumner merged commit 473335d into main Sep 3, 2026
10 of 11 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/f0abc94a/splitting-import-cjs-at-link-time branch September 3, 2026 04:39
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