From 13a4d61e74b1b6503fa74dfdb924fe4b21a41c39 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:12:06 +0000 Subject: [PATCH 1/8] bundler: destructured require() of an unwrapped package reads the import namespace When a require() of a package in the CommonJS unwrap list (react, react-dom, ...) initializes a declaration, the parser returns an E::RequireString marker so visit_decls can rename the generated import namespace to the declared identifier and drop the declaration. Only identifier bindings are handled there; for a destructuring binding the marker survived to the printer, which printed the target module's own exports ref. When that module could not be converted to ESM (it assigns module.exports), that ref is the __commonJS wrapper's local `exports` parameter, so the destructuring read from an unrelated `exports` binding and every property came out undefined. Only set is_immediately_assigned_to_decl for identifier bindings, so a destructuring initializer gets the namespace identifier that require() becomes in every other expression position. --- src/js_parser/parser.rs | 3 +- src/js_parser/visit/mod.rs | 6 ++- test/bundler/bundler_cjs2esm.test.ts | 70 +++++++++++++++++++++++++--- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 2614231907cf..1552cd5c4c6b 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -1036,8 +1036,7 @@ pub struct ExprIn { /// tests. pub(crate) assign_target: js_ast::AssignTarget, - /// Currently this is only used when unwrapping a call to `require()` - /// with `__toESM()`. + /// Identifier-binding initializer; only used to unwrap `const x = require()` into an import. pub(crate) is_immediately_assigned_to_decl: bool, pub(crate) property_access_for_method_call_maybe_should_replace_with_undefined: bool, diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 68a8fc389c57..4301e683a2b4 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -355,7 +355,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.visit_expr_in_out( &mut val, ExprIn { - is_immediately_assigned_to_decl: true, + // Only the identifier unwrap below consumes the marker this requests. + is_immediately_assigned_to_decl: matches!( + decl.binding.data, + BData::BIdentifier(_) + ), ..Default::default() }, ); diff --git a/test/bundler/bundler_cjs2esm.test.ts b/test/bundler/bundler_cjs2esm.test.ts index 7c5964726e11..06a0082e5e35 100644 --- a/test/bundler/bundler_cjs2esm.test.ts +++ b/test/bundler/bundler_cjs2esm.test.ts @@ -366,15 +366,70 @@ describe("bundler", () => { stdout: "react\nreact\nreact\nreact\nundefined\nreact\nreact\nreact\nreact\nreact\nreact\n1 react\nreact\nreact", }, }); - // A require() of an unwrapped package that initializes a destructuring - // declaration is kept as a require expression that remembers it was - // unwrapped, and prints as the namespace object. One inside try/catch is + // The required packages assign module.exports, so they stay wrapped in __commonJS. + // A destructuring declaration has to read from the import the require() became, + // not from the wrapped module's own `exports` binding. + itBundled("cjs2esm/UnwrappedModuleRequireDestructured", { + files: { + "/entry.js": /* js */ ` + const { react } = require("react"); + console.log(react); + + let { react: renamed, missing = "fallback" } = require("react"); + console.log(renamed, missing); + + var { react: { length } } = require("react"); + console.log(length); + + const before = require("react"), + { react: between } = require("react"), + after = require("react"); + console.log(before.react, between, after.react); + + function inFunction() { + const { react } = require("react"); + return react; + } + console.log(inFunction()); + + const [first, second] = require("scheduler"); + console.log(first, second); + `, + ...fakeReactNodeModules, + "/node_modules/scheduler/index.js": /* js */ ` + module.exports = ["first", "second"]; + `, + "/node_modules/scheduler/package.json": /* json */ ` + { + "name": "scheduler", + "version": "1.0.0", + "main": "index.js" + } + `, + }, + onAfterBundle: api => { + const code = api.readFile("out.js"); + expect(code).toContain("var require_react = __commonJS("); + expect(code).toContain("var require_scheduler = __commonJS("); + expect(code).toMatch(/\{ react: between \} = \w+;/); + expect(code).toMatch(/\[first, second\] = \w+;/); + }, + run: { + stdout: "react\nreact fallback\n5\nreact react react\nreact\nfirst second", + }, + }); + // Against a package that does convert to ESM, a destructuring declaration + // reads from the generated namespace object. A require() inside try/catch is // never unwrapped and prints as an ordinary require. itBundled("cjs2esm/UnwrappedModuleRequireDestructuredAndInTry", { files: { "/entry.js": /* js */ ` - const { react: named } = require("react"); - console.log(named); + const { react: named, version = "none" } = require("react"); + console.log(named, version); + + const whole = require("react"), + { react: again } = require("react"); + console.log(whole.react, again); let inTry = "missing"; try { @@ -395,11 +450,12 @@ describe("bundler", () => { }, onAfterBundle: api => { const code = api.readFile("out.js"); - expect(code).toMatch(/\{ react: named \} = \(?exports_react\)?;/); + expect(code).toMatch(/\{ react: named, version = "none" \} = \(?exports_react\)?;/); + expect(code).toMatch(/\{ react: again \} = \(?exports_react\)?;/); expect(code).toContain("__toCommonJS(exports_react)).react"); }, run: { - stdout: "react\nreact", + stdout: "react none\nreact react\nreact", }, }); // `sideEffect(); module.exports = require("./main")` in an unwrapped package From 05a680b792bf899036071eaf614a6fb6088597eb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:09:34 +0000 Subject: [PATCH 2/8] js_printer: remove the was_unwrapped_require require() printing path With the parser only producing the unwrapped RequireString marker for identifier bindings, which visit_decls always removes, no RequireString with unwrapped_id set reaches the printer any more. Remove the flag the printer threaded through RequireOrImportMeta and its callback, the printer branch that printed the target's exports_ref for it, and the FORCE_CJS_TO_ESM special case in LinkerContext::require_or_import_meta_for_source. The printer now debug-asserts that invariant where it used to read the marker. --- src/ast/e.rs | 2 +- src/bundler/LinkerContext.rs | 19 ++----------- src/js_printer/lib.rs | 54 ++++++++++-------------------------- 3 files changed, 19 insertions(+), 56 deletions(-) diff --git a/src/ast/e.rs b/src/ast/e.rs index 88edbc752911..58210591846f 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -2425,7 +2425,7 @@ pub type UnwrappedRequireIndexOptional = pub struct RequireString { pub import_record_index: u32, - /// Set when `unwrap_commonjs_to_esm` turned this `require()` into an import. + /// Set when unwrapping `const x = require()` into an import; consumed by `visit_decls`. pub unwrapped_id: UnwrappedRequireIndexOptional, } impl Default for RequireString { diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 1ff37473d24c..c3d586cea8aa 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -2489,25 +2489,16 @@ impl<'a> LinkerContext<'a> { pub(crate) fn require_or_import_meta_for_source( &mut self, source_index: crate::IndexInt, - was_unwrapped_require: bool, ) -> js_printer::RequireOrImportMeta { let flags = self.graph.meta.items_flags()[source_index as usize]; js_printer::RequireOrImportMeta { - exports_ref: if flags.wrap == WrapKind::Esm - || (was_unwrapped_require - && self.graph.ast.items_flags()[source_index as usize] - .contains(AstFlags::FORCE_CJS_TO_ESM)) - { + exports_ref: if flags.wrap == WrapKind::Esm { self.graph.ast.items_exports_ref()[source_index as usize] } else { Ref::NONE }, is_wrapper_async: flags.is_async_or_has_async_dependency, wrapper_ref: self.graph.ast.items_wrapper_ref()[source_index as usize], - - was_unwrapped_require: was_unwrapped_require - && self.graph.ast.items_flags()[source_index as usize] - .contains(AstFlags::FORCE_CJS_TO_ESM), } } @@ -2778,12 +2769,8 @@ impl<'a> LinkerContext<'a> { /// can call back into `LinkerContext::require_or_import_meta_for_source`. impl<'a> js_printer::RequireOrImportMetaSource for LinkerContext<'a> { #[inline] - fn require_or_import_meta_for_source( - &mut self, - id: u32, - was_unwrapped_require: bool, - ) -> js_printer::RequireOrImportMeta { - LinkerContext::require_or_import_meta_for_source(self, id, was_unwrapped_require) + fn require_or_import_meta_for_source(&mut self, id: u32) -> js_printer::RequireOrImportMeta { + LinkerContext::require_or_import_meta_for_source(self, id) } } diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 873a5c04e09d..bc1ae9ee5de9 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1383,11 +1383,7 @@ pub struct Options<'a> { } impl<'a> Options<'a> { - pub(crate) fn require_or_import_meta_for_source( - &self, - id: u32, - was_unwrapped_require: bool, - ) -> RequireOrImportMeta { + pub(crate) fn require_or_import_meta_for_source(&self, id: u32) -> RequireOrImportMeta { if self .require_or_import_meta_for_source_callback .ctx @@ -1395,8 +1391,7 @@ impl<'a> Options<'a> { { return RequireOrImportMeta::default(); } - self.require_or_import_meta_for_source_callback - .call(id, was_unwrapped_require) + self.require_or_import_meta_for_source_callback.call(id) } } @@ -1472,7 +1467,6 @@ pub struct RequireOrImportMeta { pub wrapper_ref: Ref, pub exports_ref: Ref, pub is_wrapper_async: bool, - pub was_unwrapped_require: bool, } // Clone/Copy: bitwise OK — `ctx` is a non-owning opaque backref the caller @@ -1480,12 +1474,12 @@ pub struct RequireOrImportMeta { #[derive(Clone, Copy)] pub struct RequireOrImportMetaCallback { pub(crate) ctx: Option>, - pub(crate) callback: fn(*mut (), u32, bool) -> RequireOrImportMeta, + pub(crate) callback: fn(*mut (), u32) -> RequireOrImportMeta, } impl Default for RequireOrImportMetaCallback { fn default() -> Self { - fn noop(_: *mut (), _: u32, _: bool) -> RequireOrImportMeta { + fn noop(_: *mut (), _: u32) -> RequireOrImportMeta { RequireOrImportMeta::default() } Self { @@ -1498,28 +1492,20 @@ impl Default for RequireOrImportMetaCallback { /// PORTING.md §Dispatch — manual vtable. The erased thunk is monomorphized /// over `T: RequireOrImportMetaSource`, so `callback` stays a captureless `fn`. pub trait RequireOrImportMetaSource { - fn require_or_import_meta_for_source( - &mut self, - id: u32, - was_unwrapped_require: bool, - ) -> RequireOrImportMeta; + fn require_or_import_meta_for_source(&mut self, id: u32) -> RequireOrImportMeta; } impl RequireOrImportMetaCallback { - pub(crate) fn call(&self, id: u32, was_unwrapped_require: bool) -> RequireOrImportMeta { - (self.callback)(self.ctx.unwrap().as_ptr(), id, was_unwrapped_require) + pub(crate) fn call(&self, id: u32) -> RequireOrImportMeta { + (self.callback)(self.ctx.unwrap().as_ptr(), id) } pub fn init(ctx: &mut T) -> Self { - fn thunk( - p: *mut (), - id: u32, - was_unwrapped_require: bool, - ) -> RequireOrImportMeta { + fn thunk(p: *mut (), id: u32) -> RequireOrImportMeta { // SAFETY: `p` was constructed from `&mut T` in `init` below; caller guarantees // `ctx` outlives this `RequireOrImportMetaCallback`, so the cast-back // deref is valid and exclusive. - unsafe { (*p.cast::()).require_or_import_meta_for_source(id, was_unwrapped_require) } + unsafe { (*p.cast::()).require_or_import_meta_for_source(id) } } Self { // Type-erased to `*mut ()` and cast back to `*mut T` inside the thunk before dereference. @@ -2013,7 +1999,6 @@ pub(crate) mod __gated_printer { match statement { None => self.print_require_or_import_expr( import.import_record_index, - false, &[], Expr::EMPTY, Level::Lowest, @@ -2034,7 +2019,6 @@ pub(crate) mod __gated_printer { self.print_equals(); self.print_require_or_import_expr( import.import_record_index, - false, &[], Expr::EMPTY, Level::Lowest, @@ -2084,7 +2068,6 @@ pub(crate) mod __gated_printer { match statement { None => self.print_require_or_import_expr( import.import_record_index, - false, &[], Expr::EMPTY, Level::Lowest, @@ -2707,7 +2690,6 @@ pub(crate) mod __gated_printer { pub(crate) fn print_require_or_import_expr( &mut self, import_record_index: u32, - was_unwrapped_require: bool, leading_interior_comments: &[G::Comment], import_options: Expr, level_: Level, @@ -2756,10 +2738,9 @@ pub(crate) mod __gated_printer { } if record.source_index.is_valid() { - let mut meta = self.options.require_or_import_meta_for_source( - record.source_index.get(), - was_unwrapped_require, - ); + let mut meta = self + .options + .require_or_import_meta_for_source(record.source_index.get()); // Don't need the namespace object if the result is unused anyway if flags.contains(ExprFlag::ExprResultIsUnused) { @@ -2803,7 +2784,6 @@ pub(crate) mod __gated_printer { // Internal "require()" or "import()" let has_side_effects = meta.wrapper_ref.is_valid() || meta.exports_ref.is_valid() - || meta.was_unwrapped_require || self.options.input_files_for_dev_server.is_some(); if record.kind == ImportKind::Dynamic { self.print_space_before_identifier(); @@ -2836,7 +2816,7 @@ pub(crate) mod __gated_printer { let path = &input_files[record.source_index.get() as usize].path; self.print_string_literal_utf8(path.pretty, false); self.print(b")"); - } else if !meta.was_unwrapped_require { + } else { // Call the wrapper if meta.wrapper_ref.is_valid() { self.print_space_before_identifier(); @@ -2872,10 +2852,6 @@ pub(crate) mod __gated_printer { self.print(b")"); } } - } else { - if !meta.exports_ref.is_empty() { - self.print_symbol(meta.exports_ref); - } } if wrap_comma_operator { @@ -3582,9 +3558,10 @@ pub(crate) mod __gated_printer { } } ExprData::ERequireString(e) => { + // An unwrapped require() is consumed by the parser's visit_decls, never printed. + debug_assert!(e.unwrapped_id.is_none()); self.print_require_or_import_expr( e.import_record_index, - e.unwrapped_id.is_some(), &[], Expr::EMPTY, level, @@ -3652,7 +3629,6 @@ pub(crate) mod __gated_printer { } else { self.print_require_or_import_expr( e.import_record_index, - false, &[], // e.leading_interior_comments, e.options, level, From 482258f10aaf3a3ac74efcdd6853fe1c2c6cc16a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:28:38 +0000 Subject: [PATCH 3/8] bundler: remove the last readers of the unwrapped require() marker The marker now exists only between transpose_require and the identifier branch of visit_decls that consumes it. require_namespace_ref and value_is_import_namespace can no longer see one. --- src/bundler/LinkerContext.rs | 3 --- src/js_parser/p.rs | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index c3d586cea8aa..9aee0dae6e6b 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -4457,9 +4457,6 @@ impl<'a> LinkerContext<'a> { // A named import that holds a namespace (`export * as`) prints as // an import identifier. bun_ast::ExprData::EImportIdentifier(identifier) => identifier.ref_, - bun_ast::ExprData::ERequireString(require) => { - return require.unwrapped_id.get().is_some(); - } _ => return false, }; // A require() lifted into an import binds an ordinary local, so user diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index cda4ec7e9ecc..e8838d82383d 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1434,7 +1434,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// `try_track_dynamic_import_destructure` / `maybe_rewrite_property_access` /// can record aliases against it. pub(crate) fn require_namespace_ref(&mut self, req: E::RequireString) -> Option { - if !self.options.bundle || req.unwrapped_id.get().is_some() { + if !self.options.bundle { return None; } let ns = self.new_symbol(js_ast::symbol::Kind::Other, b"require_ns"); From 097d1d1f7de7af614393c8b70663e886bb27ec1c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:28:38 +0000 Subject: [PATCH 4/8] bundler: apply the CommonJS unwrap list only to CommonJS files A file under node_modules/react, react-dom, scheduler and the rest of the unwrap list got unwrap_all_requires, and from it force_cjs_to_esm, from its directory name alone. On a default import the linker then put a real ES module back into a __commonJS wrapper, and tree shaking dropped every export. A JSON or text file of such a package had its default import bound to the namespace object. prepare_for_visit_pass now clears unwrap_all_requires for a file with ES module syntax, so every reader agrees, and to_lazy_export_ast clears it for a lazy export. --- src/js_parser/p.rs | 4 + src/js_parser/parse/parse_entry.rs | 4 +- test/bundler/bundler_cjs2esm.test.ts | 202 +++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 1 deletion(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index e8838d82383d..7841e6a14333 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -353,6 +353,8 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// beside the real count rather than decremented from it so the minifier's /// single-use substitution still sees every use. pub(crate) namespace_tracked_uses: HashMap, + /// A CommonJS file of a package in `unwrap_commonjs_packages`. `init` sets it + /// from the path, `prepare_for_visit_pass` clears it for an ES module. pub(crate) unwrap_all_requires: bool, pub(crate) commonjs_named_exports: bun_ast::ast_result::CommonJSNamedExports, @@ -3327,6 +3329,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O || self.esm_import_keyword.len > 0 || self.esm_export_keyword.len > 0 || self.top_level_await_keyword.len > 0; + // The unwrap list converts CommonJS files. A file with ES module syntax is not one. + self.unwrap_all_requires = self.unwrap_all_requires && !self.has_es_module_syntax; if let Some(factory) = self.lexer.jsx_pragma.jsx() { // `Span.text` is a `StoreStr` into lexer-owned source; valid for 'a. diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 743cc7c322c6..b3f05a9cde66 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -568,6 +568,9 @@ impl<'a> Parser<'a> { p.should_fold_typescript_constant_expressions = true; } + // A lazy export (JSON, text, a file path) is not CommonJS that the unwrap list converts. + p.unwrap_all_requires = false; + // If we added to `p.symbols` it's going to fuck up all the indices // in the `symbols` array. debug_assert!(p.symbols.len() == 0); @@ -1605,7 +1608,6 @@ impl<'a> Parser<'a> { if p.options.features.unwrap_commonjs_to_esm && p.unwrap_all_requires && !p.options.is_entry_point - && !p.has_es_module_syntax && p.commonjs_named_exports.count() == 0 && !p.has_top_level_return && !p.has_with_scope diff --git a/test/bundler/bundler_cjs2esm.test.ts b/test/bundler/bundler_cjs2esm.test.ts index 06a0082e5e35..d38296daaec1 100644 --- a/test/bundler/bundler_cjs2esm.test.ts +++ b/test/bundler/bundler_cjs2esm.test.ts @@ -458,6 +458,27 @@ describe("bundler", () => { stdout: "react none\nreact react\nreact", }, }); + // An external package has no namespace object in the bundle. The + // destructuring reads from the import statement that the require() became. + itBundled("cjs2esm/UnwrappedModuleRequireDestructuredExternal", { + files: { + "/entry.js": /* js */ ` + const { react, missing = "fallback" } = require("react"); + console.log(react, missing); + `, + }, + external: ["react"], + target: "bun", + runtimeFiles: fakeReactNodeModules, + onAfterBundle: api => { + const code = api.readFile("out.js"); + expect(code).toContain('import * as react from "react"'); + expect(code).toMatch(/\{ react: react2, missing = "fallback" \} = react;/); + }, + run: { + stdout: "react fallback", + }, + }); // `sideEffect(); module.exports = require("./main")` in an unwrapped package // becomes `sideEffect(); export * from "./main"`, so the file needs no // `__commonJS` wrapper and the named import binds to the export directly. @@ -806,6 +827,187 @@ describe("bundler", () => { minifyIdentifiers: true, target: "bun", }); + // A file with `import` / `export` syntax in an unwrapped package is an ES + // module like in any other package (preact/compat installed as "react"). + // A default import binds its `default` export and the file gets no wrapper. + itBundled("cjs2esm/ESModuleInUnwrappedPackageDefaultImport", { + files: { + "/entry.js": /* js */ ` + import React, { version } from "react"; + import ReactDOM from "react-dom"; + import fromRequire from "./uses-require.cjs"; + console.log(typeof React, React.version, version, ReactDOM.render(), fromRequire); + `, + "/uses-require.cjs": /* js */ ` + const React = require("react"); + module.exports = React.version + typeof React.default; + `, + "/node_modules/react/package.json": /* json */ ` + { "name": "react", "version": "19.0.0", "type": "module", "main": "./index.js" } + `, + "/node_modules/react/index.js": /* js */ ` + export const version = "19"; + export default { version }; + `, + "/node_modules/react-dom/package.json": /* json */ ` + { + "name": "@preact/compat", + "version": "18.3.1", + "exports": { ".": { "import": "./compat.mjs", "require": "./compat.js" } } + } + `, + "/node_modules/react-dom/compat.mjs": /* js */ ` + export function render() { return "rendered"; } + export default { render }; + `, + "/node_modules/react-dom/compat.js": /* js */ ` + exports.render = function () { return "wrong file"; }; + `, + }, + cjs2esm: { unhandled: ["/uses-require.cjs"] }, + run: { stdout: "object 19 19 rendered 19object" }, + }); + itBundled("cjs2esm/ESModuleInUnwrappedPackageDefaultImportMinified", { + files: { + "/entry.js": /* js */ ` + import React from "react"; + console.log(typeof React, React && React.version); + `, + "/node_modules/react/package.json": /* json */ ` + { "name": "react", "version": "19.0.0", "type": "module", "main": "./index.js" } + `, + "/node_modules/react/index.js": /* js */ ` + export const version = "19"; + export default { version }; + `, + }, + target: "browser", + minifySyntax: true, + minifyIdentifiers: true, + minifyWhitespace: true, + run: { stdout: "object 19" }, + }); + // An export that the ES module does not have is a build error, as in any + // other package. `bun run` throws a SyntaxError for both imports. + itBundled("cjs2esm/ESModuleInUnwrappedPackageMissingExport", { + files: { + "/entry.js": /* js */ ` + import React from "react"; + import { nope } from "react-dom"; + console.log(React, nope); + `, + "/node_modules/react/index.js": `export const version = "19";`, + "/node_modules/react-dom/index.js": `export const version = "19";`, + }, + bundleErrors: { + "/entry.js": [ + `No matching export in "node_modules/react/index.js" for import "default"`, + `No matching export in "node_modules/react-dom/index.js" for import "nope"`, + ], + }, + }); + // A destructured `require()` of an unwrapped package reads the namespace + // object of an ES module, inside or outside the unwrap list. + itBundled("cjs2esm/ESModuleInUnwrappedPackageDestructuredRequire", { + files: { + "/entry.js": /* js */ ` + const { version } = require("react"); + const { fromOutside } = require("react-dom"); + console.log(version, fromOutside); + `, + "/node_modules/react/index.js": /* js */ ` + export const version = "19"; + export default { version }; + `, + "/node_modules/react-dom/index.js": /* js */ ` + const { name } = require("not-in-the-list"); + exports.fromOutside = name; + `, + "/node_modules/not-in-the-list/index.js": `export const name = "outside";`, + }, + cjs2esm: true, + run: { stdout: "19 outside" }, + }); + // The namespace of an ES module has no `default` that it does not export. + itBundled("cjs2esm/ESModuleInUnwrappedPackageSplitDynamicImport", { + files: { + "/entry.js": /* js */ ` + const m = await import("react"); + console.log(m.version, typeof m.default, Object.keys(m).join(",")); + `, + "/node_modules/react/index.js": `export const version = "19";`, + }, + outdir: "/out", + splitting: true, + run: { file: "/out/entry.js", stdout: "19 undefined version" }, + }); + // A `require()` in such a file is an ordinary require, so a CommonJS sibling + // that exports a function stays callable. + itBundled("cjs2esm/ESModuleInUnwrappedPackageRequiresCommonJS", { + files: { + "/entry.js": /* js */ ` + import React, { made } from "react"; + console.log(typeof React, made, React.made); + `, + "/node_modules/react/index.js": /* js */ ` + const impl = require("./impl.cjs"); + export const made = impl("A"); + export default { made }; + `, + "/node_modules/react/impl.cjs": /* js */ ` + module.exports = function F(x) { return "F" + x; }; + `, + }, + run: { stdout: "object FA FA" }, + }); + // `import` next to `exports.x = ...` links the same way in and out of the list. + itBundled("cjs2esm/ImportAndExportsAssignmentInUnwrappedPackage", { + files: { + "/entry.js": /* js */ ` + import React from "react"; + import Other from "other"; + console.log(React.foo, Other.foo); + `, + "/node_modules/react/index.js": /* js */ ` + import { x } from "./x.js"; + exports.foo = x; + `, + "/node_modules/react/x.js": `export const x = 5;`, + "/node_modules/other/index.js": /* js */ ` + import { x } from "./x.js"; + exports.foo = x; + `, + "/node_modules/other/x.js": `export const x = 5;`, + }, + run: { stdout: "5 5" }, + }); + // A JSON or text file of an unwrapped package is not CommonJS either. + itBundled("cjs2esm/LazyExportInUnwrappedPackage", { + files: { + "/entry.js": /* js */ ` + import notice from "react/NOTICE.txt"; + import pkg from "react/package.json"; + const { version } = require("react/package.json"); + console.log(typeof notice, notice, Object.keys(pkg).join(","), version); + `, + "/node_modules/react/NOTICE.txt": `notice`, + "/node_modules/react/package.json": `{ "name": "react", "version": "19.0.0" }`, + }, + run: { stdout: "string notice name,version 19.0.0" }, + }); + itBundled("cjs2esm/LazyExportInUnwrappedPackageSplitDynamicImport", { + files: { + "/entry.js": /* js */ ` + import pkg from "react/package.json"; + const m = await import("react/package.json"); + console.log(Object.keys(pkg).join(","), m.default === pkg, Object.keys(m).join(",")); + `, + "/node_modules/react/package.json": `{ "name": "react", "version": "19.0.0" }`, + }, + outdir: "/out", + splitting: true, + run: { file: "/out/entry.js", stdout: "name,version true default,name,version" }, + }); itBundled("cjs2esm/ModuleExportsRenamingNoDeopt", { files: { "/entry.js": /* js */ ` From 494a93949218f2b3bbc44811cb4e50fb71a5841a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:06:00 +0000 Subject: [PATCH 5/8] bundler: an ES module by type is not in the CommonJS unwrap list either A file under "type": "module" or with an .mjs extension that has no import/export statement kept unwrap_all_requires, so its require() of a CommonJS sibling that exports a function became a namespace import and the call threw. --- src/js_parser/p.rs | 6 ++++-- test/bundler/bundler_cjs2esm.test.ts | 29 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 7841e6a14333..2ad137cafbdf 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -3329,8 +3329,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O || self.esm_import_keyword.len > 0 || self.esm_export_keyword.len > 0 || self.top_level_await_keyword.len > 0; - // The unwrap list converts CommonJS files. A file with ES module syntax is not one. - self.unwrap_all_requires = self.unwrap_all_requires && !self.has_es_module_syntax; + // The unwrap list converts CommonJS files. An ES module, by syntax or by type, is not one. + self.unwrap_all_requires = self.unwrap_all_requires + && !self.has_es_module_syntax + && self.options.module_type != options::ModuleType::Esm; if let Some(factory) = self.lexer.jsx_pragma.jsx() { // `Span.text` is a `StoreStr` into lexer-owned source; valid for 'a. diff --git a/test/bundler/bundler_cjs2esm.test.ts b/test/bundler/bundler_cjs2esm.test.ts index d38296daaec1..ead0937308d2 100644 --- a/test/bundler/bundler_cjs2esm.test.ts +++ b/test/bundler/bundler_cjs2esm.test.ts @@ -960,6 +960,35 @@ describe("bundler", () => { }, run: { stdout: "object FA FA" }, }); + // The same for a file that is an ES module by its type (`"type": "module"`, + // `.mjs`) and has no `import` / `export` statement. + itBundled("cjs2esm/ESModuleByTypeInUnwrappedPackageRequiresCommonJS", { + files: { + "/entry.js": /* js */ ` + import "react"; + import "react-dom/setup.mjs"; + console.log(globalThis.fromReact, globalThis.fromReactDOM); + `, + "/node_modules/react/package.json": /* json */ ` + { "name": "react", "version": "19.0.0", "type": "module", "main": "./index.js" } + `, + "/node_modules/react/index.js": /* js */ ` + const impl = require("./impl.cjs"); + globalThis.fromReact = impl("A"); + `, + "/node_modules/react/impl.cjs": /* js */ ` + module.exports = function F(x) { return "F" + x; }; + `, + "/node_modules/react-dom/setup.mjs": /* js */ ` + const impl = require("./impl.cjs"); + globalThis.fromReactDOM = impl("B"); + `, + "/node_modules/react-dom/impl.cjs": /* js */ ` + module.exports = function G(x) { return "G" + x; }; + `, + }, + run: { stdout: "FA GB" }, + }); // `import` next to `exports.x = ...` links the same way in and out of the list. itBundled("cjs2esm/ImportAndExportsAssignmentInUnwrappedPackage", { files: { From 07da4e5c5d68c0e7eae80cacdbb7332456b250cd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:07:30 +0000 Subject: [PATCH 6/8] js_parser: shorten the unwrap_all_requires doc comment --- src/js_parser/p.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 2ad137cafbdf..7b0ac2cf3554 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -353,8 +353,7 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// beside the real count rather than decremented from it so the minifier's /// single-use substitution still sees every use. pub(crate) namespace_tracked_uses: HashMap, - /// A CommonJS file of a package in `unwrap_commonjs_packages`. `init` sets it - /// from the path, `prepare_for_visit_pass` clears it for an ES module. + /// The file is CommonJS in a package of `unwrap_commonjs_packages`. pub(crate) unwrap_all_requires: bool, pub(crate) commonjs_named_exports: bun_ast::ast_result::CommonJSNamedExports, From d33ce2066dba2e191c580354327a93dcf837049b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:31:26 +0000 Subject: [PATCH 7/8] bun_core: remove GenericIndexOptional::is_some The require() printing path that this branch deletes was its only caller. --- src/bun_core/util.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index b9a99746c8f7..8ebb4747f1ac 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -3287,12 +3287,6 @@ impl GenericIndex { GenericIndexOptional(self.0, core::marker::PhantomData) } } -impl GenericIndexOptional { - #[inline] - pub fn is_some(self) -> bool { - !self.is_none() - } -} /// `GenericIndex::Optional` — `MAX` is `none`. #[repr(transparent)] From 5a3a0b88958a5241aa4c2a0bd184dd13c99ede72 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:01:47 +0000 Subject: [PATCH 8/8] js_parser: drop two checks for an unwrapped require() marker that never reach one visit_decls consumes the marker of an identifier binding before the split_require block runs, and the branches of a conditional initializer never get a marker. --- src/js_parser/p.rs | 4 +--- src/js_parser/visit/mod.rs | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 7b0ac2cf3554..99093a4eb57b 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1305,9 +1305,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.conditional_namespace_records(e.yes, out)?; self.conditional_namespace_records(e.no, out) } - js_ast::ExprData::ERequireString(req) - if self.options.bundle && req.unwrapped_id.get().is_none() => - { + js_ast::ExprData::ERequireString(req) if self.options.bundle => { out.push(req.import_record_index); Some(()) } diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 4301e683a2b4..13215ef391c4 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -559,7 +559,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let local = id.r#ref; if self.import_items_for_namespace.contains_key(&local) || !self.options.bundle - || req.unwrapped_id.get().is_some() { break 'split_require; }