From c37de907a015724b935a671e0a04dcd3f3d38379 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:16:04 +0000 Subject: [PATCH] bundler: error when a file bundled as CommonJS imports a module with top-level await An import statement in a file that is printed inside a __commonJS wrapper is lowered to a call of the imported file's __esm wrapper. When that file is async (a top-level await is on its chain of static imports), the call was printed as `await init_x()` inside the wrapper, a plain function, and the bundle did not parse. The linker now reports that import as an error, after tree shaking and before code generation. The check and the code generator take the lowering of an import statement from one function. --- src/bundler/LinkerContext.rs | 477 ++++++++++---- .../generateChunksInParallel.rs | 9 + .../generateCodeForFileInChunkJS.rs | 4 + test/bundler/bundler_cjs.test.ts | 615 +++++++++++++++++- 4 files changed, 984 insertions(+), 121 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index ea8170943457..9c11bb9c488f 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1936,6 +1936,22 @@ pub(crate) unsafe fn pending_part_range_prologue<'a>( (part_range, c_ptr, chunk_ptr, worker) } +/// What replaces an `import` or `export ... from` statement in a chunk. +#[derive(Clone, Copy)] +pub(crate) enum ImportStmtLowering { + /// The statement stays as it is. + Keep, + /// Nothing. + Remove, + /// `var ns = require("path")`, for an external module. + RequireExternal, + /// `var ns = require_x()`, for a file in a `__commonJS` wrapper. + RequireCommonJs, + /// `init_x()`, the call of the imported file's `__esm` wrapper. An async wrapper returns a + /// promise, and the call is awaited. + Init { wrapper_ref: Ref, is_async: bool }, +} + impl<'a> LinkerContext<'a> { pub(crate) fn generate_isolated_hash(&mut self, chunk: &Chunk, arena: &Bump) -> u64 { let _trace = bun::perf::trace("Bundler.generateIsolatedHash"); @@ -2169,22 +2185,10 @@ impl<'a> LinkerContext<'a> { if parent_result_tla_keyword.len > 0 { let source = &input_files[other_source_index as usize]; tla_pretty_path = source.path.pretty; - let mut text = Vec::new(); - use std::io::Write; - write!( - &mut text, - "The top-level await in {} is here:", - bstr::BStr::new(tla_pretty_path) - ) - .expect("infallible: in-memory write"); - notes.push(Data { - text: text.into(), - location: bun_ast::Location::init_or_null( - Some(source), - parent_result_tla_keyword, - ), - ..Default::default() - }); + notes.push(Self::top_level_await_note( + source, + parent_result_tla_keyword, + )); break; } @@ -2198,31 +2202,14 @@ impl<'a> LinkerContext<'a> { other_source_index = parent_tla_check.parent; - let mut text = Vec::new(); - use std::io::Write; - write!( - &mut text, - "The file {} imports the file {} here:", - bstr::BStr::new( - &input_files[parent_source_index as usize].path.pretty - ), - bstr::BStr::new( - &input_files[other_source_index as usize].path.pretty - ), - ) - .unwrap(); - notes.push(Data { - text: text.into(), - location: bun_ast::Location::init_or_null( - Some(&input_files[parent_source_index as usize]), - ast_import_records[parent_source_index as usize].as_slice() - [tla_checks[parent_source_index as usize] - .import_record_index - as usize] - .range, - ), - ..Default::default() - }); + notes.push(Self::import_toward_top_level_await_note( + &input_files[parent_source_index as usize], + &input_files[other_source_index as usize], + ast_import_records[parent_source_index as usize].as_slice() + [tla_checks[parent_source_index as usize].import_record_index + as usize] + .range, + )); } let source: &Source = &input_files[source_index as usize]; @@ -2249,117 +2236,122 @@ impl<'a> LinkerContext<'a> { } } - pub(crate) fn should_remove_import_export_stmt( - &mut self, - stmts: &mut StmtList, - loc: Loc, + /// Decides what `convert_stmts_for_chunk` prints in the place of an `import` or + /// `export ... from` statement. `namespace_ref` is the namespace symbol of the statement. + #[inline(always)] + pub(crate) fn import_stmt_lowering( + &self, + importer_exports_kind: ExportsKind, + importer_exports_ref: Ref, namespace_ref: Ref, - import_record_index: u32, - alloc: &Bump, - ast: &JSAst<'_>, - ) -> Result { - let record = &ast.import_records[import_record_index as usize]; + record: &ImportRecord, + ) -> ImportStmtLowering { // Barrel optimization: deferred import records should be dropped if record.flags.contains(bun_ast::ImportRecordFlags::IS_UNUSED) { - return Ok(true); + return ImportStmtLowering::Remove; } // Is this an external import? if !record.source_index.is_valid() { // Keep the "import" statement if import statements are supported if self.options.output_format.keep_es6_import_export_syntax() { - return Ok(false); + return ImportStmtLowering::Keep; } // Otherwise, replace this statement with a call to "require()" - stmts - .inside_wrapper_prefix - .append_non_dependency(Stmt::alloc( - S::Local { - decls: G::DeclList::from_slice(&[G::Decl { - binding: Binding::alloc( - alloc, - bun_ast::b::Identifier { - r#ref: namespace_ref, - }, - loc, - ), - value: Some(Expr::init( - E::RequireString { - import_record_index, - ..Default::default() - }, - loc, - )), - }]), - ..Default::default() - }, - record.range.loc, - )) - .expect("unreachable"); - return Ok(true); + return ImportStmtLowering::RequireExternal; } // We don't need a call to "require()" if this is a self-import inside a // CommonJS-style module, since we can just reference the exports directly. - if ast.exports_kind == ExportsKind::Cjs + if importer_exports_kind == ExportsKind::Cjs && self .graph .symbols .follow(namespace_ref) - .eql(ast.exports_ref) + .eql(importer_exports_ref) { - return Ok(true); + return ImportStmtLowering::Remove; } - let other_flags = self.graph.meta.items_flags()[record.source_index.get() as usize]; + let other_source_index = record.source_index.get() as usize; + let other_flags = self.graph.meta.items_flags()[other_source_index]; match other_flags.wrap { - WrapKind::None => {} - WrapKind::Cjs => { - // Replace the statement with a call to "require()" since the other module is CJS-wrapped - stmts - .inside_wrapper_prefix - .append_non_dependency(Stmt::alloc( - S::Local { - decls: G::DeclList::from_slice(&[G::Decl { - binding: Binding::alloc( - alloc, - bun_ast::b::Identifier { - r#ref: namespace_ref, - }, - loc, - ), - value: Some(Expr::init( - E::RequireString { - import_record_index, - ..Default::default() - }, - loc, - )), - }]), - ..Default::default() - }, - loc, - ))?; - } + WrapKind::None => ImportStmtLowering::Remove, + // Replace the statement with a call to "require()" since the other module is CJS-wrapped + WrapKind::Cjs => ImportStmtLowering::RequireCommonJs, WrapKind::Esm => { // Ignore this file if it's not included in the bundle. This can happen for // wrapped ESM files but not for wrapped CommonJS files because we allow // tree shaking inside wrapped ESM files. - if !self - .graph - .files_live - .is_set(record.source_index.get() as usize) - { - return Ok(true); + if !self.graph.files_live.is_set(other_source_index) { + return ImportStmtLowering::Remove; } - let wrapper_ref = - self.graph.ast.items_wrapper_ref()[record.source_index.get() as usize]; + let wrapper_ref = self.graph.ast.items_wrapper_ref()[other_source_index]; if wrapper_ref.is_empty() { - return Ok(true); + return ImportStmtLowering::Remove; } // Replace the statement with a call to "init()" + ImportStmtLowering::Init { + wrapper_ref, + is_async: other_flags.is_async_or_has_async_dependency, + } + } + } + } + + pub(crate) fn should_remove_import_export_stmt( + &mut self, + stmts: &mut StmtList, + loc: Loc, + namespace_ref: Ref, + import_record_index: u32, + alloc: &Bump, + ast: &JSAst<'_>, + ) -> Result { + let record = &ast.import_records[import_record_index as usize]; + let require_stmt = |stmt_loc: Loc| { + Stmt::alloc( + S::Local { + decls: G::DeclList::from_slice(&[G::Decl { + binding: Binding::alloc( + alloc, + bun_ast::b::Identifier { + r#ref: namespace_ref, + }, + loc, + ), + value: Some(Expr::init( + E::RequireString { + import_record_index, + ..Default::default() + }, + loc, + )), + }]), + ..Default::default() + }, + stmt_loc, + ) + }; + match self.import_stmt_lowering(ast.exports_kind, ast.exports_ref, namespace_ref, record) { + ImportStmtLowering::Keep => return Ok(false), + ImportStmtLowering::Remove => {} + ImportStmtLowering::RequireExternal => { + stmts + .inside_wrapper_prefix + .append_non_dependency(require_stmt(record.range.loc))?; + } + ImportStmtLowering::RequireCommonJs => { + stmts + .inside_wrapper_prefix + .append_non_dependency(require_stmt(loc))?; + } + ImportStmtLowering::Init { + wrapper_ref, + is_async, + } => { let init_call = Expr::init( E::Call { target: Expr::init_identifier(wrapper_ref, loc), @@ -2368,7 +2360,7 @@ impl<'a> LinkerContext<'a> { loc, ); - if other_flags.is_async_or_has_async_dependency { + if is_async { stmts .inside_wrapper_prefix .append_async_dependency(init_call, self.promise_all_runtime_ref)?; @@ -2383,6 +2375,251 @@ impl<'a> LinkerContext<'a> { Ok(true) } + /// Reports every `import` that would be printed as `await init_x()` inside a `__commonJS` + /// wrapper. That wrapper is a plain function, so the output would not parse. esbuild prints + /// the `await` in its `__commonJS` callback. + /// + /// Runs after tree shaking: only then is it known which files are wrapped and which + /// statements are printed. + pub(crate) fn validate_commonjs_imports_of_async_modules(&self) -> Result<(), BunError> { + // This format has no `__commonJS` wrapper. + if self.options.output_format == Format::InternalBakeDev { + return Ok(()); + } + + let flags = self.graph.meta.items_flags(); + let import_records = self.graph.ast.items_import_records(); + let exports_kinds = self.graph.ast.items_exports_kind(); + let exports_refs = self.graph.ast.items_exports_ref(); + let parts = self.graph.ast.items_parts(); + // Built for the first error. + let mut chains: Option> = None; + + for source_index in self.graph.reachable_files.slice() { + let id = source_index.get() as usize; + if !self.graph.files_live.is_set(id) || flags[id].wrap != WrapKind::Cjs { + continue; + } + + // Most CommonJS files import no async file: skip their statements. + if !import_records[id].as_slice().iter().any(|record| { + record.source_index.is_valid() + && flags + .get(record.source_index.get() as usize) + .is_some_and(|other| other.is_async_or_has_async_dependency) + }) { + continue; + } + + let parts_live = &self.graph.parts_live[id]; + for (part_index, part) in parts[id].as_slice().iter().enumerate() { + if !parts_live.is_set(part_index) { + continue; + } + for stmt in part.stmts.slice() { + // The statements that `convert_stmts_for_chunk` gives to + // `should_remove_import_export_stmt`. It prints `export * from` without an + // alias as a bare `init_x()`, so that statement needs no `await`. + let (namespace_ref, import_record_index) = match stmt.data { + bun_ast::StmtData::SImport(s) => (s.namespace_ref, s.import_record_index), + bun_ast::StmtData::SExportFrom(s) => { + (s.namespace_ref, s.import_record_index) + } + bun_ast::StmtData::SExportStar(s) if s.alias.is_some() => { + (s.namespace_ref, s.import_record_index) + } + _ => continue, + }; + let record = &import_records[id].as_slice()[import_record_index as usize]; + if let ImportStmtLowering::Init { is_async: true, .. } = self + .import_stmt_lowering( + exports_kinds[id], + exports_refs[id], + namespace_ref, + record, + ) + { + let chains = + chains.get_or_insert_with(|| self.imports_toward_top_level_await()); + self.report_commonjs_import_of_async_module(id, record, chains); + } + } + } + } + + match chains { + Some(_) => Err(BunError::BuildFailed), + None => Ok(()), + } + } + + #[cold] + fn report_commonjs_import_of_async_module( + &self, + importer: usize, + record: &ImportRecord, + chains: &[(u32, u32)], + ) { + use std::io::Write; + let sources = self.parse_graph().input_files.items_source(); + let tla_keywords = self.parse_graph().ast.items_top_level_await_keyword(); + let import_records = self.graph.ast.items_import_records(); + let importer_source = &sources[importer]; + let imported = record.source_index.get() as usize; + + // One note for each `import` on the way to the `await`, then one for the `await`. + let mut notes: Vec = Vec::new(); + let mut file = imported; + while let Some(&(next, record_index)) = chains.get(file).filter(|next| next.0 != u32::MAX) { + notes.push(Self::import_toward_top_level_await_note( + &sources[file], + &sources[next as usize], + import_records[file].as_slice()[record_index as usize].range, + )); + file = next as usize; + } + debug_assert!(tla_keywords[file].len > 0); + notes.push(Self::top_level_await_note( + &sources[file], + tla_keywords[file], + )); + + let mut hint = Vec::new(); + write!( + &mut hint, + "CommonJS code runs synchronously and cannot wait for a top-level await. Load {} with import() inside a function instead", + bun_core::fmt::quote(sources[imported].path.pretty), + ) + .expect("infallible: in-memory write"); + notes.push(Data { + text: hint.into(), + ..Default::default() + }); + + if self.graph.ast.items_module_scope()[importer].contains_direct_eval { + let mut hint = Vec::new(); + write!( + &mut hint, + "A direct eval makes a file without an \"export\" CommonJS. Add an \"export\" to {}, or call eval as (0, eval)(code)", + bun_core::fmt::quote(importer_source.path.pretty), + ) + .expect("infallible: in-memory write"); + notes.push(Data { + text: hint.into(), + ..Default::default() + }); + } + + let mut text = Vec::new(); + write!( + &mut text, + "This import is not allowed because {} is bundled as CommonJS and the {} {} contains a top-level await", + bun_core::fmt::quote(importer_source.path.pretty), + if file == imported { + "imported file" + } else { + "transitive dependency" + }, + bun_core::fmt::quote(sources[file].path.pretty), + ) + .expect("infallible: in-memory write"); + + self.log_disjoint().add_range_error_with_notes( + Some(importer_source), + record.range, + text, + notes.into_boxed_slice(), + ); + } + + /// For each file, the `import` to follow on a shortest chain of static imports to a file + /// with a top-level await: (imported file, import record). `u32::MAX` for a file with a + /// top-level await and for a file with no such chain. + #[cold] + fn imports_toward_top_level_await(&self) -> Vec<(u32, u32)> { + let tla_keywords = self.parse_graph().ast.items_top_level_await_keyword(); + let import_records = self.graph.ast.items_import_records(); + let len = import_records.len(); + + let mut importers: Vec> = vec![Vec::new(); len]; + for (importer, records) in import_records.iter().enumerate() { + for (record_index, record) in records.as_slice().iter().enumerate() { + if record.kind != ImportKind::Stmt || !record.source_index.is_valid() { + continue; + } + if let Some(importers) = importers.get_mut(record.source_index.get() as usize) { + importers.push((importer as u32, record_index as u32)); + } + } + } + + // Breadth-first from the files with a top-level await. + let mut next = vec![(u32::MAX, u32::MAX); len]; + let mut distance = vec![u32::MAX; len]; + let mut queue: Vec = Vec::new(); + for (file, keyword) in tla_keywords.iter().enumerate().take(len) { + if keyword.len > 0 { + distance[file] = 0; + queue.push(file as u32); + } + } + let mut head = 0; + while let Some(&imported) = queue.get(head) { + head += 1; + let importer_distance = distance[imported as usize] + 1; + for &(importer, record_index) in &importers[imported as usize] { + let importer = importer as usize; + if distance[importer] == u32::MAX { + distance[importer] = importer_distance; + queue.push(importer as u32); + next[importer] = (imported, record_index); + } else if distance[importer] == importer_distance && record_index < next[importer].1 + { + // Of two imports at the same distance, the first one in the file. + next[importer] = (imported, record_index); + } + } + } + next + } + + /// The note that points at the top-level await of `source`. + fn top_level_await_note(source: &Source, keyword: Range) -> Data { + use std::io::Write; + let mut text = Vec::new(); + write!( + &mut text, + "The top-level await in {} is here:", + bstr::BStr::new(source.path.pretty) + ) + .expect("infallible: in-memory write"); + Data { + text: text.into(), + location: bun_ast::Location::init_or_null(Some(source), keyword), + } + } + + /// The note that points at an `import` on the way to a top-level await. + fn import_toward_top_level_await_note( + importer: &Source, + imported: &Source, + import: Range, + ) -> Data { + use std::io::Write; + let mut text = Vec::new(); + write!( + &mut text, + "The file {} imports the file {} here:", + bstr::BStr::new(importer.path.pretty), + bstr::BStr::new(imported.path.pretty), + ) + .expect("infallible: in-memory write"); + Data { + text: text.into(), + location: bun_ast::Location::init_or_null(Some(importer), import), + } + } + pub(crate) fn print_code_for_file_in_chunk_js( &mut self, r: renamer::Renamer, diff --git a/src/bundler/linker_context/generateChunksInParallel.rs b/src/bundler/linker_context/generateChunksInParallel.rs index 9e3251214021..5560c3f6d40a 100644 --- a/src/bundler/linker_context/generateChunksInParallel.rs +++ b/src/bundler/linker_context/generateChunksInParallel.rs @@ -45,6 +45,15 @@ pub(crate) fn generate_chunks_in_parallel( ) -> crate::Result> { let _trace = bun_core::perf::trace("Bundler.generateChunksInParallel"); + // SAFETY: c points to LinkerContext which is the `linker` field of BundleV2. + let has_top_level_await = unsafe { + (*LinkerContext::bundle_v2_ptr(std::ptr::from_mut::(c))) + .has_any_top_level_await_modules + }; + if !IS_DEV_SERVER && has_top_level_await { + c.validate_commonjs_imports_of_async_modules()?; + } + c.mangle_local_css(); let mut has_js_chunk = false; diff --git a/src/bundler/linker_context/generateCodeForFileInChunkJS.rs b/src/bundler/linker_context/generateCodeForFileInChunkJS.rs index 06550ac429c6..950aa1df5006 100644 --- a/src/bundler/linker_context/generateCodeForFileInChunkJS.rs +++ b/src/bundler/linker_context/generateCodeForFileInChunkJS.rs @@ -514,6 +514,10 @@ pub fn generate_code_for_file_in_chunk_js<'r, 'src>( } } + // A `__commonJS` wrapper is not an async function: the link step fails the build for an + // `import` that needs an `await` in one (`validate_commonjs_imports_of_async_modules`). + debug_assert!(flags.wrap != WrapKind::Cjs || !stmts.inside_wrapper_prefix.has_async_dependency); + // Hoist all import statements before any normal statements. ES6 imports // are different than CommonJS imports. All modules imported via ES6 import // statements are evaluated before the module doing the importing is diff --git a/test/bundler/bundler_cjs.test.ts b/test/bundler/bundler_cjs.test.ts index 81a4caed037a..28179708e36f 100644 --- a/test/bundler/bundler_cjs.test.ts +++ b/test/bundler/bundler_cjs.test.ts @@ -1,4 +1,6 @@ -import { describe } from "bun:test"; +import type { BuildOutput } from "bun"; +import { describe, expect } from "bun:test"; +import { basename } from "node:path"; import { itBundled } from "./expectBundled"; // Tests for CommonJS <> ESM interop, specifically the __toESM helper behavior. @@ -609,6 +611,617 @@ describe("bundler", () => { }, }); + // ============================================================================ + // An import of a module with top-level await in a file that is printed + // inside a `__commonJS` wrapper + // + // The `import` becomes a call inside the wrapper, which is not an async + // function: it cannot wait for the module. The import is a build error. + // ============================================================================ + + const tlaFile = `await 0;\nexport const t = 1;`; + const importsTLAFile = `import { t } from "./tla.js";\nmodule.exports = { t };`; + const logDefaultT = `import c from "./c.js";\nconsole.log(c.t);`; + // Tree shaking removes this package when nothing reads its exports. + const shakeableTLAPackage = { + "/node_modules/pkg/package.json": `{ "name": "pkg", "main": "index.js", "sideEffects": false }`, + "/node_modules/pkg/index.js": tlaFile, + }; + + const tlaImportError = (importer: string, imported: string) => + `This import is not allowed because "${importer}" is bundled as CommonJS and the imported file "${imported}" contains a top-level await`; + const tlaImportHint = (imported: string) => + `CommonJS code runs synchronously and cannot wait for a top-level await. Load "${imported}" with import() inside a function instead`; + const directEvalHint = (importer: string) => + `A direct eval makes a file without an "export" CommonJS. Add an "export" to "${importer}", or call eval as (0, eval)(code)`; + // `file:line:column` of a log or of a note + const logPosition = (position: { file: string; line: number; column: number } | null) => + position && `${basename(position.file)}:${position.line}:${position.column}`; + const logsOf = (build: BuildOutput) => + build.logs.map((log: any) => ({ + level: log.level, + message: log.message, + at: logPosition(log.position), + notes: log.notes.map((note: any) => ({ message: note.message, at: logPosition(note.position) })), + })); + // The error for an import of "tla.js" at `line:column` of `importer` + const tlaImportLog = (importer: string, at: string, tla = "tla.js") => ({ + level: "error", + message: tlaImportError(importer, tla), + at: `${importer}:${at}`, + notes: [ + { message: `The top-level await in ${tla} is here:`, at: `${tla}:1:1` }, + { message: tlaImportHint(tla), at: null }, + ], + }); + + itBundled("cjs/tla_import_module_exports", { + files: { + "/entry.js": logDefaultT, + "/c.js": importsTLAFile, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/c.js": [ + 'This import is not allowed because "c.js" is bundled as CommonJS and the imported file "tla.js" contains a top-level await', + ], + }, + onAfterApiBundle(build) { + expect(build.success).toBe(false); + expect(logsOf(build)).toEqual([ + { + level: "error", + message: + 'This import is not allowed because "c.js" is bundled as CommonJS and the imported file "tla.js" contains a top-level await', + at: "c.js:1:19", + notes: [ + { message: "The top-level await in tla.js is here:", at: "tla.js:1:1" }, + { + message: + 'CommonJS code runs synchronously and cannot wait for a top-level await. Load "tla.js" with import() inside a function instead', + at: null, + }, + ], + }, + ]); + }, + }); + + // `bun build`, with the source map tasks that a failed link has to wait for + itBundled("cjs/tla_import_module_exports_cli", { + backend: "cli", + sourceMap: "inline", + files: { + "/entry.js": logDefaultT, + "/c.js": importsTLAFile, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/c.js": [tlaImportError("c.js", "tla.js")], + }, + }); + + itBundled("cjs/tla_import_module_exports_compile", { + compile: true, + files: { + "/entry.js": logDefaultT, + "/c.js": importsTLAFile, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/c.js": [tlaImportError("c.js", "tla.js")], + }, + }); + + // The build fails as a whole: the file of `b.js` would not parse. + itBundled("cjs/tla_import_one_of_two_entry_points", { + files: { + "/a.js": `console.log("a");`, + "/b.js": logDefaultT, + "/c.js": importsTLAFile, + "/tla.js": tlaFile, + }, + entryPoints: ["/a.js", "/b.js"], + outdir: "/out", + bundleErrors: { + "/c.js": [tlaImportError("c.js", "tla.js")], + }, + onAfterApiBundle(build) { + expect(build.outputs).toEqual([]); + expect(logsOf(build)).toEqual([tlaImportLog("c.js", "1:19")]); + }, + }); + + // `exports.t = t` alone is lifted to `export { t }`. A default import that + // is held as a value brings the wrapper back. + itBundled("cjs/tla_import_exports_with_held_default_import", { + files: { + "/entry.js": logDefaultT, + "/c.js": `import { t } from "./tla.js";\nexports.t = t;`, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/c.js": [tlaImportError("c.js", "tla.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([tlaImportLog("c.js", "1:19")]); + }, + }); + + itBundled("cjs/tla_import_entry_point", { + files: { + "/entry.js": importsTLAFile, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/entry.js": [tlaImportError("entry.js", "tla.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([tlaImportLog("entry.js", "1:19")]); + }, + }); + + for (const splitting of [false, true]) { + itBundled(`cjs/tla_import_file_loaded_with_import_call${splitting ? "_splitting" : ""}`, { + files: { + "/entry.js": `const c = await import("./c.js");\nconsole.log(c.default.t);`, + "/c.js": importsTLAFile, + "/tla.js": tlaFile, + }, + splitting, + outdir: "/out", + bundleErrors: { + "/c.js": [tlaImportError("c.js", "tla.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([tlaImportLog("c.js", "1:19")]); + }, + }); + } + + // One note for each `import` on the way to the `await`. An `import()` is not + // on the way: the chain of `mid.js` goes through `mid2.js`, not `near.js`. + itBundled("cjs/tla_import_transitive", { + files: { + "/entry.js": logDefaultT, + "/c.js": `import { t } from "./mid.js";\nmodule.exports = { t };`, + "/mid.js": `export { t } from "./mid2.js";\nexport const near = () => import("./near.js");`, + "/mid2.js": `export { t } from "./tla.js";`, + "/near.js": `await 0;\nexport const n = 1;`, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/c.js": [ + 'This import is not allowed because "c.js" is bundled as CommonJS and the transitive dependency "tla.js" contains a top-level await', + ], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([ + { + level: "error", + message: + 'This import is not allowed because "c.js" is bundled as CommonJS and the transitive dependency "tla.js" contains a top-level await', + at: "c.js:1:19", + notes: [ + { message: "The file mid.js imports the file mid2.js here:", at: "mid.js:1:19" }, + { message: "The file mid2.js imports the file tla.js here:", at: "mid2.js:1:19" }, + { message: "The top-level await in tla.js is here:", at: "tla.js:1:1" }, + { message: tlaImportHint("mid.js"), at: null }, + ], + }, + ]); + }, + }); + + // Of two imports at the same distance from an `await`, the notes take the first. + itBundled("cjs/tla_import_transitive_two_awaits", { + files: { + "/entry.js": logDefaultT, + "/c.js": `import { t } from "./mid.js";\nmodule.exports = { t };`, + "/mid.js": `export { t } from "./tla.js";\nimport "./tla2.js";`, + "/tla.js": tlaFile, + "/tla2.js": `await 0;`, + }, + bundleErrors: { + "/c.js": [ + 'This import is not allowed because "c.js" is bundled as CommonJS and the transitive dependency "tla.js" contains a top-level await', + ], + }, + onAfterApiBundle(build) { + expect(logsOf(build)[0].notes).toEqual([ + { message: "The file mid.js imports the file tla.js here:", at: "mid.js:1:19" }, + { message: "The top-level await in tla.js is here:", at: "tla.js:1:1" }, + { message: tlaImportHint("mid.js"), at: null }, + ]); + }, + }); + + itBundled("cjs/tla_import_bare", { + files: { + "/entry.js": logDefaultT, + "/c.js": `import "./tla.js";\nmodule.exports = { t: 2 };`, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/c.js": [tlaImportError("c.js", "tla.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([tlaImportLog("c.js", "1:8")]); + }, + }); + + itBundled("cjs/tla_import_cjs_extension", { + files: { + "/entry.js": `import c from "./c.cjs";\nconsole.log(c.t);`, + "/c.cjs": importsTLAFile, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/c.cjs": [tlaImportError("c.cjs", "tla.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([tlaImportLog("c.cjs", "1:19")]); + }, + }); + + // The CommonJS file still runs when nothing reads its exports. + itBundled("cjs/tla_import_unused_default_import", { + files: { + "/entry.js": `import c from "./c.js";\nconsole.log("entry");`, + "/c.js": importsTLAFile, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/c.js": [tlaImportError("c.js", "tla.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([tlaImportLog("c.js", "1:19")]); + }, + }); + + // One error for each import of a module with top-level await, and none for `plain.js`. + itBundled("cjs/tla_import_two_modules", { + files: { + "/entry.js": `import c from "./c.js";\nconsole.log(c.t, c.u, c.p);`, + "/c.js": `import { t } from "./tla.js";\nimport { p } from "./plain.js";\nimport { u } from "./tla2.js";\nmodule.exports = { t, u, p };`, + "/plain.js": `console.log("plain");\nexport const p = 3;`, + "/tla.js": tlaFile, + "/tla2.js": `await 0;\nexport const u = 2;`, + }, + bundleErrors: { + "/c.js": [tlaImportError("c.js", "tla.js"), tlaImportError("c.js", "tla2.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([tlaImportLog("c.js", "1:19"), tlaImportLog("c.js", "3:19", "tla2.js")]); + }, + }); + + // `Object.keys(exports)` keeps these two files CommonJS with their `export`: + // the bundler drops the `export` and keeps the import of the file. + itBundled("cjs/tla_import_export_from", { + files: { + "/entry.js": `import c from "./c.js";\nconsole.log(c.a);`, + "/c.js": `export { t } from "./tla.js";\nexports.a = 1;\nObject.keys(exports);`, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/c.js": [tlaImportError("c.js", "tla.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([tlaImportLog("c.js", "1:19")]); + }, + }); + + itBundled("cjs/tla_import_export_star_as", { + files: { + "/entry.js": `import c from "./c.js";\nconsole.log(c.a);`, + "/c.js": `export * as ns from "./tla.js";\nexports.a = 1;\nObject.keys(exports);`, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/c.js": [tlaImportError("c.js", "tla.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([tlaImportLog("c.js", "1:21")]); + }, + }); + + // `require("react")` is unwrapped to an `import` of the package. + itBundled("cjs/tla_import_unwrapped_require", { + files: { + "/entry.js": `import c from "./c.js";\nconsole.log(c.v);`, + "/c.js": `const React = require("react");\nmodule.exports = { v: React.version };`, + "/node_modules/react/package.json": `{ "name": "react", "version": "19.0.0", "main": "index.js", "type": "module" }`, + "/node_modules/react/index.js": `await 0;\nexport const version = "19";`, + }, + bundleErrors: { + "/c.js": [tlaImportError("c.js", "node_modules/react/index.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([ + { + level: "error", + message: tlaImportError("c.js", "node_modules/react/index.js"), + at: "c.js:1:23", + notes: [ + { message: "The top-level await in node_modules/react/index.js is here:", at: "index.js:1:1" }, + { message: tlaImportHint("node_modules/react/index.js"), at: null }, + ], + }, + ]); + }, + }); + + // Files that are CommonJS for the bundler without `module.exports` or `exports.x` + + // A direct eval can read `module` and `exports`. + for (const importer of ["c.js", "entry.js"]) { + itBundled(`cjs/tla_import_direct_eval${importer === "entry.js" ? "_entry_point" : ""}`, { + files: { + "/entry.js": `import "./c.js";`, + [`/${importer}`]: `import { t } from "./tla.js";\nconsole.log(eval("t"));`, + "/tla.js": tlaFile, + }, + bundleErrors: { + [`/${importer}`]: [tlaImportError(importer, "tla.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([ + { + level: "error", + message: tlaImportError(importer, "tla.js"), + at: `${importer}:1:19`, + notes: [ + { message: "The top-level await in tla.js is here:", at: "tla.js:1:1" }, + { message: tlaImportHint("tla.js"), at: null }, + { message: directEvalHint(importer), at: null }, + ], + }, + ]); + }, + }); + } + + // The two ways out that the note of the direct eval names + itBundled("cjs/tla_import_direct_eval_with_export", { + files: { + "/entry.js": `import "./c.js";`, + "/c.js": `import { t } from "./tla.js";\nconsole.log(eval("1 + 1"), t);\nexport {};`, + "/tla.js": tlaFile, + }, + run: { + stdout: "2 1", + }, + }); + + itBundled("cjs/tla_import_indirect_eval", { + files: { + "/entry.js": `import "./c.js";`, + "/c.js": `import { t } from "./tla.js";\nconsole.log((0, eval)("1 + 1"), t);`, + "/tla.js": tlaFile, + }, + run: { + stdout: "2 1", + }, + }); + + // A reference to `module` is enough, also in a `.mjs` file. + itBundled("cjs/tla_import_typeof_module_in_mjs", { + files: { + "/entry.mjs": `import { t } from "./tla.js";\nconsole.log(t, typeof module);`, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/entry.mjs": [tlaImportError("entry.mjs", "tla.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([tlaImportLog("entry.mjs", "1:19")]); + }, + }); + + itBundled("cjs/tla_import_module_hot", { + files: { + "/entry.js": `import { t } from "./tla.js";\nif (module.hot) module.hot.accept();\nconsole.log(t);`, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/entry.js": [tlaImportError("entry.js", "tla.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([tlaImportLog("entry.js", "1:19")]); + }, + }); + + itBundled("cjs/tla_import_ts_export_equals", { + files: { + "/entry.js": `import c from "./c.ts";\nconsole.log(c.t);`, + "/c.ts": `import { t } from "./tla.js";\nexport = { t };`, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/c.ts": [tlaImportError("c.ts", "tla.js")], + }, + onAfterApiBundle(build) { + expect(logsOf(build)).toEqual([tlaImportLog("c.ts", "1:19")]); + }, + }); + + // Tree shaking decides whether the import is in the bundle. + + // The package stays in the bundle when its export is read. + itBundled("cjs/tla_import_used_export_of_shakeable_package", { + files: { + "/entry.js": logDefaultT, + "/c.js": `import { t } from "pkg";\nmodule.exports = { t };`, + ...shakeableTLAPackage, + }, + bundleErrors: { + "/c.js": [tlaImportError("c.js", "node_modules/pkg/index.js")], + }, + }); + + // The export is not read: the import is not in the bundle. `plain.js` is, and runs. + const unusedExportOfShakeablePackage = { + "/entry.js": logDefaultT, + "/c.js": `import { t } from "pkg";\nimport "./plain.js";\nmodule.exports = { t: 5 };`, + "/plain.js": `console.log("plain");`, + ...shakeableTLAPackage, + }; + itBundled("cjs/tla_import_unused_export_of_shakeable_package", { + files: unusedExportOfShakeablePackage, + run: { + stdout: "plain\n5", + }, + }); + + // The two options that keep the package in the bundle + itBundled("cjs/tla_import_unused_export_of_shakeable_package_ignore_dce_annotations", { + files: unusedExportOfShakeablePackage, + ignoreDCEAnnotations: true, + bundleErrors: { + "/c.js": [tlaImportError("c.js", "node_modules/pkg/index.js")], + }, + }); + + itBundled("cjs/tla_import_unused_export_of_shakeable_package_no_tree_shaking", { + files: unusedExportOfShakeablePackage, + backend: "api", + treeShaking: false, + bundleErrors: { + "/c.js": [tlaImportError("c.js", "node_modules/pkg/index.js")], + }, + }); + + // The package is in the bundle for `entry.js`. The import in `c.js` is still unused. + itBundled("cjs/tla_import_unused_export_of_package_used_elsewhere", { + files: { + "/entry.js": `import c from "./c.js";\nimport { t } from "pkg";\nconsole.log(c.v, t);`, + "/c.js": `import { t } from "pkg";\nmodule.exports = { v: 5 };`, + ...shakeableTLAPackage, + }, + run: { + stdout: "5 1", + }, + }); + + // `tla.ts` is in the bundle for `entry.js`. The import in `c.ts` names a type only. + itBundled("cjs/tla_import_type_only", { + files: { + "/entry.js": `import c from "./c.ts";\nimport { t } from "./tla.ts";\nconsole.log(c.t, t);`, + "/c.ts": `import { T } from "./tla.ts";\nconst x: T = 5;\nmodule.exports = { t: x };`, + "/tla.ts": `await 0;\nexport const t = 1;\nexport type T = number;`, + }, + run: { + stdout: "5 1", + }, + }); + + // The CommonJS file is not in the bundle: nothing reads `c`. + const unusedFileOfShakeablePackage = { + "/entry.js": `import { a } from "pkg";\nconsole.log(a);`, + "/node_modules/pkg/package.json": `{ "name": "pkg", "main": "index.js", "sideEffects": false }`, + "/node_modules/pkg/index.js": `export const a = "a";\nexport { default as c } from "./c.js";`, + "/node_modules/pkg/c.js": importsTLAFile, + "/node_modules/pkg/tla.js": tlaFile, + }; + itBundled("cjs/tla_import_in_unused_file_of_shakeable_package", { + files: unusedFileOfShakeablePackage, + run: { + stdout: "a", + }, + }); + + itBundled("cjs/tla_import_in_unused_file_of_shakeable_package_ignore_dce_annotations", { + files: unusedFileOfShakeablePackage, + ignoreDCEAnnotations: true, + bundleErrors: { + "/node_modules/pkg/c.js": [tlaImportError("node_modules/pkg/c.js", "node_modules/pkg/tla.js")], + }, + }); + + // The next tests build: no `await` is inside a `__commonJS` wrapper. + + // Every importer reads `exports.t` by name: the file stays lifted to an ES module. + itBundled("cjs/tla_import_lifted_exports_file", { + files: { + "/entry.js": `import { t } from "./c.js";\nconsole.log(t);`, + "/c.js": `import { t } from "./tla.js";\nexports.t = t;`, + "/tla.js": tlaFile, + }, + run: { + stdout: "1", + }, + }); + + itBundled("cjs/tla_import_lifted_exports_file_splitting", { + files: { + "/entry.js": `const c = await import("./c.js");\nconsole.log(c.t);`, + "/c.js": `import { t } from "./tla.js";\nexports.t = t;`, + "/tla.js": tlaFile, + }, + splitting: true, + outdir: "/out", + run: { + file: "/out/entry.js", + stdout: "1", + }, + }); + + // `export *` without an alias calls the wrapper of "tla.js" and does not wait for it. + // When it waits, it needs the same error. + itBundled("cjs/tla_import_export_star", { + files: { + "/entry.js": `import c from "./c.js";\nconsole.log(c.a);`, + "/c.js": `export * from "./tla.js";\nexports.a = 1;\nObject.keys(exports);`, + "/tla.js": tlaFile, + }, + run: { + stdout: "1", + }, + }); + + itBundled("cjs/tla_import_of_sync_file_beside_tla_file", { + files: { + "/entry.js": `import { t } from "./tla.js";\nimport c from "./c.js";\nconsole.log(t, c.p);`, + "/c.js": `import { p } from "./plain.js";\nmodule.exports = { p };`, + "/plain.js": `export const p = 2;`, + "/tla.js": tlaFile, + }, + run: { + stdout: "1 2", + }, + }); + + // This format has no `__commonJS` wrapper. + itBundled("cjs/tla_import_internal_bake_dev_format", { + format: "internal_bake_dev", + files: { + "/entry.js": logDefaultT, + "/c.js": importsTLAFile, + "/tla.js": tlaFile, + }, + onAfterBundle(api) { + expect(api.readFile("/out.js")).toContain('"c.js"(hmr, module, exports) {'); + }, + }); + + // The `require()` error is reported first, and alone. + itBundled("cjs/tla_import_file_loaded_with_require", { + files: { + "/entry.js": `const c = require("./c.js");\nconsole.log(c.t);`, + "/c.js": importsTLAFile, + "/tla.js": tlaFile, + }, + bundleErrors: { + "/entry.js": [ + 'This require call is not allowed because the transitive dependency "tla.js" contains a top-level await', + ], + }, + onAfterApiBundle(build) { + expect(build.logs.length).toBe(1); + }, + }); + // ============================================================================ // isNodeMode follows the importer's module type: the file extension first, // then the nearest package.json "type". One test per importer kind.