diff --git a/docs/bundler/css.mdx b/docs/bundler/css.mdx index 809081234796..022fd3974aa6 100644 --- a/docs/bundler/css.mdx +++ b/docs/bundler/css.mdx @@ -8,6 +8,7 @@ Bun's bundler has built-in support for CSS with the following features: - Transpiling modern/future features to work on all browsers (including vendor prefixing) - Minification - CSS Modules +- CSS module scripts (`import sheet from "./a.css" with { type: "css" }`) - Tailwind (through a native bundler plugin) ## Transpiling @@ -1009,3 +1010,16 @@ When composing classes from separate files, make sure they do not contain the sa The CSS module spec says that composing classes from separate files with conflicting properties is undefined behavior: the output may differ and be unreliable. + +## CSS module scripts + +An import with the `type: "css"` import attribute is a [CSS module script](https://html.spec.whatwg.org/multipage/webappapis.html#creating-a-css-module-script): instead of adding the file to the page's stylesheet, it evaluates to a constructed `CSSStyleSheet` that you apply yourself, for example to a shadow root. + +```ts title="component.ts" icon="/icons/typescript.svg" +import sheet from "./widget.css" with { type: "css" }; + +const root = host.attachShadow({ mode: "open" }); +root.adoptedStyleSheets = [sheet]; +``` + +When bundling for the browser, Bun inlines the bundled CSS of that file (with its `@import`s and rewritten `url()`s, minified with `--minify`) into the JavaScript chunk and constructs the stylesheet at runtime, so the output also runs in browsers without native CSS module scripts. `import()` with the attribute works the same way. For the `bun` and `node` targets, which have no `CSSStyleSheet`, the import keeps evaluating to `{}`. diff --git a/packages/bun-types/ts7.1/import-attributes.d.ts b/packages/bun-types/ts7.1/import-attributes.d.ts index 6d8fd36bc802..0158e3616a2a 100644 --- a/packages/bun-types/ts7.1/import-attributes.d.ts +++ b/packages/bun-types/ts7.1/import-attributes.d.ts @@ -85,3 +85,14 @@ declare module "*" with { type: "html" } { var contents: import("bun").HTMLBundle; export = contents; } + +declare module "*" with { type: "css" } { + /** + * A CSS module script. Bundled for the browser, this is a constructed + * `CSSStyleSheet` with the file's CSS, ready for `adoptedStyleSheets` of a + * document or shadow root. `bun run` and the `bun` and `node` targets have + * no `CSSStyleSheet`, so there the value is an empty object. + */ + var sheet: typeof globalThis extends { CSSStyleSheet: { prototype: infer Sheet } } ? Sheet : object; + export = sheet; +} diff --git a/src/ast/import_record.rs b/src/ast/import_record.rs index 508550f2aa33..7000ce31ebd7 100644 --- a/src/ast/import_record.rs +++ b/src/ast/import_record.rs @@ -106,6 +106,10 @@ bitflags::bitflags! { /// chunk's namespace is `{ default: module.exports }`, so the call /// reads `.default` to return `module.exports`. const CROSS_CHUNK_REQUIRE_DEFAULT = 1 << 18; + + /// `with { type: "css" }`: a CSS module script. Browser builds export a + /// constructed `CSSStyleSheet` instead of adding the file to the page CSS. + const CSS_MODULE_SCRIPT = 1 << 19; } } diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 5d8576f64d36..691cefd99981 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -139,6 +139,32 @@ pub struct LinkerContext<'a> { pub(crate) inits_already_done: Option, /// The part `scan_imports_and_exports` adds to each entry point file (`u32::MAX` elsewhere). pub(crate) entry_point_part_indices: Vec, + /// CSS files imported from browser code with `with { type: "css" }`, by + /// source index. Their JS stub exports a `CSSStyleSheet`, page CSS skips + /// them, and chunking treats them as JS files. + pub(crate) css_module_scripts: ArrayHashMap, +} + +/// See [`LinkerContext::css_module_scripts`]. +#[derive(Clone, Copy, Default)] +pub(crate) struct CssModuleScript { + /// The stub's `__cssModule("")` call; `generate_css_module_script_texts` + /// replaces the argument with the printed CSS. + pub(crate) call: Option>, + /// ESM output with copied assets: `url()`s print as + /// `${__cssUrl(path, import.meta.url)}` so they resolve against the chunk. + pub(crate) resolve_asset_urls: bool, +} + +/// Whether chunk assignment treats `source_index` like a JS file: every file +/// but a plain CSS file (the stub of a CSS module script is a JS module). +#[inline] +pub(crate) fn is_chunked_as_js( + css_asts: &[crate::bundled_ast::CssCol], + css_module_scripts: &ArrayHashMap, + source_index: u32, +) -> bool { + css_asts[source_index as usize].is_none() || css_module_scripts.contains(&source_index) } // SAFETY: `LinkerContext` is shared across the worker pool via `each_ptr` / @@ -178,6 +204,7 @@ impl<'a> Default for LinkerContext<'a> { preload_entries: AutoBitSet::init_empty(0).expect("static AutoBitSet"), inits_already_done: None, entry_point_part_indices: Vec::new(), + css_module_scripts: ArrayHashMap::new(), } } } @@ -523,6 +550,7 @@ impl<'a> LinkerContext<'a> { }); self.cycle_detector = Vec::new(); self.inits_already_done = None; + self.css_module_scripts.clear_retaining_capacity(); // Note: `reachable_files` is `Vec`; clone the // caller-owned slice into the linker arena. @@ -2877,7 +2905,10 @@ impl<'a> LinkerContext<'a> { ctx.queue.push_back((record.source_index.get(), out_dist)); } } - continue; + // A CSS module script also has live JS parts (the stub). + if !self.is_css_module_script(source_index) { + continue; + } } // A dead part prints nothing, so only live parts reach other files. @@ -3089,7 +3120,10 @@ impl<'a> LinkerContext<'a> { } } } - return; + // A CSS module script also has live JS parts (the stub). + if !self.is_css_module_script(source_index) { + return; + } } // HTML files can reference non-JS/CSS assets (favicons, images, etc.) @@ -3320,6 +3354,12 @@ impl<'a> LinkerContext<'a> { self.graph.runtime_function(name) } + /// See [`LinkerContext::css_module_scripts`]. + #[inline] + pub(crate) fn is_css_module_script(&self, source_index: u32) -> bool { + self.css_module_scripts.count() > 0 && self.css_module_scripts.contains(&source_index) + } + /// Returns the part indices within file `id` that declare the /// top-level symbol `ref`. #[inline] diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 00ba44ff481f..109d9f55bfd5 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -6689,6 +6689,26 @@ pub mod bv2_impl { if let Some(dev_server) = self.dev_server_handle() { 'brk: { + // The dev server serves CSS outside of the module graph, + // so it cannot produce the `CSSStyleSheet` a build does. + if import_record + .flags + .contains(bun_ast::ImportRecordFlags::CSS_MODULE_SCRIPT) + && bake_graph == bake_types::Graph::Client + { + let log = + self.log_for_resolution_failures(source.path.text, bake_graph); + log.add_range_error_fmt( + Some(source), + import_record.range, + format_args!( + "The dev server does not support CSS module scripts (import attribute type: \"css\") yet. Use \"bun build\", or remove the attribute to apply \"{}\" to the page.", + bstr::BStr::new(import_record.path.text), + ), + ); + continue 'outer; + } + if path.loader(&self.transpiler.options.loaders) == Some(Loader::Html) && (import_record.loader.is_none() || import_record.loader.unwrap() == Loader::Html) diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index 923c4a6a01fe..5bd2ed8ce8e3 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -138,7 +138,11 @@ pub(crate) fn compute_chunks( } } - if css_asts[source_index as usize].is_some() { + // `import()` of a CSS module script loads its JS stub: a JS chunk. + let is_dynamic_css_module_script = this.css_module_scripts.contains(&source_index) + && this.graph.files.items_entry_point_kind()[source_index as usize] + == crate::EntryPoint::Kind::DynamicImport; + if css_asts[source_index as usize].is_some() && !is_dynamic_css_module_script { // SAFETY: see `this_ptr` note above — the helper only reads from // `this.graph` columns disjoint from the slices we hold here. let order = find_imported_files_in_css_order( @@ -317,11 +321,12 @@ pub(crate) fn compute_chunks( if js_chunks.count() > 0 { for source_index in this.graph.reachable_files.slice() { if this.graph.files_live.is_set(source_index.get() as usize) { - if css_reprs[source_index.get() as usize].is_none() { + if crate::linker_context_mod::is_chunked_as_js( + css_reprs, + &this.css_module_scripts, + source_index.get(), + ) { let entry_bits: &AutoBitSet = &file_entry_bits[source_index.get() as usize]; - if css_reprs[source_index.get() as usize].is_some() { - continue; - } if this.graph.code_splitting { if !contributes_code.is_set(source_index.get() as usize) { diff --git a/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs b/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs index 5465f739e421..65f1cbf7afb4 100644 --- a/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs +++ b/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs @@ -385,8 +385,11 @@ impl<'a, 'ctx> FindImportedPartsVisitor<'a, 'ctx> { } let is_file_in_chunk = if WITH_CODE_SPLITTING - && self.c.graph.ast.items_css()[source_index as usize].is_none() - { + && crate::linker_context_mod::is_chunked_as_js( + self.c.graph.ast.items_css(), + &self.c.css_module_scripts, + source_index, + ) { // when code splitting, include the file in the chunk if ALL of the entry points overlap self.entry_bits .eql(&self.c.graph.files.items_entry_bits()[source_index as usize]) diff --git a/src/bundler/linker_context/findImportedCSSFilesInJSOrder.rs b/src/bundler/linker_context/findImportedCSSFilesInJSOrder.rs index e4b25a83c5b8..666d6feb0137 100644 --- a/src/bundler/linker_context/findImportedCSSFilesInJSOrder.rs +++ b/src/bundler/linker_context/findImportedCSSFilesInJSOrder.rs @@ -1,6 +1,6 @@ use crate::mal_prelude::*; use bun_alloc::Arena; -use bun_ast::ImportRecord; +use bun_ast::{ImportRecord, ImportRecordFlags}; use bun_collections::VecExt; use crate::{Index, LinkerContext}; @@ -78,6 +78,12 @@ pub(crate) fn find_imported_css_files_in_js_order( if record.source_index.is_valid() && !visited.is_set(record.source_index.get() as usize) { + // A CSS module script is not part of the page CSS. + if record.flags.contains(ImportRecordFlags::CSS_MODULE_SCRIPT) + && this.is_css_module_script(record.source_index.get()) + { + continue; + } stack.push(Frame::Enter(record.source_index)); } } diff --git a/src/bundler/linker_context/generateChunksInParallel.rs b/src/bundler/linker_context/generateChunksInParallel.rs index 446f00e08097..6c5497c0dfd2 100644 --- a/src/bundler/linker_context/generateChunksInParallel.rs +++ b/src/bundler/linker_context/generateChunksInParallel.rs @@ -21,7 +21,9 @@ use crate::options; use crate::options::Loader; use crate::LinkerContext; -use crate::linker_context::generate_compile_result_for_css_chunk::generate_compile_result_for_css_chunk; +use crate::linker_context::generate_compile_result_for_css_chunk::{ + generate_compile_result_for_css_chunk, generate_css_module_script_texts, +}; use crate::linker_context::generate_compile_result_for_html_chunk::generate_compile_result_for_html_chunk; use crate::linker_context::generate_compile_result_for_js_chunk::generate_compile_result_for_js_chunk; use crate::linker_context::metafile_builder; @@ -50,6 +52,8 @@ pub(crate) fn generate_chunks_in_parallel( let _trace = bun_core::perf::trace("Bundler.generateChunksInParallel"); c.mangle_local_css(); + // Fills in AST that the JS printer reads below. + generate_css_module_script_texts(c)?; let mut has_js_chunk = false; let mut has_css_chunk = false; diff --git a/src/bundler/linker_context/generateCodeForLazyExport.rs b/src/bundler/linker_context/generateCodeForLazyExport.rs index 44c9a36142c8..e8925fac18ac 100644 --- a/src/bundler/linker_context/generateCodeForLazyExport.rs +++ b/src/bundler/linker_context/generateCodeForLazyExport.rs @@ -364,6 +364,49 @@ pub(crate) fn generate_code_for_lazy_export( } } + // A CSS module script exports `__cssModule("")`. The CSS is printed + // later (`generate_css_module_script_texts`), so the argument starts empty. + let mut css_module_script: Option = None; + if maybe_css_ast.is_some() && this.is_css_module_script(source_index) { + let stmt: Stmt = part.stmts[0]; + let StmtData::SLazyExport(mut slot) = stmt.data else { + panic!("Internal error: expected top-level lazy export statement"); + }; + let call = Expr::init( + E::Call { + target: Expr::init( + E::Identifier { + ref_: this.runtime_function(b"__cssModule"), + ..Default::default() + }, + stmt.loc, + ), + args: bun_ast::ExprNodeList::from_slice(&[Expr::init( + E::EString::init(b""), + stmt.loc, + )]), + can_be_unwrapped_if_unused: E::CallUnwrap::IfUnused, + ..Default::default() + }, + stmt.loc, + ); + let ExprData::ECall(call_ref) = call.data else { + unreachable!(); + }; + let entry = crate::linker_context_mod::CssModuleScript { + call: Some(call_ref), + resolve_asset_urls: this.options.output_format == crate::options::OutputFormat::Esm + && css_references_copied_assets(this, source_index), + }; + *this + .css_module_scripts + .get_ptr_mut(&source_index) + .expect("checked by is_css_module_script") = entry; + css_module_script = Some(entry); + // `StoreRef` is a Copy `NonNull` — write through the pointer. + *slot = call.data; + } + let stmt: Stmt = part.stmts[0]; let StmtData::SLazyExport(lazy) = stmt.data else { panic!("Internal error: expected top-level lazy export statement"); @@ -378,6 +421,17 @@ pub(crate) fn generate_code_for_lazy_export( let calls_runtime_require = matches!(expr.data, ExprData::ECall(ref c) if matches!(c.target.data, ExprData::ERequireCallTarget)) && this.options.output_format != crate::options::OutputFormat::Cjs; + let runtime_functions_called: &[&[u8]] = if calls_runtime_require { + &[b"__require"] + } else if let Some(script) = css_module_script { + if script.resolve_asset_urls { + &[b"__cssModule", b"__cssUrl"] + } else { + &[b"__cssModule"] + } + } else { + &[] + }; match exports_kind { bun_ast::ExportsKind::Cjs => { @@ -401,11 +455,11 @@ pub(crate) fn generate_code_for_lazy_export( Index::init(source_index), )?; - if calls_runtime_require { + for name in runtime_functions_called { this.graph.generate_runtime_symbol_import_and_use( source_index, Index::part(1u32), - b"__require", + name, 1, )?; } @@ -509,11 +563,11 @@ pub(crate) fn generate_code_for_lazy_export( let parts = this.graph.ast.items_parts_mut()[source_index as usize].as_mut_slice(); parts[generated.1 as usize].stmts = bun_ast::StoreSlice::new_mut(new_stmts); - if calls_runtime_require { + for name in runtime_functions_called { this.graph.generate_runtime_symbol_import_and_use( source_index, Index::part(generated.1), - b"__require", + name, 1, )?; } @@ -523,3 +577,37 @@ pub(crate) fn generate_code_for_lazy_export( Ok(()) } + +/// Whether `source_index` or a CSS file it `@import`s has a `url()` to a +/// copied asset (not a `data:` URL or an external URL). +fn css_references_copied_assets(this: &LinkerContext, source_index: IndexInt) -> bool { + let import_records = this.graph.ast.items_import_records(); + let css_asts = this.graph.ast.items_css(); + let parse_graph = this.parse_graph(); + let urls_for_css = parse_graph.ast.items_url_for_css(); + let unique_keys = parse_graph + .input_files + .items_unique_key_for_additional_file(); + + let mut visited: Vec = vec![source_index]; + let mut stack: Vec = vec![source_index]; + while let Some(index) = stack.pop() { + for record in import_records[index as usize].as_slice() { + if !record.source_index.is_valid() { + continue; + } + let other = record.source_index.get(); + if css_asts[other as usize].is_some() { + if !visited.contains(&other) { + visited.push(other); + stack.push(other); + } + } else if urls_for_css[other as usize].is_empty() + && !unique_keys[other as usize].is_empty() + { + return true; + } + } + } + false +} diff --git a/src/bundler/linker_context/generateCompileResultForCssChunk.rs b/src/bundler/linker_context/generateCompileResultForCssChunk.rs index 7e1a52100a4f..c66967f50ef9 100644 --- a/src/bundler/linker_context/generateCompileResultForCssChunk.rs +++ b/src/bundler/linker_context/generateCompileResultForCssChunk.rs @@ -1,7 +1,7 @@ use crate::mal_prelude::*; use core::sync::atomic::Ordering; -use bun_ast::ImportRecord; +use bun_ast::{E, Expr, ExprNodeList, ImportRecord}; use bun_collections::VecExt; use bun_threading::thread_pool as ThreadPoolLib; @@ -53,6 +53,198 @@ pub(crate) unsafe fn generate_compile_result_for_css_chunk(task: *mut ThreadPool unsafe { Chunk::write_compile_result_slot(chunk_ptr, part_range.i as usize, result) }; } +/// Prints each CSS module script (see [`LinkerContext::css_module_scripts`]) +/// the way a CSS chunk rooted at it would print, into the argument of its +/// stub's `__cssModule()` call. +pub(crate) fn generate_css_module_script_texts(c: &mut LinkerContext) -> Result<(), crate::Error> { + let entries: Vec<(u32, crate::linker_context_mod::CssModuleScript)> = c + .css_module_scripts + .iter() + .filter(|(_, script)| script.call.is_some()) + .map(|(&source_index, &script)| (source_index, script)) + .collect(); + if entries.is_empty() { + return Ok(()); + } + + let separator: &[u8] = if c.options.minify_whitespace { + b"" + } else { + b"\n" + }; + + // SAFETY: `c` is the `linker` field of the live `BundleV2`. Holding the + // worker also gives `Expr::init` below an AST store to allocate from. + let bundle = unsafe { &*LinkerContext::bundle_v2_ptr(std::ptr::from_mut(c)) }; + let mut worker = scopeguard::guard(Worker::get(bundle), |w| w.unget()); + let mut texts: Vec<&[u8]> = Vec::with_capacity(entries.len()); + for &(source_index, _) in &entries { + texts.push(render_css_module_script( + c, + &mut worker, + source_index, + separator, + )?); + } + + let url_ref = c.runtime_function(b"__cssUrl"); + for ((_, script), css) in entries.into_iter().zip(texts) { + let mut call = script.call.expect("filtered above"); + let loc = call.args.slice()[0].loc; + let placeholders = if script.resolve_asset_urls { + asset_placeholders(css, &c.unique_key_prefix) + } else { + Vec::new() + }; + let argument = if placeholders.is_empty() { + let mut string = E::EString::init(css); + string.prefer_template = !c.options.minify_whitespace; + Expr::init(string, loc) + } else { + // `head${__cssUrl("", import.meta.url)}tail...` + let mut parts: Vec = Vec::with_capacity(placeholders.len()); + for (i, &(start, end)) in placeholders.iter().enumerate() { + let tail_end = placeholders.get(i + 1).map_or(css.len(), |next| next.0); + let url = Expr::init( + E::Call { + target: Expr::init( + E::Identifier { + ref_: url_ref, + ..Default::default() + }, + loc, + ), + args: ExprNodeList::from_slice(&[ + Expr::init(E::EString::init(&css[start..end]), loc), + Expr::init( + E::Dot { + target: Expr::init(E::ImportMeta {}, loc), + name: b"url".as_slice().into(), + name_loc: loc, + ..Default::default() + }, + loc, + ), + ]), + can_be_unwrapped_if_unused: E::CallUnwrap::IfUnused, + ..Default::default() + }, + loc, + ); + parts.push(E::TemplatePart { + value: url, + tail: E::TemplateContents::Cooked(E::EString::init(&css[end..tail_end])), + tail_loc: loc, + }); + } + Expr::init( + E::Template { + tag: None, + head: E::TemplateContents::Cooked(E::EString::init(&css[..placeholders[0].0])), + parts: bun_ast::StoreSlice::new_mut(c.arena().alloc_slice_fill_iter(parts)), + }, + loc, + ) + }; + call.args.slice_mut()[0] = argument; + } + + Ok(()) +} + +/// `[start, end)` of every copied-asset placeholder (`A<8 digits>`) +/// in `css`, in order. See `LinkerContext::break_output_into_pieces`. +fn asset_placeholders(css: &[u8], prefix: &[u8]) -> Vec<(usize, usize)> { + let mut found = Vec::new(); + let mut offset = 0; + while let Some(at) = bun_core::strings::index_of(&css[offset..], prefix) { + let start = offset + at; + let end = start + prefix.len() + 9; + if end > css.len() { + break; + } + if css[start + prefix.len()] == crate::chunk::QueryKind::Asset.letter() + && css[start + prefix.len() + 1..end] + .iter() + .all(u8::is_ascii_digit) + { + found.push((start, end)); + } + offset = end; + } + found +} + +/// The CSS text of one CSS module script, in the linker arena. +fn render_css_module_script( + c: &mut LinkerContext, + worker: &mut Worker, + source_index: u32, + separator: &[u8], +) -> Result<&'static [u8], crate::Error> { + let order = + crate::linker_context::find_imported_files_in_css_order::find_imported_files_in_css_order( + c, + worker.arena(), + &[Index::init(source_index)], + ); + let count = order.len() as usize; + let mut chunk = Chunk { + content: Content::Css(crate::chunk::CssChunk { + imports_in_chunk_in_order: order, + asts: (0..count).map(|_| BundlerStyleSheet::empty()).collect(), + }), + ..Default::default() + }; + crate::linker_context::prepare_css_asts_for_chunk::prepare_css_asts_for_chunk_impl( + c, + &mut chunk, + worker.arena(), + ); + + let mut css: Vec = Vec::new(); + for i in 0..count { + match generate_compile_result_for_css_chunk_impl(worker, c, &chunk, i as u32) { + CompileResult::Css { + result: Ok(code), .. + } => { + let code = bun_core::strings::trim(&code, b" \n\r\t"); + if code.is_empty() { + continue; + } + if !css.is_empty() { + css.extend_from_slice(separator); + } + css.extend_from_slice(code); + } + CompileResult::Css { + result: Err(err), + source_index: failed_source_index, + .. + } => { + let source = if failed_source_index != Index::INVALID.get() { + Some(c.get_source(failed_source_index)) + } else { + None + }; + c.log_mut().add_error( + source, + bun_ast::Loc::EMPTY, + std::borrow::Cow::Owned( + format!("Failed to generate CSS for this file ({})", err.name()) + .into_bytes(), + ), + ); + return Err(crate::Error::PrintError); + } + _ => unreachable!("CSS chunk produced a non-CSS compile result"), + } + } + + // SAFETY: the linker arena outlives the JS printer that reads this text. + Ok(unsafe { bun_ptr::detach_lifetime_ref::<[u8]>(c.arena().alloc_slice_copy(&css)) }) +} + fn generate_compile_result_for_css_chunk_impl( worker: &mut Worker, c: &LinkerContext, diff --git a/src/bundler/linker_context/mergeSmallChunks.rs b/src/bundler/linker_context/mergeSmallChunks.rs index ac3f43403f01..e52390bb1148 100644 --- a/src/bundler/linker_context/mergeSmallChunks.rs +++ b/src/bundler/linker_context/mergeSmallChunks.rs @@ -661,7 +661,11 @@ pub(crate) fn merge_small_chunks( }; let is_live_js = |source_index: u32| { this.graph.files_live.is_set(source_index as usize) - && css_asts[source_index as usize].is_none() + && crate::linker_context_mod::is_chunked_as_js( + css_asts, + &this.css_module_scripts, + source_index, + ) }; // Which entries statically contain a live `import()` of each dynamic entry. diff --git a/src/bundler/linker_context/prepareCssAstsForChunk.rs b/src/bundler/linker_context/prepareCssAstsForChunk.rs index 1d751736b867..e1c038d6186b 100644 --- a/src/bundler/linker_context/prepareCssAstsForChunk.rs +++ b/src/bundler/linker_context/prepareCssAstsForChunk.rs @@ -79,7 +79,7 @@ pub(crate) unsafe fn prepare_css_asts_for_chunk(task: *mut ThreadPoolLib::Task) prepare_css_asts_for_chunk_impl(unsafe { &*linker }, unsafe { &mut *chunk }, worker.arena()); } -fn prepare_css_asts_for_chunk_impl(c: &LinkerContext, chunk: &mut Chunk, bump: &Bump) { +pub(crate) fn prepare_css_asts_for_chunk_impl(c: &LinkerContext, chunk: &mut Chunk, bump: &Bump) { // SAFETY: parse_graph backref; raw deref because `parse_graph` is held // across the log write below (split borrow). let parse_graph = unsafe { &*c.parse_graph }; diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index aa807c0dbc0d..37fc2600bcff 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -115,6 +115,7 @@ pub(crate) fn scan_imports_and_exports( // Element is a *mutable* nullable // pointer (`BundledAst.css: Option<*mut BundlerStyleSheet>`). let css_asts: *mut [CssCol] = ast.css; + let ast_targets: *mut [bun_ast::Target] = ast.target; let input_files: *mut [Source] = input.source; let loaders: *mut [Loader] = input.loader; @@ -225,6 +226,30 @@ pub(crate) fn scan_imports_and_exports( } let other_kind = col_ref!(exports_kind)[other_file]; + // Register CSS module scripts (`LinkerContext::css_module_scripts`). + // Only browser targets have `CSSStyleSheet`; elsewhere, and under + // `bun run`, the import stays a plain CSS import (`{}`). + if record.flags.contains(ImportRecordFlags::CSS_MODULE_SCRIPT) + && !record.flags.contains(ImportRecordFlags::IS_UNUSED) + && output_format != Format::InternalBakeDev + && col_ref!(ast_targets)[id] == bun_ast::Target::Browser + && let Some(other_css) = col_ref!(css_asts)[other_file].as_deref() + { + if other_css.local_scope.count() > 0 { + this.log_disjoint().add_range_error_fmt( + Some(&col_ref!(input_files)[id]), + record.range, + format_args!( + "A CSS module (\"{}\") cannot be imported with {{ type: \"css\" }}. Rename it to not end in \".module.css\", or remove the import attribute to get its class names.", + bstr::BStr::new(col_ref!(input_files)[other_file].path.pretty), + ), + ); + } else { + this.css_module_scripts + .put(other_file as u32, Default::default())?; + } + } + // Union, per importee, of the export names its importers can // observe: named static imports contribute their aliases, a // tracked `import()` / `require()` its recorded ones, anything diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 7aba6f7d8d38..c65067b72b82 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1468,7 +1468,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } if let Some(loader) = state.import_loader { - self.import_records.items_mut()[import_record_index as usize].loader = Some(loader); + self.set_import_record_loader(import_record_index, loader); } self.import_records.items_mut()[import_record_index as usize] @@ -4557,6 +4557,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(self.s(stmt, loc)) } + /// Applies the loader of a `with { type: "..." }` import attribute to the + /// import record. `type: "css"` also marks the record as a CSS module script. + pub(crate) fn set_import_record_loader( + &mut self, + import_record_index: u32, + loader: options::Loader, + ) { + let record = &mut self.import_records.items_mut()[import_record_index as usize]; + record.loader = Some(loader); + if loader == options::Loader::Css { + record + .flags + .insert(bun_ast::ImportRecordFlags::CSS_MODULE_SCRIPT); + } + } + #[cold] fn validate_and_set_import_type( &mut self, @@ -4564,8 +4580,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O stmt: &mut S::Import, ) -> Result<(), crate::Error> { if let Some(loader) = path.loader { - self.import_records.items_mut()[stmt.import_record_index as usize].loader = - Some(loader); + self.set_import_record_loader(stmt.import_record_index, loader); if loader == options::Loader::Sqlite || loader == options::Loader::SqliteEmbedded { // arena-owned `StoreSlice` valid for parser 'a. @@ -4581,7 +4596,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O break; } } - } else if loader == options::Loader::File || loader == options::Loader::Text { + } else if loader == options::Loader::File + || loader == options::Loader::Text + || loader == options::Loader::Css + { // arena-owned `StoreSlice` valid for parser 'a. for item in stmt.items.iter() { // `ClauseItem.alias` is an arena-owned `StoreStr` valid for 'a. diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 79e3207932af..9e1fdb9a4c85 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1236,13 +1236,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O namespace_ref = p.store_name_in_ref(name); } - let import_record_index = p.add_import_record( - ImportKind::Stmt, - path.loc, - path.text, - // TODO: import assertions - // path.assertions - ); + let import_record_index = + p.add_import_record(ImportKind::Stmt, path.loc, path.text); if path.is_macro { p.log().add_error( @@ -1256,6 +1251,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O loc, b"cannot use export statement with \"type\" attribute", ); + } else if let Some(loader) = path.loader { + p.set_import_record_loader(import_record_index, loader); } if Self::TRACK_SYMBOL_USAGE_DURING_PARSE_PASS { @@ -1315,6 +1312,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let import_record_index = p.add_import_record(ImportKind::Stmt, parsed_path.loc, parsed_path.text); + if !parsed_path.is_macro + && parsed_path.import_tag == ImportRecordTag::None + && let Some(loader) = parsed_path.loader + { + p.set_import_record_loader(import_record_index, loader); + } let path_name = fs::PathName::init(parsed_path.text); let namespace_ref = { use std::io::Write as _; diff --git a/src/runtime.js b/src/runtime.js index 5260178f1723..f8aa72b958c6 100644 --- a/src/runtime.js +++ b/src/runtime.js @@ -342,6 +342,17 @@ export var $$typeof = /* @__PURE__ */ Symbol.for("react.element"); export var __jsonParse = /* @__PURE__ */ a => JSON.parse(a); +// `import sheet from "./x.css" with { type: "css" }` in browser builds. Globals +// go through `globalThis` so bundling does not reserve their names. +export var __cssModule = css => { + var sheet = new globalThis.CSSStyleSheet(); + sheet.replaceSync(css); + return sheet; +}; + +// Resolves a chunk-relative asset `url()` in that CSS against the chunk. +export var __cssUrl = (url, base) => new globalThis.URL(url, base).href; + export var __promiseAll = args => Promise.all(args); // React Compiler memo-cache slot sentinels. diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index 50fbc625e0d1..a2ac1d824736 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -698,6 +698,35 @@ devTest("css import before create project relative", { }, }); +// `bun build` turns this import into a constructed `CSSStyleSheet`. The dev +// server cannot represent that yet, so it reports it instead of emitting a +// dangling binding and applying the file to the page. +devTest("css module script import is reported as unsupported", { + files: { + "index.html": emptyHtmlFile({ + styles: [], + scripts: ["index.ts"], + }), + "index.ts": ` + import sheet from "./widget.css" with { type: "css" }; + console.log(sheet); + `, + "widget.css": `body { color: red; }`, + }, + async test(dev) { + await using c = await dev.client("/", { + errors: [ + 'index.ts:1:19: error: The dev server does not support CSS module scripts (import attribute type: "css") yet. Use "bun build", or remove the attribute to apply "./widget.css" to the page.', + ], + }); + await c.expectReload(async () => { + await dev.write("index.ts", `import "./widget.css"; console.log("plain");`); + }); + await c.expectMessage("plain"); + await c.style("body").color.expect.toBe("red"); + }, +}); + function extractCssUrl(backgroundImage: string): string { const url = backgroundImage.match(/url\((['"])(.*?)\1\)/); if (!url) { diff --git a/test/bundler/bundler_promiseall_deadcode.test.ts b/test/bundler/bundler_promiseall_deadcode.test.ts index c5960784c32a..bc9e83646b31 100644 --- a/test/bundler/bundler_promiseall_deadcode.test.ts +++ b/test/bundler/bundler_promiseall_deadcode.test.ts @@ -150,7 +150,7 @@ describe("bundler", () => { await Promise.resolve(); AsyncEntryPoint(); - //# debugId=B9EF7E5F2ACBD11D64756E2164756E21 + //# debugId=FC757993609A9DD764756E2164756E21 //# sourceMappingURL=out.js.map " `); diff --git a/test/bundler/css/css-module-scripts.test.ts b/test/bundler/css/css-module-scripts.test.ts new file mode 100644 index 000000000000..4717fc6ac9fd --- /dev/null +++ b/test/bundler/css/css-module-scripts.test.ts @@ -0,0 +1,411 @@ +import { describe, expect } from "bun:test"; +import { existsSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { itBundled } from "../expectBundled"; + +// `import sheet from "./x.css" with { type: "css" }` is a CSS module script: +// in browser builds the default export is a constructed `CSSStyleSheet` and the +// file stays out of the page-level stylesheet. +// https://html.spec.whatwg.org/multipage/webappapis.html#css-module-script + +// Bun has no `CSSStyleSheet`, so the bundles run against this stand-in, which +// records the text passed to `replaceSync()`. +const cssStyleSheetShim = /* js */ ` + globalThis.CSSStyleSheet = class CSSStyleSheet { + cssText = ""; + replaceSync(text) { + this.cssText = text; + } + }; +`; + +const outputFiles = (outdir: string, ext: string) => readdirSync(outdir).filter(f => f.endsWith(ext)); + +describe("bundler", () => { + itBundled("css-module-script/DefaultImportIsConstructedStyleSheet", { + files: { + "/entry.js": /* js */ ` + import "./page.css"; + import sheet from "./widget.css" with { type: "css" }; + console.log(sheet instanceof CSSStyleSheet); + console.log(sheet.cssText); + `, + "/page.css": /* css */ `.page { color: blue }`, + "/widget.css": /* css */ `p.w { color: red }`, + "/test.js": /* js */ ` + ${cssStyleSheetShim} + await import("./out/entry.js"); + `, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + target: "browser", + onAfterBundle(api) { + // The plain import still lands in the chunk's stylesheet, the module script does not. + api.expectFile("/out/entry.css").toContain(".page"); + api.expectFile("/out/entry.css").not.toContain("p.w"); + }, + run: { + file: "/test.js", + stdout: "true\np.w {\n color: red;\n}", + }, + }); + + itBundled("css-module-script/HTMLEntryKeepsSheetOutOfThePage", { + files: { + "/index.html": /* html */ ` + + + + +

light DOM

+ + + + `, + "/component.js": /* js */ ` + import sheet from "./widget.css" with { type: "css" }; + document.body.attachShadow({ mode: "open" }).adoptedStyleSheets = [sheet]; + `, + "/widget.css": /* css */ `p.w { color: rgb(200, 0, 0); border-top: 7px solid; }`, + }, + entryPoints: ["/index.html"], + outdir: "/out", + target: "browser", + onAfterBundle(api) { + expect(outputFiles(api.outdir, ".css")).toEqual([]); + api.expectFile("/out/index.html").not.toContain("stylesheet"); + const [js] = outputFiles(api.outdir, ".js"); + api.expectFile("/out/" + js).toContain("border-top: 7px solid"); + api.expectFile("/out/" + js).toContain("CSSStyleSheet"); + }, + }); + + // `@import`ed files are inlined. A copied asset in `url()` is emitted relative + // to the JS chunk and, in ESM output, resolved against `import.meta.url` at + // runtime, so it loads no matter where the page that runs the chunk lives. + itBundled("css-module-script/InlinesAtImportAndResolvesUrl", { + files: { + "/entry.js": /* js */ ` + import sheet from "./widget.css" with { type: "css" }; + console.log(sheet.cssText); + `, + "/widget.css": /* css */ ` + @import "./base.css"; + p.w { background: url("./image.png") } + `, + "/base.css": /* css */ `p.base { color: green }`, + // Large enough to be emitted as a file instead of a data: URL. + "/image.png": Buffer.alloc(256 * 1024, 7), + "/test.js": /* js */ ` + ${cssStyleSheetShim} + await import("./out/chunks/entry.js"); + `, + }, + entryPoints: ["/entry.js"], + entryNaming: "chunks/[name].[ext]", + assetNaming: "assets/[name]-[hash].[ext]", + outdir: "/out", + target: "browser", + onAfterBundle(api) { + // A page stylesheet for this entry would be emitted next to it, in chunks/. + expect(outputFiles(api.outdir + "/chunks", ".css")).toEqual([]); + expect(outputFiles(api.outdir + "/assets", ".png")).toHaveLength(1); + }, + run: { + file: "/test.js", + validate({ stdout }) { + // base.css is inlined before the importing file's own rules. + expect(stdout.indexOf("p.base")).toBeGreaterThanOrEqual(0); + expect(stdout.indexOf("p.base")).toBeLessThan(stdout.indexOf("p.w")); + expect(stdout).not.toContain("@import"); + // The chunk is out/chunks/entry.js and the asset out/assets/image-*.png: + // the URL in the sheet is absolute and points at the emitted file. + const url = stdout.match(/url\("([^"]+)"\)/)?.[1]; + expect(url).toStartWith("file://"); + expect(url).toMatch(/\/out\/assets\/image-[a-z0-9]+\.png$/); + expect(existsSync(fileURLToPath(url!))).toBe(true); + }, + }, + }); + + // Without `import.meta` (IIFE), the asset path stays relative to the chunk. + itBundled("css-module-script/UrlStaysRelativeInIIFE", { + files: { + "/entry.js": /* js */ ` + import sheet from "./widget.css" with { type: "css" }; + console.log(sheet.cssText); + `, + "/widget.css": /* css */ `p.w { background: url("./image.png") }`, + "/image.png": Buffer.alloc(256 * 1024, 7), + "/test.js": /* js */ ` + ${cssStyleSheetShim} + await import("./out/entry.js"); + `, + }, + entryPoints: ["/entry.js"], + format: "iife", + outdir: "/out", + target: "browser", + onAfterBundle(api) { + api.expectFile("/out/entry.js").not.toContain("import.meta"); + }, + run: { + file: "/test.js", + validate({ stdout }) { + expect(stdout).toMatch(/url\("\.\/image-[a-z0-9]+\.png"\)/); + }, + }, + }); + + itBundled("css-module-script/ReExport", { + files: { + "/entry.js": /* js */ ` + import { sheet } from "./styles.js"; + console.log(sheet instanceof CSSStyleSheet, JSON.stringify(sheet.cssText)); + `, + "/styles.js": /* js */ ` + export { default as sheet } from "./widget.css" with { type: "css" }; + `, + "/widget.css": /* css */ `p.w { color: red }`, + "/test.js": /* js */ ` + ${cssStyleSheetShim} + await import("./out/entry.js"); + `, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + target: "browser", + onAfterBundle(api) { + expect(outputFiles(api.outdir, ".css")).toEqual([]); + }, + run: { + file: "/test.js", + stdout: `true "p.w {\\n color: red;\\n}"`, + }, + }); + + itBundled("css-module-script/SameFileBothWays", { + files: { + "/entry.js": /* js */ ` + import "./widget.css"; + import sheet from "./widget.css" with { type: "css" }; + console.log(sheet instanceof CSSStyleSheet, sheet.cssText.includes("p.w")); + `, + "/widget.css": /* css */ `p.w { color: red }`, + "/test.js": /* js */ ` + ${cssStyleSheetShim} + await import("./out/entry.js"); + `, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + target: "browser", + onAfterBundle(api) { + // The plain import keeps applying the file to the page. + api.expectFile("/out/entry.css").toContain("p.w"); + }, + run: { + file: "/test.js", + stdout: "true true", + }, + }); + + itBundled("css-module-script/SharedAcrossChunksKeepsIdentity", { + files: { + "/a.js": /* js */ ` + import sheet from "./widget.css" with { type: "css" }; + import { shared } from "./shared.js"; + console.log("a", sheet === shared, sheet instanceof CSSStyleSheet); + `, + "/b.js": /* js */ ` + import sheet from "./widget.css" with { type: "css" }; + import { shared } from "./shared.js"; + console.log("b", sheet === shared, sheet instanceof CSSStyleSheet); + `, + "/shared.js": /* js */ ` + import sheet from "./widget.css" with { type: "css" }; + export const shared = sheet; + `, + "/widget.css": /* css */ `p.w { color: red }`, + "/test.js": /* js */ ` + ${cssStyleSheetShim} + await import("./out/a.js"); + await import("./out/b.js"); + `, + }, + entryPoints: ["/a.js", "/b.js"], + splitting: true, + outdir: "/out", + target: "browser", + onAfterBundle(api) { + expect(outputFiles(api.outdir, ".css")).toEqual([]); + // One module, one stylesheet: the stub is emitted once, in the shared chunk. + const withSheet = outputFiles(api.outdir, ".js").filter(f => api.readFile("/out/" + f).includes("color: red")); + expect(withSheet).toHaveLength(1); + expect(withSheet[0]).not.toBe("a.js"); + expect(withSheet[0]).not.toBe("b.js"); + }, + run: { + file: "/test.js", + stdout: "a true true\nb true true", + }, + }); + + for (const splitting of [false, true]) { + itBundled(`css-module-script/DynamicImport${splitting ? "Splitting" : ""}`, { + files: { + "/entry.js": /* js */ ` + const { default: sheet } = await import("./widget.css", { with: { type: "css" } }); + console.log(sheet instanceof CSSStyleSheet, JSON.stringify(sheet.cssText)); + `, + "/widget.css": /* css */ `p.w { color: red }`, + "/test.js": /* js */ ` + ${cssStyleSheetShim} + await import("./out/entry.js"); + `, + }, + entryPoints: ["/entry.js"], + splitting, + outdir: "/out", + target: "browser", + onAfterBundle(api) { + expect(outputFiles(api.outdir, ".css")).toEqual([]); + }, + run: { + file: "/test.js", + stdout: `true "p.w {\\n color: red;\\n}"`, + }, + }); + } + + // The bundler has one module per file, so a plain import and an attributed + // `import()` of the same file share it: the plain import still applies the + // file to the page, and the `import()` resolves to the stylesheet module. + itBundled("css-module-script/PlainImportAndAttributedDynamicImport", { + files: { + "/entry.js": /* js */ ` + import "./widget.css"; + const { default: sheet } = await import("./widget.css", { with: { type: "css" } }); + console.log(sheet instanceof CSSStyleSheet, JSON.stringify(sheet.cssText)); + `, + "/widget.css": /* css */ `p.w { color: red }`, + "/test.js": /* js */ ` + ${cssStyleSheetShim} + await import("./out/entry.js"); + `, + }, + entryPoints: ["/entry.js"], + splitting: true, + outdir: "/out", + target: "browser", + onAfterBundle(api) { + api.expectFile("/out/entry.css").toContain("p.w"); + }, + run: { + file: "/test.js", + stdout: `true "p.w {\\n color: red;\\n}"`, + }, + }); + + itBundled("css-module-script/Minified", { + files: { + "/entry.js": /* js */ ` + import sheet from "./widget.css" with { type: "css" }; + console.log(sheet.cssText); + `, + "/widget.css": /* css */ `p.w { color: red; border-top: 7px solid }`, + "/test.js": /* js */ ` + ${cssStyleSheetShim} + await import("./out/entry.js"); + `, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + target: "browser", + minifyWhitespace: true, + minifySyntax: true, + minifyIdentifiers: true, + run: { + file: "/test.js", + stdout: "p.w{color:red;border-top:7px solid}", + }, + }); + + itBundled("css-module-script/UnusedImportEmitsNothing", { + files: { + "/entry.js": /* js */ ` + import sheet from "./widget.css" with { type: "css" }; + console.log("hi"); + `, + "/widget.css": /* css */ `p.w { color: red }`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + target: "browser", + onAfterBundle(api) { + expect(outputFiles(api.outdir, ".css")).toEqual([]); + api.expectFile("/out/entry.js").not.toContain("CSSStyleSheet"); + api.expectFile("/out/entry.js").not.toContain("color"); + }, + run: { + stdout: "hi", + }, + }); + + itBundled("css-module-script/CSSModulesFileIsAnError", { + files: { + "/entry.js": /* js */ ` + import sheet from "./styles.module.css" with { type: "css" }; + console.log(sheet); + `, + "/styles.module.css": /* css */ `.box { color: red }`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + target: "browser", + bundleErrors: { + "/entry.js": ['A CSS module ("styles.module.css") cannot be imported with { type: "css" }.'], + }, + }); + + itBundled("css-module-script/NamedImportIsAnError", { + files: { + "/entry.js": /* js */ ` + import sheet, { rules } from "./widget.css" with { type: "css" }; + console.log(sheet, rules); + `, + "/widget.css": /* css */ `p.w { color: red }`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + target: "browser", + bundleErrors: { + "/entry.js": ['This loader type only supports the "default" import'], + }, + }); + + // Bun and Node have no `CSSStyleSheet`: those targets keep the old behavior, + // which is also what `bun run` does with this import. + for (const target of ["bun", "node"] as const) { + itBundled(`css-module-script/Target${target[0].toUpperCase()}${target.slice(1)}IsUnchanged`, { + files: { + "/entry.js": /* js */ ` + import sheet from "./widget.css" with { type: "css" }; + console.log(JSON.stringify(sheet)); + `, + "/widget.css": /* css */ `p.w { color: red }`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + target, + onAfterBundle(api) { + api.expectFile("/out/entry.css").toContain("p.w"); + api.expectFile("/out/entry.js").not.toContain("CSSStyleSheet"); + }, + run: { + stdout: "{}", + }, + }); + } +}); diff --git a/test/integration/bun-types/fixture/ts7.1/import-attributes.ts b/test/integration/bun-types/fixture/ts7.1/import-attributes.ts index b86f0c9dc566..dc00d7e43edf 100644 --- a/test/integration/bun-types/fixture/ts7.1/import-attributes.ts +++ b/test/integration/bun-types/fixture/ts7.1/import-attributes.ts @@ -44,6 +44,10 @@ expectType(embedded).is(); import bundle from "./page.txt" with { type: "html" }; expectType(bundle).is(); +// A CSS module script is a `CSSStyleSheet` with lib.dom; this fixture has no DOM lib. +import sheet from "./styles.css" with { type: "css" }; +expectType(sheet).is(); + // Dynamic imports carry their attributes too. const dynamic = await import("./template.html", { with: { type: "text" } }); expectType(dynamic.default).is(); diff --git a/test/regression/issue/cyclic-imports-async-bundler.test.js b/test/regression/issue/cyclic-imports-async-bundler.test.js index 1932c6182961..aa82126dd68b 100644 --- a/test/regression/issue/cyclic-imports-async-bundler.test.js +++ b/test/regression/issue/cyclic-imports-async-bundler.test.js @@ -166,7 +166,7 @@ test("cyclic imports with async dependencies should generate async wrappers", as await Promise.resolve(); AsyncEntryPoint(); - //# debugId=5B573DC06E466ACE64756E2164756E21 + //# debugId=E2CCD6337A786B1564756E2164756E21 //# sourceMappingURL=entryBuild.js.map " `);