Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
95 changes: 95 additions & 0 deletions docs/bundler/css.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,101 @@ For browsers that don't support `system-ui`, Bun's CSS bundler expands it to a c

The expanded stack includes system fonts for macOS/iOS, Windows, Android, and Linux, plus fallbacks for older browsers.

## External imports

An `@import` of an `http://` or `https://` URL is an external import. Bun does not bundle the style sheet at that URL. The `@import` rule stays in the bundle, and the browser loads the style sheet.

CSS allows `@import` rules only at the start of a style sheet. So Bun prints every external import at the start of the bundle, ahead of the rules of your files:

```css title="styles.css" icon="file-code"
@import "./reset.css";
@import "https://example.com/theme.css";

.title {
font-weight: bold;
}
```

```css title="reset.css" icon="file-code"
.title {
color: black;
}
```

```css title="Output" icon="file-code"
@import "https://example.com/theme.css";

/* reset.css */
.title {
color: #000;
}

/* styles.css */
.title {
font-weight: bold;
}
```

### Rule order

In the source files, the rules of `reset.css` come before the rules of `theme.css`. In the bundle, they come after them. The browser orders two rules of the same [cascade layer](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer), and two rules that are in no layer, by their place in the style sheet. So if `reset.css` and `theme.css` both set the `color` of `.title`, the source files use the color of `theme.css`, and the bundle uses the color of `reset.css`.

To get the same result from the source files and from the bundle, write the external import first:

```css title="styles.css" icon="file-code"
@import "https://example.com/theme.css";
@import "./reset.css";
```

### Layer order

A cascade layer gets its priority from the place where its name first appears. Bun keeps that order when an external import has a layer. If the import moves ahead of a layer that one of your files declares first, Bun declares that layer ahead of the import:

```css title="styles.css" icon="file-code"
@import "./base.css";
@import "https://example.com/theme.css" layer(theme);

.title {
font-weight: bold;
}
```

```css title="base.css" icon="file-code"
@layer base {
.title {
color: black;
}
}
```

```css title="Output" icon="file-code"
@layer base;
@import "https://example.com/theme.css" layer(theme);

/* base.css */
@layer base {
.title {
color: #000;
}
}

/* styles.css */
.title {
font-weight: bold;
}
```

An `@layer` statement is not valid after an `@import` rule, and it cannot have a media query. In those places Bun prints an `@import` of an empty style sheet that declares the layer, such as `@import "data:text/css," layer(base) screen;`.

Bun cannot declare these layers ahead of an external import, so they come after the layer of the import in the bundle:

- A layer with no name: an `@layer` block with no name, or a file that you import with the `layer` keyword and no name, such as `@import "./a.css" layer;`. An external import inside such a file gets an anonymous layer of its own.
- A layer that a file declares inside `@media` or `@supports`.
- A layer under two media queries or two `supports()` conditions, from two levels of `@import`.
- Every layer that your files declare after one of these.

Bun cannot read the external style sheet. If that style sheet declares layers of its own, Bun does not know their names, and the layers of your files come after them in the bundle.

## CSS Modules

Bun's bundler also supports CSS modules, with the following features:
Expand Down
11 changes: 9 additions & 2 deletions src/bundler/Chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,11 @@ pub enum CssImportOrderKind<'a> {
/// We'll just print layer name declarations so that the original ordering is preserved.
#[strum(serialize = "layers")]
Layers(Layers),
/// A redundant earlier import of a file whose list of layer names does not say what its rules
/// declare, because the rules declare a layer under `@media` or `@supports`. It prints the
/// `@layer` names of the file inside copies of those rules.
Comment thread
robobun marked this conversation as resolved.
Resolved
#[strum(serialize = "layers_of")]
LayersOf(Index),
#[strum(serialize = "external_path")]
ExternalPath(bun_fs::Path<'a>),
#[strum(serialize = "source_index")]
Expand Down Expand Up @@ -1447,6 +1452,7 @@ impl CssImportOrder<'_> {
CssImportOrderKind::Layers(_) => 0,
CssImportOrderKind::ExternalPath(_) => 1,
CssImportOrderKind::SourceIndex(_) => 2,
CssImportOrderKind::LayersOf(_) => 3,
};
bun_core::write_any_to_hasher(hasher, tag);
match &self.kind {
Expand All @@ -1465,7 +1471,7 @@ impl CssImportOrder<'_> {
hasher.update(b"\x00");
}
CssImportOrderKind::ExternalPath(path) => hasher.update(path.text),
CssImportOrderKind::SourceIndex(idx) => {
CssImportOrderKind::SourceIndex(idx) | CssImportOrderKind::LayersOf(idx) => {
bun_core::write_any_to_hasher(hasher, idx.get())
}
}
Expand Down Expand Up @@ -1511,7 +1517,8 @@ impl<'a, 'ctx> fmt::Display for CssImportOrderDebug<'a, 'ctx> {
CssImportOrderKind::ExternalPath(path) => {
write!(writer, "\"{}\"", bstr::BStr::new(&path.pretty))?;
}
CssImportOrderKind::SourceIndex(source_index) => {
CssImportOrderKind::SourceIndex(source_index)
| CssImportOrderKind::LayersOf(source_index) => {
// SAFETY: `parse_graph` is a backref into `BundleV2.graph`, valid
// for the lifetime of the link step that owns this LinkerContext.
let source =
Expand Down
44 changes: 44 additions & 0 deletions src/bundler/LinkerContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3199,12 +3199,16 @@ impl<'a> LinkerContext<'a> {
/// `log` is an explicit parameter (not `self.log`) because the dev-server
/// caller (`finish_from_bake_dev_server`) runs this *before* `load()` has
/// initialized `self.log`, passing a stack-local `Log` instead.
///
/// `failed_to_load` says whether a file has no AST because it did not load.
/// Only the dev server goes on with such a file, and that file has the error.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) fn scan_css_imports(
file_source_index: u32,
file_import_records: &[ImportRecord],
css_asts: &[crate::bundled_ast::CssCol],
sources: &[Source],
loaders: &[Loader],
failed_to_load: impl Fn(u32) -> bool,
log: &mut Log,
) -> ScanCssImportsResult {
for record in file_import_records.iter() {
Expand All @@ -3214,6 +3218,46 @@ impl<'a> LinkerContext<'a> {
let source = &sources[file_source_index as usize];
let loader = loaders[record.source_index.get() as usize];

match record.kind {
// An "@import" takes a style sheet only. The loaders below are the ones
// that a "url()" can point at.
Comment thread
robobun marked this conversation as resolved.
Resolved
ImportKind::At | ImportKind::AtConditional => {
if loader != Loader::Css && !failed_to_load(record.source_index.get()) {
let imported = bun_core::fmt::quote(
sources[record.source_index.get() as usize].path.pretty,
);
log.add_range_error_fmt_with_notes(
Some(source),
record.range,
Box::new([Data {
text: format!(
"An \"@import\" rule can only import a CSS file, and {imported} is not a CSS file (it was loaded with the \"{}\" loader). If it holds CSS, rename it to end in \".css\" or give its extension the \"css\" loader.",
match loader {
Loader::Bunsh => "sh",
loader => loader.into(),
},
)
.into_bytes()
.into(),
location: None,
}]),
format_args!("Cannot import {imported} into a CSS file"),
);
continue;
}
}
ImportKind::EntryPointRun
| ImportKind::EntryPointBuild
| ImportKind::Stmt
| ImportKind::Require
| ImportKind::Dynamic
| ImportKind::RequireResolve
| ImportKind::Url
| ImportKind::Composes
| ImportKind::HtmlManifest
| ImportKind::Internal => {}
}

match loader {
Loader::Jsx
| Loader::Js
Expand Down
18 changes: 14 additions & 4 deletions src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5775,6 +5775,9 @@ pub mod bv2_impl {
let input_files = self.graph.input_files.slice();
let loaders = input_files.items_loader();
let sources = input_files.items_source();
// A later importer, or the entry point list, would put a style sheet with an
// invalid import back among the CSS roots. So they are removed at the end.
Comment thread
robobun marked this conversation as resolved.
Resolved
let mut css_files_with_invalid_imports: Vec<Index> = Vec::new();
for index in 1..self.graph.ast.len() {
// SAFETY: `index < ast.len()`; see note above for column aliasing.
let part_list = unsafe { &mut *parts_col.add(index) };
Expand All @@ -5800,6 +5803,9 @@ pub mod bv2_impl {
css_asts,
sources,
loaders,
// SAFETY: `target < ast.len()`. The target has no CSS AST, so it
// is not the file at `index`, whose parts are borrowed above.
|target| unsafe { (*parts_col.add(target as usize)).len() } == 0,
&mut log,
) == crate::linker_context_mod::ScanCssImportsResult::Errors
{
Expand All @@ -5817,9 +5823,8 @@ pub mod bv2_impl {
.map_err(|_| AllocError)?;
// Since there is an error, do not treat it as a
// valid CSS chunk.
let _ = start.css_entry_points.swap_remove(&Index::init(
u32::try_from(index).expect("int cast"),
));
css_files_with_invalid_imports
.push(Index::init(u32::try_from(index).expect("int cast")));
}
} else {
// HTML files are special cased because they correspond
Expand Down Expand Up @@ -5896,6 +5901,9 @@ pub mod bv2_impl {
)?;
}
}
for index in &css_files_with_invalid_imports {
let _ = start.css_entry_points.swap_remove(index);
}

break 'reachable_files self.arena().alloc_slice_copy(&js_files);
};
Expand Down Expand Up @@ -5993,8 +6001,10 @@ pub mod bv2_impl {
// Then all the distinct CSS bundles (these are JS->CSS, not CSS->CSS)
let mut css_orders = Vec::with_capacity(start.css_entry_points.count());
let mut css_too_deep = Vec::new();
let mut layer_lists =
crate::linker_context::find_imported_files_in_css_order::LayerLists::default();
for entry_point in start.css_entry_points.keys() {
match crate::linker_context::find_imported_files_in_css_order::find_imported_files_in_css_order(&self.linker, self.graph.heap, &[*entry_point]) {
match crate::linker_context::find_imported_files_in_css_order::find_imported_files_in_css_order(&self.linker, self.graph.heap, &[*entry_point], &mut layer_lists) {
Ok(order) => css_orders.push((*entry_point, order)),
Err(too_deep) => css_too_deep.push((*entry_point, too_deep)),
}
Expand Down
18 changes: 15 additions & 3 deletions src/bundler/linker_context/computeChunks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ use crate::{BundleV2, Chunk, Index, IndexInt, LinkerContext};
use super::find_all_imported_parts_in_js_order::find_all_imported_parts_in_js_order;
use super::find_imported_css_files_in_js_order::find_imported_css_files_in_js_order;
use super::find_imported_files_in_css_order::{
CssImportChainTooDeep, css_import_orders_are_equal, find_imported_files_in_css_order,
CssImportChainTooDeep, LayerLists, css_import_orders_are_equal,
find_imported_files_in_css_order,
};
use super::merge_small_chunks::merge_small_chunks;

Expand Down Expand Up @@ -82,6 +83,7 @@ pub(crate) fn compute_chunks<'a>(
// Key is the hash of the CSS order. This deduplicates identical CSS files.
let mut css_chunks: ArrayHashMap<u64, Chunk> = ArrayHashMap::new();
let mut js_chunks_with_css: usize = 0;
let mut layer_lists = LayerLists::default();

// Maps entry point IDs to their index in js_chunks.values().
// CSS-only entry points that skip JS chunk creation get maxInt as sentinel.
Expand Down Expand Up @@ -161,7 +163,12 @@ pub(crate) fn compute_chunks<'a>(
if css_asts[source_index as usize].is_some() {
let order = css_order_or_log(
this,
find_imported_files_in_css_order(this, temp, &[Index::init(source_index)]),
find_imported_files_in_css_order(
this,
temp,
&[Index::init(source_index)],
&mut layer_lists,
),
);
// Create a chunk for the entry point here to ensure that the chunk is
// always generated even if the resulting file is empty
Expand Down Expand Up @@ -234,7 +241,12 @@ pub(crate) fn compute_chunks<'a>(
if css_source_indices.len() > 0 {
let order = css_order_or_log(
this,
find_imported_files_in_css_order(this, temp, css_source_indices.slice()),
find_imported_files_in_css_order(
this,
temp,
css_source_indices.slice(),
&mut layer_lists,
),
);

// Always use content-based hashing for CSS chunk deduplication.
Expand Down
Loading
Loading