From 751b74eb1f50a25e40881995353994972bffa3e9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 3 Sep 2026 03:39:09 +0000 Subject: [PATCH] bundler: read .default of a split require() whose target is CommonJS 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 --- src/ast/import_record.rs | 5 ++ .../linker_context/scanImportsAndExports.rs | 18 +++++ src/js_printer/lib.rs | 9 +++ test/bundler/bundler_splitting.test.ts | 68 +++++++++++++++++++ 4 files changed, 100 insertions(+) diff --git a/src/ast/import_record.rs b/src/ast/import_record.rs index 13fe65c93eeb..508550f2aa33 100644 --- a/src/ast/import_record.rs +++ b/src/ast/import_record.rs @@ -101,6 +101,11 @@ bitflags::bitflags! { /// `import()` / `require()` whose value nothing reads: the linker bound /// every name read off it to an export, so it evaluates to `{}`. const NAMESPACE_UNUSED = 1 << 17; + + /// A split `require()` whose target is CommonJS at link time: the + /// chunk's namespace is `{ default: module.exports }`, so the call + /// reads `.default` to return `module.exports`. + const CROSS_CHUNK_REQUIRE_DEFAULT = 1 << 18; } } diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index e7e732b09590..c0aacd6a59cf 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -1113,6 +1113,24 @@ pub(crate) fn scan_imports_and_exports( runtime_require_uses += 1; } + // A split `require()` whose target is CommonJS at link + // time (for example a lifted `module.exports = require()` + // file the linker wrapped again): the chunk exports only + // `default: module.exports`, so the printed call reads + // `.default` to return `module.exports`, the same value + // an unsplit `require()` returns. + if kind == ImportKind::Require + && is_external_dyn + && rec_source_index.is_valid() + && col_ref!(exports_kind)[rec_source_index.get() as usize] + == ExportsKind::Cjs + { + col!(import_records_list)[id].as_mut_slice() + [import_record_index as usize] + .flags + .insert(ImportRecordFlags::CROSS_CHUNK_REQUIRE_DEFAULT); + } + // If this wasn't originally a "require()" call, then we may need // to wrap this in a call to the "__toESM" wrapper to convert from // CommonJS semantics to ESM semantics. diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index dd75ba6e321d..eda06776ec31 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -2961,6 +2961,15 @@ pub(crate) mod __gated_printer { self.print_import_record_path(record); self.print(b")"); + // A split `require()` of a module that is CommonJS at link + // time: the chunk's only export is `default: module.exports`. + if record + .flags + .contains(ImportRecordFlags::CROSS_CHUNK_REQUIRE_DEFAULT) + { + self.print(b".default"); + } + if wrap_with_to_esm { self.print_to_esm_suffix(); } diff --git a/test/bundler/bundler_splitting.test.ts b/test/bundler/bundler_splitting.test.ts index fb0a772bff25..3b8a4af91279 100644 --- a/test/bundler/bundler_splitting.test.ts +++ b/test/bundler/bundler_splitting.test.ts @@ -2395,6 +2395,74 @@ describe("bundler", () => { run: { file: "/out/main.js", stdout: "cjs value called" }, }); + // A split require() of a lifted `module.exports = require()` file that the + // linker wraps again because its target is CommonJS: the chunk's only + // export is `default: module.exports`, so the call reads `.default` and + // returns `module.exports`, not the chunk namespace. + // https://github.com/oven-sh/bun/issues/41236 + itBundled("splitting/SplitRequireOfRewrappedLiftedCommonJS#41236", { + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react-dom"); + } catch {} + console.log(typeof m, m.version, m.default()); + `, + "/node_modules/react-dom/index.js": /* js */ ` + console.log('side effect'); + module.exports = require('./impl'); + `, + "/node_modules/react-dom/impl.js": /* js */ ` + module.exports = function render() { return "rendered"; }; + module.exports.version = "19.0.0"; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + const chunk = chunkContaining(api, "side effect"); + api.expectFile("/out/main.js").toContain(`import.meta.require("./${chunk}").default`); + }, + run: { file: "/out/main.js", stdout: "side effect\nobject 19.0.0 rendered" }, + }); + + // When the lift target is itself lifted, the chunk stays an ES module and + // the split require() returns its namespace with no `.default` read. + itBundled("splitting/SplitRequireOfLiftedCommonJSStaysEsm", { + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react-dom"); + } catch {} + console.log(m.version, m.render()); + `, + "/node_modules/react-dom/index.js": /* js */ ` + console.log('side effect'); + module.exports = require('./impl'); + `, + "/node_modules/react-dom/impl.js": /* js */ ` + exports.render = function render() { return "rendered"; }; + exports.version = "19.0.0"; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + const chunk = chunkContaining(api, "side effect"); + api.expectFile("/out/main.js").toContain(`import.meta.require("./${chunk}")`); + api.expectFile("/out/main.js").not.toContain(".default"); + }, + run: { file: "/out/main.js", stdout: "side effect\n19.0.0 rendered" }, + }); + // A top-level require() in a module that the required chunk imports back // (the registry ↔ tool shape): the chunk is evaluated while the entry is // still evaluating and sees the entry's hoisted functions through live