Repository navigation
bundler: read .default of a split require() whose target is CommonJS at link time - #41243
Conversation
…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
|
Warning Review limit reached
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. View limit detailsLimit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (4)
Comment |
There was a problem hiding this comment.
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_dynalready impliesrec_source_index.is_valid(), and theexports_kind[...]indexing matches the sibling dynamic-import block at lines 1158-1168. - Printer placement of
.default—WRAP_WITH_TO_ESMis never set forImportKind::Requirein this branch, so there's no suffix-ordering interaction; the outerwrapparen still closes after.default. - Bitflag value
1 << 18is the next free bit afterNAMESPACE_UNUSED. - Both tests use the existing
chunkContaininghelper 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.
|
On the invariant that a CommonJS entry chunk exports only |
…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 -->
Problem
--splitting --target=bun, a splitrequire()of a file that is CommonJS at link time returns the chunk namespace{ default: module.exports }and notmodule.exports. In the repro of bundler: split require() of a re-wrapped lifted module returns the chunk namespace (regression on main) #41236,require("react-dom").versionisundefined. With npm react-dom 18.3.1 in development,createPortalisundefined.module.exports = require("./impl")intoexport * from "./impl", so the file is ESM at parse time andReachableFileVisitorsplits therequire()(src/bundler/bundle_v2.rs:1998). The linker then wraps the file again because./implis CommonJS (scanImportsAndExports.rs:422). Its chunk isexport default require_x(), andimport.meta.require()of that chunk returns the namespace. This is a regression on main only. No release has it.Fix
scan_imports_and_exportssets a new record flag,CROSS_CHUNK_REQUIRE_DEFAULT, on a splitrequire()whose target hasexports_kind == Cjsat link time. The printer then printsimport.meta.require("./chunk.js").default.default: module.exports. A target that stays ESM at link time keeps the bare call and gets its namespace, as before.typeof m, m.version, typeof m.defaultprintobject 19.0.0 functionin both.test/bundler/bundler_splitting.test.ts(SplitRequireOfRewrappedLiftedCommonJS#41236fails 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
require()(target bun,--splitting) makes its target an entry point with its own chunk and prints the call asimport.meta.require(path). The linker decides this at parse time fromExportsKind::Esm.exports.foo = ...ormodule.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.import(). This PR changes onlyrequire()records.Notes
Repro from the issue, in an empty directory:
Main prints
side effectthenundefined. With this change it prints19.0.0. The entry chunk now hasm = import.meta.require("./index-<hash>.js").default.module.exportsof the re-wrapped lifted file is__toESM(require_impl()), an object withdefaultset to the function. That is the #41188 lift behavior and is the same in the unsplit bundle, so the test readsm.default().The second test,
SplitRequireOfLiftedCommonJSStaysEsm, pins the other side: when./implusesexports.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_printeris clean.