From 75ba044b323378106882723d9337137744699c1e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:06:51 +0000 Subject: [PATCH 1/8] Decide a file's module type with one rule on every runtime load path The parser's module type hint (.cjs/.cts -> CommonJS, .mjs/.mts -> ESM, otherwise the nearest package.json "type") was computed differently by each runtime path that parses a file: - The entry point, require() and a Worker's main module used the extension first. Files loaded by import, import() or a --require preload go through RuntimeTranspilerStore, which derived the hint from the package.json tag alone, so an ambiguous .cjs under "type": "module" ran as ESM when imported and as CommonJS when required. - The runtime looked for package.json in the file's own directory, then fell back to the nearest one with a "name". A nameless {"type": "commonjs"} in a parent directory did not apply. bun_resolver::module_type_for_file is now the one runtime rule: the extension, else the "type" of DirInfo::package_json_for_module_type (the nearest package.json, named or not, which the bundler already uses). The on-thread transpile, the off-thread transpile and the error source preview all use it. .jsx/.tsx files now follow the package.json "type" at the entry point and via require(), as they already did when imported. The runtime CommonJS wrapper copied the JSX runtime import statement into the wrapper function, a syntax error. The wrapper now requires the JSX runtime instead, which also fixes .jsx/.tsx files that assign module.exports. The runtime transpiler cache is keyed by the content hash, and its features hash did not include the hint, so identical bytes under "type": "module" and "type": "commonjs" shared one entry. The hint is now part of the features hash. --- oxlint.json | 1 - src/js_parser/p.rs | 86 ++++++ src/js_parser/parse/parse_entry.rs | 67 +++-- src/jsc/RuntimeTranspilerStore.rs | 14 +- src/jsc/VirtualMachine.rs | 5 +- src/resolver/dir_info.rs | 4 +- src/resolver/lib.rs | 3 +- src/resolver/resolver.rs | 7 + src/runtime/jsc_hooks.rs | 82 ++---- .../cli/run/module-type-fixture/cjs/hello.cjs | 1 - .../cli/run/module-type-fixture/cjs/hello.cts | 1 - test/cli/run/module-type-fixture/cjs/hello.js | 1 - .../cli/run/module-type-fixture/cjs/hello.jsx | 1 - .../cli/run/module-type-fixture/cjs/hello.mjs | 1 - .../cli/run/module-type-fixture/cjs/hello.mts | 1 - test/cli/run/module-type-fixture/cjs/hello.ts | 1 - .../cli/run/module-type-fixture/cjs/hello.tsx | 1 - .../run/module-type-fixture/cjs/import.cjs | 3 - .../run/module-type-fixture/cjs/package.json | 3 - .../cli/run/module-type-fixture/esm/hello.cjs | 1 - .../cli/run/module-type-fixture/esm/hello.cts | 1 - test/cli/run/module-type-fixture/esm/hello.js | 1 - .../cli/run/module-type-fixture/esm/hello.jsx | 1 - .../cli/run/module-type-fixture/esm/hello.mjs | 1 - .../cli/run/module-type-fixture/esm/hello.mts | 1 - test/cli/run/module-type-fixture/esm/hello.ts | 1 - .../cli/run/module-type-fixture/esm/hello.tsx | 1 - .../run/module-type-fixture/esm/import.cjs | 3 - .../run/module-type-fixture/esm/package.json | 3 - test/cli/run/run-detect-module-type.test.ts | 270 ++++++++++++++++++ test/cli/run/run-detect-module-type.ts | 54 ---- test/js/bun/resolve/build-error.test.ts | 20 +- .../bun/resolve/esModule-annotation.test.js | 14 + 33 files changed, 469 insertions(+), 186 deletions(-) delete mode 100644 test/cli/run/module-type-fixture/cjs/hello.cjs delete mode 100644 test/cli/run/module-type-fixture/cjs/hello.cts delete mode 100644 test/cli/run/module-type-fixture/cjs/hello.js delete mode 100644 test/cli/run/module-type-fixture/cjs/hello.jsx delete mode 100644 test/cli/run/module-type-fixture/cjs/hello.mjs delete mode 100644 test/cli/run/module-type-fixture/cjs/hello.mts delete mode 100644 test/cli/run/module-type-fixture/cjs/hello.ts delete mode 100644 test/cli/run/module-type-fixture/cjs/hello.tsx delete mode 100644 test/cli/run/module-type-fixture/cjs/import.cjs delete mode 100644 test/cli/run/module-type-fixture/cjs/package.json delete mode 100644 test/cli/run/module-type-fixture/esm/hello.cjs delete mode 100644 test/cli/run/module-type-fixture/esm/hello.cts delete mode 100644 test/cli/run/module-type-fixture/esm/hello.js delete mode 100644 test/cli/run/module-type-fixture/esm/hello.jsx delete mode 100644 test/cli/run/module-type-fixture/esm/hello.mjs delete mode 100644 test/cli/run/module-type-fixture/esm/hello.mts delete mode 100644 test/cli/run/module-type-fixture/esm/hello.ts delete mode 100644 test/cli/run/module-type-fixture/esm/hello.tsx delete mode 100644 test/cli/run/module-type-fixture/esm/import.cjs delete mode 100644 test/cli/run/module-type-fixture/esm/package.json create mode 100644 test/cli/run/run-detect-module-type.test.ts delete mode 100644 test/cli/run/run-detect-module-type.ts diff --git a/oxlint.json b/oxlint.json index b0d3c682db43..e745191e9d6a 100644 --- a/oxlint.json +++ b/oxlint.json @@ -66,7 +66,6 @@ "test/bundler/native-plugin.test.ts", // parser doesn't handle import metadata "test/bundler/transpiler/with-statement-works.js", // parser doesn't allow `with` statement "test/js/node/module/extensions-fixture", // these files are not meant to be linted - "test/cli/run/module-type-fixture", // TODO: fix these "src/js/node/http2.ts", diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index d981eb71862d..f3b011535785 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -2524,6 +2524,92 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(()) } + /// [`generate_import_stmt`](Self::generate_import_stmt) as + /// `const { name: ref } = require(path)`, for a file that gets the runtime's + /// CommonJS function wrapper. + pub(crate) fn generate_require_stmt( + &mut self, + import_path: &'a [u8], + imports: I, + parts: &mut ListManaged<'a, js_ast::Part>, + symbols: &Sym, + tag: bun_ast::PartTag, + ) -> Result<(), crate::Error> + where + I: AsRef<[::Key]>, + Sym: GenerateImportSymbols, + { + let imports = imports.as_ref(); + let import_record_i = + self.add_import_record_by_range(ImportKind::Require, bun_ast::Range::NONE, import_path); + + let mut declared_symbols = bun_ast::DeclaredSymbolList::default(); + declared_symbols.ensure_total_capacity(imports.len())?; + let mut properties = BumpVec::::with_capacity_in(imports.len(), self.arena); + for alias in imports { + let ref_ = symbols.get(alias).expect("unreachable"); + let alias_name: &'static [u8] = symbols.alias_name(alias); + let key = self.new_expr( + E::String { + data: alias_name.into(), + ..Default::default() + }, + bun_ast::Loc::EMPTY, + ); + properties.push(B::Property { + flags: bun_ast::flags::PROPERTY_NONE, + key, + value: self.b(B::Identifier { r#ref: ref_ }, bun_ast::Loc::EMPTY), + default_value: None, + }); + declared_symbols.append_assume_capacity(DeclaredSymbol { + ref_, + is_top_level: true, + }); + } + + let binding = self.b( + B::Object { + properties: bun_ast::StoreSlice::from_bump(properties), + is_single_line: true, + close_brace_loc: bun_ast::Loc::EMPTY, + }, + bun_ast::Loc::EMPTY, + ); + let value = self.new_expr( + E::RequireString { + import_record_index: import_record_i, + ..Default::default() + }, + bun_ast::Loc::EMPTY, + ); + let mut decls = G::DeclList::init_capacity(1); + decls.append_assume_capacity(G::Decl { + binding, + value: Some(value), + }); + let stmt = self.s( + S::Local { + kind: js_ast::LocalKind::KConst, + decls, + ..Default::default() + }, + bun_ast::Loc::EMPTY, + ); + + let is_jsx = tag == bun_ast::PartTag::JsxImport; + parts.push(js_ast::Part { + stmts: self.arena.alloc_slice_fill_with(1, |_| stmt).into(), + declared_symbols, + import_record_indices: js_ast::PartImportRecordIndices::init_one(import_record_i), + tag, + can_be_removed_if_unused: is_jsx, + force_tree_shaking: is_jsx, + ..Default::default() + }); + Ok(()) + } + pub(crate) fn generate_react_refresh_import( &mut self, parts: &mut ListManaged<'a, js_ast::Part>, diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index d1cf892e5f49..d1c145f1569d 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -353,6 +353,14 @@ impl<'a> Options<'a> { hasher.update(b"udfcf=0"); } + // The cache file is keyed by the content hash alone, and the hint + // changes the output format. + hasher.update(match self.module_type { + options::ModuleType::Unknown => b"mt=?", + options::ModuleType::Cjs => b"mt=c", + options::ModuleType::Esm => b"mt=e", + }); + self.features.hash_for_runtime_transpiler(hasher); } @@ -2325,34 +2333,39 @@ impl<'a> Parser<'a> { let mut buf: [&'static [u8]; 3] = [b"", b"", b""]; let runtime_import_names = jsx_imports.runtime_import_names(&mut buf); - - if !runtime_import_names.is_empty() { - p.generate_import_stmt( - import_source, - runtime_import_names, - &mut before, - &jsx_imports, - None, - b"", - false, - js_ast::PartTag::JsxImport, - ) - .expect("unreachable"); - } - let source_import_names = jsx_imports.source_import_names(); - if !source_import_names.is_empty() { - p.generate_import_stmt( - package_name, - source_import_names, - &mut before, - &jsx_imports, - None, - b"", - false, - js_ast::PartTag::JsxImport, - ) - .expect("unreachable"); + + // An `import` inside the CommonJS function wrapper is a syntax error. + let wrap_in_commonjs = wrap_mode == WrapMode::BunCommonjs; + for (path, names) in [ + (import_source, runtime_import_names), + (package_name, source_import_names), + ] { + if names.is_empty() { + continue; + } + if wrap_in_commonjs { + p.generate_require_stmt( + path, + names, + &mut before, + &jsx_imports, + js_ast::PartTag::JsxImport, + ) + .expect("unreachable"); + } else { + p.generate_import_stmt( + path, + names, + &mut before, + &jsx_imports, + None, + b"", + false, + js_ast::PartTag::JsxImport, + ) + .expect("unreachable"); + } } p.jsx_imports = jsx_imports; diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 63d4e6ea734e..581d4ca8b63d 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -322,6 +322,7 @@ impl RuntimeTranspilerStore { path: &Fs::Path<'_>, referrer: String, loader: Loader, + module_type: ModuleType, package_json: Option<&PackageJSON>, module_loader: JSValue, ) -> *mut c_void { @@ -334,8 +335,8 @@ impl RuntimeTranspilerStore { let owned_path = bun_paths::fs::Path::init(unsafe { &*owned_text.cast_const() }); let promise: *mut JSInternalPromise = JSInternalPromise::create(global_object); - // NOTE: DirInfo should already be cached since module loading happens - // after module resolution, so this should be cheap + // The tag feeds the CommonJS loader's `ignoreESModuleAnnotation`; the + // parser's hint is `module_type`. let mut resolved_source = ResolvedSource::default(); if let Some(pkg) = package_json { match pkg.module_type { @@ -365,6 +366,7 @@ impl RuntimeTranspilerStore { ticket: None, log: bun_ast::Log::init(), loader, + module_type, promise: StrongOptional::create(JSValue::from_cell(promise), global_object), module_loader: if module_loader.is_empty() { StrongOptional::empty() @@ -416,6 +418,7 @@ pub struct TranspilerJob { pub(crate) non_threadsafe_input_specifier: bun_core::String, pub(crate) non_threadsafe_referrer: bun_core::String, pub(crate) loader: Loader, + pub(crate) module_type: ModuleType, pub(crate) promise: StrongOptional, /// The `JSModuleLoader` that is fetching, when it is not the global object's (a /// `Bun.ModuleGraph`'s): handed back with the result. Empty otherwise. @@ -670,6 +673,7 @@ impl TranspilerJob { let path = self.path; let specifier = self.path.text; let loader = self.loader; + let module_type = self.module_type; let this_tag = self.resolved_source.tag; // RuntimeTranspilerCache has no per-allocator fields (Box<[u8]> + global mimalloc). @@ -805,12 +809,6 @@ impl TranspilerJob { && strings::eql_long(vm_main, path.text, false) }); - let module_type: ModuleType = match this_tag { - ResolvedSourceTag::PackageJsonTypeCommonjs => ModuleType::Cjs, - ResolvedSourceTag::PackageJsonTypeModule => ModuleType::Esm, - _ => ModuleType::Unknown, - }; - let mut parse_options = ParseOptions { arena: &arena, path, diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 4d3a0115880f..abe7189d3509 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5162,10 +5162,7 @@ impl VirtualMachine { Ok(lr) => lr, Err(_) => return Err(crate::CrateError::ModuleNotFound), }; - let module_type = lr - .package_json - .map(|pkg| pkg.module_type) - .unwrap_or(bun_bundler::options::ModuleType::Unknown); + let module_type = bun_resolver::module_type_for_file(lr.path.name().ext, lr.package_json); // A drop-guard so both the normal and error paths reset the arena on // the right edge. diff --git a/src/resolver/dir_info.rs b/src/resolver/dir_info.rs index b90cba7326e7..86cdd436a7b4 100644 --- a/src/resolver/dir_info.rs +++ b/src/resolver/dir_info.rs @@ -120,8 +120,8 @@ pub struct DirInfo { pub enclosing_package_json: Option<&'static PackageJSON>, /// The nearest package.json in this directory or above it, with or without - /// a "name". The bundler takes the module type of a `.js`-like file in this - /// directory from its "type" (`finalize_result`), as esbuild does. The + /// a "name". The bundler (`finalize_result`) and the runtime take the module + /// type of a `.js`-like file in this directory from its "type". The /// lookup does not stop at a "node_modules" directory, so a package with no /// package.json of its own gets the one above it. `enclosing_package_json` /// skips a nameless package.json. diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index a9872c36e891..90e137487a01 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -49,7 +49,8 @@ pub use tsconfig_json::TSConfigJSON; /// Re-export so dependents can spell `bun_resolver::install_types::AutoInstaller`. pub use ::bun_install_types::resolver_hooks as install_types; pub use resolver::{ - AnyResolveWatcher, BrowserMapPathKind, Bufs, Dirname, Resolver, module_type_from_ext, + AnyResolveWatcher, BrowserMapPathKind, Bufs, Dirname, Resolver, module_type_for_file, + module_type_from_ext, }; pub use result::{ DebugLogs, DirEntryResolveQueueItem, ExternalKind, FlushMode, LoadResult, MatchResult, diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 08cac08e4ce9..aed476453f74 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -6916,6 +6916,13 @@ pub fn module_type_from_ext(ext: &[u8]) -> Option { MODULE_TYPE_FROM_EXT.get(ext).copied() } +/// The module type a path declares at runtime: the extension, else the "type" +/// of `package_json` ([`DirInfo::package_json_for_module_type`](crate::DirInfo::package_json_for_module_type)). +pub fn module_type_for_file(ext: &[u8], package_json: Option<&PackageJSON>) -> options::ModuleType { + module_type_from_ext(ext) + .unwrap_or_else(|| package_json.map_or(options::ModuleType::Unknown, |pkg| pkg.module_type)) +} + /// TypeScript matches `./foo.js` with `./foo.ts` (microsoft/TypeScript#4595). This /// is esbuild's `rewrittenFileExtensions` table plus Bun's `.js` → `.mts` /// (oven-sh/bun#12580). `inside_node_modules` gates only `.mjs` and `.cjs`: diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 0cbef912d979..491a24622d02 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1469,12 +1469,9 @@ mod vm_loader_ctx { // Short-lived `&mut Resolver` (not `&mut VirtualMachine`) for // the call — narrows the borrow re-entrant JS could alias. match (*this).transpiler.resolver.read_dir_info(dir) { - Ok(Some(dir_info)) => { - dir_info - .package_json() - .or(dir_info.enclosing_package_json) - .map(core::ptr::from_ref::) - } + Ok(Some(dir_info)) => dir_info + .package_json_for_module_type + .map(core::ptr::from_ref::), _ => None, } }, @@ -2972,9 +2969,7 @@ fn transpile_source_code_of_any_provenance( .resolver .read_dir_info(source.path.name().dir) } { - Ok(Some(dir_info)) => { - dir_info.package_json().or(dir_info.enclosing_package_json) - } + Ok(Some(dir_info)) => dir_info.package_json_for_module_type, _ => None, } }); @@ -3221,10 +3216,9 @@ fn transpile_source_code_of_any_provenance( // re-entrant on the JS thread and returns a // stable cache slot. match unsafe { (*jsc_vm).transpiler.resolver.read_dir_info(dir) } { - Ok(Some(dir_info)) => dir_info - .package_json() - .or(dir_info.enclosing_package_json) - .map(|p| p.module_type), + Ok(Some(dir_info)) => { + dir_info.package_json_for_module_type.map(|p| p.module_type) + } _ => None, } }) @@ -3952,8 +3946,12 @@ struct LoaderResult<'a> { path: Fs::Path<'a>, is_main: bool, specifier: &'a [u8], - /// Always `None` for non-JS-like loaders (not needed there). + /// `DirInfo::package_json_for_module_type`. `None` for non-JS-like loaders. package_json: Option<&'a bun_resolver::package_json::PackageJSON>, + /// The name of the enclosing (named) package. `None` for non-JS-like loaders. + package_name: Option<&'a [u8]>, + /// `bun_resolver::module_type_for_file` for `path`. + module_type: ModuleType, } /// `options.getLoaderAndVirtualSource` — high-tier body. @@ -4073,18 +4071,27 @@ unsafe fn get_loader_and_virtual_source<'a>( // SAFETY: per fn contract. let is_main = unsafe { &*jsc_vm }.main() == Some(specifier); - // package.json sniff for `.js`/`.ts` module-type. let dir = path.name().dir; let is_js_like = loader.map(|l| l.is_java_script_like()).unwrap_or(true); - let package_json = if is_js_like && bun_paths::is_absolute(dir) { + let (package_json, package_name) = if is_js_like && bun_paths::is_absolute(dir) { // SAFETY: per fn contract — `transpiler.resolver` is a value field of // the VM; `read_dir_info` is re-entrant on the JS thread. match unsafe { (*jsc_vm).transpiler.resolver.read_dir_info(dir) } { - Ok(Some(dir_info)) => dir_info.package_json().or(dir_info.enclosing_package_json), - _ => None, + Ok(Some(dir_info)) => ( + dir_info.package_json_for_module_type, + dir_info + .enclosing_package_json + .and_then(|pkg| (!pkg.name.is_empty()).then_some(&*pkg.name)), + ), + _ => (None, None), } } else { - None + (None, None) + }; + let module_type = if is_js_like { + bun_resolver::module_type_for_file(path.name().ext, package_json) + } else { + ModuleType::Unknown }; Ok(LoaderResult { @@ -4094,6 +4101,8 @@ unsafe fn get_loader_and_virtual_source<'a>( is_main, specifier, package_json, + package_name, + module_type, }) } @@ -4266,38 +4275,8 @@ pub(crate) unsafe extern "C" fn Bun__transpileFile( } } - // ── module_type sniff from extension / package.json ───────────────────── - let module_type: ModuleType = 'brk: { - let ext = lr.path.name().ext; - // regex /\.[cm][jt]s$/ - if ext.len() == b".cjs".len() { - if ext == b".cjs" { - break 'brk ModuleType::Cjs; - } - if ext == b".mjs" { - break 'brk ModuleType::Esm; - } - if ext == b".cts" { - break 'brk ModuleType::Cjs; - } - if ext == b".mts" { - break 'brk ModuleType::Esm; - } - } - // regex /\.[jt]s$/ - if ext.len() == b".ts".len() && (ext == b".js" || ext == b".ts") { - // Use the package.json module type if it exists. - break 'brk lr - .package_json - .map(|pkg| pkg.module_type) - .unwrap_or(ModuleType::Unknown); - } - // For JSX/TSX and other extensions, let the file contents decide. - ModuleType::Unknown - }; - let pkg_name: Option<&[u8]> = lr - .package_json - .and_then(|pkg| (!pkg.name.is_empty()).then_some(&*pkg.name)); + let module_type = lr.module_type; + let pkg_name = lr.package_name; // ── Concurrent-transpiler dispatch (`transpile_async:` block) ─────────── // We only run the transpiler concurrently when we can — today that's import statements and @@ -4350,6 +4329,7 @@ pub(crate) unsafe extern "C" fn Bun__transpileFile( &lr.path, referrer.clone(), concurrent_loader, + module_type, lr.package_json, module_loader, ) diff --git a/test/cli/run/module-type-fixture/cjs/hello.cjs b/test/cli/run/module-type-fixture/cjs/hello.cjs deleted file mode 100644 index 248453e484c4..000000000000 --- a/test/cli/run/module-type-fixture/cjs/hello.cjs +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); diff --git a/test/cli/run/module-type-fixture/cjs/hello.cts b/test/cli/run/module-type-fixture/cjs/hello.cts deleted file mode 100644 index 248453e484c4..000000000000 --- a/test/cli/run/module-type-fixture/cjs/hello.cts +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); diff --git a/test/cli/run/module-type-fixture/cjs/hello.js b/test/cli/run/module-type-fixture/cjs/hello.js deleted file mode 100644 index 72c037e9a6e9..000000000000 --- a/test/cli/run/module-type-fixture/cjs/hello.js +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); \ No newline at end of file diff --git a/test/cli/run/module-type-fixture/cjs/hello.jsx b/test/cli/run/module-type-fixture/cjs/hello.jsx deleted file mode 100644 index 72c037e9a6e9..000000000000 --- a/test/cli/run/module-type-fixture/cjs/hello.jsx +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); \ No newline at end of file diff --git a/test/cli/run/module-type-fixture/cjs/hello.mjs b/test/cli/run/module-type-fixture/cjs/hello.mjs deleted file mode 100644 index 248453e484c4..000000000000 --- a/test/cli/run/module-type-fixture/cjs/hello.mjs +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); diff --git a/test/cli/run/module-type-fixture/cjs/hello.mts b/test/cli/run/module-type-fixture/cjs/hello.mts deleted file mode 100644 index 248453e484c4..000000000000 --- a/test/cli/run/module-type-fixture/cjs/hello.mts +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); diff --git a/test/cli/run/module-type-fixture/cjs/hello.ts b/test/cli/run/module-type-fixture/cjs/hello.ts deleted file mode 100644 index 72c037e9a6e9..000000000000 --- a/test/cli/run/module-type-fixture/cjs/hello.ts +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); \ No newline at end of file diff --git a/test/cli/run/module-type-fixture/cjs/hello.tsx b/test/cli/run/module-type-fixture/cjs/hello.tsx deleted file mode 100644 index 72c037e9a6e9..000000000000 --- a/test/cli/run/module-type-fixture/cjs/hello.tsx +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); \ No newline at end of file diff --git a/test/cli/run/module-type-fixture/cjs/import.cjs b/test/cli/run/module-type-fixture/cjs/import.cjs deleted file mode 100644 index 08c3035d6051..000000000000 --- a/test/cli/run/module-type-fixture/cjs/import.cjs +++ /dev/null @@ -1,3 +0,0 @@ -import * as fs from "node:fs"; -console.log(eval("typeof module === 'undefined'")); -+fs; diff --git a/test/cli/run/module-type-fixture/cjs/package.json b/test/cli/run/module-type-fixture/cjs/package.json deleted file mode 100644 index 5bbefffbabee..000000000000 --- a/test/cli/run/module-type-fixture/cjs/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "commonjs" -} diff --git a/test/cli/run/module-type-fixture/esm/hello.cjs b/test/cli/run/module-type-fixture/esm/hello.cjs deleted file mode 100644 index 248453e484c4..000000000000 --- a/test/cli/run/module-type-fixture/esm/hello.cjs +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); diff --git a/test/cli/run/module-type-fixture/esm/hello.cts b/test/cli/run/module-type-fixture/esm/hello.cts deleted file mode 100644 index 248453e484c4..000000000000 --- a/test/cli/run/module-type-fixture/esm/hello.cts +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); diff --git a/test/cli/run/module-type-fixture/esm/hello.js b/test/cli/run/module-type-fixture/esm/hello.js deleted file mode 100644 index 72c037e9a6e9..000000000000 --- a/test/cli/run/module-type-fixture/esm/hello.js +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); \ No newline at end of file diff --git a/test/cli/run/module-type-fixture/esm/hello.jsx b/test/cli/run/module-type-fixture/esm/hello.jsx deleted file mode 100644 index 72c037e9a6e9..000000000000 --- a/test/cli/run/module-type-fixture/esm/hello.jsx +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); \ No newline at end of file diff --git a/test/cli/run/module-type-fixture/esm/hello.mjs b/test/cli/run/module-type-fixture/esm/hello.mjs deleted file mode 100644 index 248453e484c4..000000000000 --- a/test/cli/run/module-type-fixture/esm/hello.mjs +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); diff --git a/test/cli/run/module-type-fixture/esm/hello.mts b/test/cli/run/module-type-fixture/esm/hello.mts deleted file mode 100644 index 248453e484c4..000000000000 --- a/test/cli/run/module-type-fixture/esm/hello.mts +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); diff --git a/test/cli/run/module-type-fixture/esm/hello.ts b/test/cli/run/module-type-fixture/esm/hello.ts deleted file mode 100644 index 72c037e9a6e9..000000000000 --- a/test/cli/run/module-type-fixture/esm/hello.ts +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); \ No newline at end of file diff --git a/test/cli/run/module-type-fixture/esm/hello.tsx b/test/cli/run/module-type-fixture/esm/hello.tsx deleted file mode 100644 index 72c037e9a6e9..000000000000 --- a/test/cli/run/module-type-fixture/esm/hello.tsx +++ /dev/null @@ -1 +0,0 @@ -console.log(eval("typeof module === 'undefined'")); \ No newline at end of file diff --git a/test/cli/run/module-type-fixture/esm/import.cjs b/test/cli/run/module-type-fixture/esm/import.cjs deleted file mode 100644 index 08c3035d6051..000000000000 --- a/test/cli/run/module-type-fixture/esm/import.cjs +++ /dev/null @@ -1,3 +0,0 @@ -import * as fs from "node:fs"; -console.log(eval("typeof module === 'undefined'")); -+fs; diff --git a/test/cli/run/module-type-fixture/esm/package.json b/test/cli/run/module-type-fixture/esm/package.json deleted file mode 100644 index 3dbc1ca591c0..000000000000 --- a/test/cli/run/module-type-fixture/esm/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/test/cli/run/run-detect-module-type.test.ts b/test/cli/run/run-detect-module-type.test.ts new file mode 100644 index 000000000000..e3331fbb0fcb --- /dev/null +++ b/test/cli/run/run-detect-module-type.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "path"; + +// Which module format a file is evaluated as when its contents use neither +// CommonJS nor ESM syntax: +// .cjs/.cts and .mjs/.mts are decided by the extension alone, +// everything else by the nearest package.json "type" (with or without a +// "name"), and with no "type" Bun defaults to ESM. +// The answer must not depend on how the file is loaded. The entry point, +// require() and a Worker's main module transpile on the JS thread; import, +// import() and --require preloads transpile on the work pool +// (RuntimeTranspilerStore). Both paths must apply the same rule, and so must +// the bundler. + +type Format = "commonjs" | "module"; +type Scope = "commonjs" | "module" | "untyped"; + +const packageJson: Record = { + commonjs: `{ "type": "commonjs" }`, + module: `{ "type": "module" }`, + untyped: `{}`, +}; + +const extensions = [".js", ".ts", ".jsx", ".tsx", ".cjs", ".cts", ".mjs", ".mts"]; + +function expectedFormat(scope: Scope, ext: string): Format { + if (ext === ".cjs" || ext === ".cts") return "commonjs"; + if (ext === ".mjs" || ext === ".mts") return "module"; + return scope === "commonjs" ? "commonjs" : "module"; +} + +const expected: Record> = Object.fromEntries( + (Object.keys(packageJson) as Scope[]).map(scope => [ + scope, + Object.fromEntries(extensions.map(ext => [`hello${ext}`, expectedFormat(scope, ext)])), + ]), +) as Record>; + +const fixtures = extensions.map(ext => `hello${ext}`); + +// Every fixture prints " ". `module` is only mentioned inside +// eval, so the parser sees neither CommonJS nor ESM syntax and the format can +// only come from the extension or package.json. The .jsx/.tsx fixtures also +// render an element: a CommonJS .jsx file needs the JSX runtime as a +// require(), an import statement inside the CommonJS wrapper is a syntax error. +function fixture(file: string): string { + const jsx = file.endsWith("x") ? `const element =
{${JSON.stringify(file)}}
;\n` : ""; + return `${jsx}console.log("${file}", eval("typeof module") === "undefined" ? "module" : "commonjs");\n`; +} + +// A stand-in for react's JSX runtime so the fixtures stay offline. +const jsxRuntimeStub = { + "node_modules/react/package.json": `{ "name": "react", "version": "0.0.0" }`, + "node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (type, props) => ({ type, props });\nexports.Fragment = "Fragment";\n`, + "node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (type, props) => ({ type, props });\nexports.Fragment = "Fragment";\n`, +}; + +function scopeDir(scope: Scope) { + return tempDir(`module-type-${scope}`, { + "package.json": packageJson[scope], + ...jsxRuntimeStub, + ...Object.fromEntries(fixtures.map(file => [file, fixture(file)])), + "static-import.mjs": fixtures.map(file => `import "./${file}";`).join("\n") + "\n", + "dynamic-import.mjs": `for (const file of ${JSON.stringify(fixtures)}) await import("./" + file);\n`, + "require-all.cjs": fixtures.map(file => `require("./${file}");`).join("\n") + "\n", + "empty.mjs": "", + }); +} + +async function run(cwd: string, ...args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +/** Parses the " " lines the fixtures print (in any order) into a table. */ +function formats(stdout: string): Record { + return Object.fromEntries( + stdout + .split("\n") + .filter(Boolean) + .map(line => line.split(" ")), + ); +} + +describe.concurrent("module format follows the extension, then the nearest package.json", () => { + for (const scope of Object.keys(packageJson) as Scope[]) { + describe(`package.json ${packageJson[scope]}`, () => { + test("as the entry point", async () => { + using dir = scopeDir(scope); + const results = await Promise.all(fixtures.map(file => run(String(dir), file))); + expect(formats(results.map(r => r.stdout).join(""))).toEqual(expected[scope]); + expect(results.map(r => r.stderr)).toEqual(fixtures.map(() => "")); + expect(results.map(r => r.exitCode)).toEqual(fixtures.map(() => 0)); + }); + + test("via require()", async () => { + using dir = scopeDir(scope); + const { stdout, stderr, exitCode } = await run(String(dir), "require-all.cjs"); + expect(formats(stdout)).toEqual(expected[scope]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("via an import statement", async () => { + using dir = scopeDir(scope); + const { stdout, stderr, exitCode } = await run(String(dir), "static-import.mjs"); + expect(formats(stdout)).toEqual(expected[scope]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("via import()", async () => { + using dir = scopeDir(scope); + const { stdout, stderr, exitCode } = await run(String(dir), "dynamic-import.mjs"); + expect(formats(stdout)).toEqual(expected[scope]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("via --require", async () => { + using dir = scopeDir(scope); + const preloads = fixtures.flatMap(file => ["--require", `./${file}`]); + const { stdout, stderr, exitCode } = await run(String(dir), ...preloads, "empty.mjs"); + expect(formats(stdout)).toEqual(expected[scope]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + }); + } + + test("as a Worker's main module", async () => { + // A Worker starts its own VM and transpiles its entry on that thread. + // Two workers, not the whole table: each one costs about a second on a + // debug build. + using dir = tempDir("module-type-worker", { + "esm/package.json": packageJson.module, + "esm/hello.cjs": fixture("hello.cjs"), + "cjs/package.json": packageJson.commonjs, + "cjs/hello.mjs": fixture("hello.mjs"), + "workers.mjs": ` + import { Worker } from "node:worker_threads"; + await Promise.all(["./esm/hello.cjs", "./cjs/hello.mjs"].map(file => { + const worker = new Worker(new URL(file, import.meta.url)); + return new Promise((resolve, reject) => { + worker.on("exit", resolve); + worker.on("error", reject); + }); + })); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), "workers.mjs"); + expect(formats(stdout)).toEqual({ "hello.cjs": "commonjs", "hello.mjs": "module" }); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); +}); + +describe.concurrent("the nearest package.json decides, with or without a name", () => { + // A package.json without a "name" is not the package's root for bundling + // (DirInfo.enclosing_package_json), but its "type" still applies to the + // files below it. `dist/cjs/package.json` containing only + // {"type":"commonjs"} inside a "type":"module" package is the common case. + + test('a nameless {"type":"commonjs"} in a parent directory applies to the runtime', async () => { + using dir = tempDir("module-type-nameless", { + "package.json": `{ "type": "commonjs" }`, + "src/hello.js": fixture("hello.js"), + "src/import.mjs": `import "./hello.js";\n`, + }); + const entry = await run(String(dir), "src/hello.js"); + expect(entry).toEqual({ stdout: "hello.js commonjs\n", stderr: "", exitCode: 0 }); + const imported = await run(String(dir), "src/import.mjs"); + expect(imported).toEqual({ stdout: "hello.js commonjs\n", stderr: "", exitCode: 0 }); + }); + + test('a nameless {"type":"commonjs"} in a parent directory applies to the bundler', async () => { + using dir = tempDir("module-type-nameless-build", { + "package.json": `{ "type": "commonjs" }`, + "src/hello.js": `console.log("hello");\n`, + }); + const { stdout, stderr, exitCode } = await run(String(dir), "build", "src/hello.js", "--target=bun"); + expect(stdout).toContain("__commonJS("); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("a nested nameless scope overrides the named package root", async () => { + using dir = tempDir("module-type-nested", { + "package.json": `{ "name": "dual", "type": "module" }`, + "dist/cjs/package.json": `{ "type": "commonjs" }`, + "dist/cjs/lib/hello.js": fixture("hello.js"), + "dist/esm/hello.js": fixture("hello.js"), + "main.mjs": `import "./dist/cjs/lib/hello.js";\nimport "./dist/esm/hello.js";\n`, + }); + const { stdout, stderr, exitCode } = await run(String(dir), "main.mjs"); + expect(stdout).toBe("hello.js commonjs\nhello.js module\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); +}); + +describe.concurrent("JSX in a CommonJS module", () => { + // The parser adds `import { jsxDEV } from "react/jsx-dev-runtime"` to a file + // that uses JSX. Inside the CommonJS function wrapper that has to be a + // require(), or the transpiled file is a syntax error. + + test("a .jsx file that assigns module.exports", async () => { + using dir = tempDir("module-type-jsx-cjs", { + ...jsxRuntimeStub, + "component.jsx": `module.exports = function Component() {\n return
hi
;\n};\n`, + "main.cjs": `console.log(JSON.stringify(require("./component.jsx")()));\n`, + "main.mjs": `import Component from "./component.jsx";\nconsole.log(JSON.stringify(Component()));\n`, + }); + const rendered = `{"type":"div","props":{"className":"x","children":"hi"}}\n`; + expect(await run(String(dir), "main.cjs")).toEqual({ stdout: rendered, stderr: "", exitCode: 0 }); + expect(await run(String(dir), "main.mjs")).toEqual({ stdout: rendered, stderr: "", exitCode: 0 }); + }); + + test('a .test.tsx file with Jest globals under {"type":"commonjs"}', async () => { + // No import from bun:test: the globals are injected with a require() in a + // CommonJS file, the same way the JSX runtime must be. + using dir = tempDir("module-type-jsx-test", { + ...jsxRuntimeStub, + "package.json": packageJson.commonjs, + "element.test.tsx": `test("renders", () => {\n expect({1}).toEqual({ type: "span", props: { children: 1 } });\n});\n`, + }); + const { stderr, exitCode } = await run(String(dir), "test", "./element.test.tsx"); + expect(stderr).toContain(" 1 pass"); + expect(stderr).toContain(" 0 fail"); + expect(exitCode).toBe(0); + }); +}); + +test("the transpiler cache keys on the module type, not only the file contents", async () => { + // The on-disk cache file is named after a hash of the source bytes and only + // applies to files of 4 KiB or more. Identical bytes under "type":"module" + // and "type":"commonjs" transpile to different formats, so the second scope + // must not be served the first scope's entry. + const padding = "// " + Buffer.alloc(4096, "x").toString() + "\n"; + using dir = tempDir("module-type-cache", { + "cache/.keep": "", + "esm/package.json": `{ "type": "module" }`, + "esm/hello.js": padding + fixture("hello.js"), + "cjs/package.json": `{ "type": "commonjs" }`, + "cjs/hello.js": padding + fixture("hello.js"), + }); + const env = { ...bunEnv, BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(String(dir), "cache") }; + for (const [subdir, format] of [ + ["esm", "module"], + ["cjs", "commonjs"], + ]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "hello.js"], + cwd: join(String(dir), subdir), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: `hello.js ${format}\n`, stderr: "", exitCode: 0 }); + } +}); diff --git a/test/cli/run/run-detect-module-type.ts b/test/cli/run/run-detect-module-type.ts deleted file mode 100644 index e9b447a19888..000000000000 --- a/test/cli/run/run-detect-module-type.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { bunEnv, bunExe } from "harness"; -import { join } from "path"; -import { expect, test } from "bun:test"; - -// module type -> file extensions -> expected module type -const table = { - cjs: { - 'hello.cjs': 'commonjs', - 'hello.js': 'commonjs', - 'hello.mjs': 'module', - 'hello.ts': 'commonjs', - 'hello.tsx': 'module', - 'hello.cts': 'commonjs', - 'hello.jsx': 'module', - 'hello.mts': 'module', - // files using ES import and no exports will be detected as module - "import.cjs": "module", - }, - esm: { - 'hello.cjs': 'commonjs', - 'hello.js': 'module', - 'hello.mjs': 'module', - 'hello.ts': 'module', - 'hello.tsx': 'module', - 'hello.cts': 'commonjs', - 'hello.jsx': 'module', - 'hello.mts': 'module', - // files using ES import and no exports will be detected as module - "import.cjs": "module", - }, -}; - -test("detect module type", () => { - const expected = Object.entries(table).map(([moduleType, extensions]) => { - return Object.entries(extensions).map(([extension, expected]) => { - return `${moduleType} ${extension} -> ${expected}`; - }); - }).flat(); - - const actual = Object.entries(table).map(([moduleType, extensions]) => { - return Object.entries(extensions).map(([extension, expected]) => { - const proc = Bun.spawnSync({ - cmd: [bunExe(), "run", join(import.meta.dir, 'module-type-fixture', moduleType, extension)], - env: bunEnv, - }); - if (proc.exitCode !== 0) { - throw new Error(`Failed to run ${moduleType} ${extension}: ${proc.stderr.toString('utf8').trim()}`); - } - return `${moduleType} ${extension} -> ${proc.stdout.toString('utf8').trim() === "false" ? "commonjs" : "module"}`; - }); - }).flat(); - - expect(actual).toEqual(expected); -}); diff --git a/test/js/bun/resolve/build-error.test.ts b/test/js/bun/resolve/build-error.test.ts index f6c280777c6c..73d4706caf18 100644 --- a/test/js/bun/resolve/build-error.test.ts +++ b/test/js/bun/resolve/build-error.test.ts @@ -226,11 +226,14 @@ test.concurrent("a type-only import next to module.exports loads on every path", expect(directExitCode).toBe(0); }); -// The parser adds the JSX runtime import itself. It must not be reported as -// an import statement the user should replace with require(). -test.concurrent("JSX next to module.exports is not blamed on a runtime import", async () => { +// The parser adds the JSX runtime import itself. In a CommonJS file it becomes +// a require() inside the wrapper, so the file loads, and it is never reported +// as an import statement the user should replace with require(). +test.concurrent("JSX next to module.exports loads", async () => { using dir = tempDir("build-error-jsx-cjs", { - "mixed.tsx": `const el =
;\nmodule.exports = { el };`, + "node_modules/react/package.json": `{ "name": "react", "version": "0.0.0" }`, + "node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (type, props) => ({ type, props });`, + "mixed.tsx": `const el =
;\nmodule.exports = { el };\nconsole.log(JSON.stringify(el));`, }); await using proc = Bun.spawn({ @@ -242,12 +245,9 @@ test.concurrent("JSX next to module.exports is not blamed on a runtime import", const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - // The file still fails to load: the generated import is printed inside the - // CommonJS wrapper, and JSC rejects that. The error must not name it. - expect(stdout).toBe(""); - expect(stderr).not.toContain("Cannot use import statement with CommonJS-only features"); - expect(stderr).not.toContain("Try require("); - expect(exitCode).toBe(1); + expect(stderr).toBe(""); + expect(stdout).toBe(`{"type":"div","props":{}}\n`); + expect(exitCode).toBe(0); }); // The lexer reads the first token while the parser is constructed. An error it diff --git a/test/js/bun/resolve/esModule-annotation.test.js b/test/js/bun/resolve/esModule-annotation.test.js index 833cd287e004..e22b9f557f1f 100644 --- a/test/js/bun/resolve/esModule-annotation.test.js +++ b/test/js/bun/resolve/esModule-annotation.test.js @@ -69,6 +69,20 @@ describe('with type: "module"', () => { }); }); +describe('with type: "module" in a nameless package.json of a parent directory', () => { + // The "type" that decides whether the annotation counts comes from the nearest + // package.json, with or without a "name" (DirInfo.package_json_for_module_type). + test("exports.default = true; exports.__esModule = true;", async () => { + using dir = tempDir("esmodule-annotation-nameless-scope", { + "package.json": `{ "type": "module" }`, + "lib/export-esModule-annotation.cjs": `exports.default = true;\nexports.__esModule = true;\n`, + }); + const ns = await import(join(String(dir), "lib/export-esModule-annotation.cjs")); + expect(ns.default).toEqual({ default: true, __esModule: true }); + expect(ns.__esModule).toBeTrue(); + }); +}); + describe("CJS exports the ESM wrapper cannot enumerate", () => { // Building the synthetic ESM namespace enumerates module.exports; if that throws, the import // fails with the real error instead of yielding an empty namespace. From 388d33ff7d0bcc6778334fb417c2966563c1296d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:35:20 +0000 Subject: [PATCH 2/8] resolver: shorten the module_type_for_file doc, leave the DirInfo doc as it is on main --- src/resolver/dir_info.rs | 4 ++-- src/resolver/resolver.rs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/resolver/dir_info.rs b/src/resolver/dir_info.rs index 86cdd436a7b4..b90cba7326e7 100644 --- a/src/resolver/dir_info.rs +++ b/src/resolver/dir_info.rs @@ -120,8 +120,8 @@ pub struct DirInfo { pub enclosing_package_json: Option<&'static PackageJSON>, /// The nearest package.json in this directory or above it, with or without - /// a "name". The bundler (`finalize_result`) and the runtime take the module - /// type of a `.js`-like file in this directory from its "type". The + /// a "name". The bundler takes the module type of a `.js`-like file in this + /// directory from its "type" (`finalize_result`), as esbuild does. The /// lookup does not stop at a "node_modules" directory, so a package with no /// package.json of its own gets the one above it. `enclosing_package_json` /// skips a nameless package.json. diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index aed476453f74..ab24c2ffd9d6 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -6916,8 +6916,7 @@ pub fn module_type_from_ext(ext: &[u8]) -> Option { MODULE_TYPE_FROM_EXT.get(ext).copied() } -/// The module type a path declares at runtime: the extension, else the "type" -/// of `package_json` ([`DirInfo::package_json_for_module_type`](crate::DirInfo::package_json_for_module_type)). +/// The runtime's rule. `package_json` is `DirInfo::package_json_for_module_type`. pub fn module_type_for_file(ext: &[u8], package_json: Option<&PackageJSON>) -> options::ModuleType { module_type_from_ext(ext) .unwrap_or_else(|| package_json.map_or(options::ModuleType::Unknown, |pkg| pkg.module_type)) From 03a1c5dc543f7e70a3c90102ba2becc576b946eb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:38:12 +0000 Subject: [PATCH 3/8] Require the decorator helpers in the CommonJS wrapper, keep "type" to .js-like extensions A file that gets the runtime CommonJS wrapper and has a decorated class imported its helpers from "bun:wrap" with an import statement inside the wrapper function, a syntax error. The helpers are now required there, like the JSX runtime. module_type_for_file reads the package.json "type" only for .js, .ts, .jsx and .tsx. A script with no extension, bun -e and a script on stdin are left to the content sniff, as before. Source from an onLoad plugin keeps no hint: the loader evaluates it as an ES module whatever the parser decides. The cache test sets BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE so a debug build serves the cached entry. The Worker test runs on its own. --- src/js_parser/parse/parse_entry.rs | 38 +++-- src/resolver/resolver.rs | 12 +- src/runtime/jsc_hooks.rs | 2 + test/cli/run/run-detect-module-type.test.ts | 146 ++++++++++++++++---- 4 files changed, 156 insertions(+), 42 deletions(-) diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index d1c145f1569d..78d70b833eb4 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -2278,6 +2278,9 @@ impl<'a> Parser<'a> { } } + // An `import` inside the CommonJS function wrapper is a syntax error. + let wrap_in_commonjs = wrap_mode == WrapMode::BunCommonjs; + if p.has_called_runtime { let mut runtime_imports: [u8; RuntimeImports::ALL.len()] = [0; RuntimeImports::ALL.len()]; @@ -2302,17 +2305,28 @@ impl<'a> Parser<'a> { // never touches `self.runtime_imports`, so the clone is purely a // borrow-checker workaround. let symbols = p.runtime_imports.clone(); - p.generate_import_stmt( - RuntimeImports::NAME, - &runtime_imports[0..i], - &mut before, - &symbols, - None, - b"import_", - true, - js_ast::PartTag::Runtime, - ) - .expect("unreachable"); + if wrap_in_commonjs { + p.generate_require_stmt( + RuntimeImports::NAME, + &runtime_imports[0..i], + &mut before, + &symbols, + js_ast::PartTag::Runtime, + ) + .expect("unreachable"); + } else { + p.generate_import_stmt( + RuntimeImports::NAME, + &runtime_imports[0..i], + &mut before, + &symbols, + None, + b"import_", + true, + js_ast::PartTag::Runtime, + ) + .expect("unreachable"); + } } } @@ -2335,8 +2349,6 @@ impl<'a> Parser<'a> { let runtime_import_names = jsx_imports.runtime_import_names(&mut buf); let source_import_names = jsx_imports.source_import_names(); - // An `import` inside the CommonJS function wrapper is a syntax error. - let wrap_in_commonjs = wrap_mode == WrapMode::BunCommonjs; for (path, names) in [ (import_source, runtime_import_names), (package_name, source_import_names), diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index ab24c2ffd9d6..2d7cbea8f0a1 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -6918,8 +6918,16 @@ pub fn module_type_from_ext(ext: &[u8]) -> Option { /// The runtime's rule. `package_json` is `DirInfo::package_json_for_module_type`. pub fn module_type_for_file(ext: &[u8], package_json: Option<&PackageJSON>) -> options::ModuleType { - module_type_from_ext(ext) - .unwrap_or_else(|| package_json.map_or(options::ModuleType::Unknown, |pkg| pkg.module_type)) + if let Some(module_type) = module_type_from_ext(ext) { + return module_type; + } + match ext { + b".js" | b".ts" | b".jsx" | b".tsx" => { + package_json.map_or(options::ModuleType::Unknown, |pkg| pkg.module_type) + } + // No extension, `[eval]`, `[stdin]`, a custom extension: the contents decide. + _ => options::ModuleType::Unknown, + } } /// TypeScript matches `./foo.js` with `./foo.ts` (microsoft/TypeScript#4595). This diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 491a24622d02..b7d0a3f043b2 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -4566,6 +4566,8 @@ pub(crate) extern "C" fn Bun__transpileVirtualModule( let mut extra = TranspileExtra { path, loader, + // The caller evaluates this source as an ES module whatever the + // parser decides, so it must not get the CommonJS wrapper from a hint. module_type: ModuleType::Unknown, source_code_printer: printer_ptr, promise_ptr: ptr::null_mut(), // null forbids async resolution diff --git a/test/cli/run/run-detect-module-type.test.ts b/test/cli/run/run-detect-module-type.test.ts index e3331fbb0fcb..0b1ca273aa07 100644 --- a/test/cli/run/run-detect-module-type.test.ts +++ b/test/cli/run/run-detect-module-type.test.ts @@ -65,6 +65,21 @@ function scopeDir(scope: Scope) { "dynamic-import.mjs": `for (const file of ${JSON.stringify(fixtures)}) await import("./" + file);\n`, "require-all.cjs": fixtures.map(file => `require("./${file}");`).join("\n") + "\n", "empty.mjs": "", + // An onLoad plugin that hands back each fixture's own contents. + "plugin.mjs": ` + import { plugin } from "bun"; + import { readFileSync } from "node:fs"; + const loaders = { js: "js", cjs: "js", mjs: "js", jsx: "jsx", ts: "ts", cts: "ts", mts: "ts", tsx: "tsx" }; + plugin({ + name: "passthrough", + setup(build) { + build.onLoad({ filter: /hello\\.[a-z]+$/ }, ({ path }) => ({ + contents: readFileSync(path, "utf8"), + loader: loaders[path.slice(path.lastIndexOf(".") + 1)], + })); + }, + }); + `, }); } @@ -90,6 +105,31 @@ function formats(stdout: string): Record { ); } +// Not concurrent with the tests below: a Worker starts its own VM, which costs +// about a second on a debug build when it has a CPU to itself. +test("module format as a Worker's main module", async () => { + using dir = tempDir("module-type-worker", { + "esm/package.json": packageJson.module, + "esm/hello.cjs": fixture("hello.cjs"), + "cjs/package.json": packageJson.commonjs, + "cjs/hello.mjs": fixture("hello.mjs"), + "workers.mjs": ` + import { Worker } from "node:worker_threads"; + await Promise.all(["./esm/hello.cjs", "./cjs/hello.mjs"].map(file => { + const worker = new Worker(new URL(file, import.meta.url)); + return new Promise((resolve, reject) => { + worker.on("exit", resolve); + worker.on("error", reject); + }); + })); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), "workers.mjs"); + expect(formats(stdout)).toEqual({ "hello.cjs": "commonjs", "hello.mjs": "module" }); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); + describe.concurrent("module format follows the extension, then the nearest package.json", () => { for (const scope of Object.keys(packageJson) as Scope[]) { describe(`package.json ${packageJson[scope]}`, () => { @@ -133,34 +173,19 @@ describe.concurrent("module format follows the extension, then the nearest packa expect(stderr).toBe(""); expect(exitCode).toBe(0); }); - }); - } - test("as a Worker's main module", async () => { - // A Worker starts its own VM and transpiles its entry on that thread. - // Two workers, not the whole table: each one costs about a second on a - // debug build. - using dir = tempDir("module-type-worker", { - "esm/package.json": packageJson.module, - "esm/hello.cjs": fixture("hello.cjs"), - "cjs/package.json": packageJson.commonjs, - "cjs/hello.mjs": fixture("hello.mjs"), - "workers.mjs": ` - import { Worker } from "node:worker_threads"; - await Promise.all(["./esm/hello.cjs", "./cjs/hello.mjs"].map(file => { - const worker = new Worker(new URL(file, import.meta.url)); - return new Promise((resolve, reject) => { - worker.on("exit", resolve); - worker.on("error", reject); - }); - })); - `, + test("source from an onLoad plugin still runs", async () => { + // The loader evaluates an onLoad plugin's source as an ES module + // whatever the parser decides. A CommonJS wrapper from the path hint + // would never be called, so that source gets no hint. + using dir = scopeDir(scope); + const { stdout, stderr, exitCode } = await run(String(dir), "--preload", "./plugin.mjs", "static-import.mjs"); + expect(Object.keys(formats(stdout)).sort()).toEqual([...fixtures].sort()); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); }); - const { stdout, stderr, exitCode } = await run(String(dir), "workers.mjs"); - expect(formats(stdout)).toEqual({ "hello.cjs": "commonjs", "hello.mjs": "module" }); - expect(stderr).toBe(""); - expect(exitCode).toBe(0); - }); + } }); describe.concurrent("the nearest package.json decides, with or without a name", () => { @@ -239,6 +264,68 @@ describe.concurrent("JSX in a CommonJS module", () => { }); }); +test.concurrent("a decorated class in a CommonJS module", async () => { + // The decorator helpers come from "bun:wrap". Inside the CommonJS function + // wrapper they have to be required, like the JSX runtime. + const decorated = (file: string) => `function dec(target) {\n return target;\n}\n@dec\nclass A {}\n${fixture(file)}`; + using dir = tempDir("module-type-decorators", { + "package.json": packageJson.commonjs, + "hello.ts": decorated("hello.ts"), + "hello.tsx": decorated("hello.tsx"), + "hello.cts": decorated("hello.cts"), + ...jsxRuntimeStub, + "import.mjs": `import "./hello.ts";\nimport "./hello.tsx";\nimport "./hello.cts";\n`, + }); + const all = { "hello.ts": "commonjs", "hello.tsx": "commonjs", "hello.cts": "commonjs" }; + for (const file of Object.keys(all)) { + expect(await run(String(dir), file)).toEqual({ stdout: `${file} commonjs\n`, stderr: "", exitCode: 0 }); + } + const imported = await run(String(dir), "import.mjs"); + expect({ ...imported, stdout: formats(imported.stdout) }).toEqual({ stdout: all, stderr: "", exitCode: 0 }); +}); + +describe.concurrent('package.json "type" does not apply to', () => { + // "type" governs .js and its TypeScript and JSX forms. These entries have no + // such extension, so their contents decide, whatever the package.json says. + const detect = `console.log(eval("typeof module") === "undefined" ? "module" : "commonjs");`; + const usesRequire = `require("node:os");\n${detect}`; + + async function inEveryScope(files: Record, args: string[], stdin?: string) { + const result: Record = {}; + for (const scope of Object.keys(packageJson) as Scope[]) { + using dir = tempDir(`module-type-no-ext-${scope}`, { "package.json": packageJson[scope], ...files }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: bunEnv, + stdin: stdin === undefined ? "ignore" : new Blob([stdin]), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + result[scope] = { stdout, stderr, exitCode }; + } + return result; + } + const everyScope = (stdout: string) => { + const one = { stdout, stderr: "", exitCode: 0 }; + return { commonjs: one, module: one, untyped: one }; + }; + + test("bun -e", async () => { + expect(await inEveryScope({}, ["-e", detect])).toEqual(everyScope("module\n")); + }); + + test("a script on stdin", async () => { + expect(await inEveryScope({}, ["run", "-"], detect)).toEqual(everyScope("module\n")); + }); + + test("a script with no extension", async () => { + expect(await inEveryScope({ "cli": detect }, ["cli"])).toEqual(everyScope("module\n")); + expect(await inEveryScope({ "cli": usesRequire }, ["cli"])).toEqual(everyScope("commonjs\n")); + }); +}); + test("the transpiler cache keys on the module type, not only the file contents", async () => { // The on-disk cache file is named after a hash of the source bytes and only // applies to files of 4 KiB or more. Identical bytes under "type":"module" @@ -252,7 +339,12 @@ test("the transpiler cache keys on the module type, not only the file contents", "cjs/package.json": `{ "type": "commonjs" }`, "cjs/hello.js": padding + fixture("hello.js"), }); - const env = { ...bunEnv, BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(String(dir), "cache") }; + const env = { + ...bunEnv, + BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(String(dir), "cache"), + // A debug build only serves a cached entry with this set. + BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE: "1", + }; for (const [subdir, format] of [ ["esm", "module"], ["cjs", "commonjs"], From d4f5f43dab4c98c4810682c008e3a37e5779398c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:33:24 +0000 Subject: [PATCH 4/8] An empty CommonJS file is CommonJS when it is imported too The parser does not run for an empty file. The JS-thread path returned the synthetic CommonJS module for an empty .cjs or .cts file, the work pool path returned an empty ES module, so `import x from "./empty.cjs"` failed with a missing default export while require() returned {}. Both paths now return the synthetic CommonJS module when the module type hint is CommonJS, which also covers an empty .js file under "type": "commonjs". --- src/jsc/RuntimeTranspilerStore.rs | 11 +++++++ src/runtime/jsc_hooks.rs | 21 ++++++------- test/cli/run/run-detect-module-type.test.ts | 34 +++++++++++++++++++++ 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 581d4ca8b63d..74665b604efc 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -1016,6 +1016,17 @@ impl TranspilerJob { return; } + // An empty CommonJS file: synthetic `(function(){})`. + if parse_result.empty && module_type == ModuleType::Cjs { + self.resolved_source = ResolvedSource { + source_code: String::static_("(function(){})"), + is_commonjs_module: true, + tag: this_tag, + ..Default::default() + }; + return; + } + for import_record in parse_result.ast.import_records.as_mut_slice() { let import_record: &mut ImportRecord = import_record; diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index b7d0a3f043b2..fc754dd2a55a 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -2867,18 +2867,15 @@ fn transpile_source_code_of_any_provenance( }); } - // Empty .cjs/.cts: synthetic `(function(){})`. - if parse_result.empty && matches!(loader, L::Js | L::Ts) { - let ext = bun_paths::extension(source.path.text); - if ext == b".cjs" || ext == b".cts" { - return Ok(ResolvedSource { - source_code: bun_core::String::static_("(function(){})"), - source_url: input_specifier.create_if_different(path.text), - is_commonjs_module: true, - tag: ResolvedSourceTag::Javascript, - ..Default::default() - }); - } + // An empty CommonJS file: synthetic `(function(){})`. + if parse_result.empty && module_type_only_for_wrappables == ModuleType::Cjs { + return Ok(ResolvedSource { + source_code: bun_core::String::static_("(function(){})"), + source_url: input_specifier.create_if_different(path.text), + is_commonjs_module: true, + tag: ResolvedSourceTag::Javascript, + ..Default::default() + }); } // RuntimeTranspilerCache hit: skip print. diff --git a/test/cli/run/run-detect-module-type.test.ts b/test/cli/run/run-detect-module-type.test.ts index 0b1ca273aa07..17d167f9c9de 100644 --- a/test/cli/run/run-detect-module-type.test.ts +++ b/test/cli/run/run-detect-module-type.test.ts @@ -232,6 +232,40 @@ describe.concurrent("the nearest package.json decides, with or without a name", }); }); +test.concurrent("an empty file follows the same rule", async () => { + // The parser does not run for an empty file, so the loader picks the format. + using dir = tempDir("module-type-empty", { + "package.json": packageJson.commonjs, + "empty.cjs": "", + "blank.cts": "\n", + "empty.js": "", + "empty.mjs": "", + "import.mjs": ` + import cjs from "./empty.cjs"; + import cts from "./blank.cts"; + import js from "./empty.js"; + import * as mjs from "./empty.mjs"; + console.log(JSON.stringify({ cjs, cts, js, mjs: Object.keys(mjs) })); + `, + "dynamic-import.mjs": ` + const load = async file => (await import(file)).default; + const [cjs, cts, js] = await Promise.all(["./empty.cjs", "./blank.cts", "./empty.js"].map(load)); + console.log(JSON.stringify({ cjs, cts, js, mjs: Object.keys(await import("./empty.mjs")) })); + `, + "require.cjs": ` + const [cjs, cts, js] = ["./empty.cjs", "./blank.cts", "./empty.js"].map(file => require(file)); + console.log(JSON.stringify({ cjs, cts, js, mjs: Object.keys(require("./empty.mjs")) })); + `, + }); + for (const entry of ["import.mjs", "dynamic-import.mjs", "require.cjs"]) { + expect(await run(String(dir), entry)).toEqual({ + stdout: `{"cjs":{},"cts":{},"js":{},"mjs":[]}\n`, + stderr: "", + exitCode: 0, + }); + } +}); + describe.concurrent("JSX in a CommonJS module", () => { // The parser adds `import { jsxDEV } from "react/jsx-dev-runtime"` to a file // that uses JSX. Inside the CommonJS function wrapper that has to be a From 87c635298610b1c5125b962ec1b92a6da286a0e7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:38:45 +0000 Subject: [PATCH 5/8] parser: inject the bun:test globals through generate_require_stmt A CommonJS test file got its Jest globals from a second, hand-written copy of the `const { name: ref } = require(path)` emitter, chosen by `exports_kind == Cjs`. The runtime helpers and the JSX runtime use `generate_require_stmt`, chosen by `wrap_mode == BunCommonjs`. All three sites now call the helper and read one `wrap_in_commonjs` flag. The injection only runs with `commonjs_at_runtime`, where the two conditions agree, so the emitted statement is the same. The printer now puts its binding pattern on one line. --- src/js_parser/parse/parse_entry.rs | 115 ++++++----------------------- src/js_parser/parser.rs | 16 ++++ 2 files changed, 39 insertions(+), 92 deletions(-) diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 78d70b833eb4..7a03dcb78674 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -2091,6 +2091,9 @@ impl<'a> Parser<'a> { exports_kind = js_ast::ExportsKind::EsmWithDynamicFallbackFromCjs; } + // An `import` inside the CommonJS function wrapper is a syntax error. + let wrap_in_commonjs = wrap_mode == WrapMode::BunCommonjs; + // Auto inject jest globals into the test file 'outer: { if !p.options.features.inject_jest_globals { @@ -2115,102 +2118,33 @@ impl<'a> Parser<'a> { } // if they didn't use any of the jest globals, don't inject it, I guess. - // Iterates the static `Jest::FIELDS` - // table (`&[(&'static str, fn(&Jest) -> Ref)]`); declaration order - // determines the emitted clause/property order. - let items_count: usize = { - let mut count: usize = 0; - for (_name, get_ref) in Jest::FIELDS { - count += (p.symbols.as_slice()[get_ref(&p.jest).inner_index() as usize] - .use_count_estimate - > 0) as usize; + // `Jest::FIELDS` order is the emitted clause/property order. + let mut used = [0u8; Jest::FIELDS.len()]; + let mut items_count: usize = 0; + for (i, (_name, get_ref)) in Jest::FIELDS.iter().enumerate() { + if p.symbols.as_slice()[get_ref(&p.jest).inner_index() as usize].use_count_estimate + > 0 + { + used[items_count] = i as u8; + items_count += 1; } - count - }; + } if items_count == 0 { break 'outer; } - let mut declared_symbols = bun_ast::DeclaredSymbolList::default(); - declared_symbols.ensure_total_capacity(items_count)?; - - // For CommonJS modules, use require instead of import - if exports_kind == js_ast::ExportsKind::Cjs { - let import_record_id = p.add_import_record( - bun_ast::ImportKind::Require, - bun_ast::Loc::EMPTY, + if wrap_in_commonjs { + let jest = p.jest; + p.generate_require_stmt( b"bun:test", - ); - - // Create object binding pattern for destructuring - let mut properties = BumpVec::::with_capacity_in(items_count, p.arena); - for (symbol_name, get_ref) in Jest::FIELDS { - let r = get_ref(&p.jest); - if p.symbols.as_slice()[r.inner_index() as usize].use_count_estimate > 0 { - let key = p.new_expr( - E::String { - data: symbol_name.as_bytes().into(), - ..Default::default() - }, - bun_ast::Loc::EMPTY, - ); - let value = p.b(B::Identifier { r#ref: r }, bun_ast::Loc::EMPTY); - properties.push(B::Property { - flags: bun_ast::flags::PROPERTY_NONE, - key, - value, - default_value: None, - }); - declared_symbols.append_assume_capacity(DeclaredSymbol { - ref_: r, - is_top_level: true, - }); - } - } - let properties = bun_ast::StoreSlice::from_bump(properties); - - // Create: const { test, expect, ... } = require("bun:test") - let binding = p.b( - B::Object { - properties, - is_single_line: false, - close_brace_loc: bun_ast::Loc::EMPTY, - }, - bun_ast::Loc::EMPTY, - ); - let value = p.new_expr( - E::RequireString { - import_record_index: import_record_id, - ..Default::default() - }, - bun_ast::Loc::EMPTY, - ); - let mut decls = G::DeclList::init_capacity(1); - decls.append_assume_capacity(G::Decl { - binding, - value: Some(value), - }); - - let local_stmt = p.s( - S::Local { - kind: js_ast::LocalKind::KConst, - decls, - ..Default::default() - }, - bun_ast::Loc::EMPTY, - ); - let part_stmts = p.arena.alloc_slice_fill_with(1, |_| local_stmt); - - before.push(js_ast::Part { - stmts: part_stmts.into(), - declared_symbols, - import_record_indices: js_ast::PartImportRecordIndices::init_one( - import_record_id, - ), - tag: bun_ast::PartTag::BunTest, - ..Default::default() - }); + &used[..items_count], + &mut before, + &jest, + bun_ast::PartTag::BunTest, + )?; } else { + let mut declared_symbols = bun_ast::DeclaredSymbolList::default(); + declared_symbols.ensure_total_capacity(items_count)?; let import_record_id = p.add_import_record( bun_ast::ImportKind::Stmt, bun_ast::Loc::EMPTY, @@ -2278,9 +2212,6 @@ impl<'a> Parser<'a> { } } - // An `import` inside the CommonJS function wrapper is a syntax error. - let wrap_in_commonjs = wrap_mode == WrapMode::BunCommonjs; - if p.has_called_runtime { let mut runtime_imports: [u8; RuntimeImports::ALL.len()] = [0; RuntimeImports::ALL.len()]; diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index b37f40c8f55a..c1ed5904136b 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -626,6 +626,21 @@ impl crate::p::GenerateImportSymbols for RuntimeImports { } } +impl crate::p::GenerateImportSymbols for Jest { + /// Index into [`Jest::FIELDS`]. + type Key = u8; + + #[inline] + fn get(&self, key: &u8) -> Option { + Some((Jest::FIELDS[*key as usize].1)(self)) + } + + #[inline] + fn alias_name(&self, key: &u8) -> &'static [u8] { + Jest::FIELDS[*key as usize].0.as_bytes() + } +} + impl crate::p::GenerateImportSymbols for JSXImportSymbols { type Key = &'static [u8]; @@ -1593,6 +1608,7 @@ impl<'a> MacroState<'a> { } } +#[derive(Clone, Copy)] pub struct Jest { pub(crate) test: Ref, pub(crate) it: Ref, From e84e861e2f093ab0e784d27827fc04701bfe3628 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:38:45 +0000 Subject: [PATCH 6/8] test: start fewer processes at once in run-detect-module-type On a debug build the file started up to 39 child processes at once, and one test ran six spawns one after the other. Tests took 4 to 5 s there, which is the default timeout. - The Worker test uses the global Worker. The node:worker_threads wrapper costs about 1.7 s for each worker on a debug build. - The six tests of a package.json scope share one fixture directory. They only read it. - A test that needs several child processes starts them together. - The transpiler cache test, which is not concurrent, sits before the last concurrent block. The file now takes 8 to 11 s on a debug build, with the slowest test under 2 s. The assertions are the same. --- test/cli/run/run-detect-module-type.test.ts | 152 ++++++++++---------- 1 file changed, 80 insertions(+), 72 deletions(-) diff --git a/test/cli/run/run-detect-module-type.test.ts b/test/cli/run/run-detect-module-type.test.ts index 17d167f9c9de..865e4cf1cda9 100644 --- a/test/cli/run/run-detect-module-type.test.ts +++ b/test/cli/run/run-detect-module-type.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; import { join } from "path"; @@ -105,8 +105,8 @@ function formats(stdout: string): Record { ); } -// Not concurrent with the tests below: a Worker starts its own VM, which costs -// about a second on a debug build when it has a CPU to itself. +// Not concurrent with the tests below: a Worker starts its own VM, which is +// slow on a debug build unless it has a CPU to itself. test("module format as a Worker's main module", async () => { using dir = tempDir("module-type-worker", { "esm/package.json": packageJson.module, @@ -114,12 +114,11 @@ test("module format as a Worker's main module", async () => { "cjs/package.json": packageJson.commonjs, "cjs/hello.mjs": fixture("hello.mjs"), "workers.mjs": ` - import { Worker } from "node:worker_threads"; await Promise.all(["./esm/hello.cjs", "./cjs/hello.mjs"].map(file => { const worker = new Worker(new URL(file, import.meta.url)); return new Promise((resolve, reject) => { - worker.on("exit", resolve); - worker.on("error", reject); + worker.addEventListener("close", resolve); + worker.addEventListener("error", reject); }); })); `, @@ -133,8 +132,14 @@ test("module format as a Worker's main module", async () => { describe.concurrent("module format follows the extension, then the nearest package.json", () => { for (const scope of Object.keys(packageJson) as Scope[]) { describe(`package.json ${packageJson[scope]}`, () => { + // The tests only read the fixtures, so a scope's tests share one directory. + let dir: ReturnType; + beforeAll(() => { + dir = scopeDir(scope); + }); + afterAll(() => dir?.[Symbol.dispose]()); + test("as the entry point", async () => { - using dir = scopeDir(scope); const results = await Promise.all(fixtures.map(file => run(String(dir), file))); expect(formats(results.map(r => r.stdout).join(""))).toEqual(expected[scope]); expect(results.map(r => r.stderr)).toEqual(fixtures.map(() => "")); @@ -142,7 +147,6 @@ describe.concurrent("module format follows the extension, then the nearest packa }); test("via require()", async () => { - using dir = scopeDir(scope); const { stdout, stderr, exitCode } = await run(String(dir), "require-all.cjs"); expect(formats(stdout)).toEqual(expected[scope]); expect(stderr).toBe(""); @@ -150,7 +154,6 @@ describe.concurrent("module format follows the extension, then the nearest packa }); test("via an import statement", async () => { - using dir = scopeDir(scope); const { stdout, stderr, exitCode } = await run(String(dir), "static-import.mjs"); expect(formats(stdout)).toEqual(expected[scope]); expect(stderr).toBe(""); @@ -158,7 +161,6 @@ describe.concurrent("module format follows the extension, then the nearest packa }); test("via import()", async () => { - using dir = scopeDir(scope); const { stdout, stderr, exitCode } = await run(String(dir), "dynamic-import.mjs"); expect(formats(stdout)).toEqual(expected[scope]); expect(stderr).toBe(""); @@ -166,7 +168,6 @@ describe.concurrent("module format follows the extension, then the nearest packa }); test("via --require", async () => { - using dir = scopeDir(scope); const preloads = fixtures.flatMap(file => ["--require", `./${file}`]); const { stdout, stderr, exitCode } = await run(String(dir), ...preloads, "empty.mjs"); expect(formats(stdout)).toEqual(expected[scope]); @@ -178,7 +179,6 @@ describe.concurrent("module format follows the extension, then the nearest packa // The loader evaluates an onLoad plugin's source as an ES module // whatever the parser decides. A CommonJS wrapper from the path hint // would never be called, so that source gets no hint. - using dir = scopeDir(scope); const { stdout, stderr, exitCode } = await run(String(dir), "--preload", "./plugin.mjs", "static-import.mjs"); expect(Object.keys(formats(stdout)).sort()).toEqual([...fixtures].sort()); expect(stderr).toBe(""); @@ -200,10 +200,9 @@ describe.concurrent("the nearest package.json decides, with or without a name", "src/hello.js": fixture("hello.js"), "src/import.mjs": `import "./hello.js";\n`, }); - const entry = await run(String(dir), "src/hello.js"); - expect(entry).toEqual({ stdout: "hello.js commonjs\n", stderr: "", exitCode: 0 }); - const imported = await run(String(dir), "src/import.mjs"); - expect(imported).toEqual({ stdout: "hello.js commonjs\n", stderr: "", exitCode: 0 }); + const [entry, imported] = await Promise.all([run(String(dir), "src/hello.js"), run(String(dir), "src/import.mjs")]); + const commonjs = { stdout: "hello.js commonjs\n", stderr: "", exitCode: 0 }; + expect({ entry, imported }).toEqual({ entry: commonjs, imported: commonjs }); }); test('a nameless {"type":"commonjs"} in a parent directory applies to the bundler', async () => { @@ -257,13 +256,12 @@ test.concurrent("an empty file follows the same rule", async () => { console.log(JSON.stringify({ cjs, cts, js, mjs: Object.keys(require("./empty.mjs")) })); `, }); - for (const entry of ["import.mjs", "dynamic-import.mjs", "require.cjs"]) { - expect(await run(String(dir), entry)).toEqual({ - stdout: `{"cjs":{},"cts":{},"js":{},"mjs":[]}\n`, - stderr: "", - exitCode: 0, - }); - } + const entries = ["import.mjs", "dynamic-import.mjs", "require.cjs"]; + const results = await Promise.all(entries.map(entry => run(String(dir), entry))); + const empty = { stdout: `{"cjs":{},"cts":{},"js":{},"mjs":[]}\n`, stderr: "", exitCode: 0 }; + expect(Object.fromEntries(entries.map((entry, i) => [entry, results[i]]))).toEqual( + Object.fromEntries(entries.map(entry => [entry, empty])), + ); }); describe.concurrent("JSX in a CommonJS module", () => { @@ -278,9 +276,9 @@ describe.concurrent("JSX in a CommonJS module", () => { "main.cjs": `console.log(JSON.stringify(require("./component.jsx")()));\n`, "main.mjs": `import Component from "./component.jsx";\nconsole.log(JSON.stringify(Component()));\n`, }); - const rendered = `{"type":"div","props":{"className":"x","children":"hi"}}\n`; - expect(await run(String(dir), "main.cjs")).toEqual({ stdout: rendered, stderr: "", exitCode: 0 }); - expect(await run(String(dir), "main.mjs")).toEqual({ stdout: rendered, stderr: "", exitCode: 0 }); + const [required, imported] = await Promise.all([run(String(dir), "main.cjs"), run(String(dir), "main.mjs")]); + const rendered = { stdout: `{"type":"div","props":{"className":"x","children":"hi"}}\n`, stderr: "", exitCode: 0 }; + expect({ required, imported }).toEqual({ required: rendered, imported: rendered }); }); test('a .test.tsx file with Jest globals under {"type":"commonjs"}', async () => { @@ -311,55 +309,17 @@ test.concurrent("a decorated class in a CommonJS module", async () => { "import.mjs": `import "./hello.ts";\nimport "./hello.tsx";\nimport "./hello.cts";\n`, }); const all = { "hello.ts": "commonjs", "hello.tsx": "commonjs", "hello.cts": "commonjs" }; - for (const file of Object.keys(all)) { - expect(await run(String(dir), file)).toEqual({ stdout: `${file} commonjs\n`, stderr: "", exitCode: 0 }); - } - const imported = await run(String(dir), "import.mjs"); + const files = Object.keys(all); + const [imported, ...entries] = await Promise.all([ + run(String(dir), "import.mjs"), + ...files.map(file => run(String(dir), file)), + ]); + expect(Object.fromEntries(files.map((file, i) => [file, entries[i]]))).toEqual( + Object.fromEntries(files.map(file => [file, { stdout: `${file} commonjs\n`, stderr: "", exitCode: 0 }])), + ); expect({ ...imported, stdout: formats(imported.stdout) }).toEqual({ stdout: all, stderr: "", exitCode: 0 }); }); -describe.concurrent('package.json "type" does not apply to', () => { - // "type" governs .js and its TypeScript and JSX forms. These entries have no - // such extension, so their contents decide, whatever the package.json says. - const detect = `console.log(eval("typeof module") === "undefined" ? "module" : "commonjs");`; - const usesRequire = `require("node:os");\n${detect}`; - - async function inEveryScope(files: Record, args: string[], stdin?: string) { - const result: Record = {}; - for (const scope of Object.keys(packageJson) as Scope[]) { - using dir = tempDir(`module-type-no-ext-${scope}`, { "package.json": packageJson[scope], ...files }); - await using proc = Bun.spawn({ - cmd: [bunExe(), ...args], - cwd: String(dir), - env: bunEnv, - stdin: stdin === undefined ? "ignore" : new Blob([stdin]), - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - result[scope] = { stdout, stderr, exitCode }; - } - return result; - } - const everyScope = (stdout: string) => { - const one = { stdout, stderr: "", exitCode: 0 }; - return { commonjs: one, module: one, untyped: one }; - }; - - test("bun -e", async () => { - expect(await inEveryScope({}, ["-e", detect])).toEqual(everyScope("module\n")); - }); - - test("a script on stdin", async () => { - expect(await inEveryScope({}, ["run", "-"], detect)).toEqual(everyScope("module\n")); - }); - - test("a script with no extension", async () => { - expect(await inEveryScope({ "cli": detect }, ["cli"])).toEqual(everyScope("module\n")); - expect(await inEveryScope({ "cli": usesRequire }, ["cli"])).toEqual(everyScope("commonjs\n")); - }); -}); - test("the transpiler cache keys on the module type, not only the file contents", async () => { // The on-disk cache file is named after a hash of the source bytes and only // applies to files of 4 KiB or more. Identical bytes under "type":"module" @@ -394,3 +354,51 @@ test("the transpiler cache keys on the module type, not only the file contents", expect({ stdout, stderr, exitCode }).toEqual({ stdout: `hello.js ${format}\n`, stderr: "", exitCode: 0 }); } }); + +describe.concurrent('package.json "type" does not apply to', () => { + // "type" governs .js and its TypeScript and JSX forms. These entries have no + // such extension, so their contents decide, whatever the package.json says. + const detect = `console.log(eval("typeof module") === "undefined" ? "module" : "commonjs");`; + const usesRequire = `require("node:os");\n${detect}`; + + async function inEveryScope(files: Record, args: string[], stdin?: string) { + const scopes = Object.keys(packageJson) as Scope[]; + const results = await Promise.all( + scopes.map(async scope => { + using dir = tempDir(`module-type-no-ext-${scope}`, { "package.json": packageJson[scope], ...files }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: bunEnv, + stdin: stdin === undefined ? "ignore" : new Blob([stdin]), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + }), + ); + return Object.fromEntries(scopes.map((scope, i) => [scope, results[i]])); + } + const everyScope = (stdout: string) => { + const one = { stdout, stderr: "", exitCode: 0 }; + return { commonjs: one, module: one, untyped: one }; + }; + + test("bun -e", async () => { + expect(await inEveryScope({}, ["-e", detect])).toEqual(everyScope("module\n")); + }); + + test("a script on stdin", async () => { + expect(await inEveryScope({}, ["run", "-"], detect)).toEqual(everyScope("module\n")); + }); + + test("a script with no extension", async () => { + const [ambiguous, withRequire] = await Promise.all([ + inEveryScope({ "cli": detect }, ["cli"]), + inEveryScope({ "cli": usesRequire }, ["cli"]), + ]); + expect(ambiguous).toEqual(everyScope("module\n")); + expect(withRequire).toEqual(everyScope("commonjs\n")); + }); +}); From 74d6aca3d1663f97ded31dfacd9a0a404596c7a4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:01:39 +0000 Subject: [PATCH 7/8] test: module syntax in the file decides on every load path The old `module-type-fixture/*/import.cjs` fixtures said that a `.cjs` file with an `import` statement is ESM. They went away with the old test file, which never ran. Add a block that loads four files on every load path: `import` in a `.cjs` file and in a `.js` file under "type": "commonjs", and `module.exports` in a `.mjs` file and in a `.js` file under "type": "module". The contents decide for all four. These tests pass with and without the new hint. The load-path tests of the scope matrix and of this block come from one function. --- test/cli/run/run-detect-module-type.test.ts | 109 +++++++++++++------- 1 file changed, 69 insertions(+), 40 deletions(-) diff --git a/test/cli/run/run-detect-module-type.test.ts b/test/cli/run/run-detect-module-type.test.ts index 865e4cf1cda9..26d6017bc73c 100644 --- a/test/cli/run/run-detect-module-type.test.ts +++ b/test/cli/run/run-detect-module-type.test.ts @@ -30,7 +30,7 @@ function expectedFormat(scope: Scope, ext: string): Format { return scope === "commonjs" ? "commonjs" : "module"; } -const expected: Record> = Object.fromEntries( +const matrix: Record> = Object.fromEntries( (Object.keys(packageJson) as Scope[]).map(scope => [ scope, Object.fromEntries(extensions.map(ext => [`hello${ext}`, expectedFormat(scope, ext)])), @@ -56,15 +56,22 @@ const jsxRuntimeStub = { "node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (type, props) => ({ type, props });\nexports.Fragment = "Fragment";\n`, }; +// The files that load every one of `files` in another way than as the entry point. +function loaders(files: string[]) { + return { + "static-import.mjs": files.map(file => `import "./${file}";`).join("\n") + "\n", + "dynamic-import.mjs": `for (const file of ${JSON.stringify(files)}) await import("./" + file);\n`, + "require-all.cjs": files.map(file => `require("./${file}");`).join("\n") + "\n", + "empty.mjs": "", + }; +} + function scopeDir(scope: Scope) { return tempDir(`module-type-${scope}`, { "package.json": packageJson[scope], ...jsxRuntimeStub, ...Object.fromEntries(fixtures.map(file => [file, fixture(file)])), - "static-import.mjs": fixtures.map(file => `import "./${file}";`).join("\n") + "\n", - "dynamic-import.mjs": `for (const file of ${JSON.stringify(fixtures)}) await import("./" + file);\n`, - "require-all.cjs": fixtures.map(file => `require("./${file}");`).join("\n") + "\n", - "empty.mjs": "", + ...loaders(fixtures), // An onLoad plugin that hands back each fixture's own contents. "plugin.mjs": ` import { plugin } from "bun"; @@ -105,6 +112,34 @@ function formats(stdout: string): Record { ); } +/** + * One test for each way to load `files`. Every way must print `expected`. + * `cwd()` has the files and `loaders(files)`. + */ +function everyLoadPath(cwd: () => string, files: string[], expected: Record) { + test("as the entry point", async () => { + const results = await Promise.all(files.map(file => run(cwd(), file))); + expect(formats(results.map(r => r.stdout).join(""))).toEqual(expected); + expect(results.map(r => r.stderr)).toEqual(files.map(() => "")); + expect(results.map(r => r.exitCode)).toEqual(files.map(() => 0)); + }); + + const preloads = files.flatMap(file => ["--require", `./${file}`]); + for (const [name, args] of [ + ["via require()", ["require-all.cjs"]], + ["via an import statement", ["static-import.mjs"]], + ["via import()", ["dynamic-import.mjs"]], + ["via --require", [...preloads, "empty.mjs"]], + ] as const) { + test(name, async () => { + const { stdout, stderr, exitCode } = await run(cwd(), ...args); + expect(formats(stdout)).toEqual(expected); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + } +} + // Not concurrent with the tests below: a Worker starts its own VM, which is // slow on a debug build unless it has a CPU to itself. test("module format as a Worker's main module", async () => { @@ -139,41 +174,7 @@ describe.concurrent("module format follows the extension, then the nearest packa }); afterAll(() => dir?.[Symbol.dispose]()); - test("as the entry point", async () => { - const results = await Promise.all(fixtures.map(file => run(String(dir), file))); - expect(formats(results.map(r => r.stdout).join(""))).toEqual(expected[scope]); - expect(results.map(r => r.stderr)).toEqual(fixtures.map(() => "")); - expect(results.map(r => r.exitCode)).toEqual(fixtures.map(() => 0)); - }); - - test("via require()", async () => { - const { stdout, stderr, exitCode } = await run(String(dir), "require-all.cjs"); - expect(formats(stdout)).toEqual(expected[scope]); - expect(stderr).toBe(""); - expect(exitCode).toBe(0); - }); - - test("via an import statement", async () => { - const { stdout, stderr, exitCode } = await run(String(dir), "static-import.mjs"); - expect(formats(stdout)).toEqual(expected[scope]); - expect(stderr).toBe(""); - expect(exitCode).toBe(0); - }); - - test("via import()", async () => { - const { stdout, stderr, exitCode } = await run(String(dir), "dynamic-import.mjs"); - expect(formats(stdout)).toEqual(expected[scope]); - expect(stderr).toBe(""); - expect(exitCode).toBe(0); - }); - - test("via --require", async () => { - const preloads = fixtures.flatMap(file => ["--require", `./${file}`]); - const { stdout, stderr, exitCode } = await run(String(dir), ...preloads, "empty.mjs"); - expect(formats(stdout)).toEqual(expected[scope]); - expect(stderr).toBe(""); - expect(exitCode).toBe(0); - }); + everyLoadPath(() => String(dir), fixtures, matrix[scope]); test("source from an onLoad plugin still runs", async () => { // The loader evaluates an onLoad plugin's source as an ES module @@ -188,6 +189,34 @@ describe.concurrent("module format follows the extension, then the nearest packa } }); +describe.concurrent('module syntax in the file decides before the extension and "type"', () => { + // `import` makes a file ESM and `module.exports` makes it CommonJS, whatever + // its extension and the package.json say. The path decides only for a file + // with neither. + const withImport = (file: string) => `import * as os from "node:os";\nos;\n${fixture(file)}`; + const withExports = (file: string) => `module.exports = {};\n${fixture(file)}`; + const expected: Record = { + "with-import.cjs": "module", + "with-exports.mjs": "commonjs", + "commonjs/with-import.js": "module", + "module/with-exports.js": "commonjs", + }; + const files = Object.keys(expected); + + let dir: ReturnType; + beforeAll(() => { + dir = tempDir("module-type-syntax", { + "commonjs/package.json": packageJson.commonjs, + "module/package.json": packageJson.module, + ...Object.fromEntries(files.map(file => [file, (expected[file] === "module" ? withImport : withExports)(file)])), + ...loaders(files), + }); + }); + afterAll(() => dir?.[Symbol.dispose]()); + + everyLoadPath(() => String(dir), files, expected); +}); + describe.concurrent("the nearest package.json decides, with or without a name", () => { // A package.json without a "name" is not the package's root for bundling // (DirInfo.enclosing_package_json), but its "type" still applies to the From a82c5c522aa860fa5c84e52b64654ae760b6a562 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:07:33 +0000 Subject: [PATCH 8/8] test: state the evaluation order of an imported file whose format the rule changes Seven rows with Node's output. Six are test.failing: Bun runs an imported CommonJS file when its fetch settles, ahead of every ES module of the graph. --- test/cli/run/run-detect-module-type.test.ts | 104 ++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/test/cli/run/run-detect-module-type.test.ts b/test/cli/run/run-detect-module-type.test.ts index 26d6017bc73c..b85b5d9edf54 100644 --- a/test/cli/run/run-detect-module-type.test.ts +++ b/test/cli/run/run-detect-module-type.test.ts @@ -431,3 +431,107 @@ describe.concurrent('package.json "type" does not apply to', () => { expect(withRequire).toEqual(everyScope("commonjs\n")); }); }); + +describe.concurrent("evaluation order of a file that an ES module imports", () => { + // Every row states Node's output. Bun runs an imported CommonJS file when + // its fetch settles, ahead of every ES module of the graph. So a file that + // the rule above makes CommonJS runs before an ES module imported ahead of + // it, and a file that it makes an ES module runs after a CommonJS file + // imported behind it. The `failing` rows pass once an imported CommonJS file + // runs where it is imported. + const format = `eval("typeof module") === "undefined" ? "module" : "commonjs"`; + const setState = `process.env.DSN = "set";\n`; + /** Prints " ". */ + const readState = (file: string) => `console.log("${file}", ${format}, process.env.DSN);\n`; + const entry = (...files: string[]) => files.map(file => `import "./${file}";`).join("\n") + "\n"; + const named = (type?: Format) => JSON.stringify({ name: "app", type }); + + const rows: { name: string; files: Record; stdout: string; failing?: true }[] = [ + { + name: 'under "type": "module", a .cjs file that calls require() runs after the ES module ahead of it', + files: { + "package.json": named("module"), + "entry.mjs": entry("env.js", "setup.cjs"), + "env.js": setState, + "setup.cjs": `require("sdk").init("setup.cjs", ${format});\n`, + "node_modules/sdk/package.json": `{ "name": "sdk", "version": "1.0.0" }`, + "node_modules/sdk/index.js": `exports.init = (file, format) => console.log(file, format, process.env.DSN);\n`, + }, + stdout: "setup.cjs commonjs set\n", + failing: true, + }, + { + name: 'under "type": "module", a .cjs file with "use strict" runs after the ES module ahead of it', + files: { + "package.json": named("module"), + "entry.mjs": entry("env.js", "setup.cjs"), + "env.js": setState, + "setup.cjs": `"use strict";\n${readState("setup.cjs")}`, + }, + stdout: "setup.cjs commonjs set\n", + failing: true, + }, + { + name: 'under "type": "module", a .cjs file with neither runs after the ES module ahead of it', + files: { + "package.json": named("module"), + "entry.mjs": entry("env.js", "setup.cjs"), + "env.js": setState, + "setup.cjs": readState("setup.cjs"), + }, + stdout: "setup.cjs commonjs set\n", + failing: true, + }, + { + name: 'with no "type", a .cjs file with neither runs after the ES module ahead of it', + files: { + "package.json": named(), + "entry.mjs": entry("env.mjs", "setup.cjs"), + "env.mjs": setState, + "setup.cjs": readState("setup.cjs"), + }, + stdout: "setup.cjs commonjs set\n", + failing: true, + }, + { + name: 'a .js file below a nameless {"type":"commonjs"} runs after the ES module ahead of it', + files: { + "package.json": named("module"), + "dist/cjs/package.json": packageJson.commonjs, + "entry.mjs": entry("env.js", "dist/cjs/lib/setup.js"), + "env.js": setState, + "dist/cjs/lib/setup.js": readState("setup.js"), + }, + stdout: "setup.js commonjs set\n", + failing: true, + }, + { + name: 'under "type": "commonjs", a CommonJS file runs after the .mjs file ahead of it', + files: { + "package.json": named("commonjs"), + "entry.mjs": entry("env.mjs", "lib.cjs"), + "env.mjs": setState, + "lib.cjs": `module.exports = {};\n${readState("lib.cjs")}`, + }, + stdout: "lib.cjs commonjs set\n", + failing: true, + }, + { + name: 'under "type": "commonjs", a .mjs file with neither runs after the ES module ahead of it', + files: { + "package.json": named("commonjs"), + "entry.mjs": entry("env.mjs", "setup.mjs"), + "env.mjs": `${setState}export {};\n`, + "setup.mjs": readState("setup.mjs"), + }, + stdout: "setup.mjs module set\n", + }, + ]; + + for (const { name, files, stdout, failing } of rows) { + test.failingIf(!!failing)(failing ? `known failure: ${name}` : name, async () => { + using dir = tempDir("module-type-order", files); + expect(await run(String(dir), "entry.mjs")).toEqual({ stdout, stderr: "", exitCode: 0 }); + }); + } +});