Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/bundler/css.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

</Warning>

## 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 `{}`.
11 changes: 11 additions & 0 deletions packages/bun-types/ts7.1/import-attributes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,14 @@ declare module "*" with { type: "html" } {
var contents: import("bun").HTMLBundle;
export = contents;
}

declare module "*" with { type: "css" } {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* 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;
}
4 changes: 4 additions & 0 deletions src/ast/import_record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
const CSS_MODULE_SCRIPT = 1 << 19;
}
}

Expand Down
44 changes: 42 additions & 2 deletions src/bundler/LinkerContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,32 @@ pub struct LinkerContext<'a> {
pub(crate) inits_already_done: Option<AutoBitSet>,
/// The part `scan_imports_and_exports` adds to each entry point file (`u32::MAX` elsewhere).
pub(crate) entry_point_part_indices: Vec<u32>,
/// 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.
Comment thread
robobun marked this conversation as resolved.
pub(crate) css_module_scripts: ArrayHashMap<u32, CssModuleScript>,
}

/// 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.
Comment thread
robobun marked this conversation as resolved.
pub(crate) call: Option<bun_ast::StoreRef<E::Call>>,
/// ESM output with copied assets: `url()`s print as
/// `${__cssUrl(path, import.meta.url)}` so they resolve against the chunk.
Comment thread
robobun marked this conversation as resolved.
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).
Comment thread
robobun marked this conversation as resolved.
#[inline]
pub(crate) fn is_chunked_as_js(
css_asts: &[crate::bundled_ast::CssCol],
css_module_scripts: &ArrayHashMap<u32, CssModuleScript>,
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` /
Expand Down Expand Up @@ -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(),
}
}
}
Expand Down Expand Up @@ -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<Index>`; clone the
// caller-owned slice into the linker arena.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.)
Expand Down Expand Up @@ -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]
Expand Down
20 changes: 20 additions & 0 deletions src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
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)
Expand Down
15 changes: 10 additions & 5 deletions src/bundler/linker_context/computeChunks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 5 additions & 2 deletions src/bundler/linker_context/findAllImportedPartsInJSOrder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
8 changes: 7 additions & 1 deletion src/bundler/linker_context/findImportedCSSFilesInJSOrder.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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));
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/bundler/linker_context/generateChunksInParallel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,6 +52,8 @@ pub(crate) fn generate_chunks_in_parallel<const IS_DEV_SERVER: bool>(
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;
Expand Down
96 changes: 92 additions & 4 deletions src/bundler/linker_context/generateCodeForLazyExport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,49 @@ pub(crate) fn generate_code_for_lazy_export(
}
}

// A CSS module script exports `__cssModule("<css>")`. The CSS is printed
// later (`generate_css_module_script_texts`), so the argument starts empty.
Comment thread
robobun marked this conversation as resolved.
let mut css_module_script: Option<crate::linker_context_mod::CssModuleScript> = 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<ExprData>` 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");
Expand All @@ -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 => {
Expand All @@ -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,
)?;
}
Expand Down Expand Up @@ -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,
)?;
}
Expand All @@ -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).
Comment thread
robobun marked this conversation as resolved.
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<IndexInt> = vec![source_index];
let mut stack: Vec<IndexInt> = 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
}
Loading
Loading