From 7418f67e0084639d9db89eafa47f81813b1cf705 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:54:24 +0000 Subject: [PATCH 1/7] bundler: honor `with { type: "css" }` as a CSS module script in browser builds An import with the `type: "css"` attribute now evaluates to a constructed CSSStyleSheet that holds the bundled CSS of that file, and the file no longer lands in the importing entry point's stylesheet. Before, the JS binding was `{}` and the rules were applied to the whole page. The dev server cannot represent this yet and reports the import as unsupported instead of emitting a dangling binding. --- src/ast/import_record.rs | 5 + src/bundler/LinkerContext.rs | 29 +- src/bundler/bundle_v2.rs | 22 ++ src/bundler/linker_context/computeChunks.rs | 16 +- .../findAllImportedPartsInJSOrder.rs | 3 +- .../findImportedCSSFilesInJSOrder.rs | 9 +- .../generateChunksInParallel.rs | 7 +- .../generateCodeForLazyExport.rs | 52 ++- .../generateCompileResultForCssChunk.rs | 93 ++++++ .../linker_context/mergeSmallChunks.rs | 3 +- .../linker_context/prepareCssAstsForChunk.rs | 2 +- .../linker_context/scanImportsAndExports.rs | 27 ++ src/js_parser/p.rs | 19 +- src/runtime.js | 8 + test/bake/dev/css.test.ts | 28 ++ test/bundler/css/css-module-scripts.test.ts | 296 ++++++++++++++++++ 16 files changed, 599 insertions(+), 20 deletions(-) create mode 100644 test/bundler/css/css-module-scripts.test.ts diff --git a/src/ast/import_record.rs b/src/ast/import_record.rs index 508550f2aa33..4e579e44fe03 100644 --- a/src/ast/import_record.rs +++ b/src/ast/import_record.rs @@ -106,6 +106,11 @@ 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; + + /// Written with the `with { type: "css" }` import attribute: a CSS module + /// script, whose default export is a constructed `CSSStyleSheet` rather + /// than a stylesheet applied to the document. + const CSS_MODULE_SCRIPT = 1 << 19; } } diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 5d8576f64d36..8f965302b84b 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -139,6 +139,14 @@ 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 that browser code imports with `with { type: "css" }` (CSS + /// module scripts), by source index. Their JS stub exports a constructed + /// `CSSStyleSheet` instead of `{}`, the importing chunk's CSS leaves them + /// out, and chunk assignment treats them as JS files. The value is the + /// string literal argument of the stub's `__cssModule()` call: + /// `generate_code_for_lazy_export` creates it empty and + /// `generate_chunks_in_parallel` fills in the printed CSS. + pub(crate) css_module_scripts: ArrayHashMap>>, } // SAFETY: `LinkerContext` is shared across the worker pool via `each_ptr` / @@ -178,6 +186,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 +532,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 +2887,11 @@ impl<'a> LinkerContext<'a> { ctx.queue.push_back((record.source_index.get(), out_dist)); } } - continue; + // Except the JS stub of a CSS module script, whose export part + // depends on the runtime's `__cssModule`. + if !self.is_css_module_script(source_index) { + continue; + } } // A dead part prints nothing, so only live parts reach other files. @@ -3089,7 +3103,12 @@ impl<'a> LinkerContext<'a> { } } } - return; + // The JS stub of a CSS module script is a module of its own: as an + // `import()` target or inside a CommonJS wrapper its parts are live + // like those of any other lazy-export file. + if !self.is_css_module_script(source_index) { + return; + } } // HTML files can reference non-JS/CSS assets (favicons, images, etc.) @@ -3320,6 +3339,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..0f0673802160 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -6689,6 +6689,28 @@ pub mod bv2_impl { if let Some(dev_server) = self.dev_server_handle() { 'brk: { + // A production build turns this into a constructed + // `CSSStyleSheet`. The dev server serves CSS as hot-reloaded + // `` tags outside of the module graph, which cannot + // represent that yet. + 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..fb536a2b2a4f 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -138,7 +138,12 @@ pub(crate) fn compute_chunks( } } - if css_asts[source_index as usize].is_some() { + // `import("./x.css", { with: { type: "css" } })` loads the JS stub of a + // CSS module script, so that entry point gets a JS chunk below. + 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 +322,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() { + // The JS stub of a CSS module script is a regular module (its + // `CSSStyleSheet` has an identity), so it is placed like JS. + if css_reprs[source_index.get() as usize].is_none() + || this.css_module_scripts.contains(&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..761befd39911 100644 --- a/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs +++ b/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs @@ -385,7 +385,8 @@ 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() + && (self.c.graph.ast.items_css()[source_index as usize].is_none() + || self.c.is_css_module_script(source_index)) { // when code splitting, include the file in the chunk if ALL of the entry points overlap self.entry_bits diff --git a/src/bundler/linker_context/findImportedCSSFilesInJSOrder.rs b/src/bundler/linker_context/findImportedCSSFilesInJSOrder.rs index e4b25a83c5b8..22feebb75de3 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,13 @@ 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 reaches JS as a `CSSStyleSheet` object, + // not as part of this chunk's stylesheet. + 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..31b1dc8a7ad7 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,9 @@ pub(crate) fn generate_chunks_in_parallel( let _trace = bun_core::perf::trace("Bundler.generateChunksInParallel"); c.mangle_local_css(); + // Must run before any JS is printed: it fills in the string literals that + // the CSS module script stubs print. + 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..5ef79540a0f8 100644 --- a/src/bundler/linker_context/generateCodeForLazyExport.rs +++ b/src/bundler/linker_context/generateCodeForLazyExport.rs @@ -364,6 +364,43 @@ pub(crate) fn generate_code_for_lazy_export( } } + // A CSS module script exports `__cssModule("")`, a constructed + // `CSSStyleSheet`. The CSS text is only known once chunks are generated, so + // the string literal starts empty and `LinkerContext::css_module_scripts` + // remembers it. + let css_module_script = maybe_css_ast.is_some() && this.is_css_module_script(source_index); + if css_module_script { + let stmt: Stmt = part.stmts[0]; + let StmtData::SLazyExport(mut slot) = stmt.data else { + panic!("Internal error: expected top-level lazy export statement"); + }; + let css_text = Expr::init(E::EString::init(b""), stmt.loc); + let ExprData::EString(css_text_string) = css_text.data else { + unreachable!(); + }; + *this + .css_module_scripts + .get_ptr_mut(&source_index) + .expect("checked by is_css_module_script") = Some(css_text_string); + 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(&[css_text]), + can_be_unwrapped_if_unused: E::CallUnwrap::IfUnused, + ..Default::default() + }, + stmt.loc, + ); + // `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 +415,13 @@ 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_function_called: Option<&[u8]> = if calls_runtime_require { + Some(b"__require") + } else if css_module_script { + Some(b"__cssModule") + } else { + None + }; match exports_kind { bun_ast::ExportsKind::Cjs => { @@ -401,11 +445,11 @@ pub(crate) fn generate_code_for_lazy_export( Index::init(source_index), )?; - if calls_runtime_require { + if let Some(name) = runtime_function_called { this.graph.generate_runtime_symbol_import_and_use( source_index, Index::part(1u32), - b"__require", + name, 1, )?; } @@ -509,11 +553,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 { + if let Some(name) = runtime_function_called { this.graph.generate_runtime_symbol_import_and_use( source_index, Index::part(generated.1), - b"__require", + name, 1, )?; } diff --git a/src/bundler/linker_context/generateCompileResultForCssChunk.rs b/src/bundler/linker_context/generateCompileResultForCssChunk.rs index 7e1a52100a4f..372468865526 100644 --- a/src/bundler/linker_context/generateCompileResultForCssChunk.rs +++ b/src/bundler/linker_context/generateCompileResultForCssChunk.rs @@ -53,6 +53,99 @@ 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 the CSS of every CSS module script (see +/// [`LinkerContext::css_module_scripts`]) into the string literal of its JS +/// stub. Each file is rendered as a CSS chunk of its own would be: `@import`ed +/// files are inlined in order and `url()` assets print as unique-key +/// placeholders, which the JS chunk later replaces with output paths. +pub(crate) fn generate_css_module_script_texts(c: &mut LinkerContext) -> Result<(), crate::Error> { + let entries: Vec<(u32, bun_ast::StoreRef)> = c + .css_module_scripts + .iter() + .filter_map(|(&source_index, string)| string.map(|string| (source_index, string))) + .collect(); + if entries.is_empty() { + return Ok(()); + } + + // SAFETY: `c` is the `linker` field of the live `BundleV2` that drives this + // link step; `Worker::get` only needs `&BundleV2`. + let bundle = unsafe { &*LinkerContext::bundle_v2_ptr(std::ptr::from_mut(c)) }; + let mut worker = scopeguard::guard(Worker::get(bundle), |w| w.unget()); + let separator: &[u8] = if c.options.minify_whitespace { + b"" + } else { + b"\n" + }; + + for (source_index, mut string) in entries { + 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(&mut 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"), + } + } + + // The text must outlive the JS printer, so it lives in the linker arena. + let text: &[u8] = c.arena().alloc_slice_copy(&css); + *string = bun_ast::E::EString::init(text); + string.prefer_template = !c.options.minify_whitespace; + } + + Ok(()) +} + 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..52e00c416959 100644 --- a/src/bundler/linker_context/mergeSmallChunks.rs +++ b/src/bundler/linker_context/mergeSmallChunks.rs @@ -661,7 +661,8 @@ 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() + && (css_asts[source_index as usize].is_none() + || this.css_module_scripts.contains(&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..f1f9e22ed9a0 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,32 @@ pub(crate) fn scan_imports_and_exports( } let other_kind = col_ref!(exports_kind)[other_file]; + // `import sheet from "./x.css" with { type: "css" }` from browser + // code is a CSS module script: `x.css` becomes a constructed + // `CSSStyleSheet` (see `LinkerContext::css_module_scripts`). + // Other targets have no `CSSStyleSheet` (and `bun run` evaluates + // this import to `{}`), so they keep the plain CSS import. The + // dev server rejects these imports while resolving them. + 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, None)?; + } + } + // 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..bbab74a74c28 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1468,7 +1468,13 @@ 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); + 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); + } } self.import_records.items_mut()[import_record_index as usize] @@ -4564,10 +4570,15 @@ 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); + let record = &mut self.import_records.items_mut()[stmt.import_record_index as usize]; + record.loader = Some(loader); - if loader == options::Loader::Sqlite || loader == options::Loader::SqliteEmbedded { + if loader == options::Loader::Css { + record + .flags + .insert(bun_ast::ImportRecordFlags::CSS_MODULE_SCRIPT); + } else if loader == options::Loader::Sqlite || loader == options::Loader::SqliteEmbedded + { // 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/runtime.js b/src/runtime.js index 5260178f1723..048a9cd362f5 100644 --- a/src/runtime.js +++ b/src/runtime.js @@ -342,6 +342,14 @@ export var $$typeof = /* @__PURE__ */ Symbol.for("react.element"); export var __jsonParse = /* @__PURE__ */ a => JSON.parse(a); +// `import sheet from "./x.css" with { type: "css" }` (a CSS module script): +// the default export is a constructed stylesheet with the bundled CSS text. +export var __cssModule = css => { + var sheet = new CSSStyleSheet(); + sheet.replaceSync(css); + return sheet; +}; + 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..bbc3ac020b56 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -698,6 +698,34 @@ 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": `p { 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"); + }, +}); + function extractCssUrl(backgroundImage: string): string { const url = backgroundImage.match(/url\((['"])(.*?)\1\)/); if (!url) { 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..1c2bf88bcaf9 --- /dev/null +++ b/test/bundler/css/css-module-scripts.test.ts @@ -0,0 +1,296 @@ +import { describe, expect } from "bun:test"; +import { readdirSync } from "node:fs"; +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("new CSSStyleSheet"); + }, + }); + + itBundled("css-module-script/InlinesAtImportAndRewritesUrl", { + 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/entry.js"); + `, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + target: "browser", + onAfterBundle(api) { + expect(outputFiles(api.outdir, ".css")).toEqual([]); + expect(outputFiles(api.outdir, ".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"); + expect(stdout).toMatch(/url\("?\.\/image-[a-z0-9]+\.png"?\)/); + }, + }, + }); + + 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}"`, + }, + }); + } + + 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" }.'], + }, + }); + + // Bun and Node have no `CSSStyleSheet`: those targets keep the old behavior, + // which is also what `bun run` does with this import. + itBundled("css-module-script/TargetBunIsUnchanged", { + 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: "bun", + onAfterBundle(api) { + api.expectFile("/out/entry.css").toContain("p.w"); + api.expectFile("/out/entry.js").not.toContain("CSSStyleSheet"); + }, + run: { + stdout: "{}", + }, + }); +}); From f33152a035be86e0d19483b27e16b1337a3b1a56 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:48:50 +0000 Subject: [PATCH 2/7] bundler: resolve CSS module script url() assets against the chunk, cover export-from - url() to a copied asset prints as ${__cssUrl(path, import.meta.url)} in ESM output, so it resolves relative to the JS chunk (where the asset path points) instead of the page. - Go through globalThis for CSSStyleSheet and URL so bundling does not reserve those names and rename user declarations (happy-dom's CSSStyleSheet class). - export { default as x } from "./a.css" with { type: "css" } (and other type attributes on export-from) now applies the attribute's loader like import does. - Update the two debugId snapshots that move whenever runtime.js changes. - Docs: CSS module scripts section in docs/bundler/css.mdx. --- docs/bundler/css.mdx | 14 ++ src/bundler/LinkerContext.rs | 21 +- .../generateCodeForLazyExport.rs | 87 +++++-- .../generateCompileResultForCssChunk.rs | 233 +++++++++++++----- .../linker_context/scanImportsAndExports.rs | 3 +- src/js_parser/p.rs | 34 +-- src/js_parser/parse/parse_stmt.rs | 17 +- src/runtime.js | 8 +- test/bake/dev/css.test.ts | 3 +- .../bundler_promiseall_deadcode.test.ts | 2 +- test/bundler/css/css-module-scripts.test.ts | 148 +++++++++-- .../cyclic-imports-async-bundler.test.js | 2 +- 12 files changed, 430 insertions(+), 142 deletions(-) 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/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 8f965302b84b..4293bc03867c 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -142,11 +142,22 @@ pub struct LinkerContext<'a> { /// CSS files that browser code imports with `with { type: "css" }` (CSS /// module scripts), by source index. Their JS stub exports a constructed /// `CSSStyleSheet` instead of `{}`, the importing chunk's CSS leaves them - /// out, and chunk assignment treats them as JS files. The value is the - /// string literal argument of the stub's `__cssModule()` call: - /// `generate_code_for_lazy_export` creates it empty and - /// `generate_chunks_in_parallel` fills in the printed CSS. - pub(crate) css_module_scripts: ArrayHashMap>>, + /// out, and chunk assignment 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_code_for_lazy_export` + /// creates it and `generate_chunks_in_parallel` replaces the argument with + /// the printed CSS. + pub(crate) call: Option>, + /// The CSS references copied assets and the output is ESM: each such + /// `url()` prints as `${__cssUrl(path, import.meta.url)}`, so it resolves + /// against the chunk like it would in a `.css` file next to it, not + /// against the page. + pub(crate) resolve_asset_urls: bool, } // SAFETY: `LinkerContext` is shared across the worker pool via `each_ptr` / diff --git a/src/bundler/linker_context/generateCodeForLazyExport.rs b/src/bundler/linker_context/generateCodeForLazyExport.rs index 5ef79540a0f8..9ddeab5734b0 100644 --- a/src/bundler/linker_context/generateCodeForLazyExport.rs +++ b/src/bundler/linker_context/generateCodeForLazyExport.rs @@ -366,22 +366,14 @@ pub(crate) fn generate_code_for_lazy_export( // A CSS module script exports `__cssModule("")`, a constructed // `CSSStyleSheet`. The CSS text is only known once chunks are generated, so - // the string literal starts empty and `LinkerContext::css_module_scripts` - // remembers it. - let css_module_script = maybe_css_ast.is_some() && this.is_css_module_script(source_index); - if css_module_script { + // the argument starts empty and `LinkerContext::css_module_scripts` + // remembers the call. + 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 css_text = Expr::init(E::EString::init(b""), stmt.loc); - let ExprData::EString(css_text_string) = css_text.data else { - unreachable!(); - }; - *this - .css_module_scripts - .get_ptr_mut(&source_index) - .expect("checked by is_css_module_script") = Some(css_text_string); let call = Expr::init( E::Call { target: Expr::init( @@ -391,12 +383,28 @@ pub(crate) fn generate_code_for_lazy_export( }, stmt.loc, ), - args: bun_ast::ExprNodeList::from_slice(&[css_text]), + 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; } @@ -415,12 +423,16 @@ 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_function_called: Option<&[u8]> = if calls_runtime_require { - Some(b"__require") - } else if css_module_script { - Some(b"__cssModule") + 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 { - None + &[] }; match exports_kind { @@ -445,7 +457,7 @@ pub(crate) fn generate_code_for_lazy_export( Index::init(source_index), )?; - if let Some(name) = runtime_function_called { + for name in runtime_functions_called { this.graph.generate_runtime_symbol_import_and_use( source_index, Index::part(1u32), @@ -553,7 +565,7 @@ 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 let Some(name) = runtime_function_called { + for name in runtime_functions_called { this.graph.generate_runtime_symbol_import_and_use( source_index, Index::part(generated.1), @@ -567,3 +579,38 @@ pub(crate) fn generate_code_for_lazy_export( Ok(()) } + +/// Whether printing the CSS file `source_index`, with the CSS files it +/// `@import`s inlined, emits a `url()` that points at a copied asset rather +/// than 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 372468865526..f197390b1c38 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; @@ -54,96 +54,199 @@ pub(crate) unsafe fn generate_compile_result_for_css_chunk(task: *mut ThreadPool } /// Prints the CSS of every CSS module script (see -/// [`LinkerContext::css_module_scripts`]) into the string literal of its JS -/// stub. Each file is rendered as a CSS chunk of its own would be: `@import`ed -/// files are inlined in order and `url()` assets print as unique-key -/// placeholders, which the JS chunk later replaces with output paths. +/// [`LinkerContext::css_module_scripts`]) into the argument of its JS stub's +/// `__cssModule()` call. Each file is rendered as a CSS chunk of its own would +/// be: `@import`ed files are inlined in order and `url()` assets print as +/// unique-key placeholders, which the JS chunk later replaces with output paths. pub(crate) fn generate_css_module_script_texts(c: &mut LinkerContext) -> Result<(), crate::Error> { - let entries: Vec<(u32, bun_ast::StoreRef)> = c + let entries: Vec<(u32, crate::linker_context_mod::CssModuleScript)> = c .css_module_scripts .iter() - .filter_map(|(&source_index, string)| string.map(|string| (source_index, string))) + .filter(|(_, script)| script.call.is_some()) + .map(|(&source_index, &script)| (source_index, script)) .collect(); if entries.is_empty() { return Ok(()); } - // SAFETY: `c` is the `linker` field of the live `BundleV2` that drives this - // link step; `Worker::get` only needs `&BundleV2`. - let bundle = unsafe { &*LinkerContext::bundle_v2_ptr(std::ptr::from_mut(c)) }; - let mut worker = scopeguard::guard(Worker::get(bundle), |w| w.unget()); let separator: &[u8] = if c.options.minify_whitespace { b"" } else { b"\n" }; - for (source_index, mut string) in entries { - let order = crate::linker_context::find_imported_files_in_css_order::find_imported_files_in_css_order( + // SAFETY: `c` is the `linker` field of the live `BundleV2` that drives this + // link step; `Worker::get` only needs `&BundleV2`. Holding the worker also + // makes its AST store the one `Expr::init` allocates from below. + 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, - 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() + &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, + ) }; - crate::linker_context::prepare_css_asts_for_chunk::prepare_css_asts_for_chunk_impl( + 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, allocated in the linker arena so it +/// outlives the JS printer. +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, - &mut chunk, 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(&mut 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); + 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; } - 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); + if !css.is_empty() { + css.extend_from_slice(separator); } - _ => unreachable!("CSS chunk produced a non-CSS compile result"), + 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"), } - - // The text must outlive the JS printer, so it lives in the linker arena. - let text: &[u8] = c.arena().alloc_slice_copy(&css); - *string = bun_ast::E::EString::init(text); - string.prefer_template = !c.options.minify_whitespace; } - Ok(()) + // 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( diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index f1f9e22ed9a0..d8254c165a70 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -248,7 +248,8 @@ pub(crate) fn scan_imports_and_exports( ), ); } else { - this.css_module_scripts.put(other_file as u32, None)?; + this.css_module_scripts + .put(other_file as u32, Default::default())?; } } diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index bbab74a74c28..0f33a434dfe4 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1468,13 +1468,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } if let Some(loader) = state.import_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); - } + self.set_import_record_loader(import_record_index, loader); } self.import_records.items_mut()[import_record_index as usize] @@ -4563,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, @@ -4570,15 +4580,9 @@ 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 { - let record = &mut self.import_records.items_mut()[stmt.import_record_index as usize]; - record.loader = Some(loader); + self.set_import_record_loader(stmt.import_record_index, loader); - if loader == options::Loader::Css { - record - .flags - .insert(bun_ast::ImportRecordFlags::CSS_MODULE_SCRIPT); - } else if loader == options::Loader::Sqlite || loader == options::Loader::SqliteEmbedded - { + if loader == options::Loader::Sqlite || loader == options::Loader::SqliteEmbedded { // 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 048a9cd362f5..2d84a38708e0 100644 --- a/src/runtime.js +++ b/src/runtime.js @@ -344,12 +344,18 @@ export var __jsonParse = /* @__PURE__ */ a => JSON.parse(a); // `import sheet from "./x.css" with { type: "css" }` (a CSS module script): // the default export is a constructed stylesheet with the bundled CSS text. +// Globals go through `globalThis` so bundling does not reserve their names +// (DOM implementations such as happy-dom declare their own `CSSStyleSheet`). export var __cssModule = css => { - var sheet = new CSSStyleSheet(); + var sheet = new globalThis.CSSStyleSheet(); sheet.replaceSync(css); return sheet; }; +// A `url()` in that CSS points at an asset relative to the JS chunk, so ESM +// output resolves it against `import.meta.url` instead of the page. +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 bbc3ac020b56..a2ac1d824736 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -711,7 +711,7 @@ devTest("css module script import is reported as unsupported", { import sheet from "./widget.css" with { type: "css" }; console.log(sheet); `, - "widget.css": `p { color: red; }`, + "widget.css": `body { color: red; }`, }, async test(dev) { await using c = await dev.client("/", { @@ -723,6 +723,7 @@ devTest("css module script import is reported as unsupported", { await dev.write("index.ts", `import "./widget.css"; console.log("plain");`); }); await c.expectMessage("plain"); + await c.style("body").color.expect.toBe("red"); }, }); 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 index 1c2bf88bcaf9..1febe6208336 100644 --- a/test/bundler/css/css-module-scripts.test.ts +++ b/test/bundler/css/css-module-scripts.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test"; -import { readdirSync } from "node:fs"; +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: @@ -76,11 +77,14 @@ describe("bundler", () => { 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("new CSSStyleSheet"); + api.expectFile("/out/" + js).toContain("CSSStyleSheet"); }, }); - itBundled("css-module-script/InlinesAtImportAndRewritesUrl", { + // `@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" }; @@ -95,15 +99,17 @@ describe("bundler", () => { "/image.png": Buffer.alloc(256 * 1024, 7), "/test.js": /* js */ ` ${cssStyleSheetShim} - await import("./out/entry.js"); + await import("./out/chunks/entry.js"); `, }, entryPoints: ["/entry.js"], + entryNaming: "chunks/[name].[ext]", + assetNaming: "assets/[name]-[hash].[ext]", outdir: "/out", target: "browser", onAfterBundle(api) { expect(outputFiles(api.outdir, ".css")).toEqual([]); - expect(outputFiles(api.outdir, ".png")).toHaveLength(1); + expect(outputFiles(api.outdir + "/assets", ".png")).toHaveLength(1); }, run: { file: "/test.js", @@ -112,11 +118,72 @@ describe("bundler", () => { expect(stdout.indexOf("p.base")).toBeGreaterThanOrEqual(0); expect(stdout.indexOf("p.base")).toBeLessThan(stdout.indexOf("p.w")); expect(stdout).not.toContain("@import"); - expect(stdout).toMatch(/url\("?\.\/image-[a-z0-9]+\.png"?\)/); + // 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 */ ` @@ -211,6 +278,35 @@ describe("bundler", () => { }); } + // 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 */ ` @@ -274,23 +370,25 @@ describe("bundler", () => { // Bun and Node have no `CSSStyleSheet`: those targets keep the old behavior, // which is also what `bun run` does with this import. - itBundled("css-module-script/TargetBunIsUnchanged", { - 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: "bun", - onAfterBundle(api) { - api.expectFile("/out/entry.css").toContain("p.w"); - api.expectFile("/out/entry.js").not.toContain("CSSStyleSheet"); - }, - run: { - stdout: "{}", - }, - }); + 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/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 " `); From 15d8aef5f57885bddf88600b0eb85b861b83d03d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:54:09 +0000 Subject: [PATCH 3/7] tighten comments --- src/ast/import_record.rs | 5 ++-- src/bundler/LinkerContext.rs | 25 +++++++------------ src/bundler/bundle_v2.rs | 6 ++--- src/bundler/linker_context/computeChunks.rs | 6 ++--- .../findImportedCSSFilesInJSOrder.rs | 3 +-- .../generateChunksInParallel.rs | 3 +-- .../generateCodeForLazyExport.rs | 11 +++----- .../generateCompileResultForCssChunk.rs | 16 +++++------- .../linker_context/scanImportsAndExports.rs | 9 +++---- src/runtime.js | 9 +++---- 10 files changed, 33 insertions(+), 60 deletions(-) diff --git a/src/ast/import_record.rs b/src/ast/import_record.rs index 4e579e44fe03..7000ce31ebd7 100644 --- a/src/ast/import_record.rs +++ b/src/ast/import_record.rs @@ -107,9 +107,8 @@ bitflags::bitflags! { /// reads `.default` to return `module.exports`. const CROSS_CHUNK_REQUIRE_DEFAULT = 1 << 18; - /// Written with the `with { type: "css" }` import attribute: a CSS module - /// script, whose default export is a constructed `CSSStyleSheet` rather - /// than a stylesheet applied to the document. + /// `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 4293bc03867c..2d45f240d624 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -139,24 +139,20 @@ 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 that browser code imports with `with { type: "css" }` (CSS - /// module scripts), by source index. Their JS stub exports a constructed - /// `CSSStyleSheet` instead of `{}`, the importing chunk's CSS leaves them - /// out, and chunk assignment treats them as JS files. + /// 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_code_for_lazy_export` - /// creates it and `generate_chunks_in_parallel` replaces the argument with - /// the printed CSS. + /// The stub's `__cssModule("")` call; `generate_css_module_script_texts` + /// replaces the argument with the printed CSS. pub(crate) call: Option>, - /// The CSS references copied assets and the output is ESM: each such - /// `url()` prints as `${__cssUrl(path, import.meta.url)}`, so it resolves - /// against the chunk like it would in a `.css` file next to it, not - /// against the page. + /// 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, } @@ -2898,8 +2894,7 @@ impl<'a> LinkerContext<'a> { ctx.queue.push_back((record.source_index.get(), out_dist)); } } - // Except the JS stub of a CSS module script, whose export part - // depends on the runtime's `__cssModule`. + // A CSS module script also has live JS parts (the stub). if !self.is_css_module_script(source_index) { continue; } @@ -3114,9 +3109,7 @@ impl<'a> LinkerContext<'a> { } } } - // The JS stub of a CSS module script is a module of its own: as an - // `import()` target or inside a CommonJS wrapper its parts are live - // like those of any other lazy-export file. + // A CSS module script also has live JS parts (the stub). if !self.is_css_module_script(source_index) { return; } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 0f0673802160..109d9f55bfd5 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -6689,10 +6689,8 @@ pub mod bv2_impl { if let Some(dev_server) = self.dev_server_handle() { 'brk: { - // A production build turns this into a constructed - // `CSSStyleSheet`. The dev server serves CSS as hot-reloaded - // `` tags outside of the module graph, which cannot - // represent that yet. + // 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) diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index fb536a2b2a4f..6d912cc63a2d 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -138,8 +138,7 @@ pub(crate) fn compute_chunks( } } - // `import("./x.css", { with: { type: "css" } })` loads the JS stub of a - // CSS module script, so that entry point gets a JS chunk below. + // `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; @@ -322,8 +321,7 @@ 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) { - // The JS stub of a CSS module script is a regular module (its - // `CSSStyleSheet` has an identity), so it is placed like JS. + // A CSS module script's stub is placed like a JS file. if css_reprs[source_index.get() as usize].is_none() || this.css_module_scripts.contains(&source_index.get()) { diff --git a/src/bundler/linker_context/findImportedCSSFilesInJSOrder.rs b/src/bundler/linker_context/findImportedCSSFilesInJSOrder.rs index 22feebb75de3..666d6feb0137 100644 --- a/src/bundler/linker_context/findImportedCSSFilesInJSOrder.rs +++ b/src/bundler/linker_context/findImportedCSSFilesInJSOrder.rs @@ -78,8 +78,7 @@ 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 reaches JS as a `CSSStyleSheet` object, - // not as part of this chunk's stylesheet. + // 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()) { diff --git a/src/bundler/linker_context/generateChunksInParallel.rs b/src/bundler/linker_context/generateChunksInParallel.rs index 31b1dc8a7ad7..6c5497c0dfd2 100644 --- a/src/bundler/linker_context/generateChunksInParallel.rs +++ b/src/bundler/linker_context/generateChunksInParallel.rs @@ -52,8 +52,7 @@ pub(crate) fn generate_chunks_in_parallel( let _trace = bun_core::perf::trace("Bundler.generateChunksInParallel"); c.mangle_local_css(); - // Must run before any JS is printed: it fills in the string literals that - // the CSS module script stubs print. + // Fills in AST that the JS printer reads below. generate_css_module_script_texts(c)?; let mut has_js_chunk = false; diff --git a/src/bundler/linker_context/generateCodeForLazyExport.rs b/src/bundler/linker_context/generateCodeForLazyExport.rs index 9ddeab5734b0..e8925fac18ac 100644 --- a/src/bundler/linker_context/generateCodeForLazyExport.rs +++ b/src/bundler/linker_context/generateCodeForLazyExport.rs @@ -364,10 +364,8 @@ pub(crate) fn generate_code_for_lazy_export( } } - // A CSS module script exports `__cssModule("")`, a constructed - // `CSSStyleSheet`. The CSS text is only known once chunks are generated, so - // the argument starts empty and `LinkerContext::css_module_scripts` - // remembers the call. + // 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]; @@ -580,9 +578,8 @@ pub(crate) fn generate_code_for_lazy_export( Ok(()) } -/// Whether printing the CSS file `source_index`, with the CSS files it -/// `@import`s inlined, emits a `url()` that points at a copied asset rather -/// than a `data:` URL or an external URL. +/// 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(); diff --git a/src/bundler/linker_context/generateCompileResultForCssChunk.rs b/src/bundler/linker_context/generateCompileResultForCssChunk.rs index f197390b1c38..c66967f50ef9 100644 --- a/src/bundler/linker_context/generateCompileResultForCssChunk.rs +++ b/src/bundler/linker_context/generateCompileResultForCssChunk.rs @@ -53,11 +53,9 @@ 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 the CSS of every CSS module script (see -/// [`LinkerContext::css_module_scripts`]) into the argument of its JS stub's -/// `__cssModule()` call. Each file is rendered as a CSS chunk of its own would -/// be: `@import`ed files are inlined in order and `url()` assets print as -/// unique-key placeholders, which the JS chunk later replaces with output paths. +/// 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 @@ -75,9 +73,8 @@ pub(crate) fn generate_css_module_script_texts(c: &mut LinkerContext) -> Result< b"\n" }; - // SAFETY: `c` is the `linker` field of the live `BundleV2` that drives this - // link step; `Worker::get` only needs `&BundleV2`. Holding the worker also - // makes its AST store the one `Expr::init` allocates from below. + // 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()); @@ -178,8 +175,7 @@ fn asset_placeholders(css: &[u8], prefix: &[u8]) -> Vec<(usize, usize)> { found } -/// The CSS text of one CSS module script, allocated in the linker arena so it -/// outlives the JS printer. +/// The CSS text of one CSS module script, in the linker arena. fn render_css_module_script( c: &mut LinkerContext, worker: &mut Worker, diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index d8254c165a70..37fc2600bcff 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -226,12 +226,9 @@ pub(crate) fn scan_imports_and_exports( } let other_kind = col_ref!(exports_kind)[other_file]; - // `import sheet from "./x.css" with { type: "css" }` from browser - // code is a CSS module script: `x.css` becomes a constructed - // `CSSStyleSheet` (see `LinkerContext::css_module_scripts`). - // Other targets have no `CSSStyleSheet` (and `bun run` evaluates - // this import to `{}`), so they keep the plain CSS import. The - // dev server rejects these imports while resolving them. + // 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 diff --git a/src/runtime.js b/src/runtime.js index 2d84a38708e0..f8aa72b958c6 100644 --- a/src/runtime.js +++ b/src/runtime.js @@ -342,18 +342,15 @@ export var $$typeof = /* @__PURE__ */ Symbol.for("react.element"); export var __jsonParse = /* @__PURE__ */ a => JSON.parse(a); -// `import sheet from "./x.css" with { type: "css" }` (a CSS module script): -// the default export is a constructed stylesheet with the bundled CSS text. -// Globals go through `globalThis` so bundling does not reserve their names -// (DOM implementations such as happy-dom declare their own `CSSStyleSheet`). +// `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; }; -// A `url()` in that CSS points at an asset relative to the JS chunk, so ESM -// output resolves it against `import.meta.url` instead of the page. +// 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); From 0c752d8a9be6215607f4675addf0ec0c4d0dad79 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:52:54 +0000 Subject: [PATCH 4/7] parser: a CSS module script import only has a default export Reject named imports with { type: "css" } at parse time, like the file and text loaders do, instead of leaving it to the linker. --- src/js_parser/p.rs | 5 ++++- test/bundler/css/css-module-scripts.test.ts | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 0f33a434dfe4..c65067b72b82 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -4596,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/test/bundler/css/css-module-scripts.test.ts b/test/bundler/css/css-module-scripts.test.ts index 1febe6208336..483a415ee477 100644 --- a/test/bundler/css/css-module-scripts.test.ts +++ b/test/bundler/css/css-module-scripts.test.ts @@ -368,6 +368,22 @@ describe("bundler", () => { }, }); + 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) { From f33f41330dc2531156884641206491fca6ab2719 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:16:44 +0000 Subject: [PATCH 5/7] test: look for the page stylesheet where it would be emitted --- test/bundler/css/css-module-scripts.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/bundler/css/css-module-scripts.test.ts b/test/bundler/css/css-module-scripts.test.ts index 483a415ee477..4717fc6ac9fd 100644 --- a/test/bundler/css/css-module-scripts.test.ts +++ b/test/bundler/css/css-module-scripts.test.ts @@ -108,7 +108,8 @@ describe("bundler", () => { outdir: "/out", target: "browser", onAfterBundle(api) { - expect(outputFiles(api.outdir, ".css")).toEqual([]); + // 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: { From de5bb03b80a01805f9cb8f9d865f36e59bf68817 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:10:17 +0000 Subject: [PATCH 6/7] bun-types: type `with { type: "css" }` imports; one predicate for JS-chunked files - packages/bun-types/ts7.1/import-attributes.d.ts: a CSS module script is a CSSStyleSheet when lib.dom is loaded, else an object. - is_chunked_as_js(): the single place that decides a CSS module script's stub is placed like a JS file (computeChunks, findAllImportedPartsInJSOrder, mergeSmallChunks). --- packages/bun-types/ts7.1/import-attributes.d.ts | 11 +++++++++++ src/bundler/LinkerContext.rs | 11 +++++++++++ src/bundler/linker_context/computeChunks.rs | 9 +++++---- .../linker_context/findAllImportedPartsInJSOrder.rs | 8 +++++--- src/bundler/linker_context/mergeSmallChunks.rs | 7 +++++-- .../bun-types/fixture/ts7.1/import-attributes.ts | 4 ++++ 6 files changed, 41 insertions(+), 9 deletions(-) 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/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 2d45f240d624..691cefd99981 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -156,6 +156,17 @@ pub(crate) struct CssModuleScript { 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` / // `SourceMapDataTask`. The raw-pointer fields (`parse_graph`, `resolver`, // `r#loop`, `framework`) are backrefs into `BundleV2`/`Transpiler` whose diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index 6d912cc63a2d..5bd2ed8ce8e3 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -321,10 +321,11 @@ 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) { - // A CSS module script's stub is placed like a JS file. - if css_reprs[source_index.get() as usize].is_none() - || this.css_module_scripts.contains(&source_index.get()) - { + 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 this.graph.code_splitting { diff --git a/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs b/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs index 761befd39911..65f1cbf7afb4 100644 --- a/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs +++ b/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs @@ -385,9 +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() - || self.c.is_css_module_script(source_index)) - { + && 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/mergeSmallChunks.rs b/src/bundler/linker_context/mergeSmallChunks.rs index 52e00c416959..e52390bb1148 100644 --- a/src/bundler/linker_context/mergeSmallChunks.rs +++ b/src/bundler/linker_context/mergeSmallChunks.rs @@ -661,8 +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() - || this.css_module_scripts.contains(&source_index)) + && 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/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(); From 65e38f01a1b236464acc9c11f551ba3f63acf3bb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:56:28 +0000 Subject: [PATCH 7/7] ci: retrigger