From f628c3f18fa49fa565acd4f3e71d5ae66bfd549f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:09:31 +0000 Subject: [PATCH 01/12] bundler: skip `__toCommonJS` and the split chunk for require() of a lifted CommonJS file A `require()` that stays a call (inside `try`/`catch`, or through an alias of the package) of a CommonJS file that the parser lifted to an ES module evaluated to `__toCommonJS(exports_x)`: a read-only copy with an `__esModule` property that the file never set. With `--splitting --target=bun` the call was `import.meta.require(chunk)`, which returns the module namespace of the chunk with the same property. The parser now records when a lifted file is CommonJS to `bun run` (`NAMESPACE_IS_MODULE_EXPORTS`): a top-level `exports.x = ...` became an export, or a `require()` became an import and the file is not an ES module by type. For such a target the linker does not wrap the call in `__toCommonJS`, and `find_reachable_files` does not split the call. The call evaluates to `(init_x(), exports_x)`, the namespace object that an unwrapped `require()` and a default import already use. --- docs/bundler/index.mdx | 4 +- src/ast/ast_result.rs | 8 + src/bundler/LinkerContext.rs | 51 +++ src/bundler/bundle_v2.rs | 27 +- src/bundler/bundled_ast.rs | 9 +- .../linker_context/scanImportsAndExports.rs | 25 +- src/js_parser/p.rs | 13 +- test/bundler/bundler_cjs2esm.test.ts | 378 +++++++++++++++++- test/bundler/bundler_splitting.test.ts | 218 +++++++++- 9 files changed, 715 insertions(+), 18 deletions(-) diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index dcbed53d79c0..fdcf18333168 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -560,7 +560,7 @@ The generated `chunk-dqmx6gc8.js` file contains the shared code. To avoid collis Each `import()` of a bundled JavaScript module also becomes its own chunk. Tree shaking applies to these chunks too: if every `import()` of a module lives in code that tree shaking removes — for example inside a function that is only called behind a [`define`](#define) or [`features`](#features) gate that evaluates to `false` — and nothing else in the live output imports the module, its chunk is not written and the module is absent from the [metafile](#metafile)'s `inputs` and `outputs`. Setting `treeShaking: false` keeps every `import()` chunk. This differs from esbuild, which emits a chunk for every reachable `import()` target. -With `target: "bun"`, a `require()` of a bundled ES module becomes a chunk of its own as well. The call is emitted as `import.meta.require("./chunk-.js")` and stays synchronous — Bun evaluates the chunk when the call runs — so a `require()` inside a function that never runs keeps its module out of the startup working set entirely. Tree shaking treats these chunks like `import()` chunks. `require()` of a CommonJS module is unaffected and keeps returning `module.exports`. A `require()` cycle between such chunks follows Bun's runtime `require()` of an ES module: a module required again while its chunk is still being loaded sees the partially initialized CommonJS placeholder (`{}`) rather than the live bindings the in-chunk wrapper provides. For other targets — and with `splitRequire: false` (`--no-split-require`) — the required module is inlined into the calling chunk behind a lazy wrapper instead, because the call must return synchronously. +With `target: "bun"`, a `require()` of a bundled ES module becomes a chunk of its own as well. The call is emitted as `import.meta.require("./chunk-.js")` and stays synchronous — Bun evaluates the chunk when the call runs — so a `require()` inside a function that never runs keeps its module out of the startup working set entirely. Tree shaking treats these chunks like `import()` chunks. `require()` of a CommonJS module is unaffected and keeps returning `module.exports`. That includes a CommonJS module whose `exports.x = ...` assignments the bundler lifted to ES module exports, such as the files of `react` and `react-dom`: it stays in the calling chunk, and `require()` returns its namespace object, which stands in for `module.exports`. A `require()` cycle between such chunks follows Bun's runtime `require()` of an ES module: a module required again while its chunk is still being loaded sees the partially initialized CommonJS placeholder (`{}`) rather than the live bindings the in-chunk wrapper provides. For other targets — and with `splitRequire: false` (`--no-split-require`) — the required module is inlined into the calling chunk behind a lazy wrapper instead, because the call must return synchronously. #### Tree-shaking `import()` and `require()` results @@ -1439,7 +1439,7 @@ In each case the member access compiles to a direct reference to `object`, the ` Assignments (`z.x = 1`), optional chains, and non-literal computed keys (`z[key]`) are left as property accesses; `z["object"]` is treated like `z.object`. `export default someImport` is followed only when it ends at a namespace; a default that snapshots a `let` export keeps snapshot semantics. -The same applies to the default import of a CommonJS module whose `exports.x = ...` assignments the bundler lifted to ES module exports, such as `react` and `scheduler`. The default import of a CommonJS module is its `module.exports`, which is that module's namespace, so `import React from "react"; React.useState()` compiles to a direct call of the lifted `useState` binding. This holds while every use of `React` reads, calls or assigns one of its properties: `React.useState`, `React[key]`, `const { useState } = React`. A write, `React.useLayoutEffect = React.useEffect`, goes through a namespace object whose setters assign the lifted binding, so every importer sees the new value, the same as a write to `module.exports`. Code that holds `React` itself as a value (`fn(React)`, `Object.keys(React)`, `const R = React`, a re-export) or deletes one of its properties can change the object in ways a namespace object cannot pass on to the lifted bindings (`Object.defineProperty`, `delete`, `Object.freeze`). The module then keeps its CommonJS wrapper and the default import is the real `module.exports` object. This rule looks at `import React from` only. `ns.default` on `import * as ns`, the `default` of an `import()` and the `this` of a call like `React.fn()` are still the namespace object of a module that stays lifted. `React.default`, and `ns.default` on `import * as ns`, is the lifted `default` export when the module has one, and otherwise the namespace itself, as `module.exports` is in Node. A module that sets both `exports.__esModule` and `exports.default` keeps its CommonJS wrapper when the importer is not an ES module by type (`.mjs`, `.mts`, or `"type": "module"`), because the default import then depends on that flag at run time. An `import()` of a module that does not set both resolves to that same namespace object as `default`, with or without code splitting. +The same applies to the default import of a CommonJS module whose `exports.x = ...` assignments the bundler lifted to ES module exports, such as `react` and `scheduler`. The default import of a CommonJS module is its `module.exports`, which is that module's namespace, so `import React from "react"; React.useState()` compiles to a direct call of the lifted `useState` binding. This holds while every use of `React` reads, calls or assigns one of its properties: `React.useState`, `React[key]`, `const { useState } = React`. A write, `React.useLayoutEffect = React.useEffect`, goes through a namespace object whose setters assign the lifted binding, so every importer sees the new value, the same as a write to `module.exports`. Code that holds `React` itself as a value (`fn(React)`, `Object.keys(React)`, `const R = React`, a re-export) or deletes one of its properties can change the object in ways a namespace object cannot pass on to the lifted bindings (`Object.defineProperty`, `delete`, `Object.freeze`). The module then keeps its CommonJS wrapper and the default import is the real `module.exports` object. This rule looks at `import React from` only. `ns.default` on `import * as ns`, the `default` of an `import()` and the `this` of a call like `React.fn()` are still the namespace object of a module that stays lifted. `React.default`, and `ns.default` on `import * as ns`, is the lifted `default` export when the module has one, and otherwise the namespace itself, as `module.exports` is in Node. A module that sets both `exports.__esModule` and `exports.default` keeps its CommonJS wrapper when the importer is not an ES module by type (`.mjs`, `.mts`, or `"type": "module"`), because the default import then depends on that flag at run time. An `import()` of a module that does not set both resolves to that same namespace object as `default`, with or without code splitting. A `require()` of a lifted module evaluates to that namespace object too, also when the call stays a call in the output because it is inside `try`/`catch` or its specifier is an alias of the package. The object has an `__esModule` property only if the module sets `exports.__esModule`, and a write through it assigns the lifted binding. `Object.defineProperty`, `delete` and `Object.freeze` on it do not reach the lifted bindings. ### deprecatedNamespaceObjectSetters diff --git a/src/ast/ast_result.rs b/src/ast/ast_result.rs index dcc3b920b894..215f62681fc0 100644 --- a/src/ast/ast_result.rs +++ b/src/ast/ast_result.rs @@ -39,6 +39,13 @@ pub struct Ast<'a> { /// `force_cjs_to_esm` for a file with no ES module syntax: its exports were /// lifted from `exports.foo = ...`, so its `module.exports` is the namespace. pub commonjs_lifted_to_esm: bool, + /// `commonjs_lifted_to_esm` for a file that is an ES module in this bundle + /// and CommonJS to `bun run`: a top-level `exports.foo = ...` became an + /// export, or a `require()` became an import and the file is not an ES + /// module by type. `require()` of it returns the namespace. A file with + /// neither is an ES module to `bun run` too: its `require()` value has + /// `__esModule`. + pub namespace_is_module_exports: bool, pub exports_kind: ExportsKind, // This is a list of ES6 features. They are ranges instead of booleans so @@ -119,6 +126,7 @@ impl<'a> Ast<'a> { commonjs_module_exports_assigned_deoptimized: false, force_cjs_to_esm: false, commonjs_lifted_to_esm: false, + namespace_is_module_exports: false, exports_kind: ExportsKind::None, export_keyword: Range::NONE, top_level_await_keyword: Range::NONE, diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 7ed96c3aaa28..eaa67b23d496 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -4533,6 +4533,57 @@ impl<'a> LinkerContext<'a> { && ast_flags.contains(AstFlags::COMMONJS_LIFTED_TO_ESM) } + /// Does `require()` of `target` evaluate to its namespace object? It does + /// when that object stands in for `module.exports` + /// (`NAMESPACE_IS_MODULE_EXPORTS`): the call gets no `__toCommonJS` copy, + /// and the file is not a chunk that `import.meta.require` loads as an ES + /// module. A lifted `module.exports = require("./impl")` is an `export *`, + /// so `./impl` decides. + pub(crate) fn require_returns_namespace( + ast_flags: &[AstFlags], + export_star_import_records: &[bun_alloc::AstVec], + import_records: &[bun_ast::import_record::List<'_>], + redirects: &[u32], + target: u32, + ) -> bool { + let mut file = target as usize; + // Each step follows one record, so a longer walk is a cycle. + for _ in 0..ast_flags.len() { + let record_index = match redirects.get(file) { + // `find_reachable_files` points a record past a redirect file + // only when it visits the importer. + Some(&redirect) if redirect != u32::MAX => redirect, + _ => { + if !ast_flags + .get(file) + .is_some_and(|flags| flags.contains(AstFlags::NAMESPACE_IS_MODULE_EXPORTS)) + { + return false; + } + match export_star_import_records + .get(file) + .map(|stars| stars.as_slice()) + { + Some([]) => return true, + Some(&[star]) => star, + _ => return false, + } + } + }; + let Some(record) = import_records + .get(file) + .and_then(|records| records.as_slice().get(record_index as usize)) + else { + return false; + }; + if !record.source_index.is_valid() { + return false; + } + file = record.source_index.get() as usize; + } + false + } + /// Resolves every named import in one file to its matching export, /// recording the bindings in `imports_to_bind`. pub(crate) fn match_imports_with_exports_for_file( diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index c92db0195230..26d3bcf532f1 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2068,6 +2068,8 @@ pub mod bv2_impl { pub(crate) dynamic_import_entry_points: &'a mut ArrayHashMap, pub(crate) split_require: bool, pub(crate) all_exports_kinds: &'a [ExportsKind], + pub(crate) all_ast_flags: &'a [crate::bundled_ast::Flags], + pub(crate) all_export_star_import_records: &'a [bun_alloc::AstVec], pub(crate) all_targets: &'a [Target], /// Files which are Server Component Boundaries pub(crate) scb_bitset: Option, @@ -2238,21 +2240,32 @@ pub mod bv2_impl { } let next_source = import_record.source_index; + let kind = import_record.kind; // The importing file's own target decides: a server // build's browser-side files cannot call `import.meta.require`. let target_is_bun = self.split_require && self.all_targets[source_index.get() as usize].is_bun(); - let mut was_dynamic_import = CHECK_DYNAMIC_IMPORTS - && import_record.kind.can_be_lazy_chunk(target_is_bun); + let mut was_dynamic_import = + CHECK_DYNAMIC_IMPORTS && kind.can_be_lazy_chunk(target_is_bun); // A `require()` splits only when it targets another ES // module; the flag is the linker's and printer's signal. - if was_dynamic_import && import_record.kind == ImportKind::Require { + // A lifted CommonJS module is CommonJS here: the call + // returns its exports object, not a chunk's namespace. + if was_dynamic_import && kind == ImportKind::Require { was_dynamic_import = next_source.is_valid() && next_source != import_record_list_id && self.all_exports_kinds[next_source.get() as usize] - == ExportsKind::Esm; + == ExportsKind::Esm + && !LinkerContext::require_returns_namespace( + self.all_ast_flags, + self.all_export_star_import_records, + self.all_import_records, + self.redirects, + next_source.get(), + ); if was_dynamic_import { - import_record + self.all_import_records[import_record_list_id.get() as usize] + .as_mut_slice()[ir_idx] .flags .insert(bun_ast::ImportRecordFlags::CROSS_CHUNK_REQUIRE); } @@ -2357,6 +2370,8 @@ pub mod bv2_impl { ast_slice.split_mut().import_records; let all_urls_for_css = self.graph.ast.items_url_for_css(); let all_exports_kinds = self.graph.ast.items_exports_kind(); + let all_ast_flags = self.graph.ast.items_flags(); + let all_export_star_import_records = self.graph.ast.items_export_star_import_records(); let all_targets = self.graph.ast.items_target(); let mut visitor = ReachableFileVisitor { @@ -2369,6 +2384,8 @@ pub mod bv2_impl { dynamic_import_entry_points: &mut self.dynamic_import_entry_points, split_require: self.transpiler.options.split_require, all_exports_kinds, + all_ast_flags, + all_export_star_import_records, all_targets, scb_bitset, scb_list, diff --git a/src/bundler/bundled_ast.rs b/src/bundler/bundled_ast.rs index 58c0b71f07b5..c7deab994e4e 100644 --- a/src/bundler/bundled_ast.rs +++ b/src/bundler/bundled_ast.rs @@ -157,7 +157,9 @@ bitflags::bitflags! { const HAS_IMPORT_META = 1 << 8; /// See `Ast::commonjs_lifted_to_esm`. const COMMONJS_LIFTED_TO_ESM = 1 << 9; - // _padding: u6 fills the rest + /// See `Ast::namespace_is_module_exports`. + const NAMESPACE_IS_MODULE_EXPORTS = 1 << 10; + // _padding: u5 fills the rest } } @@ -262,6 +264,7 @@ impl<'arena> BundledAst<'arena> { }, force_cjs_to_esm: self.flags.contains(Flags::FORCE_CJS_TO_ESM), commonjs_lifted_to_esm: self.flags.contains(Flags::COMMONJS_LIFTED_TO_ESM), + namespace_is_module_exports: self.flags.contains(Flags::NAMESPACE_IS_MODULE_EXPORTS), has_lazy_export: self.flags.contains(Flags::HAS_LAZY_EXPORT), commonjs_module_exports_assigned_deoptimized: self .flags @@ -287,6 +290,10 @@ impl<'arena> BundledAst<'arena> { flags.set(Flags::USES_EXPORT_KEYWORD, ast.export_keyword.len > 0); flags.set(Flags::FORCE_CJS_TO_ESM, ast.force_cjs_to_esm); flags.set(Flags::COMMONJS_LIFTED_TO_ESM, ast.commonjs_lifted_to_esm); + flags.set( + Flags::NAMESPACE_IS_MODULE_EXPORTS, + ast.namespace_is_module_exports, + ); flags.set(Flags::HAS_LAZY_EXPORT, ast.has_lazy_export); flags.set( Flags::COMMONJS_MODULE_EXPORTS_ASSIGNED_DEOPTIMIZED, diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index 73dee5b22d28..c2fa4972bca7 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -108,6 +108,7 @@ pub(crate) fn scan_imports_and_exports( let ast_flags_list: *mut [AstFlags] = ast.flags; let module_types: *mut [crate::options::ModuleType] = ast.module_type; let export_star_import_records: *mut [bun_alloc::AstVec] = ast.export_star_import_records; + let redirects: *mut [u32] = ast.redirect_import_record_index; let exports_refs: *mut [Ref] = ast.exports_ref; let module_refs: *mut [Ref] = ast.module_ref; let wrapper_refs: *mut [Ref] = ast.wrapper_ref; @@ -1155,6 +1156,17 @@ pub(crate) fn scan_imports_and_exports( // `default: module.exports`, so the printed call reads // `.default` to return `module.exports`, the same value // an unsplit `require()` returns. + debug_assert!( + !(kind == ImportKind::Require && is_external_dyn) + || !LinkerContext::require_returns_namespace( + col_ref!(ast_flags_list), + col_ref!(export_star_import_records), + col_ref!(import_records_list), + col_ref!(redirects), + rec_source_index.get(), + ), + "find_reachable_files splits no require() of a lifted CommonJS file" + ); if kind == ImportKind::Require && is_external_dyn && rec_source_index.is_valid() @@ -1278,7 +1290,18 @@ pub(crate) fn scan_imports_and_exports( // code should see "__esModule". This is an extremely complex // and subtle set of transpiler interop issues. See for example // https://github.com/evanw/esbuild/issues/1591. - if kind == ImportKind::Require { + // + // A lifted CommonJS module was never an ES module: its + // exports object is its `module.exports`. + if kind == ImportKind::Require + && !LinkerContext::require_returns_namespace( + col_ref!(ast_flags_list), + col_ref!(export_star_import_records), + col_ref!(import_records_list), + col_ref!(redirects), + other_source_index, + ) + { col!(import_records_list)[id].as_mut_slice() [import_record_index as usize] .flags diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 7fdd497ff95a..97e896931db3 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -9232,6 +9232,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let force_cjs_to_esm = self.unwrap_all_requires || exports_kind == js_ast::ExportsKind::EsmWithDynamicFallbackFromCjs; + let commonjs_lifted_to_esm = force_cjs_to_esm && !self.has_es_module_syntax; + let namespace_is_module_exports = commonjs_lifted_to_esm + && exports_kind == js_ast::ExportsKind::Esm + && (self + .commonjs_named_exports + .values() + .iter() + .any(|export| !export.needs_decl) + || (self.options.module_type != options::ModuleType::Esm + && !self.imports_to_convert_from_require.is_empty())); let wrapper_ref: Ref = 'brk: { if self.options.features.hot_module_reloading { @@ -9358,7 +9368,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O require_ref, force_cjs_to_esm, - commonjs_lifted_to_esm: force_cjs_to_esm && !self.has_es_module_syntax, + commonjs_lifted_to_esm, + namespace_is_module_exports, uses_module_ref, uses_exports_ref, uses_require_ref, diff --git a/test/bundler/bundler_cjs2esm.test.ts b/test/bundler/bundler_cjs2esm.test.ts index 57e31a43c888..659765694e26 100644 --- a/test/bundler/bundler_cjs2esm.test.ts +++ b/test/bundler/bundler_cjs2esm.test.ts @@ -369,7 +369,7 @@ describe("bundler", () => { // 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 - // never unwrapped and prints as an ordinary require. + // never unwrapped and stays a call, which returns that same object. itBundled("cjs2esm/UnwrappedModuleRequireDestructuredAndInTry", { files: { "/entry.js": /* js */ ` @@ -396,12 +396,386 @@ describe("bundler", () => { onAfterBundle: api => { const code = api.readFile("out.js"); expect(code).toMatch(/\{ react: named \} = \(?exports_react\)?;/); - expect(code).toContain("__toCommonJS(exports_react)).react"); + expect(code).toContain("(init_react(), exports_react).react"); + expect(code).not.toContain("__toCommonJS"); }, run: { stdout: "react\nreact", }, }); + + // A require() that stays a call, of a CommonJS file of an unwrapped package + // that was lifted to an ES module, returns the file's namespace object. + // That object stands in for `module.exports`: it has no `__esModule` unless + // the file sets one, every require() of the file returns it, and a write + // reaches the lifted binding. A `__toCommonJS` copy has none of these. + // Every stdout below is what `bun run` prints for the same files. + const reactWithCounter = { + "/node_modules/react/package.json": /* json */ ` + { "name": "react", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react/index.js": /* js */ ` + exports.make = x => "F" + x; + exports.count = 0; + exports.inc = () => ++exports.count; + `, + }; + for (const backend of ["cli", "api"] as const) { + // The call stays in a `try` body and in a `catch` body. The same file + // under a name off the unwrap list keeps its `__commonJS` wrapper. + itBundled(`cjs2esm/KeptRequireOfLiftedCommonJS-${backend}`, { + files: { + "/entry.js": /* js */ ` + const unwrapped = require("react"); + let inTry, inCatch, other; + try { inTry = require("react"); } catch {} + try { throw 1; } catch { inCatch = require("react"); } + try { other = require("other"); } catch {} + console.log(inTry.__esModule, typeof inTry.default, other.__esModule, typeof other.default); + console.log((inTry.__esModule ? inTry.default : inTry).make("A"), Object.keys(inTry).join()); + console.log(inTry === unwrapped, inCatch === unwrapped); + inTry.count = 41; + console.log(inTry.count, inTry.inc(), unwrapped.count); + `, + ...reactWithCounter, + "/node_modules/other/package.json": /* json */ ` + { "name": "other", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/other/index.js": reactWithCounter["/node_modules/react/index.js"], + }, + backend, + onAfterBundle(api) { + const code = api.readFile("out.js"); + expect(code).toContain("inTry = (init_react(), exports_react);"); + expect(code).toContain("inCatch = (init_react(), exports_react);"); + expect(code).toContain("other = require_other();"); + expect(code).not.toContain("__toCommonJS"); + }, + run: { + stdout: "undefined undefined undefined undefined\nFA make,count,inc\ntrue true\n41 42 42", + }, + }); + } + // A specifier that is not on the unwrap list can resolve to a file of a + // listed package. The parser keeps such a call with no `try` around it. + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSThroughAlias", { + files: { + "/entry.js": /* js */ ` + import { viaRelativePath } from "sibling"; + const unwrapped = require("react"); + const viaTsconfigPaths = require("tsconfig-alias"); + const viaPackageImports = require("#react"); + for (const m of [viaTsconfigPaths, viaPackageImports, viaRelativePath()]) { + console.log(m.__esModule, m === unwrapped, (m.count = m.count + 40), m.inc()); + } + `, + "/tsconfig.json": /* json */ ` + { "compilerOptions": { "baseUrl": ".", "paths": { "tsconfig-alias": ["./node_modules/react/index.js"] } } } + `, + "/package.json": /* json */ ` + { "name": "app", "imports": { "#react": "react" } } + `, + "/node_modules/sibling/package.json": /* json */ ` + { "name": "sibling", "version": "1.0.0", "main": "index.js" } + `, + "/node_modules/sibling/index.js": /* js */ ` + exports.viaRelativePath = () => require("../react"); + `, + ...reactWithCounter, + }, + onAfterBundle(api) { + api.expectFile("/out.js").not.toContain("__toCommonJS"); + }, + run: { + stdout: "undefined true 40 41\nundefined true 81 82\nundefined true 122 123", + }, + }); + // The parser binds names read off a require() result, but it leaves + // `__esModule` and `default` to the object that the call returns. + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSReadsEsModuleOffNamespace", { + files: { + "/entry.js": /* js */ ` + try { + const m = require("react"); + console.log(m.__esModule, typeof m.default, m.make("A")); + const { make, __esModule } = require("react"); + console.log(__esModule, make("B")); + } catch (e) { + console.log(e); + } + `, + ...reactWithCounter, + }, + onAfterBundle(api) { + api.expectFile("/out.js").not.toContain("__toCommonJS"); + }, + run: { + stdout: "undefined undefined FA\nundefined FB", + }, + }); + // A lifted file that sets `exports.__esModule` exports that name itself. + // It is enumerable, as on `module.exports`, and not on a `__toCommonJS` copy. + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSThatSetsEsModule", { + files: { + "/entry.js": /* js */ ` + let m; + try { m = require("react"); } catch {} + console.log(m.__esModule, Object.keys(m).join(), (m.__esModule ? m.default : m).tag, m.named); + `, + "/node_modules/react/package.json": reactWithCounter["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + exports.__esModule = true; + exports.default = { tag: "default" }; + exports.named = 1; + `, + }, + onAfterBundle(api) { + api.expectFile("/out.js").not.toContain("__toCommonJS"); + }, + run: { + stdout: "true __esModule,default,named default 1", + }, + }); + // The shape of react-dom/server.node.js, read the way Babel and TypeScript + // compile `import Server from "react-dom/server"`. With a synthetic + // `__esModule`, `Server.default` is `undefined`. + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSThroughInteropHelper", { + files: { + "/entry.js": /* js */ ` + function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + let Server; + try { Server = _interopRequireDefault(require("react-dom/server")); } catch {} + console.log(typeof Server.default, Server.default.renderToString("x"), Server.default.version); + `, + "/node_modules/react-dom/package.json": /* json */ ` + { "name": "react-dom", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-dom/server.js": /* js */ ` + 'use strict'; + module.exports = require('./server.node'); + `, + "/node_modules/react-dom/server.node.js": /* js */ ` + 'use strict'; + var l; + if (process.env.NODE_ENV === 'production') { + l = require('./cjs/react-dom-server-legacy.node.production.js'); + } else { + l = require('./cjs/react-dom-server-legacy.node.development.js'); + } + exports.version = l.version; + exports.renderToString = l.renderToString; + `, + "/node_modules/react-dom/cjs/react-dom-server-legacy.node.production.js": /* js */ ` + exports.version = "19.0.0"; + exports.renderToString = x => "<" + x + ">"; + `, + "/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js": /* js */ ` + exports.version = "19.0.0-development"; + exports.renderToString = x => ""; + `, + }, + env: { + NODE_ENV: "production", + }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain("_interopRequireDefault((init_server_node(), exports_server_node))"); + }, + run: { + stdout: "object 19.0.0", + }, + }); + // The shape of react-dom/index.js in production: `module.exports = + // require()` after a side effect is lifted to `export * from`. The file is + // as much CommonJS as the file it re-exports. + itBundled("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequire", { + files: { + "/entry.js": /* js */ ` + let m; + try { m = require("react-dom"); } catch {} + console.log(m.__esModule, Object.keys(m).join(), m.createRoot()); + `, + "/node_modules/react-dom/package.json": /* json */ ` + { "name": "react-dom", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-dom/index.js": /* js */ ` + 'use strict'; + function checkDCE() { + if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ === 'undefined') { + console.log('checkDCE'); + } + } + if (process.env.NODE_ENV === 'production') { + checkDCE(); + module.exports = require('./cjs/react-dom.production.js'); + } else { + module.exports = require('./cjs/react-dom.development.js'); + } + `, + "/node_modules/react-dom/cjs/react-dom.production.js": /* js */ ` + exports.createRoot = function createRoot() { return "production root"; }; + exports.version = "19.0.0"; + `, + "/node_modules/react-dom/cjs/react-dom.development.js": /* js */ ` + exports.createRoot = function createRoot() { return "FAILED"; }; + exports.version = "19.0.0"; + `, + }, + minifySyntax: true, + env: { + NODE_ENV: "production", + }, + onAfterBundle(api) { + const code = api.readFile("out.js"); + expect(code).toContain("m = (init_react_dom(), exports_react_dom)"); + expect(code).not.toContain("__toCommonJS"); + }, + run: { + stdout: "checkDCE\nundefined createRoot,version production root", + }, + }); + // `bun run` loads a file with no exports as CommonJS when it calls + // require(). The parser turned that call into an import. + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSWithNoExports", { + files: { + "/entry.js": /* js */ ` + let m; + try { m = require("react"); } catch {} + console.log(m.__esModule, Object.keys(m).length, globalThis.fromDependency); + `, + "/node_modules/react/package.json": reactWithCounter["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + const dependency = require("./dependency"); + globalThis.fromDependency = dependency.value; + `, + "/node_modules/react/dependency.js": /* js */ ` + exports.value = "value"; + `, + }, + onAfterBundle(api) { + api.expectFile("/out.js").not.toContain("__toCommonJS"); + }, + run: { + stdout: "undefined 0 value", + }, + }); + // `bun run` loads a file that assigns `exports.x` as CommonJS, also in a + // package with "type": "module". + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSInTypeModulePackage", { + files: { + "/entry.js": /* js */ ` + let m; + try { m = require("react"); } catch {} + console.log(m.__esModule, m.make("A")); + `, + "/node_modules/react/package.json": /* json */ ` + { "name": "react", "version": "19.0.0", "main": "index.js", "type": "module" } + `, + "/node_modules/react/index.js": reactWithCounter["/node_modules/react/index.js"], + }, + onAfterBundle(api) { + api.expectFile("/out.js").not.toContain("__toCommonJS"); + }, + run: { + stdout: "undefined FA", + }, + }); + // Controls: `bun run` loads each of these files as an ES module, so + // require() of it has `__esModule` and keeps the `__toCommonJS` copy. + itBundled("cjs2esm/KeptRequireOfESModuleInUnwrappedPackageKeepsEsModule", { + files: { + "/entry.js": /* js */ ` + const loaded = []; + try { loaded.push(require("react")); } catch {} + try { loaded.push(require("react-dom")); } catch {} + try { loaded.push(require("scheduler")); } catch {} + try { loaded.push(require("react-is")); } catch {} + for (const m of loaded) console.log(m.__esModule, JSON.stringify(Object.keys(m))); + `, + // No exports and no require(). + "/node_modules/react/package.json": reactWithCounter["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + globalThis.sideEffect = true; + `, + // An ES module by type that calls require(). + "/node_modules/react-dom/package.json": /* json */ ` + { "name": "react-dom", "version": "19.0.0", "main": "index.js", "type": "module" } + `, + "/node_modules/react-dom/index.js": /* js */ ` + const dependency = require("./dependency.cjs"); + globalThis.fromDependency = dependency.value; + `, + "/node_modules/react-dom/dependency.cjs": /* js */ ` + exports.value = "value"; + `, + // An ES module by syntax that calls require(). + "/node_modules/scheduler/package.json": /* json */ ` + { "name": "scheduler", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/scheduler/index.js": /* js */ ` + const dependency = require("./dependency.js"); + export const value = dependency.value; + `, + "/node_modules/scheduler/dependency.js": /* js */ ` + exports.value = "value"; + `, + // A lifted `module.exports = require()` of an ES module. + "/node_modules/react-is/package.json": /* json */ ` + { "name": "react-is", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-is/index.js": /* js */ ` + globalThis.sideEffect = true; + module.exports = require("./impl.mjs"); + `, + "/node_modules/react-is/impl.mjs": /* js */ ` + export const isElement = () => true; + `, + }, + onAfterBundle(api) { + const code = api.readFile("out.js"); + for (const name of ["react", "react_dom", "scheduler", "react_is"]) { + expect(code).toContain(`__toCommonJS(exports_${name})`); + } + }, + run: { + stdout: 'true []\ntrue []\ntrue ["value"]\ntrue ["isElement"]', + }, + }); + // An `import()` of the file in the same bundle resolves to a namespace + // whose `default` is that same object. + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSIsDefaultOfImport", { + files: { + "/entry.js": /* js */ ` + let m; + try { m = require("react"); } catch {} + const ns = await import("react"); + console.log(m.__esModule, ns.default === m, ns.make === m.make); + `, + ...reactWithCounter, + }, + run: { + stdout: "undefined true true", + }, + }); + // With CommonJS output nothing is lifted: the file keeps its wrapper. + itBundled("cjs2esm/KeptRequireOfCommonJSFormatCjs", { + files: { + "/entry.js": /* js */ ` + const unwrapped = require("react"); + let m; + try { m = require("react"); } catch {} + m.count = 41; + console.log(m.__esModule, m === unwrapped, m.inc()); + `, + ...reactWithCounter, + }, + format: "cjs", + onAfterBundle(api) { + api.expectFile("/out.js").toContain("m = require_react();"); + }, + run: { + stdout: "undefined true 42", + }, + }); // `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. diff --git a/test/bundler/bundler_splitting.test.ts b/test/bundler/bundler_splitting.test.ts index 120d1617be3e..6a7d690e5d63 100644 --- a/test/bundler/bundler_splitting.test.ts +++ b/test/bundler/bundler_splitting.test.ts @@ -3694,16 +3694,19 @@ describe("bundler", () => { 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", { + // A CommonJS file of an unwrapped package that was lifted to an ES module + // is still CommonJS to require(): the call returns the file's exports + // object, with no `__esModule` that the file did not set. So the file is + // not split either. `import.meta.require` of a chunk would return the + // chunk's module namespace. Here the lift target is itself lifted. + itBundled("splitting/SplitRequireLeavesLiftedCommonJSTargetInline", { files: { "/main.ts": /* ts */ ` let m; try { m = require("react-dom"); } catch {} - console.log(m.version, m.render()); + console.log(m.__esModule, m.version, m.render()); `, "/node_modules/react-dom/index.js": /* js */ ` console.log('side effect'); @@ -3719,12 +3722,215 @@ describe("bundler", () => { target: "bun", outdir: "/out", format: "esm", + onAfterBundle(api) { + expect(jsFilesIn(api)).toEqual(["main.js"]); + api.expectFile("/out/main.js").not.toContain("import.meta.require"); + }, + run: { file: "/out/main.js", stdout: "side effect\nundefined 19.0.0 rendered" }, + }); + + // The lift target is reached through a file that is only + // `module.exports = require()`. + itBundled("splitting/SplitRequireLeavesLiftedCommonJSTargetInlineThroughRedirect", { + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react-dom"); + } catch {} + console.log(m.__esModule, m.version, m.render()); + `, + "/node_modules/react-dom/index.js": /* js */ ` + console.log('side effect'); + module.exports = require('./server'); + `, + "/node_modules/react-dom/server.js": /* js */ ` + 'use strict'; + module.exports = require('./server.node'); + `, + "/node_modules/react-dom/server.node.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) { + expect(jsFilesIn(api)).toEqual(["main.js"]); + }, + run: { file: "/out/main.js", stdout: "side effect\nundefined 19.0.0 rendered" }, + }); + + // The lift target calls require() as well, but it assigns `module.exports` + // and keeps its wrapper. The file that re-exports it is wrapped again and + // stays a chunk, as in #41236. + itBundled("splitting/SplitRequireOfRewrappedLiftedCommonJSThatCallsRequire", { + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react-dom"); + } catch {} + console.log(m.__esModule, m.version); + `, + "/node_modules/react-dom/index.js": /* js */ ` + console.log('side effect'); + module.exports = require('./impl'); + `, + "/node_modules/react-dom/impl.js": /* js */ ` + const dependency = require('./dependency'); + module.exports = function render() {}; + module.exports.version = dependency.version; + `, + "/node_modules/react-dom/dependency.js": /* js */ ` + 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\nundefined 19.0.0" }, + }); + + // The same value with a split require(), with `splitRequire: false` and + // for a target that cannot split a require(): a write reaches the lifted + // binding, and the `default` of an import() of the file is that object. + // Each stdout is what `bun run` prints for the same files. + const liftedCommonJSFiles = { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react"); + } catch {} + m.count = 41; + const ns = await import("react"); + console.log(m.__esModule, m.inc(), ns.default === m); + `, + "/node_modules/react/package.json": /* json */ ` + { "name": "react", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react/index.js": /* js */ ` + console.log("react evaluated"); + exports.count = 0; + exports.inc = () => ++exports.count; + `, + }; + for (const [name, options] of [ + ["Split", { target: "bun" }], + ["SplitRequireOff", { target: "bun", splitRequire: false }], + ["TargetNode", { target: "node" }], + ] as const) { + itBundled(`splitting/RequireOfLiftedCommonJSReturnsNamespace-${name}`, { + files: liftedCommonJSFiles, + entryPoints: ["/main.ts"], + splitting: true, + outdir: "/out", + format: "esm", + ...options, + onAfterBundle(api) { + api.expectFile("/out/main.js").not.toContain("import.meta.require"); + api.expectFile("/out/main.js").not.toContain("__toCommonJS"); + }, + run: { file: "/out/main.js", stdout: "react evaluated\nundefined 42 true" }, + }); + } + + // The file stays lazy inside its importer's chunk: it runs when the call does. + itBundled("splitting/SplitRequireOfLiftedCommonJSRunsAtTheCall", { + files: { + ...liftedCommonJSFiles, + "/main.ts": /* ts */ ` + function load() { + try { + return require("react"); + } catch {} + } + console.log("main"); + console.log(load().inc()); + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + expect(jsFilesIn(api)).toEqual(["main.js"]); + }, + run: { file: "/out/main.js", stdout: "main\nreact evaluated\n1" }, + }); + + // `bun run` loads a file with no exports as CommonJS when it calls + // require(), which the parser turned into an import. + itBundled("splitting/SplitRequireLeavesLiftedCommonJSWithNoExportsInline", { + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react"); + } catch {} + console.log(m.__esModule, Object.keys(m).length, globalThis.fromDependency); + `, + "/node_modules/react/package.json": liftedCommonJSFiles["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + const dependency = require("./dependency"); + globalThis.fromDependency = dependency.value; + `, + "/node_modules/react/dependency.js": /* js */ ` + exports.value = "value"; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + expect(jsFilesIn(api)).toEqual(["main.js"]); + }, + run: { file: "/out/main.js", stdout: "undefined 0 value" }, + }); + + // Control: an ES module inside an unwrapped package is an ES module to + // `bun run` too. Its require() splits and has `__esModule`. + itBundled("splitting/SplitRequireOfESModuleInUnwrappedPackage", { + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react"); + } catch {} + console.log(m.__esModule, m.value); + `, + "/node_modules/react/package.json": liftedCommonJSFiles["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + const dependency = require("./dependency"); + console.log("react evaluated"); + export const value = dependency.value; + `, + "/node_modules/react/dependency.js": /* js */ ` + exports.value = "value"; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + const chunk = chunkContaining(api, "react evaluated"); 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" }, + run: { file: "/out/main.js", stdout: "react evaluated\ntrue value" }, }); // A top-level require() in a module that the required chunk imports back From 80e71b09834b9cd7f39e40b00369b0f2682d4ffc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:15:09 +0000 Subject: [PATCH 02/12] bundler: read the existing lifted flag for require() of a lifted CommonJS file `require_returns_namespace` now tests `COMMONJS_LIFTED_TO_ESM` on a file that is still `ExportsKind::Esm`, and the parser flag `NAMESPACE_IS_MODULE_EXPORTS` is gone. The step 6 check applies to ESM output only, because no other format lifts. A cycle of files that re-export each other counts as lifted CommonJS. The helper calls `get_redirect_id` for a redirect file, and the debug assertion sits above the comment of the `.default` arm. Tests: each case also runs its entry point with `bun run` and compares the output. New cases cover the re-export file of react-dom, a cycle, IIFE output, an alias and a second entry point under code splitting, and a compiled executable. Two `todo` cases record what is still wrong: a write through a re-export file, and a lifted re-export of an external package under code splitting. Docs: the added sentences say only what runs on real React. --- docs/bundler/index.mdx | 6 +- src/ast/ast_result.rs | 8 - src/bundler/LinkerContext.rs | 42 +-- src/bundler/bundle_v2.rs | 3 +- src/bundler/bundled_ast.rs | 9 +- .../linker_context/scanImportsAndExports.rs | 33 +- src/js_parser/p.rs | 13 +- test/bundler/bundler_cjs2esm.test.ts | 322 ++++++++++++++---- .../bundler/bundler_compile_splitting.test.ts | 30 ++ test/bundler/bundler_splitting.test.ts | 184 ++++++++-- 10 files changed, 482 insertions(+), 168 deletions(-) diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index fdcf18333168..a03e78d1d2a3 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -560,7 +560,7 @@ The generated `chunk-dqmx6gc8.js` file contains the shared code. To avoid collis Each `import()` of a bundled JavaScript module also becomes its own chunk. Tree shaking applies to these chunks too: if every `import()` of a module lives in code that tree shaking removes — for example inside a function that is only called behind a [`define`](#define) or [`features`](#features) gate that evaluates to `false` — and nothing else in the live output imports the module, its chunk is not written and the module is absent from the [metafile](#metafile)'s `inputs` and `outputs`. Setting `treeShaking: false` keeps every `import()` chunk. This differs from esbuild, which emits a chunk for every reachable `import()` target. -With `target: "bun"`, a `require()` of a bundled ES module becomes a chunk of its own as well. The call is emitted as `import.meta.require("./chunk-.js")` and stays synchronous — Bun evaluates the chunk when the call runs — so a `require()` inside a function that never runs keeps its module out of the startup working set entirely. Tree shaking treats these chunks like `import()` chunks. `require()` of a CommonJS module is unaffected and keeps returning `module.exports`. That includes a CommonJS module whose `exports.x = ...` assignments the bundler lifted to ES module exports, such as the files of `react` and `react-dom`: it stays in the calling chunk, and `require()` returns its namespace object, which stands in for `module.exports`. A `require()` cycle between such chunks follows Bun's runtime `require()` of an ES module: a module required again while its chunk is still being loaded sees the partially initialized CommonJS placeholder (`{}`) rather than the live bindings the in-chunk wrapper provides. For other targets — and with `splitRequire: false` (`--no-split-require`) — the required module is inlined into the calling chunk behind a lazy wrapper instead, because the call must return synchronously. +With `target: "bun"`, a `require()` of a bundled ES module becomes a chunk of its own as well. The call is emitted as `import.meta.require("./chunk-.js")` and stays synchronous — Bun evaluates the chunk when the call runs — so a `require()` inside a function that never runs keeps its module out of the startup working set entirely. Tree shaking treats these chunks like `import()` chunks. `require()` of a CommonJS module is unaffected and keeps returning `module.exports`. A `require()` cycle between such chunks follows Bun's runtime `require()` of an ES module: a module required again while its chunk is still being loaded sees the partially initialized CommonJS placeholder (`{}`) rather than the live bindings the in-chunk wrapper provides. For other targets — and with `splitRequire: false` (`--no-split-require`) — the required module is inlined into the calling chunk behind a lazy wrapper instead, because the call must return synchronously. A CommonJS file whose `exports.x = ...` assignments the bundler lifted to ES module exports gets that lazy wrapper on every target. One example is `react-dom/server`. The file runs when the call runs, and `require()` returns its namespace object, which stands in for `module.exports`. #### Tree-shaking `import()` and `require()` results @@ -575,7 +575,7 @@ await import("./polyfill"); // keeps only side effects Recognized uses: destructuring (`const { a, b: c, ...rest } = await import(x)`, also `let`/`var` and `export const { a } = …`), property access on the awaited value or on a local holding it (`(await import(x)).a`, `const ns = await import(x); ns.a; const { b } = ns`), `.then(({ a }) => …)` / `.then(ns => ns.a)` with an arrow function, per-element destructuring of `await Promise.all([import(x), import(y)])`, the same shapes for `require()`, and a bare `import(x);` / `await import(x);` / `require(x);` statement (nothing observed). -The module keeps every export as soon as one use can't be followed: the namespace is passed, returned, stored, spread or iterated (`() => import(x)`, `fn(ns)`, `{ ...ns }`, `Object.keys(ns)`), accessed with a computed key (or `?.` directly on the `await import(x)` expression), handed to a non-arrow `.then` callback, reached via `import * as ns` elsewhere, or read under a direct `eval`. CommonJS targets always keep everything. The imported module itself is always evaluated; only with `"sideEffects": false` can a module it merely re-exports from be skipped when none of those re-exports are observed. +The module keeps every export as soon as one use can't be followed: the namespace is passed, returned, stored, spread or iterated (`() => import(x)`, `fn(ns)`, `{ ...ns }`, `Object.keys(ns)`), accessed with a computed key (or `?.` directly on the `await import(x)` expression), handed to a non-arrow `.then` callback, reached via `import * as ns` elsewhere, or read under a direct `eval`. CommonJS targets always keep everything. The exception is a file whose `exports.x = ...` assignments the bundler lifted to ES module exports: Bun narrows it like an ES module. The imported module itself is always evaluated; only with `"sideEffects": false` can a module it merely re-exports from be skipped when none of those re-exports are observed. @@ -1439,7 +1439,7 @@ In each case the member access compiles to a direct reference to `object`, the ` Assignments (`z.x = 1`), optional chains, and non-literal computed keys (`z[key]`) are left as property accesses; `z["object"]` is treated like `z.object`. `export default someImport` is followed only when it ends at a namespace; a default that snapshots a `let` export keeps snapshot semantics. -The same applies to the default import of a CommonJS module whose `exports.x = ...` assignments the bundler lifted to ES module exports, such as `react` and `scheduler`. The default import of a CommonJS module is its `module.exports`, which is that module's namespace, so `import React from "react"; React.useState()` compiles to a direct call of the lifted `useState` binding. This holds while every use of `React` reads, calls or assigns one of its properties: `React.useState`, `React[key]`, `const { useState } = React`. A write, `React.useLayoutEffect = React.useEffect`, goes through a namespace object whose setters assign the lifted binding, so every importer sees the new value, the same as a write to `module.exports`. Code that holds `React` itself as a value (`fn(React)`, `Object.keys(React)`, `const R = React`, a re-export) or deletes one of its properties can change the object in ways a namespace object cannot pass on to the lifted bindings (`Object.defineProperty`, `delete`, `Object.freeze`). The module then keeps its CommonJS wrapper and the default import is the real `module.exports` object. This rule looks at `import React from` only. `ns.default` on `import * as ns`, the `default` of an `import()` and the `this` of a call like `React.fn()` are still the namespace object of a module that stays lifted. `React.default`, and `ns.default` on `import * as ns`, is the lifted `default` export when the module has one, and otherwise the namespace itself, as `module.exports` is in Node. A module that sets both `exports.__esModule` and `exports.default` keeps its CommonJS wrapper when the importer is not an ES module by type (`.mjs`, `.mts`, or `"type": "module"`), because the default import then depends on that flag at run time. An `import()` of a module that does not set both resolves to that same namespace object as `default`, with or without code splitting. A `require()` of a lifted module evaluates to that namespace object too, also when the call stays a call in the output because it is inside `try`/`catch` or its specifier is an alias of the package. The object has an `__esModule` property only if the module sets `exports.__esModule`, and a write through it assigns the lifted binding. `Object.defineProperty`, `delete` and `Object.freeze` on it do not reach the lifted bindings. +The same applies to the default import of a CommonJS module whose `exports.x = ...` assignments the bundler lifted to ES module exports, such as `react` and `scheduler`. The default import of a CommonJS module is its `module.exports`, which is that module's namespace, so `import React from "react"; React.useState()` compiles to a direct call of the lifted `useState` binding. This holds while every use of `React` reads, calls or assigns one of its properties: `React.useState`, `React[key]`, `const { useState } = React`. A write, `React.useLayoutEffect = React.useEffect`, goes through a namespace object whose setters assign the lifted binding, so every importer sees the new value, the same as a write to `module.exports`. Code that holds `React` itself as a value (`fn(React)`, `Object.keys(React)`, `const R = React`, a re-export) or deletes one of its properties can change the object in ways a namespace object cannot pass on to the lifted bindings (`Object.defineProperty`, `delete`, `Object.freeze`). The module then keeps its CommonJS wrapper and the default import is the real `module.exports` object. This rule looks at `import React from` only. `ns.default` on `import * as ns`, the `default` of an `import()` and the `this` of a call like `React.fn()` are still the namespace object of a module that stays lifted. `React.default`, and `ns.default` on `import * as ns`, is the lifted `default` export when the module has one, and otherwise the namespace itself, as `module.exports` is in Node. A module that sets both `exports.__esModule` and `exports.default` keeps its CommonJS wrapper when the importer is not an ES module by type (`.mjs`, `.mts`, or `"type": "module"`), because the default import then depends on that flag at run time. An `import()` of a module that does not set both resolves to that same namespace object as `default`, with or without code splitting. A `require()` of a lifted module that stays a call in the output evaluates to that namespace object too. The call stays a call when it is inside `try`/`catch`, or when its specifier is an alias of the package. The object has an `__esModule` property only if the module sets `exports.__esModule`. A write to an export that the module assigns itself reaches the lifted binding. A name that the module re-exports with `module.exports = require("./impl")` is read-only. `Object.defineProperty`, `delete` and `Object.freeze` on the object do not reach the lifted bindings. ### deprecatedNamespaceObjectSetters diff --git a/src/ast/ast_result.rs b/src/ast/ast_result.rs index 215f62681fc0..dcc3b920b894 100644 --- a/src/ast/ast_result.rs +++ b/src/ast/ast_result.rs @@ -39,13 +39,6 @@ pub struct Ast<'a> { /// `force_cjs_to_esm` for a file with no ES module syntax: its exports were /// lifted from `exports.foo = ...`, so its `module.exports` is the namespace. pub commonjs_lifted_to_esm: bool, - /// `commonjs_lifted_to_esm` for a file that is an ES module in this bundle - /// and CommonJS to `bun run`: a top-level `exports.foo = ...` became an - /// export, or a `require()` became an import and the file is not an ES - /// module by type. `require()` of it returns the namespace. A file with - /// neither is an ES module to `bun run` too: its `require()` value has - /// `__esModule`. - pub namespace_is_module_exports: bool, pub exports_kind: ExportsKind, // This is a list of ES6 features. They are ranges instead of booleans so @@ -126,7 +119,6 @@ impl<'a> Ast<'a> { commonjs_module_exports_assigned_deoptimized: false, force_cjs_to_esm: false, commonjs_lifted_to_esm: false, - namespace_is_module_exports: false, exports_kind: ExportsKind::None, export_keyword: Range::NONE, top_level_await_keyword: Range::NONE, diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index eaa67b23d496..a34a22713d65 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -4534,54 +4534,46 @@ impl<'a> LinkerContext<'a> { } /// Does `require()` of `target` evaluate to its namespace object? It does - /// when that object stands in for `module.exports` - /// (`NAMESPACE_IS_MODULE_EXPORTS`): the call gets no `__toCommonJS` copy, + /// for a CommonJS file that the parser lifted to an ES module: that object + /// stands in for `module.exports`. The call gets no `__toCommonJS` copy, /// and the file is not a chunk that `import.meta.require` loads as an ES /// module. A lifted `module.exports = require("./impl")` is an `export *`, /// so `./impl` decides. pub(crate) fn require_returns_namespace( ast_flags: &[AstFlags], + exports_kinds: &[ExportsKind], export_star_import_records: &[bun_alloc::AstVec], import_records: &[bun_ast::import_record::List<'_>], redirects: &[u32], target: u32, ) -> bool { let mut file = target as usize; - // Each step follows one record, so a longer walk is a cycle. + // Each step follows one record. A longer walk is a cycle of files + // that all passed the test. for _ in 0..ast_flags.len() { - let record_index = match redirects.get(file) { + let record_index = match crate::bundle_v2::bv2_impl::get_redirect_id(redirects[file]) { // `find_reachable_files` points a record past a redirect file // only when it visits the importer. - Some(&redirect) if redirect != u32::MAX => redirect, - _ => { - if !ast_flags - .get(file) - .is_some_and(|flags| flags.contains(AstFlags::NAMESPACE_IS_MODULE_EXPORTS)) + Some(redirect) => redirect, + None => { + if exports_kinds[file] != ExportsKind::Esm + || !ast_flags[file].contains(AstFlags::COMMONJS_LIFTED_TO_ESM) { return false; } - match export_star_import_records - .get(file) - .map(|stars| stars.as_slice()) - { - Some([]) => return true, - Some(&[star]) => star, - _ => return false, + match export_star_import_records[file].first() { + Some(&star) => star, + None => return true, } } }; - let Some(record) = import_records - .get(file) - .and_then(|records| records.as_slice().get(record_index as usize)) - else { - return false; - }; - if !record.source_index.is_valid() { + let source = import_records[file].as_slice()[record_index as usize].source_index; + if !source.is_valid() { return false; } - file = record.source_index.get() as usize; + file = source.get() as usize; } - false + true } /// Resolves every named import in one file to its matching export, diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 26d3bcf532f1..a18975d0a4c5 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2258,6 +2258,7 @@ pub mod bv2_impl { == ExportsKind::Esm && !LinkerContext::require_returns_namespace( self.all_ast_flags, + self.all_exports_kinds, self.all_export_star_import_records, self.all_import_records, self.redirects, @@ -8401,7 +8402,7 @@ pub mod bv2_impl { } } - fn get_redirect_id(id: u32) -> Option { + pub(crate) fn get_redirect_id(id: u32) -> Option { if id == u32::MAX { return None; } diff --git a/src/bundler/bundled_ast.rs b/src/bundler/bundled_ast.rs index c7deab994e4e..58c0b71f07b5 100644 --- a/src/bundler/bundled_ast.rs +++ b/src/bundler/bundled_ast.rs @@ -157,9 +157,7 @@ bitflags::bitflags! { const HAS_IMPORT_META = 1 << 8; /// See `Ast::commonjs_lifted_to_esm`. const COMMONJS_LIFTED_TO_ESM = 1 << 9; - /// See `Ast::namespace_is_module_exports`. - const NAMESPACE_IS_MODULE_EXPORTS = 1 << 10; - // _padding: u5 fills the rest + // _padding: u6 fills the rest } } @@ -264,7 +262,6 @@ impl<'arena> BundledAst<'arena> { }, force_cjs_to_esm: self.flags.contains(Flags::FORCE_CJS_TO_ESM), commonjs_lifted_to_esm: self.flags.contains(Flags::COMMONJS_LIFTED_TO_ESM), - namespace_is_module_exports: self.flags.contains(Flags::NAMESPACE_IS_MODULE_EXPORTS), has_lazy_export: self.flags.contains(Flags::HAS_LAZY_EXPORT), commonjs_module_exports_assigned_deoptimized: self .flags @@ -290,10 +287,6 @@ impl<'arena> BundledAst<'arena> { flags.set(Flags::USES_EXPORT_KEYWORD, ast.export_keyword.len > 0); flags.set(Flags::FORCE_CJS_TO_ESM, ast.force_cjs_to_esm); flags.set(Flags::COMMONJS_LIFTED_TO_ESM, ast.commonjs_lifted_to_esm); - flags.set( - Flags::NAMESPACE_IS_MODULE_EXPORTS, - ast.namespace_is_module_exports, - ); flags.set(Flags::HAS_LAZY_EXPORT, ast.has_lazy_export); flags.set( Flags::COMMONJS_MODULE_EXPORTS_ASSIGNED_DEOPTIMIZED, diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index c2fa4972bca7..75b3cd405de1 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -1150,16 +1150,11 @@ 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. debug_assert!( !(kind == ImportKind::Require && is_external_dyn) || !LinkerContext::require_returns_namespace( col_ref!(ast_flags_list), + col_ref!(exports_kind), col_ref!(export_star_import_records), col_ref!(import_records_list), col_ref!(redirects), @@ -1167,6 +1162,13 @@ pub(crate) fn scan_imports_and_exports( ), "find_reachable_files splits no require() of a lifted CommonJS file" ); + + // 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() @@ -1292,15 +1294,18 @@ pub(crate) fn scan_imports_and_exports( // https://github.com/evanw/esbuild/issues/1591. // // A lifted CommonJS module was never an ES module: its - // exports object is its `module.exports`. + // exports object is its `module.exports`. Only ESM + // output lifts. if kind == ImportKind::Require - && !LinkerContext::require_returns_namespace( - col_ref!(ast_flags_list), - col_ref!(export_star_import_records), - col_ref!(import_records_list), - col_ref!(redirects), - other_source_index, - ) + && !(output_format == Format::Esm + && LinkerContext::require_returns_namespace( + col_ref!(ast_flags_list), + col_ref!(exports_kind), + col_ref!(export_star_import_records), + col_ref!(import_records_list), + col_ref!(redirects), + other_source_index, + )) { col!(import_records_list)[id].as_mut_slice() [import_record_index as usize] diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 97e896931db3..7fdd497ff95a 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -9232,16 +9232,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let force_cjs_to_esm = self.unwrap_all_requires || exports_kind == js_ast::ExportsKind::EsmWithDynamicFallbackFromCjs; - let commonjs_lifted_to_esm = force_cjs_to_esm && !self.has_es_module_syntax; - let namespace_is_module_exports = commonjs_lifted_to_esm - && exports_kind == js_ast::ExportsKind::Esm - && (self - .commonjs_named_exports - .values() - .iter() - .any(|export| !export.needs_decl) - || (self.options.module_type != options::ModuleType::Esm - && !self.imports_to_convert_from_require.is_empty())); let wrapper_ref: Ref = 'brk: { if self.options.features.hot_module_reloading { @@ -9368,8 +9358,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O require_ref, force_cjs_to_esm, - commonjs_lifted_to_esm, - namespace_is_module_exports, + commonjs_lifted_to_esm: force_cjs_to_esm && !self.has_es_module_syntax, uses_module_ref, uses_exports_ref, uses_require_ref, diff --git a/test/bundler/bundler_cjs2esm.test.ts b/test/bundler/bundler_cjs2esm.test.ts index 659765694e26..57416b67ad29 100644 --- a/test/bundler/bundler_cjs2esm.test.ts +++ b/test/bundler/bundler_cjs2esm.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test"; +import { bunEnv, bunExe } from "harness"; import { readdirSync } from "node:fs"; -import { itBundled, type BundlerTestBundleAPI } from "./expectBundled"; +import { itBundled, type BundlerTestBundleAPI, type BundlerTestInput } from "./expectBundled"; const fakeReactNodeModules = { "/node_modules/react/index.js": /* js */ ` @@ -409,7 +410,28 @@ describe("bundler", () => { // That object stands in for `module.exports`: it has no `__esModule` unless // the file sets one, every require() of the file returns it, and a write // reaches the lifted binding. A `__toCommonJS` copy has none of these. - // Every stdout below is what `bun run` prints for the same files. + // + // `itBundledLikeBunRun` also runs the entry point of a case with `bun run`, + // which must print the stdout that the case expects of the bundle. + const itBundledLikeBunRun = (id: string, opts: BundlerTestInput) => + itBundled(id, { + ...opts, + async onAfterBundle(api) { + await opts.onAfterBundle?.(api); + await using unbundled = Bun.spawn({ + cmd: [bunExe(), api.join(Object.keys(opts.files)[0])], + cwd: api.root, + env: { ...bunEnv, ...opts.env }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([unbundled.stdout.text(), unbundled.exited]); + expect({ stdout: stdout.trim(), exitCode }).toEqual({ + stdout: (opts.run as { stdout: string }).stdout, + exitCode: 0, + }); + }, + }); const reactWithCounter = { "/node_modules/react/package.json": /* json */ ` { "name": "react", "version": "19.0.0", "main": "index.js" } @@ -423,7 +445,7 @@ describe("bundler", () => { for (const backend of ["cli", "api"] as const) { // The call stays in a `try` body and in a `catch` body. The same file // under a name off the unwrap list keeps its `__commonJS` wrapper. - itBundled(`cjs2esm/KeptRequireOfLiftedCommonJS-${backend}`, { + itBundledLikeBunRun(`cjs2esm/KeptRequireOfLiftedCommonJS-${backend}`, { files: { "/entry.js": /* js */ ` const unwrapped = require("react"); @@ -458,7 +480,7 @@ describe("bundler", () => { } // A specifier that is not on the unwrap list can resolve to a file of a // listed package. The parser keeps such a call with no `try` around it. - itBundled("cjs2esm/KeptRequireOfLiftedCommonJSThroughAlias", { + itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSThroughAlias", { files: { "/entry.js": /* js */ ` import { viaRelativePath } from "sibling"; @@ -492,7 +514,7 @@ describe("bundler", () => { }); // The parser binds names read off a require() result, but it leaves // `__esModule` and `default` to the object that the call returns. - itBundled("cjs2esm/KeptRequireOfLiftedCommonJSReadsEsModuleOffNamespace", { + itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSReadsEsModuleOffNamespace", { files: { "/entry.js": /* js */ ` try { @@ -515,7 +537,7 @@ describe("bundler", () => { }); // A lifted file that sets `exports.__esModule` exports that name itself. // It is enumerable, as on `module.exports`, and not on a `__toCommonJS` copy. - itBundled("cjs2esm/KeptRequireOfLiftedCommonJSThatSetsEsModule", { + itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSThatSetsEsModule", { files: { "/entry.js": /* js */ ` let m; @@ -539,7 +561,7 @@ describe("bundler", () => { // The shape of react-dom/server.node.js, read the way Babel and TypeScript // compile `import Server from "react-dom/server"`. With a synthetic // `__esModule`, `Server.default` is `undefined`. - itBundled("cjs2esm/KeptRequireOfLiftedCommonJSThroughInteropHelper", { + itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSThroughInteropHelper", { files: { "/entry.js": /* js */ ` function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } @@ -584,41 +606,47 @@ describe("bundler", () => { stdout: "object 19.0.0", }, }); - // The shape of react-dom/index.js in production: `module.exports = - // require()` after a side effect is lifted to `export * from`. The file is - // as much CommonJS as the file it re-exports. - itBundled("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequire", { + // The shape of react-dom/index.js. With `--minify-syntax` and a production + // NODE_ENV, `module.exports = require()` after the side effect is lifted to + // `export * from`. The file is as much CommonJS as the file it re-exports. + const reactDomIndex = { + "/node_modules/react-dom/package.json": /* json */ ` + { "name": "react-dom", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-dom/index.js": /* js */ ` + 'use strict'; + function checkDCE() { + if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ === 'undefined') { + console.log('checkDCE'); + } + } + if (process.env.NODE_ENV === 'production') { + checkDCE(); + module.exports = require('./cjs/react-dom.production.js'); + } else { + module.exports = require('./cjs/react-dom.development.js'); + } + `, + "/node_modules/react-dom/cjs/react-dom.production.js": /* js */ ` + exports.createRoot = function createRoot() { return "production root"; }; + exports.version = "19.0.0"; + `, + "/node_modules/react-dom/cjs/react-dom.development.js": /* js */ ` + 'use strict'; + (function () { + exports.createRoot = function createRoot() { return "development root"; }; + exports.version = "19.0.0"; + })(); + `, + }; + itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequire", { files: { "/entry.js": /* js */ ` let m; try { m = require("react-dom"); } catch {} console.log(m.__esModule, Object.keys(m).join(), m.createRoot()); `, - "/node_modules/react-dom/package.json": /* json */ ` - { "name": "react-dom", "version": "19.0.0", "main": "index.js" } - `, - "/node_modules/react-dom/index.js": /* js */ ` - 'use strict'; - function checkDCE() { - if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ === 'undefined') { - console.log('checkDCE'); - } - } - if (process.env.NODE_ENV === 'production') { - checkDCE(); - module.exports = require('./cjs/react-dom.production.js'); - } else { - module.exports = require('./cjs/react-dom.development.js'); - } - `, - "/node_modules/react-dom/cjs/react-dom.production.js": /* js */ ` - exports.createRoot = function createRoot() { return "production root"; }; - exports.version = "19.0.0"; - `, - "/node_modules/react-dom/cjs/react-dom.development.js": /* js */ ` - exports.createRoot = function createRoot() { return "FAILED"; }; - exports.version = "19.0.0"; - `, + ...reactDomIndex, }, minifySyntax: true, env: { @@ -633,14 +661,80 @@ describe("bundler", () => { stdout: "checkDCE\nundefined createRoot,version production root", }, }); - // `bun run` loads a file with no exports as CommonJS when it calls - // require(). The parser turned that call into an import. - itBundled("cjs2esm/KeptRequireOfLiftedCommonJSWithNoExports", { + // The namespace of such a file has a getter and no setter for a name that + // it re-exports, and it is not the object of the file it re-exports. + itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequireWriteAndIdentity", { + todo: true, files: { "/entry.js": /* js */ ` let m; + try { m = require("react-dom"); } catch {} + m.version = "patched"; + console.log(m.version, m === require("react-dom/cjs/react-dom.production.js")); + `, + ...reactDomIndex, + }, + minifySyntax: true, + env: { + NODE_ENV: "production", + }, + run: { + stdout: "checkDCE\npatched true", + }, + }); + // Controls for the same file. Without `--minify-syntax` the `if` stays, and + // the file keeps its `__commonJS` wrapper. In a development build the + // wrapper also converts the development file, which is another defect. + itBundledLikeBunRun("cjs2esm/KeptRequireOfModuleExportsEqualsRequireInAnIfKeepsWrapper", { + files: { + "/entry.js": /* js */ ` + let m; + try { m = require("react-dom"); } catch {} + console.log(m.__esModule, Object.keys(m).join(), m.createRoot()); + `, + ...reactDomIndex, + }, + env: { + NODE_ENV: "production", + }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain("m = require_react_dom();"); + }, + run: { + stdout: "checkDCE\nundefined createRoot,version production root", + }, + }); + itBundled("cjs2esm/KeptRequireOfModuleExportsEqualsRequireDevelopmentKeepsWrapper", { + files: { + "/entry.js": /* js */ ` + let m; + try { m = require("react-dom"); } catch {} + console.log(m.createRoot()); + `, + ...reactDomIndex, + }, + env: { + NODE_ENV: "development", + }, + onAfterBundle(api) { + const code = api.readFile("out.js"); + expect(code).toContain("m = require_react_dom();"); + expect(code).toContain("__toESM(require_react_dom_development())"); + }, + run: { + stdout: "development root", + }, + }); + // `bun run` loads a file with no exports as CommonJS when it calls + // require(), or when it starts with "use strict". + itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSWithNoExports", { + files: { + "/entry.js": /* js */ ` + let m, n; try { m = require("react"); } catch {} + try { n = require("scheduler"); } catch {} console.log(m.__esModule, Object.keys(m).length, globalThis.fromDependency); + console.log(n.__esModule, Object.keys(n).length, globalThis.sideEffect); `, "/node_modules/react/package.json": reactWithCounter["/node_modules/react/package.json"], "/node_modules/react/index.js": /* js */ ` @@ -650,17 +744,24 @@ describe("bundler", () => { "/node_modules/react/dependency.js": /* js */ ` exports.value = "value"; `, + "/node_modules/scheduler/package.json": /* json */ ` + { "name": "scheduler", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/scheduler/index.js": /* js */ ` + 'use strict'; + globalThis.sideEffect = true; + `, }, onAfterBundle(api) { api.expectFile("/out.js").not.toContain("__toCommonJS"); }, run: { - stdout: "undefined 0 value", + stdout: "undefined 0 value\nundefined 0 true", }, }); // `bun run` loads a file that assigns `exports.x` as CommonJS, also in a // package with "type": "module". - itBundled("cjs2esm/KeptRequireOfLiftedCommonJSInTypeModulePackage", { + itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSInTypeModulePackage", { files: { "/entry.js": /* js */ ` let m; @@ -679,35 +780,42 @@ describe("bundler", () => { stdout: "undefined FA", }, }); - // Controls: `bun run` loads each of these files as an ES module, so - // require() of it has `__esModule` and keeps the `__toCommonJS` copy. - itBundled("cjs2esm/KeptRequireOfESModuleInUnwrappedPackageKeepsEsModule", { + // Two files that re-export each other never reach a file that decides. + itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequireCycle", { files: { "/entry.js": /* js */ ` - const loaded = []; - try { loaded.push(require("react")); } catch {} - try { loaded.push(require("react-dom")); } catch {} - try { loaded.push(require("scheduler")); } catch {} - try { loaded.push(require("react-is")); } catch {} - for (const m of loaded) console.log(m.__esModule, JSON.stringify(Object.keys(m))); + let m; + try { m = require("react"); } catch {} + console.log(m.__esModule, Object.keys(m).length, globalThis.loaded); `, - // No exports and no require(). "/node_modules/react/package.json": reactWithCounter["/node_modules/react/package.json"], "/node_modules/react/index.js": /* js */ ` - globalThis.sideEffect = true; - `, - // An ES module by type that calls require(). - "/node_modules/react-dom/package.json": /* json */ ` - { "name": "react-dom", "version": "19.0.0", "main": "index.js", "type": "module" } + globalThis.loaded = (globalThis.loaded ?? 0) + 1; + module.exports = require("./other"); `, - "/node_modules/react-dom/index.js": /* js */ ` - const dependency = require("./dependency.cjs"); - globalThis.fromDependency = dependency.value; + "/node_modules/react/other.js": /* js */ ` + globalThis.loaded = (globalThis.loaded ?? 0) + 1; + module.exports = require("./index"); `, - "/node_modules/react-dom/dependency.cjs": /* js */ ` - exports.value = "value"; + }, + onAfterBundle(api) { + api.expectFile("/out.js").not.toContain("__toCommonJS"); + }, + run: { + stdout: "undefined 0 2", + }, + }); + // Controls: an ES module by syntax is an ES module to `bun run` too, so + // require() of it has `__esModule` and keeps the `__toCommonJS` copy. The + // same holds for a lifted `module.exports = require()` of an ES module. + itBundledLikeBunRun("cjs2esm/KeptRequireOfESModuleInUnwrappedPackageKeepsEsModule", { + files: { + "/entry.js": /* js */ ` + const loaded = []; + try { loaded.push(require("scheduler")); } catch {} + try { loaded.push(require("react-is")); } catch {} + for (const m of loaded) console.log(m.__esModule, JSON.stringify(Object.keys(m))); `, - // An ES module by syntax that calls require(). "/node_modules/scheduler/package.json": /* json */ ` { "name": "scheduler", "version": "19.0.0", "main": "index.js" } `, @@ -718,7 +826,6 @@ describe("bundler", () => { "/node_modules/scheduler/dependency.js": /* js */ ` exports.value = "value"; `, - // A lifted `module.exports = require()` of an ES module. "/node_modules/react-is/package.json": /* json */ ` { "name": "react-is", "version": "19.0.0", "main": "index.js" } `, @@ -732,17 +839,17 @@ describe("bundler", () => { }, onAfterBundle(api) { const code = api.readFile("out.js"); - for (const name of ["react", "react_dom", "scheduler", "react_is"]) { + for (const name of ["scheduler", "react_is"]) { expect(code).toContain(`__toCommonJS(exports_${name})`); } }, run: { - stdout: 'true []\ntrue []\ntrue ["value"]\ntrue ["isElement"]', + stdout: 'true ["value"]\ntrue ["isElement"]', }, }); // An `import()` of the file in the same bundle resolves to a namespace // whose `default` is that same object. - itBundled("cjs2esm/KeptRequireOfLiftedCommonJSIsDefaultOfImport", { + itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSIsDefaultOfImport", { files: { "/entry.js": /* js */ ` let m; @@ -756,8 +863,10 @@ describe("bundler", () => { stdout: "undefined true true", }, }); - // With CommonJS output nothing is lifted: the file keeps its wrapper. - itBundled("cjs2esm/KeptRequireOfCommonJSFormatCjs", { + // Only ESM output lifts. With CommonJS output the file keeps its wrapper. + // With IIFE output a file with no exports is an ES module, as under any + // other package name. + itBundledLikeBunRun("cjs2esm/KeptRequireOfCommonJSFormatCjs", { files: { "/entry.js": /* js */ ` const unwrapped = require("react"); @@ -776,6 +885,83 @@ describe("bundler", () => { stdout: "undefined true 42", }, }); + itBundledLikeBunRun("cjs2esm/KeptRequireOfFileWithNoExportsFormatIife", { + files: { + "/entry.js": /* js */ ` + import "./side-effect.js"; + let m; + try { m = require("react"); } catch {} + console.log(m.__esModule, globalThis.sideEffect); + `, + "/side-effect.js": /* js */ ` + globalThis.entryLoaded = true; + `, + "/node_modules/react/package.json": reactWithCounter["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + globalThis.sideEffect = true; + `, + }, + format: "iife", + onAfterBundle(api) { + api.expectFile("/out.js").toContain("__toCommonJS(exports_react)"); + }, + run: { + stdout: "true true", + }, + }); + // Where the namespace object is not `module.exports`. Each comment gives + // what `bun run` prints. + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSDiffersFromBunRun", { + files: { + "/entry.js": /* js */ ` + let m, plain; + try { m = require("react"); } catch {} + try { plain = require("scheduler"); } catch {} + // 65 70: the module reads the property that was defined. + Object.defineProperty(m, "x", { value: 65, writable: true, enumerable: true, configurable: true }); + console.log(m.x, m.sum()); + // true NaN: the module reads the property that was deleted. + console.log(delete m.y, m.sum()); + // 3: a frozen object ignores the write. + Object.freeze(m); + m.z = 66; + console.log(m.z); + // true: a file with no exports, no require() and no "use strict" is an ES module at run time. + console.log(plain.__esModule); + `, + "/node_modules/react/package.json": reactWithCounter["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + exports.x = 1; + exports.y = 2; + exports.z = 3; + exports.sum = () => exports.x + exports.y + exports.z; + `, + "/node_modules/scheduler/package.json": /* json */ ` + { "name": "scheduler", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/scheduler/index.js": /* js */ ` + globalThis.sideEffect = true; + `, + }, + run: { + stdout: "65 6\ntrue 6\n66\nundefined", + }, + }); + // `import * as ns` of a lifted file is that same object. `bun run` prints false. + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSIsTheImportStarObject", { + files: { + "/entry.js": /* js */ ` + import * as ns from "react"; + let m; + try { m = require("react"); } catch {} + console.log(m === ns); + `, + ...reactWithCounter, + }, + run: { + stdout: "true", + }, + }); // `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. diff --git a/test/bundler/bundler_compile_splitting.test.ts b/test/bundler/bundler_compile_splitting.test.ts index 1e11636cbb14..b6a76435784d 100644 --- a/test/bundler/bundler_compile_splitting.test.ts +++ b/test/bundler/bundler_compile_splitting.test.ts @@ -597,6 +597,36 @@ describe("bundler", () => { }); } + // A CommonJS file of an unwrapped package that was lifted to an ES module + // is CommonJS to require(): it is not a chunk, and the call returns the + // file's exports object. `bun run` prints the same line. + itBundled("compile/splitting/SplitRequireLeavesLiftedCommonJSInline", { + compile: true, + splitting: true, + format: "esm", + files: { + "/entry.ts": /* js */ ` + let m; + try { + m = require("react"); + } catch {} + m.count = 41; + const ns = await import("react"); + console.log(m.__esModule, m.inc(), ns.default === m); + `, + "/node_modules/react/package.json": /* json */ ` + { "name": "react", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react/index.js": /* js */ ` + exports.count = 0; + exports.inc = () => ++exports.count; + `, + }, + run: { + stdout: "undefined 42 true", + }, + }); + // A chunk that is only linked statically has no module namespace object. itBundled("compile/splitting/NamespaceObjectsOnlyForImportedChunks", { compile: true, diff --git a/test/bundler/bundler_splitting.test.ts b/test/bundler/bundler_splitting.test.ts index 6a7d690e5d63..4da75757ccd4 100644 --- a/test/bundler/bundler_splitting.test.ts +++ b/test/bundler/bundler_splitting.test.ts @@ -3696,10 +3696,27 @@ describe("bundler", () => { // A CommonJS file of an unwrapped package that was lifted to an ES module // is still CommonJS to require(): the call returns the file's exports - // object, with no `__esModule` that the file did not set. So the file is - // not split either. `import.meta.require` of a chunk would return the - // chunk's module namespace. Here the lift target is itself lifted. - itBundled("splitting/SplitRequireLeavesLiftedCommonJSTargetInline", { + // object, with no `__esModule` that the file did not set. So the call is + // not a chunk boundary either. `import.meta.require` of a chunk would + // return the chunk's module namespace. + // + // `itBundledLikeBunRun` also runs the entry point of a case with `bun run`, + // which must print the stdout that the case expects of the bundle. + const itBundledLikeBunRun = (id: string, opts: BundlerTestInput) => + itBundled(id, { + ...opts, + async onAfterBundle(api) { + await opts.onAfterBundle?.(api); + const unbundled = await run([bunExe(), api.join(opts.entryPoints![0])], api.root, env); + expect({ stdout: unbundled.stdout.trim(), exitCode: unbundled.exitCode }).toEqual({ + stdout: (opts.run as { stdout: string }).stdout, + exitCode: 0, + }); + }, + }); + + // Here the lift target is itself lifted. + itBundledLikeBunRun("splitting/SplitRequireLeavesLiftedCommonJSTargetInline", { files: { "/main.ts": /* ts */ ` let m; @@ -3731,7 +3748,7 @@ describe("bundler", () => { // The lift target is reached through a file that is only // `module.exports = require()`. - itBundled("splitting/SplitRequireLeavesLiftedCommonJSTargetInlineThroughRedirect", { + itBundledLikeBunRun("splitting/SplitRequireLeavesLiftedCommonJSTargetInlineThroughRedirect", { files: { "/main.ts": /* ts */ ` let m; @@ -3764,29 +3781,26 @@ describe("bundler", () => { run: { file: "/out/main.js", stdout: "side effect\nundefined 19.0.0 rendered" }, }); - // The lift target calls require() as well, but it assigns `module.exports` - // and keeps its wrapper. The file that re-exports it is wrapped again and - // stays a chunk, as in #41236. - itBundled("splitting/SplitRequireOfRewrappedLiftedCommonJSThatCallsRequire", { + // Two files that re-export each other never reach a file that decides. + itBundledLikeBunRun("splitting/SplitRequireLeavesLiftedCommonJSCycleInline", { files: { "/main.ts": /* ts */ ` let m; try { - m = require("react-dom"); + m = require("react"); } catch {} - console.log(m.__esModule, m.version); + console.log(m.__esModule, Object.keys(m).length, globalThis.loaded); `, - "/node_modules/react-dom/index.js": /* js */ ` - console.log('side effect'); - module.exports = require('./impl'); + "/node_modules/react/package.json": /* json */ ` + { "name": "react", "version": "19.0.0", "main": "index.js" } `, - "/node_modules/react-dom/impl.js": /* js */ ` - const dependency = require('./dependency'); - module.exports = function render() {}; - module.exports.version = dependency.version; + "/node_modules/react/index.js": /* js */ ` + globalThis.loaded = (globalThis.loaded ?? 0) + 1; + module.exports = require("./other"); `, - "/node_modules/react-dom/dependency.js": /* js */ ` - exports.version = "19.0.0"; + "/node_modules/react/other.js": /* js */ ` + globalThis.loaded = (globalThis.loaded ?? 0) + 1; + module.exports = require("./index"); `, }, entryPoints: ["/main.ts"], @@ -3795,16 +3809,14 @@ describe("bundler", () => { outdir: "/out", format: "esm", onAfterBundle(api) { - const chunk = chunkContaining(api, "side effect"); - api.expectFile("/out/main.js").toContain(`import.meta.require("./${chunk}").default`); + expect(jsFilesIn(api)).toEqual(["main.js"]); }, - run: { file: "/out/main.js", stdout: "side effect\nundefined 19.0.0" }, + run: { file: "/out/main.js", stdout: "undefined 0 2" }, }); // The same value with a split require(), with `splitRequire: false` and // for a target that cannot split a require(): a write reaches the lifted // binding, and the `default` of an import() of the file is that object. - // Each stdout is what `bun run` prints for the same files. const liftedCommonJSFiles = { "/main.ts": /* ts */ ` let m; @@ -3829,7 +3841,7 @@ describe("bundler", () => { ["SplitRequireOff", { target: "bun", splitRequire: false }], ["TargetNode", { target: "node" }], ] as const) { - itBundled(`splitting/RequireOfLiftedCommonJSReturnsNamespace-${name}`, { + itBundledLikeBunRun(`splitting/RequireOfLiftedCommonJSReturnsNamespace-${name}`, { files: liftedCommonJSFiles, entryPoints: ["/main.ts"], splitting: true, @@ -3844,8 +3856,9 @@ describe("bundler", () => { }); } - // The file stays lazy inside its importer's chunk: it runs when the call does. - itBundled("splitting/SplitRequireOfLiftedCommonJSRunsAtTheCall", { + // The file stays lazy inside the chunks that load with its importer: it + // runs when the call does. + itBundledLikeBunRun("splitting/SplitRequireOfLiftedCommonJSRunsAtTheCall", { files: { ...liftedCommonJSFiles, "/main.ts": /* ts */ ` @@ -3869,9 +3882,85 @@ describe("bundler", () => { run: { file: "/out/main.js", stdout: "main\nreact evaluated\n1" }, }); + // The call needs no `try` when its specifier is an alias of the file. + itBundledLikeBunRun("splitting/SplitRequireLeavesLiftedCommonJSBehindAliasInline", { + files: { + ...liftedCommonJSFiles, + "/main.ts": /* ts */ ` + const m = require("react-alias"); + m.count = 41; + console.log(m.__esModule, m.inc(), m === require("react")); + `, + "/tsconfig.json": /* json */ ` + { "compilerOptions": { "baseUrl": ".", "paths": { "react-alias": ["./node_modules/react/index.js"] } } } + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + expect(jsFilesIn(api)).toEqual(["main.js"]); + }, + run: { file: "/out/main.js", stdout: "react evaluated\nundefined 42 true" }, + }); + + // The files that the lifted file imports get lazy wrappers as well, for + // every entry point. An entry point that only imports one of them calls + // its wrapper first, and prints what `bun run` prints. + itBundled("splitting/SplitRequireOfLiftedCommonJSWrapsItsImportsForOtherEntryPoints", { + files: { + "/server.ts": /* ts */ ` + export function render() { + try { + return require("react-dom").render(); + } catch {} + } + console.log("server"); + `, + "/client.ts": /* ts */ ` + import { useState } from "react"; + console.log("client", useState()); + `, + "/node_modules/react-dom/package.json": /* json */ ` + { "name": "react-dom", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-dom/index.js": /* js */ ` + const React = require("react"); + exports.render = () => "rendered " + React.useState(); + `, + "/node_modules/react/package.json": /* json */ ` + { "name": "react", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react/index.js": /* js */ ` + console.log("react evaluated"); + exports.useState = () => "state"; + `, + }, + entryPoints: ["/server.ts", "/client.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + async onAfterBundle(api) { + api.expectFile("/out/client.js").toContain("init_react()"); + for (const [entry, stdout] of [ + ["client.ts", "react evaluated\nclient state\n"], + ["server.ts", "server\n"], + ]) { + expect(await run([bunExe(), api.join(entry)], api.root, env)).toEqual({ stdout, stderr: "", exitCode: 0 }); + } + }, + run: [ + { file: "/out/client.js", stdout: "react evaluated\nclient state" }, + { file: "/out/server.js", stdout: "server" }, + ], + }); + // `bun run` loads a file with no exports as CommonJS when it calls // require(), which the parser turned into an import. - itBundled("splitting/SplitRequireLeavesLiftedCommonJSWithNoExportsInline", { + itBundledLikeBunRun("splitting/SplitRequireLeavesLiftedCommonJSWithNoExportsInline", { files: { "/main.ts": /* ts */ ` let m; @@ -3902,7 +3991,7 @@ describe("bundler", () => { // Control: an ES module inside an unwrapped package is an ES module to // `bun run` too. Its require() splits and has `__esModule`. - itBundled("splitting/SplitRequireOfESModuleInUnwrappedPackage", { + itBundledLikeBunRun("splitting/SplitRequireOfESModuleInUnwrappedPackage", { files: { "/main.ts": /* ts */ ` let m; @@ -3933,6 +4022,43 @@ describe("bundler", () => { run: { file: "/out/main.js", stdout: "react evaluated\ntrue value" }, }); + // A lifted `module.exports = require()` of an external package is still a + // chunk, and that chunk has `export *` inside a function. + itBundledLikeBunRun("splitting/SplitRequireOfLiftedReExportOfExternal", { + todo: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react-dom"); + } catch (e) { + console.log(e.message); + } + console.log(m?.value); + `, + "/node_modules/react-dom/package.json": /* json */ ` + { "name": "react-dom", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-dom/index.js": /* js */ ` + console.log('side effect'); + module.exports = require('external-package'); + `, + "/node_modules/external-package/package.json": /* json */ ` + { "name": "external-package", "version": "1.0.0", "main": "index.js" } + `, + "/node_modules/external-package/index.js": /* js */ ` + exports.value = "external value"; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + external: ["external-package"], + run: { file: "/out/main.js", stdout: "side effect\nexternal value" }, + }); + // 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 From 3f0bb4e8675086914f6fbc8ae64e9dcea956231d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:13:13 +0000 Subject: [PATCH 03/12] test: pin the evaluation order of another entry point and the external re-export control Another entry point that imports a file of the lifted target's import closure now calls its wrapper in import order. A lifted `module.exports = require()` of an external package stays a chunk boundary: with an `import()` of the same file the call loads that chunk. --- test/bundler/bundler_splitting.test.ts | 52 +++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/test/bundler/bundler_splitting.test.ts b/test/bundler/bundler_splitting.test.ts index 4da75757ccd4..18e2db05220d 100644 --- a/test/bundler/bundler_splitting.test.ts +++ b/test/bundler/bundler_splitting.test.ts @@ -3908,7 +3908,9 @@ describe("bundler", () => { // The files that the lifted file imports get lazy wrappers as well, for // every entry point. An entry point that only imports one of them calls - // its wrapper first, and prints what `bun run` prints. + // its wrapper first, and prints what `bun run` prints. A wrapper runs in + // import order, as in a `--target=node` build. With no wrapper, the file + // is in a shared chunk that runs before the entry point's own imports. itBundled("splitting/SplitRequireOfLiftedCommonJSWrapsItsImportsForOtherEntryPoints", { files: { "/server.ts": /* ts */ ` @@ -3923,6 +3925,14 @@ describe("bundler", () => { import { useState } from "react"; console.log("client", useState()); `, + "/ordered.ts": /* ts */ ` + import "./first"; + import { useState } from "react"; + console.log("ordered", useState()); + `, + "/first.ts": /* ts */ ` + console.log("first evaluated"); + `, "/node_modules/react-dom/package.json": /* json */ ` { "name": "react-dom", "version": "19.0.0", "main": "index.js" } `, @@ -3938,7 +3948,7 @@ describe("bundler", () => { exports.useState = () => "state"; `, }, - entryPoints: ["/server.ts", "/client.ts"], + entryPoints: ["/server.ts", "/client.ts", "/ordered.ts"], splitting: true, target: "bun", outdir: "/out", @@ -3955,6 +3965,7 @@ describe("bundler", () => { run: [ { file: "/out/client.js", stdout: "react evaluated\nclient state" }, { file: "/out/server.js", stdout: "server" }, + { file: "/out/ordered.js", stdout: "first evaluated\nreact evaluated\nordered state" }, ], }); @@ -4059,6 +4070,43 @@ describe("bundler", () => { run: { file: "/out/main.js", stdout: "side effect\nexternal value" }, }); + // Control: with an `import()` of that file the chunk has no function around + // `export *`, and the call loads it. Inline, the file gets that function. + itBundledLikeBunRun("splitting/SplitRequireOfLiftedReExportOfExternalWithImport", { + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react-dom"); + } catch (e) { + console.log(e.message); + } + const ns = await import("react-dom"); + console.log(m?.value, ns.value); + `, + "/node_modules/react-dom/package.json": /* json */ ` + { "name": "react-dom", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-dom/index.js": /* js */ ` + console.log('side effect'); + module.exports = require('external-package'); + `, + "/node_modules/external-package/package.json": /* json */ ` + { "name": "external-package", "version": "1.0.0", "main": "index.js" } + `, + "/node_modules/external-package/index.js": /* js */ ` + exports.value = "external value"; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + external: ["external-package"], + run: { file: "/out/main.js", stdout: "side effect\nexternal value external value" }, + }); + // 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 From 829b25c963a7fb9bf4befbaae096e4efc4e9a9b1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:46:51 +0000 Subject: [PATCH 04/12] test: pin the second shape that differs from bun run, and keep the compile file as it is A file that only calls require() in a package with "type": "module" is an ES module to Bun. The bundle treats it as a lifted CommonJS file. --- test/bundler/bundler_cjs2esm.test.ts | 19 ++++++++++-- .../bundler/bundler_compile_splitting.test.ts | 30 ------------------- 2 files changed, 16 insertions(+), 33 deletions(-) diff --git a/test/bundler/bundler_cjs2esm.test.ts b/test/bundler/bundler_cjs2esm.test.ts index 57416b67ad29..e6fb9b650a61 100644 --- a/test/bundler/bundler_cjs2esm.test.ts +++ b/test/bundler/bundler_cjs2esm.test.ts @@ -914,9 +914,10 @@ describe("bundler", () => { itBundled("cjs2esm/KeptRequireOfLiftedCommonJSDiffersFromBunRun", { files: { "/entry.js": /* js */ ` - let m, plain; + let m, plain, typeModule; try { m = require("react"); } catch {} try { plain = require("scheduler"); } catch {} + try { typeModule = require("react-is"); } catch {} // 65 70: the module reads the property that was defined. Object.defineProperty(m, "x", { value: 65, writable: true, enumerable: true, configurable: true }); console.log(m.x, m.sum()); @@ -926,8 +927,10 @@ describe("bundler", () => { Object.freeze(m); m.z = 66; console.log(m.z); - // true: a file with no exports, no require() and no "use strict" is an ES module at run time. + // true: a file with no exports, no require() and no "use strict" is an ES module to Bun. Node prints undefined. console.log(plain.__esModule); + // true value: so is a file that only calls require(), in a package with "type": "module". + console.log(typeModule.__esModule, globalThis.fromDependency); `, "/node_modules/react/package.json": reactWithCounter["/node_modules/react/package.json"], "/node_modules/react/index.js": /* js */ ` @@ -942,9 +945,19 @@ describe("bundler", () => { "/node_modules/scheduler/index.js": /* js */ ` globalThis.sideEffect = true; `, + "/node_modules/react-is/package.json": /* json */ ` + { "name": "react-is", "version": "19.0.0", "main": "index.js", "type": "module" } + `, + "/node_modules/react-is/index.js": /* js */ ` + const dependency = require("./dependency.cjs"); + globalThis.fromDependency = dependency.value; + `, + "/node_modules/react-is/dependency.cjs": /* js */ ` + exports.value = "value"; + `, }, run: { - stdout: "65 6\ntrue 6\n66\nundefined", + stdout: "65 6\ntrue 6\n66\nundefined\nundefined value", }, }); // `import * as ns` of a lifted file is that same object. `bun run` prints false. diff --git a/test/bundler/bundler_compile_splitting.test.ts b/test/bundler/bundler_compile_splitting.test.ts index b6a76435784d..1e11636cbb14 100644 --- a/test/bundler/bundler_compile_splitting.test.ts +++ b/test/bundler/bundler_compile_splitting.test.ts @@ -597,36 +597,6 @@ describe("bundler", () => { }); } - // A CommonJS file of an unwrapped package that was lifted to an ES module - // is CommonJS to require(): it is not a chunk, and the call returns the - // file's exports object. `bun run` prints the same line. - itBundled("compile/splitting/SplitRequireLeavesLiftedCommonJSInline", { - compile: true, - splitting: true, - format: "esm", - files: { - "/entry.ts": /* js */ ` - let m; - try { - m = require("react"); - } catch {} - m.count = 41; - const ns = await import("react"); - console.log(m.__esModule, m.inc(), ns.default === m); - `, - "/node_modules/react/package.json": /* json */ ` - { "name": "react", "version": "19.0.0", "main": "index.js" } - `, - "/node_modules/react/index.js": /* js */ ` - exports.count = 0; - exports.inc = () => ++exports.count; - `, - }, - run: { - stdout: "undefined 42 true", - }, - }); - // A chunk that is only linked statically has no module namespace object. itBundled("compile/splitting/NamespaceObjectsOnlyForImportedChunks", { compile: true, From f65c7b755dabd2cdb0bbc71d3d63fb2e8adfbdc5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:04:02 +0000 Subject: [PATCH 05/12] bundler: say why an external re-export does not count as a lifted CommonJS target --- src/bundler/LinkerContext.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index a34a22713d65..016e4d8f1aa3 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -4568,6 +4568,7 @@ impl<'a> LinkerContext<'a> { } }; let source = import_records[file].as_slice()[record_index as usize].source_index; + // An external module can be an ES module. if !source.is_valid() { return false; } From 6cb1273dcd021fd7b1ed7ce7e818827f55b0ebe9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:04:04 +0000 Subject: [PATCH 06/12] bundler: keep the split route of a lifted CommonJS require() as it is on main The change now covers a `require()` that stays a call in the same chunk as its lifted target. Step 6 of `scan_imports_and_exports` adds no `__toCommonJS` copy there, and the call evaluates to the namespace object of the file. - `find_reachable_files` is back at main. With `--splitting --target=bun` a lifted file is a chunk again, and the call there keeps the value of main. - `require_returns_namespace` also accepts a lifted CommonJS file of a package outside the unwrap list as the target of a lifted `module.exports = require()`. - The cases are in `test/bundler/bundler_cjs2esm.test.ts`. `test/bundler/bundler_splitting.test.ts` is the file of main again. - `matchesBunRun` is an option of the bundler test harness. It replaces the two local helpers. --- docs/bundler/index.mdx | 6 +- src/bundler/LinkerContext.rs | 46 +- src/bundler/bundle_v2.rs | 30 +- .../linker_context/scanImportsAndExports.rs | 15 - test/bundler/bundler_cjs2esm.test.ts | 228 ++++++++-- test/bundler/bundler_splitting.test.ts | 394 +----------------- test/bundler/expectBundled.ts | 25 ++ 7 files changed, 252 insertions(+), 492 deletions(-) diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index 4110bfabf8a4..ee43c765c760 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -560,7 +560,7 @@ The generated `chunk-dqmx6gc8.js` file contains the shared code. To avoid collis Each `import()` of a bundled JavaScript module also becomes its own chunk. Tree shaking applies to these chunks too: if every `import()` of a module lives in code that tree shaking removes — for example inside a function that is only called behind a [`define`](#define) or [`features`](#features) gate that evaluates to `false` — and nothing else in the live output imports the module, its chunk is not written and the module is absent from the [metafile](#metafile)'s `inputs` and `outputs`. Setting `treeShaking: false` keeps every `import()` chunk. This differs from esbuild, which emits a chunk for every reachable `import()` target. -With `target: "bun"`, a `require()` of a bundled ES module becomes a chunk of its own as well. The call is emitted as `import.meta.require("./chunk-.js")` and stays synchronous — Bun evaluates the chunk when the call runs — so a `require()` inside a function that never runs keeps its module out of the startup working set entirely. Tree shaking treats these chunks like `import()` chunks. `require()` of a CommonJS module is unaffected and keeps returning `module.exports`. A `require()` cycle between such chunks follows Bun's runtime `require()` of an ES module: a module required again while its chunk is still being loaded sees the partially initialized CommonJS placeholder (`{}`) rather than the live bindings the in-chunk wrapper provides. For other targets — and with `splitRequire: false` (`--no-split-require`) — the required module is inlined into the calling chunk behind a lazy wrapper instead, because the call must return synchronously. A CommonJS file whose `exports.x = ...` assignments the bundler lifted to ES module exports gets that lazy wrapper on every target. One example is `react-dom/server`. The file runs when the call runs, and `require()` returns its namespace object, which stands in for `module.exports`. +With `target: "bun"`, a `require()` of a bundled ES module becomes a chunk of its own as well. The call is emitted as `import.meta.require("./chunk-.js")` and stays synchronous — Bun evaluates the chunk when the call runs — so a `require()` inside a function that never runs keeps its module out of the startup working set entirely. Tree shaking treats these chunks like `import()` chunks. `require()` of a CommonJS module is unaffected and keeps returning `module.exports`. A `require()` cycle between such chunks follows Bun's runtime `require()` of an ES module: a module required again while its chunk is still being loaded sees the partially initialized CommonJS placeholder (`{}`) rather than the live bindings the in-chunk wrapper provides. For other targets — and with `splitRequire: false` (`--no-split-require`) — the required module is inlined into the calling chunk behind a lazy wrapper instead, because the call must return synchronously. #### Tree-shaking `import()` and `require()` results @@ -575,7 +575,7 @@ await import("./polyfill"); // keeps only side effects Recognized uses: destructuring (`const { a, b: c, ...rest } = await import(x)`, also `let`/`var` and `export const { a } = …`), property access on the awaited value or on a local holding it (`(await import(x)).a`, `const ns = await import(x); ns.a; const { b } = ns`), `.then(({ a }) => …)` / `.then(ns => ns.a)` with an arrow function, per-element destructuring of `await Promise.all([import(x), import(y)])`, the same shapes for `require()`, and a bare `import(x);` / `await import(x);` / `require(x);` statement (nothing observed). -The module keeps every export as soon as one use can't be followed: the namespace is passed, returned, stored, spread or iterated (`() => import(x)`, `fn(ns)`, `{ ...ns }`, `Object.keys(ns)`), accessed with a computed key (or `?.` directly on the `await import(x)` expression), handed to a non-arrow `.then` callback, reached via `import * as ns` elsewhere, or read under a direct `eval`. CommonJS targets always keep everything. The exception is a file whose `exports.x = ...` assignments the bundler lifted to ES module exports: Bun narrows it like an ES module. The imported module itself is always evaluated; only with `"sideEffects": false` can a module it merely re-exports from be skipped when none of those re-exports are observed. +The module keeps every export as soon as one use can't be followed: the namespace is passed, returned, stored, spread or iterated (`() => import(x)`, `fn(ns)`, `{ ...ns }`, `Object.keys(ns)`), accessed with a computed key (or `?.` directly on the `await import(x)` expression), handed to a non-arrow `.then` callback, reached via `import * as ns` elsewhere, or read under a direct `eval`. CommonJS targets always keep everything. The imported module itself is always evaluated; only with `"sideEffects": false` can a module it merely re-exports from be skipped when none of those re-exports are observed. @@ -1487,7 +1487,7 @@ In each case the member access compiles to a direct reference to `object`, the ` Assignments (`z.x = 1`), optional chains, and non-literal computed keys (`z[key]`) are left as property accesses; `z["object"]` is treated like `z.object`. `export default someImport` is followed only when it ends at a namespace; a default that snapshots a `let` export keeps snapshot semantics. -The same applies to the default import of a CommonJS module whose `exports.x = ...` assignments the bundler lifted to ES module exports, such as `react` and `scheduler`. The default import of a CommonJS module is its `module.exports`, which is that module's namespace, so `import React from "react"; React.useState()` compiles to a direct call of the lifted `useState` binding. This holds while every use of `React` reads, calls or assigns one of its properties: `React.useState`, `React[key]`, `const { useState } = React`. A write, `React.useLayoutEffect = React.useEffect`, goes through a namespace object whose setters assign the lifted binding, so every importer sees the new value, the same as a write to `module.exports`. Code that holds `React` itself as a value (`fn(React)`, `Object.keys(React)`, `const R = React`, a re-export) or deletes one of its properties can change the object in ways a namespace object cannot pass on to the lifted bindings (`Object.defineProperty`, `delete`, `Object.freeze`). The module then keeps its CommonJS wrapper and the default import is the real `module.exports` object. This rule looks at `import React from` only. `ns.default` on `import * as ns`, the `default` of an `import()` and the `this` of a call like `React.fn()` are still the namespace object of a module that stays lifted. `React.default`, and `ns.default` on `import * as ns`, is the lifted `default` export when the module has one, and otherwise the namespace itself, as `module.exports` is in Node. A module that sets both `exports.__esModule` and `exports.default` keeps its CommonJS wrapper when the importer is not an ES module by type (`.mjs`, `.mts`, or `"type": "module"`), because the default import then depends on that flag at run time. An `import()` of a module that does not set both resolves to that same namespace object as `default`, with or without code splitting. A `require()` of a lifted module that stays a call in the output evaluates to that namespace object too. The call stays a call when it is inside `try`/`catch`, or when its specifier is an alias of the package. The object has an `__esModule` property only if the module sets `exports.__esModule`. A write to an export that the module assigns itself reaches the lifted binding. A name that the module re-exports with `module.exports = require("./impl")` is read-only. `Object.defineProperty`, `delete` and `Object.freeze` on the object do not reach the lifted bindings. +The same applies to the default import of a CommonJS module whose `exports.x = ...` assignments the bundler lifted to ES module exports, such as `react` and `scheduler`. The default import of a CommonJS module is its `module.exports`, which is that module's namespace, so `import React from "react"; React.useState()` compiles to a direct call of the lifted `useState` binding. This holds while every use of `React` reads, calls or assigns one of its properties: `React.useState`, `React[key]`, `const { useState } = React`. A write, `React.useLayoutEffect = React.useEffect`, goes through a namespace object whose setters assign the lifted binding, so every importer sees the new value, the same as a write to `module.exports`. Code that holds `React` itself as a value (`fn(React)`, `Object.keys(React)`, `const R = React`, a re-export) or deletes one of its properties can change the object in ways a namespace object cannot pass on to the lifted bindings (`Object.defineProperty`, `delete`, `Object.freeze`). The module then keeps its CommonJS wrapper and the default import is the real `module.exports` object. This rule looks at `import React from` only. `ns.default` on `import * as ns`, the `default` of an `import()` and the `this` of a call like `React.fn()` are still the namespace object of a module that stays lifted. `React.default`, and `ns.default` on `import * as ns`, is the lifted `default` export when the module has one, and otherwise the namespace itself, as `module.exports` is in Node. A module that sets both `exports.__esModule` and `exports.default` keeps its CommonJS wrapper when the importer is not an ES module by type (`.mjs`, `.mts`, or `"type": "module"`), because the default import then depends on that flag at run time. An `import()` of a module that does not set both resolves to that same namespace object as `default`, with or without code splitting. A `require()` of a lifted module that stays a call in the output evaluates to that namespace object too. The call stays a call when it is inside `try`/`catch`. It also stays a call when its specifier is not a listed package name but resolves into one, for example through a tsconfig `paths` entry or a package.json `imports` entry. The object has an `__esModule` property only if the module sets `exports.__esModule`. A write to an export that the module assigns itself reaches the lifted binding. A module that runs a statement and then assigns `module.exports = require("./impl")` follows `./impl`. The call evaluates to the namespace object when `./impl` is a lifted CommonJS module, and the names of `./impl` are read-only on that object. `Object.defineProperty`, `delete` and `Object.freeze` on the object do not reach the lifted bindings. One case differs. With `splitting: true` and `target: "bun"`, the module becomes a chunk of its own, and the call returns the module namespace of that chunk: a read-only object with `__esModule: true`. ### deprecatedNamespaceObjectSetters diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 016e4d8f1aa3..d0217807e046 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -4533,41 +4533,37 @@ impl<'a> LinkerContext<'a> { && ast_flags.contains(AstFlags::COMMONJS_LIFTED_TO_ESM) } - /// Does `require()` of `target` evaluate to its namespace object? It does - /// for a CommonJS file that the parser lifted to an ES module: that object - /// stands in for `module.exports`. The call gets no `__toCommonJS` copy, - /// and the file is not a chunk that `import.meta.require` loads as an ES - /// module. A lifted `module.exports = require("./impl")` is an `export *`, - /// so `./impl` decides. + /// Is `target` CommonJS to a `require()` call, although the parser lifted + /// it to an ES module? Its namespace object then stands in for + /// `module.exports`, and a `require()` in the same chunk evaluates to + /// that object with no `__toCommonJS` copy. A lifted + /// `module.exports = require("./impl")` is an `export *`, so `./impl` + /// decides. pub(crate) fn require_returns_namespace( ast_flags: &[AstFlags], exports_kinds: &[ExportsKind], export_star_import_records: &[bun_alloc::AstVec], import_records: &[bun_ast::import_record::List<'_>], - redirects: &[u32], target: u32, ) -> bool { let mut file = target as usize; - // Each step follows one record. A longer walk is a cycle of files - // that all passed the test. + // Each step follows one export star. A longer walk is a cycle of + // files that all passed the test. for _ in 0..ast_flags.len() { - let record_index = match crate::bundle_v2::bv2_impl::get_redirect_id(redirects[file]) { - // `find_reachable_files` points a record past a redirect file - // only when it visits the importer. - Some(redirect) => redirect, - None => { - if exports_kinds[file] != ExportsKind::Esm - || !ast_flags[file].contains(AstFlags::COMMONJS_LIFTED_TO_ESM) - { - return false; - } - match export_star_import_records[file].first() { - Some(&star) => star, - None => return true, - } - } + // A file of a listed package also has the flag when it stays + // CommonJS, and when it is JSON, text or empty. + if !ast_flags[file].contains(AstFlags::COMMONJS_LIFTED_TO_ESM) + || !matches!( + exports_kinds[file], + ExportsKind::Esm | ExportsKind::EsmWithDynamicFallbackFromCjs + ) + { + return false; + } + let Some(&star) = export_star_import_records[file].first() else { + return true; }; - let source = import_records[file].as_slice()[record_index as usize].source_index; + let source = import_records[file].as_slice()[star as usize].source_index; // An external module can be an ES module. if !source.is_valid() { return false; diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index dfafdeff771b..0c03a1bea3d3 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2082,8 +2082,6 @@ pub mod bv2_impl { pub(crate) dynamic_import_entry_points: &'a mut ArrayHashMap, pub(crate) split_require: bool, pub(crate) all_exports_kinds: &'a [ExportsKind], - pub(crate) all_ast_flags: &'a [crate::bundled_ast::Flags], - pub(crate) all_export_star_import_records: &'a [bun_alloc::AstVec], pub(crate) all_targets: &'a [Target], /// Files which are Server Component Boundaries pub(crate) scb_bitset: Option, @@ -2254,33 +2252,21 @@ pub mod bv2_impl { } let next_source = import_record.source_index; - let kind = import_record.kind; // The importing file's own target decides: a server // build's browser-side files cannot call `import.meta.require`. let target_is_bun = self.split_require && self.all_targets[source_index.get() as usize].is_bun(); - let mut was_dynamic_import = - CHECK_DYNAMIC_IMPORTS && kind.can_be_lazy_chunk(target_is_bun); + let mut was_dynamic_import = CHECK_DYNAMIC_IMPORTS + && import_record.kind.can_be_lazy_chunk(target_is_bun); // A `require()` splits only when it targets another ES // module; the flag is the linker's and printer's signal. - // A lifted CommonJS module is CommonJS here: the call - // returns its exports object, not a chunk's namespace. - if was_dynamic_import && kind == ImportKind::Require { + if was_dynamic_import && import_record.kind == ImportKind::Require { was_dynamic_import = next_source.is_valid() && next_source != import_record_list_id && self.all_exports_kinds[next_source.get() as usize] - == ExportsKind::Esm - && !LinkerContext::require_returns_namespace( - self.all_ast_flags, - self.all_exports_kinds, - self.all_export_star_import_records, - self.all_import_records, - self.redirects, - next_source.get(), - ); + == ExportsKind::Esm; if was_dynamic_import { - self.all_import_records[import_record_list_id.get() as usize] - .as_mut_slice()[ir_idx] + import_record .flags .insert(bun_ast::ImportRecordFlags::CROSS_CHUNK_REQUIRE); } @@ -2385,8 +2371,6 @@ pub mod bv2_impl { ast_slice.split_mut().import_records; let all_urls_for_css = self.graph.ast.items_url_for_css(); let all_exports_kinds = self.graph.ast.items_exports_kind(); - let all_ast_flags = self.graph.ast.items_flags(); - let all_export_star_import_records = self.graph.ast.items_export_star_import_records(); let all_targets = self.graph.ast.items_target(); let mut visitor = ReachableFileVisitor { @@ -2399,8 +2383,6 @@ pub mod bv2_impl { dynamic_import_entry_points: &mut self.dynamic_import_entry_points, split_require: self.transpiler.options.split_require, all_exports_kinds, - all_ast_flags, - all_export_star_import_records, all_targets, scb_bitset, scb_list, @@ -8477,7 +8459,7 @@ pub mod bv2_impl { } } - pub(crate) fn get_redirect_id(id: u32) -> Option { + fn get_redirect_id(id: u32) -> Option { if id == u32::MAX { return None; } diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index 75b3cd405de1..cdd4607df1ec 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -108,7 +108,6 @@ pub(crate) fn scan_imports_and_exports( let ast_flags_list: *mut [AstFlags] = ast.flags; let module_types: *mut [crate::options::ModuleType] = ast.module_type; let export_star_import_records: *mut [bun_alloc::AstVec] = ast.export_star_import_records; - let redirects: *mut [u32] = ast.redirect_import_record_index; let exports_refs: *mut [Ref] = ast.exports_ref; let module_refs: *mut [Ref] = ast.module_ref; let wrapper_refs: *mut [Ref] = ast.wrapper_ref; @@ -1150,19 +1149,6 @@ pub(crate) fn scan_imports_and_exports( runtime_require_uses += 1; } - debug_assert!( - !(kind == ImportKind::Require && is_external_dyn) - || !LinkerContext::require_returns_namespace( - col_ref!(ast_flags_list), - col_ref!(exports_kind), - col_ref!(export_star_import_records), - col_ref!(import_records_list), - col_ref!(redirects), - rec_source_index.get(), - ), - "find_reachable_files splits no require() of a lifted CommonJS file" - ); - // 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 @@ -1303,7 +1289,6 @@ pub(crate) fn scan_imports_and_exports( col_ref!(exports_kind), col_ref!(export_star_import_records), col_ref!(import_records_list), - col_ref!(redirects), other_source_index, )) { diff --git a/test/bundler/bundler_cjs2esm.test.ts b/test/bundler/bundler_cjs2esm.test.ts index e6fb9b650a61..ddaf036dad74 100644 --- a/test/bundler/bundler_cjs2esm.test.ts +++ b/test/bundler/bundler_cjs2esm.test.ts @@ -1,7 +1,6 @@ import { describe, expect } from "bun:test"; -import { bunEnv, bunExe } from "harness"; import { readdirSync } from "node:fs"; -import { itBundled, type BundlerTestBundleAPI, type BundlerTestInput } from "./expectBundled"; +import { itBundled, type BundlerTestBundleAPI } from "./expectBundled"; const fakeReactNodeModules = { "/node_modules/react/index.js": /* js */ ` @@ -411,27 +410,8 @@ describe("bundler", () => { // the file sets one, every require() of the file returns it, and a write // reaches the lifted binding. A `__toCommonJS` copy has none of these. // - // `itBundledLikeBunRun` also runs the entry point of a case with `bun run`, - // which must print the stdout that the case expects of the bundle. - const itBundledLikeBunRun = (id: string, opts: BundlerTestInput) => - itBundled(id, { - ...opts, - async onAfterBundle(api) { - await opts.onAfterBundle?.(api); - await using unbundled = Bun.spawn({ - cmd: [bunExe(), api.join(Object.keys(opts.files)[0])], - cwd: api.root, - env: { ...bunEnv, ...opts.env }, - stdout: "pipe", - stderr: "inherit", - }); - const [stdout, exitCode] = await Promise.all([unbundled.stdout.text(), unbundled.exited]); - expect({ stdout: stdout.trim(), exitCode }).toEqual({ - stdout: (opts.run as { stdout: string }).stdout, - exitCode: 0, - }); - }, - }); + // `matchesBunRun` also runs the entry point of a case with `bun run`, which + // must print the stdout that the case expects of the bundle. const reactWithCounter = { "/node_modules/react/package.json": /* json */ ` { "name": "react", "version": "19.0.0", "main": "index.js" } @@ -445,7 +425,8 @@ describe("bundler", () => { for (const backend of ["cli", "api"] as const) { // The call stays in a `try` body and in a `catch` body. The same file // under a name off the unwrap list keeps its `__commonJS` wrapper. - itBundledLikeBunRun(`cjs2esm/KeptRequireOfLiftedCommonJS-${backend}`, { + itBundled(`cjs2esm/KeptRequireOfLiftedCommonJS-${backend}`, { + matchesBunRun: true, files: { "/entry.js": /* js */ ` const unwrapped = require("react"); @@ -480,7 +461,8 @@ describe("bundler", () => { } // A specifier that is not on the unwrap list can resolve to a file of a // listed package. The parser keeps such a call with no `try` around it. - itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSThroughAlias", { + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSThroughAlias", { + matchesBunRun: true, files: { "/entry.js": /* js */ ` import { viaRelativePath } from "sibling"; @@ -514,7 +496,8 @@ describe("bundler", () => { }); // The parser binds names read off a require() result, but it leaves // `__esModule` and `default` to the object that the call returns. - itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSReadsEsModuleOffNamespace", { + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSReadsEsModuleOffNamespace", { + matchesBunRun: true, files: { "/entry.js": /* js */ ` try { @@ -537,7 +520,8 @@ describe("bundler", () => { }); // A lifted file that sets `exports.__esModule` exports that name itself. // It is enumerable, as on `module.exports`, and not on a `__toCommonJS` copy. - itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSThatSetsEsModule", { + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSThatSetsEsModule", { + matchesBunRun: true, files: { "/entry.js": /* js */ ` let m; @@ -561,7 +545,8 @@ describe("bundler", () => { // The shape of react-dom/server.node.js, read the way Babel and TypeScript // compile `import Server from "react-dom/server"`. With a synthetic // `__esModule`, `Server.default` is `undefined`. - itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSThroughInteropHelper", { + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSThroughInteropHelper", { + matchesBunRun: true, files: { "/entry.js": /* js */ ` function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } @@ -639,7 +624,8 @@ describe("bundler", () => { })(); `, }; - itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequire", { + itBundled("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequire", { + matchesBunRun: true, files: { "/entry.js": /* js */ ` let m; @@ -663,7 +649,8 @@ describe("bundler", () => { }); // The namespace of such a file has a getter and no setter for a name that // it re-exports, and it is not the object of the file it re-exports. - itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequireWriteAndIdentity", { + itBundled("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequireWriteAndIdentity", { + matchesBunRun: true, todo: true, files: { "/entry.js": /* js */ ` @@ -682,10 +669,94 @@ describe("bundler", () => { stdout: "checkDCE\npatched true", }, }); + // The kept call, an unwrapped require() and the `default` of an import() of + // such a file are one object. + itBundled("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequireIdentity", { + matchesBunRun: true, + files: { + "/entry.js": /* js */ ` + let kept; + try { kept = require("react-dom"); } catch {} + const unwrapped = require("react-dom"); + const ns = await import("react-dom"); + console.log(kept === unwrapped, ns.default === kept); + `, + ...reactDomIndex, + }, + minifySyntax: true, + env: { + NODE_ENV: "production", + }, + run: { + stdout: "checkDCE\ntrue true", + }, + }); + // The file that such a module re-exports can be a CommonJS file of a + // package outside the list. The parser lifts the exports of that file too. + itBundled("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequireOfOtherPackage", { + matchesBunRun: true, + files: { + "/entry.js": /* js */ ` + let m; + try { m = require("react-is"); } catch {} + const ns = await import("react-is"); + console.log(m.__esModule, Object.keys(m).join(), m.bump(), m.value, globalThis.sideEffect, ns.default === m); + `, + "/node_modules/react-is/package.json": /* json */ ` + { "name": "react-is", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-is/index.js": /* js */ ` + globalThis.sideEffect = true; + module.exports = require("other"); + `, + "/node_modules/other/package.json": /* json */ ` + { "name": "other", "version": "1.0.0", "main": "index.js" } + `, + "/node_modules/other/index.js": /* js */ ` + exports.value = "v"; + exports.bump = () => (exports.value += "!"); + `, + }, + onAfterBundle(api) { + const code = api.readFile("out.js"); + expect(code).toContain("m = (init_react_is(), exports_react_is)"); + expect(code).not.toContain("__toCommonJS"); + }, + run: { + stdout: "undefined value,bump v! v! true true", + }, + }); + // Control: a file with no JavaScript exports, here an empty one, is not a + // lifted CommonJS file. `bun run` loads it as an ES module. + itBundled("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequireOfEmptyFile", { + matchesBunRun: true, + files: { + "/entry.js": /* js */ ` + let m; + try { m = require("react-is"); } catch {} + console.log(m.__esModule, JSON.stringify(Object.keys(m)), globalThis.sideEffect); + `, + "/node_modules/react-is/package.json": /* json */ ` + { "name": "react-is", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-is/index.js": /* js */ ` + globalThis.sideEffect = true; + module.exports = require("./empty.js"); + `, + "/node_modules/react-is/empty.js": "", + }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain("m = (init_react_is(), __toCommonJS(exports_react_is));"); + }, + run: { + stdout: "true [] true", + }, + }); // Controls for the same file. Without `--minify-syntax` the `if` stays, and // the file keeps its `__commonJS` wrapper. In a development build the // wrapper also converts the development file, which is another defect. - itBundledLikeBunRun("cjs2esm/KeptRequireOfModuleExportsEqualsRequireInAnIfKeepsWrapper", { + itBundled("cjs2esm/KeptRequireOfModuleExportsEqualsRequireInAnIfKeepsWrapper", { + matchesBunRun: true, files: { "/entry.js": /* js */ ` let m; @@ -727,7 +798,8 @@ describe("bundler", () => { }); // `bun run` loads a file with no exports as CommonJS when it calls // require(), or when it starts with "use strict". - itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSWithNoExports", { + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSWithNoExports", { + matchesBunRun: true, files: { "/entry.js": /* js */ ` let m, n; @@ -761,7 +833,8 @@ describe("bundler", () => { }); // `bun run` loads a file that assigns `exports.x` as CommonJS, also in a // package with "type": "module". - itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSInTypeModulePackage", { + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSInTypeModulePackage", { + matchesBunRun: true, files: { "/entry.js": /* js */ ` let m; @@ -781,7 +854,8 @@ describe("bundler", () => { }, }); // Two files that re-export each other never reach a file that decides. - itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequireCycle", { + itBundled("cjs2esm/KeptRequireOfLiftedModuleExportsEqualsRequireCycle", { + matchesBunRun: true, files: { "/entry.js": /* js */ ` let m; @@ -808,7 +882,8 @@ describe("bundler", () => { // Controls: an ES module by syntax is an ES module to `bun run` too, so // require() of it has `__esModule` and keeps the `__toCommonJS` copy. The // same holds for a lifted `module.exports = require()` of an ES module. - itBundledLikeBunRun("cjs2esm/KeptRequireOfESModuleInUnwrappedPackageKeepsEsModule", { + itBundled("cjs2esm/KeptRequireOfESModuleInUnwrappedPackageKeepsEsModule", { + matchesBunRun: true, files: { "/entry.js": /* js */ ` const loaded = []; @@ -849,7 +924,8 @@ describe("bundler", () => { }); // An `import()` of the file in the same bundle resolves to a namespace // whose `default` is that same object. - itBundledLikeBunRun("cjs2esm/KeptRequireOfLiftedCommonJSIsDefaultOfImport", { + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSIsDefaultOfImport", { + matchesBunRun: true, files: { "/entry.js": /* js */ ` let m; @@ -863,10 +939,42 @@ describe("bundler", () => { stdout: "undefined true true", }, }); + // The same with code splitting, when the lifted file stays in a chunk that + // the caller imports: with `splitRequire: false`, and for a target that + // cannot split a require(). + for (const [name, options] of [ + ["SplitRequireOff", { target: "bun", splitRequire: false }], + ["TargetNode", { target: "node" }], + ] as const) { + itBundled(`cjs2esm/KeptRequireOfLiftedCommonJSWithSplitting-${name}`, { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { m = require("react"); } catch {} + m.count = 41; + const ns = await import("react"); + console.log(m.__esModule, m.inc(), ns.default === m); + `, + ...reactWithCounter, + }, + entryPoints: ["/main.ts"], + splitting: true, + outdir: "/out", + format: "esm", + ...options, + onAfterBundle(api) { + api.expectFile("/out/main.js").toContain("m = (init_react(), exports_react);"); + api.expectFile("/out/main.js").not.toContain("__toCommonJS"); + }, + run: { file: "/out/main.js", stdout: "undefined 42 true" }, + }); + } // Only ESM output lifts. With CommonJS output the file keeps its wrapper. // With IIFE output a file with no exports is an ES module, as under any // other package name. - itBundledLikeBunRun("cjs2esm/KeptRequireOfCommonJSFormatCjs", { + itBundled("cjs2esm/KeptRequireOfCommonJSFormatCjs", { + matchesBunRun: true, files: { "/entry.js": /* js */ ` const unwrapped = require("react"); @@ -885,7 +993,8 @@ describe("bundler", () => { stdout: "undefined true 42", }, }); - itBundledLikeBunRun("cjs2esm/KeptRequireOfFileWithNoExportsFormatIife", { + itBundled("cjs2esm/KeptRequireOfFileWithNoExportsFormatIife", { + matchesBunRun: true, files: { "/entry.js": /* js */ ` import "./side-effect.js"; @@ -960,6 +1069,49 @@ describe("bundler", () => { stdout: "65 6\ntrue 6\n66\nundefined\nundefined value", }, }); + // A write after `Object.freeze()` still reaches the lifted binding, through + // the setter of the namespace object. `bun run` prints `3 true 6`. + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSWriteAfterFreeze", { + files: { + "/entry.js": /* js */ ` + let m; + try { m = require("react"); } catch {} + Object.freeze(m); + try { m.z = 66; } catch (e) { console.log(e.constructor.name); } + console.log(m.z, Object.isFrozen(m), m.sum()); + `, + "/node_modules/react/package.json": reactWithCounter["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + exports.x = 1; + exports.y = 2; + exports.z = 3; + exports.sum = () => exports.x + exports.y + exports.z; + `, + }, + run: { + stdout: "66 true 69", + }, + }); + // A lifted file can set a property named "module.exports". The call returns + // the whole object, as for any CommonJS file. + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSWithModuleExportsName", { + matchesBunRun: true, + files: { + "/entry.js": /* js */ ` + let m; + try { m = require("react"); } catch {} + console.log(m.__esModule, m["module.exports"], m.named); + `, + "/node_modules/react/package.json": reactWithCounter["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + exports["module.exports"] = 5; + exports.named = 1; + `, + }, + run: { + stdout: "undefined 5 1", + }, + }); // `import * as ns` of a lifted file is that same object. `bun run` prints false. itBundled("cjs2esm/KeptRequireOfLiftedCommonJSIsTheImportStarObject", { files: { diff --git a/test/bundler/bundler_splitting.test.ts b/test/bundler/bundler_splitting.test.ts index 18e2db05220d..120d1617be3e 100644 --- a/test/bundler/bundler_splitting.test.ts +++ b/test/bundler/bundler_splitting.test.ts @@ -3694,36 +3694,16 @@ describe("bundler", () => { run: { file: "/out/main.js", stdout: "side effect\nobject 19.0.0 rendered" }, }); - // A CommonJS file of an unwrapped package that was lifted to an ES module - // is still CommonJS to require(): the call returns the file's exports - // object, with no `__esModule` that the file did not set. So the call is - // not a chunk boundary either. `import.meta.require` of a chunk would - // return the chunk's module namespace. - // - // `itBundledLikeBunRun` also runs the entry point of a case with `bun run`, - // which must print the stdout that the case expects of the bundle. - const itBundledLikeBunRun = (id: string, opts: BundlerTestInput) => - itBundled(id, { - ...opts, - async onAfterBundle(api) { - await opts.onAfterBundle?.(api); - const unbundled = await run([bunExe(), api.join(opts.entryPoints![0])], api.root, env); - expect({ stdout: unbundled.stdout.trim(), exitCode: unbundled.exitCode }).toEqual({ - stdout: (opts.run as { stdout: string }).stdout, - exitCode: 0, - }); - }, - }); - - // Here the lift target is itself lifted. - itBundledLikeBunRun("splitting/SplitRequireLeavesLiftedCommonJSTargetInline", { + // 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.__esModule, m.version, m.render()); + console.log(m.version, m.render()); `, "/node_modules/react-dom/index.js": /* js */ ` console.log('side effect'); @@ -3740,371 +3720,11 @@ describe("bundler", () => { outdir: "/out", format: "esm", onAfterBundle(api) { - expect(jsFilesIn(api)).toEqual(["main.js"]); - api.expectFile("/out/main.js").not.toContain("import.meta.require"); - }, - run: { file: "/out/main.js", stdout: "side effect\nundefined 19.0.0 rendered" }, - }); - - // The lift target is reached through a file that is only - // `module.exports = require()`. - itBundledLikeBunRun("splitting/SplitRequireLeavesLiftedCommonJSTargetInlineThroughRedirect", { - files: { - "/main.ts": /* ts */ ` - let m; - try { - m = require("react-dom"); - } catch {} - console.log(m.__esModule, m.version, m.render()); - `, - "/node_modules/react-dom/index.js": /* js */ ` - console.log('side effect'); - module.exports = require('./server'); - `, - "/node_modules/react-dom/server.js": /* js */ ` - 'use strict'; - module.exports = require('./server.node'); - `, - "/node_modules/react-dom/server.node.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) { - expect(jsFilesIn(api)).toEqual(["main.js"]); - }, - run: { file: "/out/main.js", stdout: "side effect\nundefined 19.0.0 rendered" }, - }); - - // Two files that re-export each other never reach a file that decides. - itBundledLikeBunRun("splitting/SplitRequireLeavesLiftedCommonJSCycleInline", { - files: { - "/main.ts": /* ts */ ` - let m; - try { - m = require("react"); - } catch {} - console.log(m.__esModule, Object.keys(m).length, globalThis.loaded); - `, - "/node_modules/react/package.json": /* json */ ` - { "name": "react", "version": "19.0.0", "main": "index.js" } - `, - "/node_modules/react/index.js": /* js */ ` - globalThis.loaded = (globalThis.loaded ?? 0) + 1; - module.exports = require("./other"); - `, - "/node_modules/react/other.js": /* js */ ` - globalThis.loaded = (globalThis.loaded ?? 0) + 1; - module.exports = require("./index"); - `, - }, - entryPoints: ["/main.ts"], - splitting: true, - target: "bun", - outdir: "/out", - format: "esm", - onAfterBundle(api) { - expect(jsFilesIn(api)).toEqual(["main.js"]); - }, - run: { file: "/out/main.js", stdout: "undefined 0 2" }, - }); - - // The same value with a split require(), with `splitRequire: false` and - // for a target that cannot split a require(): a write reaches the lifted - // binding, and the `default` of an import() of the file is that object. - const liftedCommonJSFiles = { - "/main.ts": /* ts */ ` - let m; - try { - m = require("react"); - } catch {} - m.count = 41; - const ns = await import("react"); - console.log(m.__esModule, m.inc(), ns.default === m); - `, - "/node_modules/react/package.json": /* json */ ` - { "name": "react", "version": "19.0.0", "main": "index.js" } - `, - "/node_modules/react/index.js": /* js */ ` - console.log("react evaluated"); - exports.count = 0; - exports.inc = () => ++exports.count; - `, - }; - for (const [name, options] of [ - ["Split", { target: "bun" }], - ["SplitRequireOff", { target: "bun", splitRequire: false }], - ["TargetNode", { target: "node" }], - ] as const) { - itBundledLikeBunRun(`splitting/RequireOfLiftedCommonJSReturnsNamespace-${name}`, { - files: liftedCommonJSFiles, - entryPoints: ["/main.ts"], - splitting: true, - outdir: "/out", - format: "esm", - ...options, - onAfterBundle(api) { - api.expectFile("/out/main.js").not.toContain("import.meta.require"); - api.expectFile("/out/main.js").not.toContain("__toCommonJS"); - }, - run: { file: "/out/main.js", stdout: "react evaluated\nundefined 42 true" }, - }); - } - - // The file stays lazy inside the chunks that load with its importer: it - // runs when the call does. - itBundledLikeBunRun("splitting/SplitRequireOfLiftedCommonJSRunsAtTheCall", { - files: { - ...liftedCommonJSFiles, - "/main.ts": /* ts */ ` - function load() { - try { - return require("react"); - } catch {} - } - console.log("main"); - console.log(load().inc()); - `, - }, - entryPoints: ["/main.ts"], - splitting: true, - target: "bun", - outdir: "/out", - format: "esm", - onAfterBundle(api) { - expect(jsFilesIn(api)).toEqual(["main.js"]); - }, - run: { file: "/out/main.js", stdout: "main\nreact evaluated\n1" }, - }); - - // The call needs no `try` when its specifier is an alias of the file. - itBundledLikeBunRun("splitting/SplitRequireLeavesLiftedCommonJSBehindAliasInline", { - files: { - ...liftedCommonJSFiles, - "/main.ts": /* ts */ ` - const m = require("react-alias"); - m.count = 41; - console.log(m.__esModule, m.inc(), m === require("react")); - `, - "/tsconfig.json": /* json */ ` - { "compilerOptions": { "baseUrl": ".", "paths": { "react-alias": ["./node_modules/react/index.js"] } } } - `, - }, - entryPoints: ["/main.ts"], - splitting: true, - target: "bun", - outdir: "/out", - format: "esm", - onAfterBundle(api) { - expect(jsFilesIn(api)).toEqual(["main.js"]); - }, - run: { file: "/out/main.js", stdout: "react evaluated\nundefined 42 true" }, - }); - - // The files that the lifted file imports get lazy wrappers as well, for - // every entry point. An entry point that only imports one of them calls - // its wrapper first, and prints what `bun run` prints. A wrapper runs in - // import order, as in a `--target=node` build. With no wrapper, the file - // is in a shared chunk that runs before the entry point's own imports. - itBundled("splitting/SplitRequireOfLiftedCommonJSWrapsItsImportsForOtherEntryPoints", { - files: { - "/server.ts": /* ts */ ` - export function render() { - try { - return require("react-dom").render(); - } catch {} - } - console.log("server"); - `, - "/client.ts": /* ts */ ` - import { useState } from "react"; - console.log("client", useState()); - `, - "/ordered.ts": /* ts */ ` - import "./first"; - import { useState } from "react"; - console.log("ordered", useState()); - `, - "/first.ts": /* ts */ ` - console.log("first evaluated"); - `, - "/node_modules/react-dom/package.json": /* json */ ` - { "name": "react-dom", "version": "19.0.0", "main": "index.js" } - `, - "/node_modules/react-dom/index.js": /* js */ ` - const React = require("react"); - exports.render = () => "rendered " + React.useState(); - `, - "/node_modules/react/package.json": /* json */ ` - { "name": "react", "version": "19.0.0", "main": "index.js" } - `, - "/node_modules/react/index.js": /* js */ ` - console.log("react evaluated"); - exports.useState = () => "state"; - `, - }, - entryPoints: ["/server.ts", "/client.ts", "/ordered.ts"], - splitting: true, - target: "bun", - outdir: "/out", - format: "esm", - async onAfterBundle(api) { - api.expectFile("/out/client.js").toContain("init_react()"); - for (const [entry, stdout] of [ - ["client.ts", "react evaluated\nclient state\n"], - ["server.ts", "server\n"], - ]) { - expect(await run([bunExe(), api.join(entry)], api.root, env)).toEqual({ stdout, stderr: "", exitCode: 0 }); - } - }, - run: [ - { file: "/out/client.js", stdout: "react evaluated\nclient state" }, - { file: "/out/server.js", stdout: "server" }, - { file: "/out/ordered.js", stdout: "first evaluated\nreact evaluated\nordered state" }, - ], - }); - - // `bun run` loads a file with no exports as CommonJS when it calls - // require(), which the parser turned into an import. - itBundledLikeBunRun("splitting/SplitRequireLeavesLiftedCommonJSWithNoExportsInline", { - files: { - "/main.ts": /* ts */ ` - let m; - try { - m = require("react"); - } catch {} - console.log(m.__esModule, Object.keys(m).length, globalThis.fromDependency); - `, - "/node_modules/react/package.json": liftedCommonJSFiles["/node_modules/react/package.json"], - "/node_modules/react/index.js": /* js */ ` - const dependency = require("./dependency"); - globalThis.fromDependency = dependency.value; - `, - "/node_modules/react/dependency.js": /* js */ ` - exports.value = "value"; - `, - }, - entryPoints: ["/main.ts"], - splitting: true, - target: "bun", - outdir: "/out", - format: "esm", - onAfterBundle(api) { - expect(jsFilesIn(api)).toEqual(["main.js"]); - }, - run: { file: "/out/main.js", stdout: "undefined 0 value" }, - }); - - // Control: an ES module inside an unwrapped package is an ES module to - // `bun run` too. Its require() splits and has `__esModule`. - itBundledLikeBunRun("splitting/SplitRequireOfESModuleInUnwrappedPackage", { - files: { - "/main.ts": /* ts */ ` - let m; - try { - m = require("react"); - } catch {} - console.log(m.__esModule, m.value); - `, - "/node_modules/react/package.json": liftedCommonJSFiles["/node_modules/react/package.json"], - "/node_modules/react/index.js": /* js */ ` - const dependency = require("./dependency"); - console.log("react evaluated"); - export const value = dependency.value; - `, - "/node_modules/react/dependency.js": /* js */ ` - exports.value = "value"; - `, - }, - entryPoints: ["/main.ts"], - splitting: true, - target: "bun", - outdir: "/out", - format: "esm", - onAfterBundle(api) { - const chunk = chunkContaining(api, "react evaluated"); + 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: "react evaluated\ntrue value" }, - }); - - // A lifted `module.exports = require()` of an external package is still a - // chunk, and that chunk has `export *` inside a function. - itBundledLikeBunRun("splitting/SplitRequireOfLiftedReExportOfExternal", { - todo: true, - files: { - "/main.ts": /* ts */ ` - let m; - try { - m = require("react-dom"); - } catch (e) { - console.log(e.message); - } - console.log(m?.value); - `, - "/node_modules/react-dom/package.json": /* json */ ` - { "name": "react-dom", "version": "19.0.0", "main": "index.js" } - `, - "/node_modules/react-dom/index.js": /* js */ ` - console.log('side effect'); - module.exports = require('external-package'); - `, - "/node_modules/external-package/package.json": /* json */ ` - { "name": "external-package", "version": "1.0.0", "main": "index.js" } - `, - "/node_modules/external-package/index.js": /* js */ ` - exports.value = "external value"; - `, - }, - entryPoints: ["/main.ts"], - splitting: true, - target: "bun", - outdir: "/out", - format: "esm", - external: ["external-package"], - run: { file: "/out/main.js", stdout: "side effect\nexternal value" }, - }); - - // Control: with an `import()` of that file the chunk has no function around - // `export *`, and the call loads it. Inline, the file gets that function. - itBundledLikeBunRun("splitting/SplitRequireOfLiftedReExportOfExternalWithImport", { - files: { - "/main.ts": /* ts */ ` - let m; - try { - m = require("react-dom"); - } catch (e) { - console.log(e.message); - } - const ns = await import("react-dom"); - console.log(m?.value, ns.value); - `, - "/node_modules/react-dom/package.json": /* json */ ` - { "name": "react-dom", "version": "19.0.0", "main": "index.js" } - `, - "/node_modules/react-dom/index.js": /* js */ ` - console.log('side effect'); - module.exports = require('external-package'); - `, - "/node_modules/external-package/package.json": /* json */ ` - { "name": "external-package", "version": "1.0.0", "main": "index.js" } - `, - "/node_modules/external-package/index.js": /* js */ ` - exports.value = "external value"; - `, - }, - entryPoints: ["/main.ts"], - splitting: true, - target: "bun", - outdir: "/out", - format: "esm", - external: ["external-package"], - run: { file: "/out/main.js", stdout: "side effect\nexternal value external value" }, + 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 diff --git a/test/bundler/expectBundled.ts b/test/bundler/expectBundled.ts index 27fbb770a50b..51f6c32b0510 100644 --- a/test/bundler/expectBundled.ts +++ b/test/bundler/expectBundled.ts @@ -308,6 +308,10 @@ export interface BundlerTestInput { * Pass an array to run multiple times with different options. */ run?: boolean | BundlerTestRunOptions | BundlerTestRunOptions[]; + /** + * The first entry point, run by `bun` without bundling, must print the `stdout` of the first `run`. + */ + matchesBunRun?: boolean; /** * Shorthand for testing dead code elimination cases. @@ -527,6 +531,7 @@ function expectBundled( legalComments, loader, mainFields, + matchesBunRun, matchesReference, metafile, minifyIdentifiers, @@ -1693,6 +1698,26 @@ for (const [key, blob] of build.outputs) { await onAfterBundle(api); } + if (matchesBunRun) { + await using unbundled = Bun.spawn({ + cmd: [bunExe(), entryPaths[0]], + env: { ...bunEnv, ...env }, + stdio: ["ignore", "pipe", "pipe"], + cwd: root, + }); + const [unbundledStdout, unbundledStderr, unbundledExitCode] = await Promise.all([ + unbundled.stdout.text(), + unbundled.stderr.text(), + unbundled.exited, + ]); + const firstRun = (Array.isArray(run) ? run[0] : run) as BundlerTestRunOptions; + expect({ stdout: unbundledStdout.trim(), stderr: unbundledStderr, exitCode: unbundledExitCode }).toEqual({ + stdout: dedent(firstRun.stdout as string).trim(), + stderr: "", + exitCode: 0, + }); + } + // check reference if (matchesReference) { const { ref } = matchesReference; From b42f24c589cdfab0acd55e5c3af605fc0fa57d7e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:23:23 +0000 Subject: [PATCH 07/12] bundler: shorten the comments of the lifted require() check --- src/bundler/LinkerContext.rs | 14 ++++---------- .../linker_context/scanImportsAndExports.rs | 5 +---- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index d0217807e046..cf3a8b3357d9 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -4533,12 +4533,7 @@ impl<'a> LinkerContext<'a> { && ast_flags.contains(AstFlags::COMMONJS_LIFTED_TO_ESM) } - /// Is `target` CommonJS to a `require()` call, although the parser lifted - /// it to an ES module? Its namespace object then stands in for - /// `module.exports`, and a `require()` in the same chunk evaluates to - /// that object with no `__toCommonJS` copy. A lifted - /// `module.exports = require("./impl")` is an `export *`, so `./impl` - /// decides. + /// Is `target` a lifted CommonJS file, whose namespace object `require()` returns with no copy? pub(crate) fn require_returns_namespace( ast_flags: &[AstFlags], exports_kinds: &[ExportsKind], @@ -4547,11 +4542,8 @@ impl<'a> LinkerContext<'a> { target: u32, ) -> bool { let mut file = target as usize; - // Each step follows one export star. A longer walk is a cycle of - // files that all passed the test. for _ in 0..ast_flags.len() { - // A file of a listed package also has the flag when it stays - // CommonJS, and when it is JSON, text or empty. + // The flag is also on CommonJS, JSON, text and empty files of a listed package. if !ast_flags[file].contains(AstFlags::COMMONJS_LIFTED_TO_ESM) || !matches!( exports_kinds[file], @@ -4560,6 +4552,7 @@ impl<'a> LinkerContext<'a> { { return false; } + // A lifted `module.exports = require("./impl")` is an `export *`: `./impl` decides. let Some(&star) = export_star_import_records[file].first() else { return true; }; @@ -4570,6 +4563,7 @@ impl<'a> LinkerContext<'a> { } file = source.get() as usize; } + // The walk is a cycle of lifted files. true } diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index cdd4607df1ec..cc9b7edfd521 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -1278,10 +1278,7 @@ pub(crate) fn scan_imports_and_exports( // code should see "__esModule". This is an extremely complex // and subtle set of transpiler interop issues. See for example // https://github.com/evanw/esbuild/issues/1591. - // - // A lifted CommonJS module was never an ES module: its - // exports object is its `module.exports`. Only ESM - // output lifts. + // Not for a lifted CommonJS file, which only ESM output has. if kind == ImportKind::Require && !(output_format == Format::Esm && LinkerContext::require_returns_namespace( From c583deddbfa4daeab5a4493b6fc6cab3d00190f5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:46:21 +0000 Subject: [PATCH 08/12] test: pin the printed call of each alias path; bound and check the unbundled run --- test/bundler/bundler_cjs2esm.test.ts | 6 +++++- test/bundler/expectBundled.ts | 22 ++++++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/test/bundler/bundler_cjs2esm.test.ts b/test/bundler/bundler_cjs2esm.test.ts index ddaf036dad74..143a4fa52f74 100644 --- a/test/bundler/bundler_cjs2esm.test.ts +++ b/test/bundler/bundler_cjs2esm.test.ts @@ -488,7 +488,11 @@ describe("bundler", () => { ...reactWithCounter, }, onAfterBundle(api) { - api.expectFile("/out.js").not.toContain("__toCommonJS"); + const code = api.readFile("out.js"); + expect(code).toContain("viaTsconfigPaths = (init_react(), exports_react);"); + expect(code).toContain("viaPackageImports = (init_react(), exports_react);"); + expect(code).toContain("viaRelativePath = () => (init_react(), exports_react);"); + expect(code).not.toContain("__toCommonJS"); }, run: { stdout: "undefined true 40 41\nundefined true 81 82\nundefined true 122 123", diff --git a/test/bundler/expectBundled.ts b/test/bundler/expectBundled.ts index 51f6c32b0510..66ebc038273d 100644 --- a/test/bundler/expectBundled.ts +++ b/test/bundler/expectBundled.ts @@ -310,6 +310,7 @@ export interface BundlerTestInput { run?: boolean | BundlerTestRunOptions | BundlerTestRunOptions[]; /** * The first entry point, run by `bun` without bundling, must print the `stdout` of the first `run`. + * The unbundled run gets the build `env` and the `env`, `args` and `bunArgs` of that `run`. */ matchesBunRun?: boolean; @@ -1699,9 +1700,15 @@ for (const [key, blob] of build.outputs) { } if (matchesBunRun) { + const firstRun = Array.isArray(run) ? run[0] : run; + if (typeof firstRun !== "object" || typeof firstRun.stdout !== "string") { + throw new Error(`[${id}] matchesBunRun needs a first \`run\` with a string \`stdout\``); + } + const cmd = [bunExe(), ...(firstRun.bunArgs ?? []), entryPaths[0], ...(firstRun.args ?? [])]; await using unbundled = Bun.spawn({ - cmd: [bunExe(), entryPaths[0]], - env: { ...bunEnv, ...env }, + cmd, + env: { ...bunEnv, ...env, ...(firstRun.env || {}) }, + timeout: 60_000, stdio: ["ignore", "pipe", "pipe"], cwd: root, }); @@ -1710,9 +1717,16 @@ for (const [key, blob] of build.outputs) { unbundled.stderr.text(), unbundled.exited, ]); - const firstRun = (Array.isArray(run) ? run[0] : run) as BundlerTestRunOptions; + if (unbundled.signalCode) { + throw new Error( + `[${id}] unbundled run killed by ${unbundled.signalCode}\n` + + `cmd: ${cmd.join(" ")}\n` + + `STDOUT: ${unbundledStdout.slice(0, 2000)}\n` + + `STDERR: ${unbundledStderr.slice(0, 2000)}`, + ); + } expect({ stdout: unbundledStdout.trim(), stderr: unbundledStderr, exitCode: unbundledExitCode }).toEqual({ - stdout: dedent(firstRun.stdout as string).trim(), + stdout: dedent(firstRun.stdout).trim(), stderr: "", exitCode: 0, }); From e5647fa21082700095b1fb1e4ad80fb7d9251999 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:04:25 +0000 Subject: [PATCH 09/12] test: resolve the root placeholder in the env of the unbundled run --- test/bundler/expectBundled.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/bundler/expectBundled.ts b/test/bundler/expectBundled.ts index 66ebc038273d..6016de129326 100644 --- a/test/bundler/expectBundled.ts +++ b/test/bundler/expectBundled.ts @@ -1705,9 +1705,13 @@ for (const [key, blob] of build.outputs) { throw new Error(`[${id}] matchesBunRun needs a first \`run\` with a string \`stdout\``); } const cmd = [bunExe(), ...(firstRun.bunArgs ?? []), entryPaths[0], ...(firstRun.args ?? [])]; + const unbundledEnv: Record = {}; + for (const [key, value] of Object.entries({ ...bunEnv, ...env, ...(firstRun.env || {}) })) { + if (typeof value === "string") unbundledEnv[key] = value.replaceAll("{{root}}", root); + } await using unbundled = Bun.spawn({ cmd, - env: { ...bunEnv, ...env, ...(firstRun.env || {}) }, + env: unbundledEnv, timeout: 60_000, stdio: ["ignore", "pipe", "pipe"], cwd: root, From c4e522f2dde6258d2b09dcc4564c0881f6119509 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:25:30 +0000 Subject: [PATCH 10/12] test: pin the value of the split route that the docs describe --- test/bundler/bundler_cjs2esm.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/bundler/bundler_cjs2esm.test.ts b/test/bundler/bundler_cjs2esm.test.ts index 143a4fa52f74..11161d7f355f 100644 --- a/test/bundler/bundler_cjs2esm.test.ts +++ b/test/bundler/bundler_cjs2esm.test.ts @@ -974,6 +974,31 @@ describe("bundler", () => { run: { file: "/out/main.js", stdout: "undefined 42 true" }, }); } + // Control: with `target: "bun"` and the default `splitRequire`, the lifted + // file is a chunk of its own, and this change does not reach that route. + // The call returns the module namespace of the chunk, which is read-only + // and has `__esModule: true`. `bun run` prints `undefined ok 42`. + itBundled("cjs2esm/KeptRequireOfLiftedCommonJSWithSplitting-SplitRequire", { + files: { + "/main.ts": /* ts */ ` + let m; + try { m = require("react"); } catch {} + let write = "ok"; + try { m.count = 41; } catch (e) { write = e.constructor.name; } + console.log(m.__esModule, write, m.inc()); + `, + ...reactWithCounter, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + api.expectFile("/out/main.js").toMatch(/m = import\.meta\.require\("\.\/[^"]+"\);/); + }, + run: { file: "/out/main.js", stdout: "true TypeError 1" }, + }); // Only ESM output lifts. With CommonJS output the file keeps its wrapper. // With IIFE output a file with no exports is an ES module, as under any // other package name. From 17596f4db65b3f8ef81229c1e471c63fc6c84a6f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:11:28 +0000 Subject: [PATCH 11/12] test: give the bundled run and the unbundled run the same run env --- test/bundler/expectBundled.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/bundler/expectBundled.ts b/test/bundler/expectBundled.ts index 6016de129326..895986239c4f 100644 --- a/test/bundler/expectBundled.ts +++ b/test/bundler/expectBundled.ts @@ -1706,9 +1706,10 @@ for (const [key, blob] of build.outputs) { } const cmd = [bunExe(), ...(firstRun.bunArgs ?? []), entryPaths[0], ...(firstRun.args ?? [])]; const unbundledEnv: Record = {}; - for (const [key, value] of Object.entries({ ...bunEnv, ...env, ...(firstRun.env || {}) })) { + for (const [key, value] of Object.entries({ ...bunEnv, ...env })) { if (typeof value === "string") unbundledEnv[key] = value.replaceAll("{{root}}", root); } + Object.assign(unbundledEnv, firstRun.env); await using unbundled = Bun.spawn({ cmd, env: unbundledEnv, From 5a021a3637959f4dbb81a952abc6be4f8368a5b5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:21:56 +0000 Subject: [PATCH 12/12] bundler: export the namespace object of a lifted CommonJS chunk as "module.exports" With `--splitting --target=bun`, a `require()` of a lifted CommonJS file that stays a call loads the chunk of the file with `import.meta.require`. That call returned the module namespace of the chunk, which is read-only and has `__esModule: true`. The chunk of such a file now exports its namespace object under the name "module.exports". The loader of Bun returns that export from `require()` of an ES module, so the call evaluates to the object. The linker marks the file in step 1 of `scan_imports_and_exports`, when a split `require()` does more than read named exports off its result. --- docs/bundler/index.mdx | 6 +- src/bundler/LinkerContext.rs | 5 + src/bundler/LinkerGraph.rs | 2 + .../computeCrossChunkDependencies.rs | 11 +- .../linker_context/postProcessJSChunk.rs | 28 + .../linker_context/scanImportsAndExports.rs | 27 +- test/bundler/bundler_cjs2esm.test.ts | 10 +- test/bundler/bundler_splitting.test.ts | 896 +++++++++++++++++- 8 files changed, 968 insertions(+), 17 deletions(-) diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index ee43c765c760..e44cf4578544 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -560,7 +560,7 @@ The generated `chunk-dqmx6gc8.js` file contains the shared code. To avoid collis Each `import()` of a bundled JavaScript module also becomes its own chunk. Tree shaking applies to these chunks too: if every `import()` of a module lives in code that tree shaking removes — for example inside a function that is only called behind a [`define`](#define) or [`features`](#features) gate that evaluates to `false` — and nothing else in the live output imports the module, its chunk is not written and the module is absent from the [metafile](#metafile)'s `inputs` and `outputs`. Setting `treeShaking: false` keeps every `import()` chunk. This differs from esbuild, which emits a chunk for every reachable `import()` target. -With `target: "bun"`, a `require()` of a bundled ES module becomes a chunk of its own as well. The call is emitted as `import.meta.require("./chunk-.js")` and stays synchronous — Bun evaluates the chunk when the call runs — so a `require()` inside a function that never runs keeps its module out of the startup working set entirely. Tree shaking treats these chunks like `import()` chunks. `require()` of a CommonJS module is unaffected and keeps returning `module.exports`. A `require()` cycle between such chunks follows Bun's runtime `require()` of an ES module: a module required again while its chunk is still being loaded sees the partially initialized CommonJS placeholder (`{}`) rather than the live bindings the in-chunk wrapper provides. For other targets — and with `splitRequire: false` (`--no-split-require`) — the required module is inlined into the calling chunk behind a lazy wrapper instead, because the call must return synchronously. +With `target: "bun"`, a `require()` of a bundled ES module becomes a chunk of its own as well. The call is emitted as `import.meta.require("./chunk-.js")` and stays synchronous — Bun evaluates the chunk when the call runs — so a `require()` inside a function that never runs keeps its module out of the startup working set entirely. Tree shaking treats these chunks like `import()` chunks. `require()` of a CommonJS module is unaffected and keeps returning `module.exports`. A `require()` cycle between such chunks follows Bun's runtime `require()` of an ES module: a module required again while its chunk is still being loaded sees the partially initialized CommonJS placeholder (`{}`) rather than the live bindings the in-chunk wrapper provides. For other targets — and with `splitRequire: false` (`--no-split-require`) — the required module is inlined into the calling chunk behind a lazy wrapper instead, because the call must return synchronously. A CommonJS file whose `exports.x = ...` assignments the bundler lifted to ES module exports is a chunk as well. One example is `react-dom/server`. A `require()` of that file returns the object that stands in for its `module.exports`. When the bundle has such a `require()` that does more than read named exports off its result, an `import()` of the same file has a `module.exports` key in its namespace, as in Node. This also applies when tree shaking removes the call. On a Bun runtime older than 1.2.6 the `require()` returns the module namespace of the chunk. #### Tree-shaking `import()` and `require()` results @@ -575,7 +575,7 @@ await import("./polyfill"); // keeps only side effects Recognized uses: destructuring (`const { a, b: c, ...rest } = await import(x)`, also `let`/`var` and `export const { a } = …`), property access on the awaited value or on a local holding it (`(await import(x)).a`, `const ns = await import(x); ns.a; const { b } = ns`), `.then(({ a }) => …)` / `.then(ns => ns.a)` with an arrow function, per-element destructuring of `await Promise.all([import(x), import(y)])`, the same shapes for `require()`, and a bare `import(x);` / `await import(x);` / `require(x);` statement (nothing observed). -The module keeps every export as soon as one use can't be followed: the namespace is passed, returned, stored, spread or iterated (`() => import(x)`, `fn(ns)`, `{ ...ns }`, `Object.keys(ns)`), accessed with a computed key (or `?.` directly on the `await import(x)` expression), handed to a non-arrow `.then` callback, reached via `import * as ns` elsewhere, or read under a direct `eval`. CommonJS targets always keep everything. The imported module itself is always evaluated; only with `"sideEffects": false` can a module it merely re-exports from be skipped when none of those re-exports are observed. +The module keeps every export as soon as one use can't be followed: the namespace is passed, returned, stored, spread or iterated (`() => import(x)`, `fn(ns)`, `{ ...ns }`, `Object.keys(ns)`), accessed with a computed key (or `?.` directly on the `await import(x)` expression), handed to a non-arrow `.then` callback, reached via `import * as ns` elsewhere, or read under a direct `eval`. CommonJS targets always keep everything. The exception is a file whose `exports.x = ...` assignments the bundler lifted to ES module exports: Bun drops the exports that nothing reads, as for an ES module. The imported module itself is always evaluated; only with `"sideEffects": false` can a module it merely re-exports from be skipped when none of those re-exports are observed. @@ -1487,7 +1487,7 @@ In each case the member access compiles to a direct reference to `object`, the ` Assignments (`z.x = 1`), optional chains, and non-literal computed keys (`z[key]`) are left as property accesses; `z["object"]` is treated like `z.object`. `export default someImport` is followed only when it ends at a namespace; a default that snapshots a `let` export keeps snapshot semantics. -The same applies to the default import of a CommonJS module whose `exports.x = ...` assignments the bundler lifted to ES module exports, such as `react` and `scheduler`. The default import of a CommonJS module is its `module.exports`, which is that module's namespace, so `import React from "react"; React.useState()` compiles to a direct call of the lifted `useState` binding. This holds while every use of `React` reads, calls or assigns one of its properties: `React.useState`, `React[key]`, `const { useState } = React`. A write, `React.useLayoutEffect = React.useEffect`, goes through a namespace object whose setters assign the lifted binding, so every importer sees the new value, the same as a write to `module.exports`. Code that holds `React` itself as a value (`fn(React)`, `Object.keys(React)`, `const R = React`, a re-export) or deletes one of its properties can change the object in ways a namespace object cannot pass on to the lifted bindings (`Object.defineProperty`, `delete`, `Object.freeze`). The module then keeps its CommonJS wrapper and the default import is the real `module.exports` object. This rule looks at `import React from` only. `ns.default` on `import * as ns`, the `default` of an `import()` and the `this` of a call like `React.fn()` are still the namespace object of a module that stays lifted. `React.default`, and `ns.default` on `import * as ns`, is the lifted `default` export when the module has one, and otherwise the namespace itself, as `module.exports` is in Node. A module that sets both `exports.__esModule` and `exports.default` keeps its CommonJS wrapper when the importer is not an ES module by type (`.mjs`, `.mts`, or `"type": "module"`), because the default import then depends on that flag at run time. An `import()` of a module that does not set both resolves to that same namespace object as `default`, with or without code splitting. A `require()` of a lifted module that stays a call in the output evaluates to that namespace object too. The call stays a call when it is inside `try`/`catch`. It also stays a call when its specifier is not a listed package name but resolves into one, for example through a tsconfig `paths` entry or a package.json `imports` entry. The object has an `__esModule` property only if the module sets `exports.__esModule`. A write to an export that the module assigns itself reaches the lifted binding. A module that runs a statement and then assigns `module.exports = require("./impl")` follows `./impl`. The call evaluates to the namespace object when `./impl` is a lifted CommonJS module, and the names of `./impl` are read-only on that object. `Object.defineProperty`, `delete` and `Object.freeze` on the object do not reach the lifted bindings. One case differs. With `splitting: true` and `target: "bun"`, the module becomes a chunk of its own, and the call returns the module namespace of that chunk: a read-only object with `__esModule: true`. +The same applies to the default import of a CommonJS module whose `exports.x = ...` assignments the bundler lifted to ES module exports, such as `react` and `scheduler`. The default import of a CommonJS module is its `module.exports`, which is that module's namespace, so `import React from "react"; React.useState()` compiles to a direct call of the lifted `useState` binding. This holds while every use of `React` reads, calls or assigns one of its properties: `React.useState`, `React[key]`, `const { useState } = React`. A write, `React.useLayoutEffect = React.useEffect`, goes through a namespace object whose setters assign the lifted binding, so every importer sees the new value, the same as a write to `module.exports`. Code that holds `React` itself as a value (`fn(React)`, `Object.keys(React)`, `const R = React`, a re-export) or deletes one of its properties can change the object in ways a namespace object cannot pass on to the lifted bindings (`Object.defineProperty`, `delete`, `Object.freeze`). The module then keeps its CommonJS wrapper and the default import is the real `module.exports` object. This rule looks at `import React from` only. `ns.default` on `import * as ns`, the `default` of an `import()` and the `this` of a call like `React.fn()` are still the namespace object of a module that stays lifted. `React.default`, and `ns.default` on `import * as ns`, is the lifted `default` export when the module has one, and otherwise the namespace itself, as `module.exports` is in Node. A module that sets both `exports.__esModule` and `exports.default` keeps its CommonJS wrapper when the importer is not an ES module by type (`.mjs`, `.mts`, or `"type": "module"`), because the default import then depends on that flag at run time. An `import()` of a module that does not set both resolves to that same namespace object as `default`, with or without code splitting. A `require()` of a lifted module that stays a call in the output evaluates to that namespace object too. The call stays a call when it is inside `try`/`catch`. It also stays a call when its specifier is not a listed package name but resolves into one, for example through a tsconfig `paths` entry or a package.json `imports` entry. The object has an `__esModule` property only if the module sets `exports.__esModule`. A write to an export that the module assigns itself reaches the lifted binding. A module that runs a statement and then assigns `module.exports = require("./impl")` follows `./impl`. The call evaluates to the namespace object when `./impl` is a lifted CommonJS module, and the names of `./impl` are read-only on that object. `Object.defineProperty`, `delete` and `Object.freeze` on the object do not reach the lifted bindings. The same holds when the module becomes a chunk of its own, with `splitting: true` and `target: "bun"`. ### deprecatedNamespaceObjectSetters diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index cf3a8b3357d9..ce461b25625c 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -4533,6 +4533,11 @@ impl<'a> LinkerContext<'a> { && ast_flags.contains(AstFlags::COMMONJS_LIFTED_TO_ESM) } + /// Does the chunk of this file export its namespace object as `"module.exports"`? + pub(crate) fn chunk_module_exports_is_namespace(meta_flags: crate::js_meta::Flags) -> bool { + meta_flags.needs_module_exports_export && meta_flags.wrap != WrapKind::Cjs + } + /// Is `target` a lifted CommonJS file, whose namespace object `require()` returns with no copy? pub(crate) fn require_returns_namespace( ast_flags: &[AstFlags], diff --git a/src/bundler/LinkerGraph.rs b/src/bundler/LinkerGraph.rs index 8379670d81ff..4c0bbfddd36a 100644 --- a/src/bundler/LinkerGraph.rs +++ b/src/bundler/LinkerGraph.rs @@ -150,6 +150,8 @@ pub mod js_meta { pub(crate) needs_export_symbol_from_runtime: bool, pub(crate) did_wrap_dependencies: bool, pub(crate) needs_synthetic_default_export: bool, + /// The chunk of this lifted file exports its namespace object as `"module.exports"`. + pub(crate) needs_module_exports_export: bool, pub(crate) wrap: WrapKind, } pub use crate::WrapKind as Wrap; diff --git a/src/bundler/linker_context/computeCrossChunkDependencies.rs b/src/bundler/linker_context/computeCrossChunkDependencies.rs index 3d52a49378c5..21347bd85352 100644 --- a/src/bundler/linker_context/computeCrossChunkDependencies.rs +++ b/src/bundler/linker_context/computeCrossChunkDependencies.rs @@ -272,6 +272,8 @@ impl<'a, 'bump> CrossChunkDependencies<'a, 'bump> { flags, deps.ast_flags[chunk.entry_point.source_index() as usize], ); + let module_exports_is_namespace = + LinkerContext::chunk_module_exports_is_namespace(flags); if flags.wrap != WrapKind::Cjs { let resolved_exports = &deps.resolved_exports[chunk.entry_point.source_index() as usize]; @@ -279,7 +281,9 @@ impl<'a, 'bump> CrossChunkDependencies<'a, 'bump> { .sorted_and_filtered_export_aliases [chunk.entry_point.source_index() as usize]; for alias in sorted_and_filtered_export_aliases.iter() { - if default_is_namespace && **alias == *b"default" { + if (default_is_namespace && **alias == *b"default") + || (module_exports_is_namespace && **alias == *b"module.exports") + { continue; } let export_ = resolved_exports.get(alias).unwrap(); @@ -324,7 +328,10 @@ impl<'a, 'bump> CrossChunkDependencies<'a, 'bump> { // Ensure "exports" is included if the current output format needs it // https://github.com/evanw/esbuild/blob/v0.27.2/internal/linker/linker.go#L1049-L1051 - if flags.force_include_exports_for_entry_point || default_is_namespace { + if flags.force_include_exports_for_entry_point + || default_is_namespace + || module_exports_is_namespace + { // result intentionally discarded let _ = chunk_meta.imports.put( deps.exports_refs[chunk.entry_point.source_index() as usize], diff --git a/src/bundler/linker_context/postProcessJSChunk.rs b/src/bundler/linker_context/postProcessJSChunk.rs index a06ec843e39c..613ce98f0708 100644 --- a/src/bundler/linker_context/postProcessJSChunk.rs +++ b/src/bundler/linker_context/postProcessJSChunk.rs @@ -915,9 +915,12 @@ pub(crate) fn generate_entry_point_tail_js<'a>( let default_is_namespace = LinkerContext::chunk_default_export_is_namespace(flags, ast.flags); + let module_exports_is_namespace = + LinkerContext::chunk_module_exports_is_namespace(flags); if !sorted_and_filtered_export_aliases.is_empty() || !cross_chunk_exports.is_empty() + || module_exports_is_namespace { let resolved_exports: &ResolvedExports = &c.graph.meta.items_resolved_exports()[source_index as usize]; @@ -939,6 +942,10 @@ pub(crate) fn generate_entry_point_tail_js<'a>( if default_is_namespace && **alias == *b"default" { continue; } + // The namespace object takes the name. The file's own is a key of it. + if module_exports_is_namespace && **alias == *b"module.exports" { + continue; + } // Only `.data` (an `ImportTracker`, `Copy`) is // read/mutated below, so copy that field instead of @@ -1073,6 +1080,19 @@ pub(crate) fn generate_entry_point_tail_js<'a>( } } + if module_exports_is_namespace { + // "export { exports_foo as "module.exports" };" + items.push(bun_ast::ClauseItem { + name: bun_ast::LocRef { + ref_: ast.exports_ref, + loc: bun_ast::Loc::EMPTY, + }, + alias: bun_ast::StoreStr::new(b"module.exports"), + alias_loc: bun_ast::Loc::EMPTY, + ..Default::default() + }); + } + let own_len = items.len(); items.extend(cross_chunk_exports.iter().map(|item| bun_ast::ClauseItem { alias: item.alias, @@ -1084,6 +1104,14 @@ pub(crate) fn generate_entry_point_tail_js<'a>( // collected Vec into the linker arena. The arena slice is also iterated // below for the synthetic-default-export path. let items: &mut [bun_ast::ClauseItem] = arena.alloc_slice_fill_iter(items); + debug_assert!( + (0..items.len()).all(|i| { + items[..i] + .iter() + .all(|item| item.alias.slice() != items[i].alias.slice()) + }), + "two items of an export clause have one alias" + ); if !items.is_empty() { stmts.push(Stmt::alloc( S::ExportClause { diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index cc9b7edfd521..464ce7da008d 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -369,6 +369,17 @@ pub(crate) fn scan_imports_and_exports( col!(flags)[other_file].wrap = WrapKind::Cjs; col!(exports_kind)[other_file] = ExportsKind::Cjs; } + } else if LinkerContext::require_returns_namespace( + col_ref!(ast_flags_list), + col_ref!(exports_kind), + col_ref!(export_star_import_records), + col_ref!(import_records_list), + other_file as u32, + ) && require_reads_exports_object( + col_ref!(dynamic_import_aliases)[id].get(&(import_record_index as u32)), + ) { + // `require()` of an ES module returns its `"module.exports"` export. + col!(flags)[other_file].needs_module_exports_export = true; } } ImportKind::Dynamic => { @@ -1001,7 +1012,8 @@ pub(crate) fn scan_imports_and_exports( || LinkerContext::chunk_default_export_is_namespace( flag, col_ref!(ast_flags_list)[id], - ); + ) + || LinkerContext::chunk_module_exports_is_namespace(flag); let add_wrapper = wrap != WrapKind::None; let extra_count = (include_namespace as usize) + (add_wrapper as usize); @@ -1450,6 +1462,19 @@ pub(crate) fn scan_imports_and_exports( Ok(()) } +/// Does a `require()` observe the object it returns, and not only names that a chunk exports too? +fn require_reads_exports_object( + dynamic_use: Option<&bun_ast::ast_result::DynamicImportUse>, +) -> bool { + dynamic_use.is_none_or(|dynamic_use| { + dynamic_use + .aliases + .slice() + .iter() + .any(|alias| matches!(alias.slice(), b"default" | b"__esModule")) + }) +} + #[inline] fn should_call_runtime_require(format: options::Format) -> bool { format != Format::Cjs diff --git a/test/bundler/bundler_cjs2esm.test.ts b/test/bundler/bundler_cjs2esm.test.ts index 11161d7f355f..75489ef67f5c 100644 --- a/test/bundler/bundler_cjs2esm.test.ts +++ b/test/bundler/bundler_cjs2esm.test.ts @@ -974,11 +974,11 @@ describe("bundler", () => { run: { file: "/out/main.js", stdout: "undefined 42 true" }, }); } - // Control: with `target: "bun"` and the default `splitRequire`, the lifted - // file is a chunk of its own, and this change does not reach that route. - // The call returns the module namespace of the chunk, which is read-only - // and has `__esModule: true`. `bun run` prints `undefined ok 42`. + // With `target: "bun"` and the default `splitRequire`, the lifted file is a + // chunk of its own. The chunk exports the exports object of the file as + // "module.exports", which `import.meta.require` returns. itBundled("cjs2esm/KeptRequireOfLiftedCommonJSWithSplitting-SplitRequire", { + matchesBunRun: true, files: { "/main.ts": /* ts */ ` let m; @@ -997,7 +997,7 @@ describe("bundler", () => { onAfterBundle(api) { api.expectFile("/out/main.js").toMatch(/m = import\.meta\.require\("\.\/[^"]+"\);/); }, - run: { file: "/out/main.js", stdout: "true TypeError 1" }, + run: { file: "/out/main.js", stdout: "undefined ok 42" }, }); // Only ESM output lifts. With CommonJS output the file keeps its wrapper. // With IIFE output a file with no exports is an ES module, as under any diff --git a/test/bundler/bundler_splitting.test.ts b/test/bundler/bundler_splitting.test.ts index 120d1617be3e..3ba535b02f79 100644 --- a/test/bundler/bundler_splitting.test.ts +++ b/test/bundler/bundler_splitting.test.ts @@ -3694,16 +3694,25 @@ describe("bundler", () => { 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. + // A CommonJS file of an unwrapped package that was lifted to an ES module + // is still CommonJS to require(): the call returns the file's exports + // object, with no `__esModule` that the file did not set. The file stays a + // chunk. The chunk exports that object as "module.exports", the export + // that `import.meta.require` returns in place of the module namespace. + // + // `matchesBunRun` also runs the entry point of a case with `bun run`, which + // must print the stdout that the case expects of the bundle. + + // Here the lift target is itself lifted. itBundled("splitting/SplitRequireOfLiftedCommonJSStaysEsm", { + matchesBunRun: true, files: { "/main.ts": /* ts */ ` let m; try { m = require("react-dom"); } catch {} - console.log(m.version, m.render()); + console.log(m.__esModule, m.version, m.render()); `, "/node_modules/react-dom/index.js": /* js */ ` console.log('side effect'); @@ -3721,10 +3730,885 @@ describe("bundler", () => { 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"); + api.expectFile("/out/main.js").toContain(`m = import.meta.require("./${chunk}");`); + api.expectFile("/out/" + chunk).toContain(`exports_react_dom as "module.exports"`); + }, + run: { file: "/out/main.js", stdout: "side effect\nundefined 19.0.0 rendered" }, + }); + + // The lift target is reached through a file that is only + // `module.exports = require()`. + itBundled("splitting/SplitRequireOfLiftedCommonJSThroughRedirect", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react-dom"); + } catch {} + console.log(m.__esModule, m.version, m.render()); + `, + "/node_modules/react-dom/index.js": /* js */ ` + console.log('side effect'); + module.exports = require('./server'); + `, + "/node_modules/react-dom/server.js": /* js */ ` + 'use strict'; + module.exports = require('./server.node'); + `, + "/node_modules/react-dom/server.node.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/" + chunk).toContain(`exports_react_dom as "module.exports"`); + }, + run: { file: "/out/main.js", stdout: "side effect\nundefined 19.0.0 rendered" }, + }); + + // Two files that re-export each other never reach a file that decides. + itBundled("splitting/SplitRequireOfLiftedCommonJSReExportCycle", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react"); + } catch {} + console.log(m.__esModule, Object.keys(m).length, globalThis.loaded); + `, + "/node_modules/react/package.json": /* json */ ` + { "name": "react", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react/index.js": /* js */ ` + globalThis.loaded = (globalThis.loaded ?? 0) + 1; + module.exports = require("./other"); + `, + "/node_modules/react/other.js": /* js */ ` + globalThis.loaded = (globalThis.loaded ?? 0) + 1; + module.exports = require("./index"); + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + api.expectFile("/out/main.js").toMatch(/m = import\.meta\.require\("\.\/[^"]+"\);/); + }, + run: { file: "/out/main.js", stdout: "undefined 0 2" }, + }); + + // The same value with a split require(), with `splitRequire: false` and + // for a target that cannot split a require(): a write reaches the lifted + // binding, and the `default` of an import() of the file is that object. + const liftedCommonJSFiles = { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react"); + } catch {} + m.count = 41; + const ns = await import("react"); + console.log(m.__esModule, m.inc(), ns.default === m); + `, + "/node_modules/react/package.json": /* json */ ` + { "name": "react", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react/index.js": /* js */ ` + console.log("react evaluated"); + exports.count = 0; + exports.inc = () => ++exports.count; + `, + }; + itBundled("splitting/SplitRequireOfLiftedCommonJSReturnsNamespace", { + matchesBunRun: true, + files: liftedCommonJSFiles, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + const chunk = chunkContaining(api, "react evaluated"); + api.expectFile("/out/main.js").toContain(`m = import.meta.require("./${chunk}");`); + api.expectFile("/out/" + chunk).toContain(`exports_react as "module.exports"`); + }, + run: { file: "/out/main.js", stdout: "react evaluated\nundefined 42 true" }, + }); + + // The chunk loads when the call runs, and not at all when it never runs. + itBundled("splitting/SplitRequireOfLiftedCommonJSRunsAtTheCall", { + matchesBunRun: true, + files: { + ...liftedCommonJSFiles, + "/main.ts": /* ts */ ` + function load() { + try { + return require("react"); + } catch {} + } + console.log("main"); + if (process.argv[2] !== "skip") { + const m = load(); + console.log(m.__esModule, m.inc()); + } + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + const chunk = chunkContaining(api, "react evaluated"); + api.expectFile("/out/main.js").toContain(`return import.meta.require("./${chunk}");`); + }, + run: [ + { file: "/out/main.js", stdout: "main\nreact evaluated\nundefined 1" }, + { file: "/out/main.js", args: ["skip"], stdout: "main" }, + ], + }); + + // The call needs no `try` when its specifier is an alias of the file. + itBundled("splitting/SplitRequireOfLiftedCommonJSBehindAlias", { + matchesBunRun: true, + files: { + ...liftedCommonJSFiles, + "/main.ts": /* ts */ ` + const m = require("react-alias"); + m.count = 41; + console.log(m.__esModule, m.inc(), m === require("react")); + `, + "/tsconfig.json": /* json */ ` + { "compilerOptions": { "baseUrl": ".", "paths": { "react-alias": ["./node_modules/react/index.js"] } } } + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + api.expectFile("/out/main.js").not.toContain("__toCommonJS"); }, - run: { file: "/out/main.js", stdout: "side effect\n19.0.0 rendered" }, + run: { file: "/out/main.js", stdout: "react evaluated\nundefined 42 true" }, + }); + + // Control: a read of a name that the parser tracks needs no exports object. + // The output is what main prints. + itBundled("splitting/SplitRequireOfLiftedCommonJSTrackedRead", { + matchesBunRun: true, + files: { + ...liftedCommonJSFiles, + "/main.ts": /* ts */ ` + try { + console.log(require("react").inc()); + } catch {} + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + const chunk = chunkContaining(api, "react evaluated"); + api.expectFile("/out/main.js").toContain(`import.meta.require("./${chunk}").inc()`); + api.expectFile("/out/" + chunk).not.toContain("module.exports"); + }, + run: { file: "/out/main.js", stdout: "react evaluated\n1" }, + }); + + // `__esModule` and `default` are read off the exports object. + itBundled("splitting/SplitRequireOfLiftedCommonJSTrackedEsModule", { + matchesBunRun: true, + files: { + ...liftedCommonJSFiles, + "/main.ts": /* ts */ ` + try { + const { inc, __esModule } = require("react"); + console.log(__esModule, inc()); + const m = require("react"); + console.log(typeof m.default, m.inc()); + } catch {} + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + const chunk = chunkContaining(api, "react evaluated"); + api.expectFile("/out/" + chunk).toContain(`exports_react as "module.exports"`); + }, + run: { file: "/out/main.js", stdout: "react evaluated\nundefined 1\nundefined 2" }, + }); + + // Control: an `import()` of the file gives the chunk a `default`. A tracked + // read still needs no exports object. The output is what main prints. + itBundled("splitting/SplitRequireOfLiftedCommonJSTrackedReadWithImport", { + matchesBunRun: true, + files: { + ...liftedCommonJSFiles, + "/main.ts": /* ts */ ` + try { + console.log(require("react").inc()); + } catch {} + const ns = await import("react"); + console.log(ns.default.inc()); + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + const chunk = chunkContaining(api, "react evaluated"); + api.expectFile("/out/main.js").toContain(`import.meta.require("./${chunk}").inc()`); + api.expectFile("/out/" + chunk).toContain("export default exports_react;"); + api.expectFile("/out/" + chunk).not.toContain("module.exports"); + }, + run: { file: "/out/main.js", stdout: "react evaluated\n1\n2" }, + }); + + // A call that holds its result and a tracked read of the same file get one + // object. + itBundled("splitting/SplitRequireOfLiftedCommonJSHeldAndTrackedAreOneObject", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + let m, viaTracked; + try { + m = require("react"); + } catch {} + try { + viaTracked = require("react").self(); + } catch {} + console.log(m.__esModule, viaTracked === m, m.self() === m); + `, + "/node_modules/react/package.json": liftedCommonJSFiles["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + exports.self = function () { return this; }; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + run: { file: "/out/main.js", stdout: "undefined true true" }, + }); + + // The code of the file can be in a chunk that an entry point imports. The + // chunk of the call then takes the exports object from there. + itBundled("splitting/SplitRequireOfLiftedCommonJSAlsoImportedStatically", { + matchesBunRun: true, + files: { + ...liftedCommonJSFiles, + "/main.ts": /* ts */ ` + import { inc } from "react"; + let m; + try { + m = require("react"); + } catch {} + m.count = 41; + console.log(m.__esModule, inc(), m.inc === inc); + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + expect( + jsFilesIn(api).some(file => api.readFile("/out/" + file).includes(`exports_react as "module.exports"`)), + ).toBe(true); + }, + run: { file: "/out/main.js", stdout: "react evaluated\nundefined 42 true" }, + }); + + // A file with no export of its own: an `import()` of its chunk sees the two + // names that the chunk exports. + itBundled("splitting/SplitRequireOfLiftedCommonJSWithNoExportsAndImport", { + files: { + "/main.ts": /* ts */ ` + import { shared } from "./shared"; + let m; + try { + m = require("react"); + } catch {} + const ns = await import("react"); + const other = await import("./other"); + console.log(m.__esModule, Object.keys(m).length, Object.keys(ns).join(), shared, other.value); + `, + "/shared.ts": /* ts */ ` + export const shared = "shared"; + `, + "/other.ts": /* ts */ ` + import { shared } from "./shared"; + export const value = "other " + shared; + `, + "/node_modules/react/package.json": liftedCommonJSFiles["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + const dependency = require("./dependency"); + globalThis.fromDependency = dependency.value; + `, + "/node_modules/react/dependency.js": /* js */ ` + exports.value = "value"; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + run: { file: "/out/main.js", stdout: "undefined 0 default,module.exports shared other shared" }, + }); + + // Control: a file that the linker scans later can put the lifted file back + // into `__commonJS`. Its chunk then exports `module.exports` as `default`. + itBundled("splitting/SplitRequireOfLiftedCommonJSThatIsWrappedAgainLater", { + matchesBunRun: true, + files: { + ...liftedCommonJSFiles, + "/main.ts": /* ts */ ` + import React from "react"; + import { viaRequire } from "./via-require"; + globalThis.keep = [React]; + const m = viaRequire(); + console.log(m.__esModule, m === React, typeof m.inc, Object.keys(m).join()); + `, + "/via-require.ts": /* ts */ ` + export function viaRequire() { + let m; + try { + m = require("react"); + } catch {} + return m; + } + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + const chunk = chunkContaining(api, "export default require_react()"); + expect( + jsFilesIn(api).some(file => api.readFile("/out/" + file).includes(`import.meta.require("./${chunk}").default`)), + ).toBe(true); + }, + run: { file: "/out/main.js", stdout: "react evaluated\nundefined true function count,inc" }, + }); + + // The file can have an export with that name itself. It stays a property + // of the exports object. + itBundled("splitting/SplitRequireOfLiftedCommonJSWithModuleExportsName", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react"); + } catch {} + console.log(m.__esModule, m["module.exports"], m.named); + `, + "/node_modules/react/package.json": liftedCommonJSFiles["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + exports["module.exports"] = 5; + exports.named = 1; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + run: { file: "/out/main.js", stdout: "undefined 5 1" }, + }); + + // The same file in a chunk that an entry point imports. The chunk of the + // call takes the exports object from there, and not the binding of that name. + itBundled("splitting/SplitRequireOfLiftedCommonJSWithModuleExportsNameAlsoImportedStatically", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + import { named } from "react"; + let m; + try { + m = require("react"); + } catch {} + console.log(m.__esModule, m["module.exports"], m.named, named); + `, + "/node_modules/react/package.json": liftedCommonJSFiles["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + exports["module.exports"] = 5; + exports.named = 1; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + const chunk = jsFilesIn(api).find(file => + api.readFile("/out/" + file).includes(`exports_react as "module.exports"`), + ); + expect(chunk).toBeDefined(); + api.expectFile("/out/" + chunk).not.toContain("$module_exports"); + }, + run: { file: "/out/main.js", stdout: "undefined 5 1 1" }, + }); + + // The shape of the real react-dom/index.js, minified. Only with + // `--minify-syntax` and a production NODE_ENV is the `if` folded and the + // file lifted. + itBundled("splitting/SplitRequireOfLiftedCommonJSProductionMinified", { + matchesBunRun: true, + files: { + "/main.js": /* js */ ` + let m; + try { + m = require("react-dom"); + } catch {} + console.log(m.__esModule, Object.keys(m).join(), m.createRoot()); + `, + "/node_modules/react-dom/package.json": /* json */ ` + { "name": "react-dom", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-dom/index.js": /* js */ ` + 'use strict'; + function checkDCE() { + if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ === 'undefined') { + console.log('checkDCE'); + } + } + if (process.env.NODE_ENV === 'production') { + checkDCE(); + module.exports = require('./cjs/react-dom.production.js'); + } else { + module.exports = require('./cjs/react-dom.development.js'); + } + `, + "/node_modules/react-dom/cjs/react-dom.production.js": /* js */ ` + exports.createRoot = function createRoot() { return "production root"; }; + exports.version = "19.0.0"; + `, + "/node_modules/react-dom/cjs/react-dom.development.js": /* js */ ` + 'use strict'; + (function () { + exports.createRoot = function createRoot() { return "development root"; }; + exports.version = "19.0.0"; + })(); + `, + }, + entryPoints: ["/main.js"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + minifySyntax: true, + minifyWhitespace: true, + minifyIdentifiers: true, + env: { + NODE_ENV: "production", + }, + onAfterBundle(api) { + const chunks = jsFilesIn(api).filter(file => /as\s*"module\.exports"/.test(api.readFile("/out/" + file))); + expect(chunks).toHaveLength(1); + api.expectFile("/out/main.js").toContain(`import.meta.require("./${chunks[0]}")`); + }, + run: { file: "/out/main.js", stdout: "checkDCE\nundefined createRoot,version production root" }, + }); + + // Control: the chunk loads at the call, so a failure to load it is caught + // there. + itBundled("splitting/SplitRequireOfLiftedCommonJSThatFailsToLoad", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + console.log("ready"); + let m; + try { + m = require("react"); + } catch { + console.log("caught"); + } + console.log(typeof m); + `, + "/node_modules/react/package.json": liftedCommonJSFiles["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + const missing = require("missing-external"); + exports.value = missing.value; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + external: ["missing-external"], + run: { file: "/out/main.js", stdout: "ready\ncaught\nundefined" }, + }); + + // A lifted file that is an entry point itself: its output file is the + // chunk, and it exports the exports object too. + itBundled("splitting/SplitRequireOfLiftedCommonJSEntryPoint", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("scheduler"); + } catch {} + m.count = 41; + console.log(m.__esModule, m.inc()); + `, + "/node_modules/scheduler/package.json": /* json */ ` + { "name": "scheduler", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/scheduler/index.js": liftedCommonJSFiles["/node_modules/react/index.js"], + }, + entryPoints: ["/main.ts", "/node_modules/scheduler/index.js"], + entryNaming: "[name].[ext]", + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + api.expectFile("/out/main.js").toContain(`m = import.meta.require("./index.js");`); + api.expectFile("/out/index.js").toContain(`exports_scheduler as "module.exports"`); + }, + run: { file: "/out/main.js", stdout: "react evaluated\nundefined 42" }, + }); + + // An entry point with a `default` export of its own keeps it for its + // importers. + itBundled("splitting/SplitRequireOfLiftedCommonJSEntryPointWithDefault", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("scheduler"); + } catch {} + console.log(m.__esModule, m.default.tag, m.named); + `, + "/node_modules/scheduler/package.json": /* json */ ` + { "name": "scheduler", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/scheduler/index.js": /* js */ ` + exports.default = { tag: "default" }; + exports.named = 1; + `, + }, + entryPoints: ["/main.ts", "/node_modules/scheduler/index.js"], + entryNaming: "[name].[ext]", + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + api.expectFile("/out/index.js").toContain("$default as default"); + api.expectFile("/out/index.js").toContain(`exports_scheduler as "module.exports"`); + }, + run: { file: "/out/main.js", stdout: "undefined default 1" }, + }); + + // `bun run` loads a file with no exports as CommonJS when it calls + // require(), which the parser turned into an import. + itBundled("splitting/SplitRequireOfLiftedCommonJSWithNoExports", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react"); + } catch {} + console.log(m.__esModule, Object.keys(m).length, globalThis.fromDependency); + `, + "/node_modules/react/package.json": liftedCommonJSFiles["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + const dependency = require("./dependency"); + globalThis.fromDependency = dependency.value; + `, + "/node_modules/react/dependency.js": /* js */ ` + exports.value = "value"; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + api.expectFile("/out/main.js").toMatch(/m = import\.meta\.require\("\.\/[^"]+"\);/); + }, + run: { file: "/out/main.js", stdout: "undefined 0 value" }, + }); + + // A lifted file that sets `__esModule` and `default` itself: the call + // returns its exports object, and the `default` of its chunk stays the + // file's own for an `import()`. + itBundled("splitting/SplitRequireOfLiftedCommonJSWithEsModuleAndDefault", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react"); + } catch {} + console.log(m.__esModule, Object.keys(m).join(), m.default.tag, m.named); + const ns = await import("react"); + console.log(ns.default.tag, ns.named); + m.named = 2; + console.log(m.named); + `, + "/node_modules/react/package.json": liftedCommonJSFiles["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + console.log("react evaluated"); + exports.__esModule = true; + exports.default = { tag: "default" }; + exports.named = 1; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + const chunk = chunkContaining(api, "react evaluated"); + api.expectFile("/out/" + chunk).toContain(`exports_react as "module.exports"`); + }, + run: { file: "/out/main.js", stdout: "react evaluated\ntrue __esModule,default,named default 1\ndefault 1\n2" }, + }); + + // Control: an ES module inside an unwrapped package is an ES module to + // `bun run` too. Its require() returns the chunk's namespace, which has + // `__esModule`. + itBundled("splitting/SplitRequireOfESModuleInUnwrappedPackage", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react"); + } catch {} + console.log(m.__esModule, m.value); + `, + "/node_modules/react/package.json": liftedCommonJSFiles["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + const dependency = require("./dependency"); + console.log("react evaluated"); + export const value = dependency.value; + `, + "/node_modules/react/dependency.js": /* js */ ` + exports.value = "value"; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + const chunk = chunkContaining(api, "react evaluated"); + api.expectFile("/out/main.js").toContain(`m = import.meta.require("./${chunk}");`); + }, + run: { file: "/out/main.js", stdout: "react evaluated\ntrue value" }, + }); + + // Control: a lifted `module.exports = require()` of an ES module returns + // what require() of that module returns, and its chunk gets no `default`. + itBundled("splitting/SplitRequireOfLiftedReExportOfESModule", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react-is"); + } catch {} + console.log(m.__esModule, Object.keys(m).join()); + `, + "/node_modules/react-is/package.json": /* json */ ` + { "name": "react-is", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-is/index.js": /* js */ ` + globalThis.sideEffect = true; + module.exports = require("./impl.mjs"); + `, + "/node_modules/react-is/impl.mjs": /* js */ ` + export const isElement = () => true; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + run: { file: "/out/main.js", stdout: "true isElement" }, + }); + + // Control: with an `import()` the chunk has a `default`, which is not that + // value. The chunk of an ES module gets no "module.exports" export. + itBundled("splitting/SplitRequireOfLiftedReExportOfESModuleWithImport", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react-is"); + } catch {} + const ns = await import("react-is"); + console.log(m.__esModule, typeof m.isElement, Object.keys(ns).join()); + `, + "/node_modules/react-is/package.json": /* json */ ` + { "name": "react-is", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-is/index.js": /* js */ ` + globalThis.sideEffect = true; + module.exports = require("./impl.mjs"); + `, + "/node_modules/react-is/impl.mjs": /* js */ ` + export const isElement = () => true; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + onAfterBundle(api) { + const chunk = chunkContaining(api, "sideEffect"); + api.expectFile("/out/main.js").toContain(`m = import.meta.require("./${chunk}");`); + api.expectFile("/out/" + chunk).toContain("export default exports_react_is;"); + }, + run: { file: "/out/main.js", stdout: "true function default,isElement" }, + }); + + // A lifted `module.exports = require()` of an external package is still a + // chunk, and that chunk has `export *` inside a function. + itBundled("splitting/SplitRequireOfLiftedReExportOfExternal", { + matchesBunRun: true, + todo: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react-dom"); + } catch (e) { + console.log(e.message); + } + console.log(m?.value); + `, + "/node_modules/react-dom/package.json": /* json */ ` + { "name": "react-dom", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-dom/index.js": /* js */ ` + console.log('side effect'); + module.exports = require('external-package'); + `, + "/node_modules/external-package/package.json": /* json */ ` + { "name": "external-package", "version": "1.0.0", "main": "index.js" } + `, + "/node_modules/external-package/index.js": /* js */ ` + exports.value = "external value"; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + external: ["external-package"], + run: { file: "/out/main.js", stdout: "side effect\nexternal value" }, + }); + + // Control: with an `import()` of that file the chunk has no function around + // `export *`, and the call loads it. Inline, the file gets that function. + itBundled("splitting/SplitRequireOfLiftedReExportOfExternalWithImport", { + matchesBunRun: true, + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react-dom"); + } catch (e) { + console.log(e.message); + } + const ns = await import("react-dom"); + console.log(m?.value, ns.value); + `, + "/node_modules/react-dom/package.json": /* json */ ` + { "name": "react-dom", "version": "19.0.0", "main": "index.js" } + `, + "/node_modules/react-dom/index.js": /* js */ ` + console.log('side effect'); + module.exports = require('external-package'); + `, + "/node_modules/external-package/package.json": /* json */ ` + { "name": "external-package", "version": "1.0.0", "main": "index.js" } + `, + "/node_modules/external-package/index.js": /* js */ ` + exports.value = "external value"; + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + external: ["external-package"], + run: { file: "/out/main.js", stdout: "side effect\nexternal value external value" }, + }); + + // A file that the lifted file imports requires it back while its chunk + // loads: the inner call gets the placeholder of the runtime, as for any + // chunk. `bun run` prints `undefined 1 object:first`. + itBundled("splitting/SplitRequireCycleThroughLiftedCommonJS", { + files: { + "/main.ts": /* ts */ ` + let m; + try { + m = require("react"); + } catch {} + console.log(m.__esModule, m.first, m.second); + `, + "/node_modules/react/package.json": liftedCommonJSFiles["/node_modules/react/package.json"], + "/node_modules/react/index.js": /* js */ ` + exports.first = 1; + const back = require("./back.js"); + exports.second = back.seen; + `, + "/node_modules/react/back.js": /* js */ ` + let inner; + try { + inner = require("./index.js"); + } catch {} + exports.seen = typeof inner + ":" + Object.keys(inner ?? {}).join(); + `, + }, + entryPoints: ["/main.ts"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + run: { file: "/out/main.js", stdout: "undefined 1 object:" }, }); // A top-level require() in a module that the required chunk imports back