diff --git a/scripts/css-layer-fuzz.ts b/scripts/css-layer-fuzz.ts index 9822ecc8902d..a814cb6dc7c7 100644 --- a/scripts/css-layer-fuzz.ts +++ b/scripts/css-layer-fuzz.ts @@ -1,5 +1,5 @@ // bun scripts/css-layer-fuzz.ts [first seed] [baseline bun] -// [--cycles] [--anonymous] [--no-leading-statements] [--empty-blocks] +// [--cycles] [--anonymous] [--no-leading-statements] [--empty-blocks] [--conditions] // Runs test/bundler/css-layer-order.ts over many seeds against and prints the seeds where the bundle does not // have the cascade layers of its sources. `` = 1 prints the files of that seed too. With a baseline binary it // also prints the seeds the baseline gets right and gets wrong, and how many are the other way round. It does @@ -13,7 +13,7 @@ import { type GraphResult, } from "../test/bundler/css-layer-order"; -const known = ["--cycles", "--anonymous", "--no-leading-statements", "--empty-blocks"]; +const known = ["--cycles", "--anonymous", "--no-leading-statements", "--empty-blocks", "--conditions"]; const flags = process.argv.slice(2).filter(arg => arg.startsWith("--")); const [bun, count = "100", first = "1", baseline] = process.argv.slice(2).filter(arg => !arg.startsWith("--")); if (!bun || flags.some(flag => !known.includes(flag))) { @@ -27,6 +27,7 @@ const options: GraphOptions = { anonymous: flags.includes("--anonymous"), leadingStatements: !flags.includes("--no-leading-statements"), emptyBlocks: flags.includes("--empty-blocks"), + conditions: flags.includes("--conditions"), }; const locate = (binary: string) => Bun.which(binary) ?? resolve(binary); const [target, before] = [locate(bun), baseline && locate(baseline)]; diff --git a/src/bundler/linker_context/findImportedFilesInCSSOrder.rs b/src/bundler/linker_context/findImportedFilesInCSSOrder.rs index 1e211f4196c8..c9dfdd30ea96 100644 --- a/src/bundler/linker_context/findImportedFilesInCSSOrder.rs +++ b/src/bundler/linker_context/findImportedFilesInCSSOrder.rs @@ -101,6 +101,8 @@ pub(crate) fn find_imported_files_in_css_order<'a>( has_external_import: bool, /// Whether `order` has an entry for the layer of an "@import" that closes a cycle. has_cyclic_import_layer: bool, + /// Whether `order` has a file that first declares a layer inside "@media", "@supports" or "@-moz-document". + has_conditional_layer_file: bool, visited: Vec, order: Vec, @@ -343,6 +345,7 @@ pub(crate) fn find_imported_files_in_css_order<'a>( self.input_file_pretty(source_index), ); } + self.has_conditional_layer_file |= repr.has_conditional_layer_names; // Accumulate imports in depth-first postorder self.order.push(CssImportOrder { // `crate::Index` (= `bun_ast::Index`) and the @@ -376,6 +379,7 @@ pub(crate) fn find_imported_files_in_css_order<'a>( all_import_records: all_import_records_slice, has_external_import: false, has_cyclic_import_layer: false, + has_conditional_layer_file: false, order: Vec::new(), stack_check: StackCheck::init(), too_deep: None, @@ -400,11 +404,12 @@ pub(crate) fn find_imported_files_in_css_order<'a>( let has_external_import = visitor.has_external_import; let mut order = visitor.order; - // Unlike esbuild: with a file's pre-import layers in the order, a `layer(...)` that only one of two condition lists has is not ignored. - let layers_matter = order - .slice() - .iter() - .any(|entry| matches!(entry.kind, CssImportOrderKind::Layers(Layers::PreImport(_)))); + // Unlike esbuild: with a file's pre-import layers in the order, or a file that first declares a layer under a condition, a `layer(...)` that only one of two condition lists has is not ignored. + let layers_matter = visitor.has_conditional_layer_file + || order + .slice() + .iter() + .any(|entry| matches!(entry.kind, CssImportOrderKind::Layers(Layers::PreImport(_)))); // Without them the order stays as esbuild has it. The layers of cyclic imports are the only other layer entries here. if visitor.has_cyclic_import_layer && !layers_matter { order.retain(|entry| !matches!(entry.kind, CssImportOrderKind::Layers(_))); @@ -481,14 +486,20 @@ pub(crate) fn find_imported_files_in_css_order<'a>( } let stylesheet = css_asts[idx.get() as usize].as_deref().unwrap(); // A CSS module with "composes" fails to print inside a wrapper, so its copy under a layer stays a list. - let layers_matter = layers_matter && stylesheet.composes.count() == 0; + let can_print_in_a_wrapper = stylesheet.composes.count() == 0; for &j in gop.value_ptr.slice() { // SAFETY: j < order.len; see note above. let later = unsafe { &(*order_ptr.add(j as usize)).conditions }; - if is_conditional_import_redundant(&entry.conditions, later, layers_matter) - { + if is_conditional_import_redundant( + &entry.conditions, + later, + layers_matter && can_print_in_a_wrapper, + ) { // A list of layer names cannot say that the file declares one of them only under "@media". - if layers_matter && stylesheet.has_conditional_layer_names { + if layers_matter + && stylesheet.has_conditional_layer_names + && (can_print_in_a_wrapper || entry.conditions.len() == 0) + { copies_in_place_of_lists.push(i); continue 'next_backward; } diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index ec48f776b3e7..cb3eeb84ea54 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -676,10 +676,21 @@ pub trait CustomAtRuleParser { Vec::new() } - /// Called with 1 ahead of the block of `@media`, `@supports` or `@-moz-document`, and with -1 after it. - fn bump_conditional_depth(_this: &mut Self, _amount: i32) {} + /// How many `@layer` names `on_layer_rule` has registered so far. + fn layer_name_count(_this: &mut Self) -> u32 { + 0 + } - fn has_conditional_layer_names(_this: &mut Self) -> bool { + /// Called after the block of `@media` (with its query), `@supports` or `@-moz-document`. `first_layer_name` is `layer_name_count` from before the block. + fn on_conditional_block_end( + _this: &mut Self, + _first_layer_name: u32, + _media: Option<&MediaList>, + ) { + } + + /// Whether the file first declares one of its layers inside `@media`, `@supports` or `@-moz-document`. Call it before `take_layer_names`. + fn has_conditional_layer_names(_this: &mut Self, _rules: &CssRuleList) -> bool { false } } @@ -720,9 +731,8 @@ pub struct BundlerAtRuleParser<'a> { pub(crate) layer_names: Vec, /// `layer_names` as it was when the first `@import` was parsed. pub(crate) layers_pre_import: Option>, - pub(crate) conditional_depth: u32, - /// Whether a name in `layer_names` has its first mention inside a conditional group rule. - pub(crate) has_conditional_layer_names: bool, + /// For the first names of `layer_names`: whether `on_layer_rule` registered the name inside `@media`, `@supports` or `@-moz-document`. A name behind the end has `false`. + pub(crate) is_conditional_layer_name: Vec, /// Having _named_ layers nested inside of an _anonymous_ layer has no /// effect. See: https://drafts.csswg.org/css-cascade-5/#example-787042b6 pub(crate) anon_layer_count: u32, @@ -818,7 +828,7 @@ impl<'a> CustomAtRuleParser for BundlerAtRuleParser<'a> { this.layer_names .ensure_unused_capacity(layers.len() as usize); for layer in layers.slice() { - let name = if this.enclosing_layer.v.len() > 0 { + if this.enclosing_layer.v.len() > 0 { let mut cloned = LayerName { v: SmallList::default(), }; @@ -826,19 +836,11 @@ impl<'a> CustomAtRuleParser for BundlerAtRuleParser<'a> { // calls each grow once. cloned.v.append_slice(this.enclosing_layer.v.slice()); cloned.v.append_slice(layer.v.slice()); - cloned + this.layer_names.append_assume_capacity(cloned); } else { - layer.deep_clone(this.arena) - }; - // A name that the file also declares earlier, outside a condition, is in the order either way. - if this.conditional_depth > 0 && !this.has_conditional_layer_names { - this.has_conditional_layer_names = !this - .layer_names - .slice() - .iter() - .any(|known| known.eql(&name)); + this.layer_names + .append_assume_capacity(layer.deep_clone(this.arena)); } - this.layer_names.append_assume_capacity(name); } } @@ -861,12 +863,23 @@ impl<'a> CustomAtRuleParser for BundlerAtRuleParser<'a> { this.layers_pre_import.take().unwrap_or_default() } - fn bump_conditional_depth(this: &mut Self, amount: i32) { - this.conditional_depth = this.conditional_depth.wrapping_add_signed(amount); + fn layer_name_count(this: &mut Self) -> u32 { + this.layer_names.len() as u32 } - fn has_conditional_layer_names(this: &mut Self) -> bool { - this.has_conditional_layer_names + fn on_conditional_block_end(this: &mut Self, first_layer_name: u32, media: Option<&MediaList>) { + if this.layer_names.len() as u32 != first_layer_name { + this.mark_conditional_layer_names(first_layer_name, media); + } + } + + fn has_conditional_layer_names(this: &mut Self, rules: &BundlerCssRuleList) -> bool { + !this.is_conditional_layer_name.is_empty() + && first_declares_a_layer_under_a_condition( + this.layer_names.slice(), + &this.is_conditional_layer_name, + rules, + ) } fn reset_enclosing_layer(this: &mut Self, len: u32) { @@ -885,6 +898,72 @@ impl<'a> CustomAtRuleParser for BundlerAtRuleParser<'a> { } } +impl BundlerAtRuleParser<'_> { + /// `layer_names[start..]` were registered inside `@media` (with its query), `@supports` or `@-moz-document`. + #[cold] + fn mark_conditional_layer_names(&mut self, start: u32, media: Option<&MediaList>) { + // `@media all` is no condition. + if media.is_some_and(MediaList::always_matches) { + return; + } + self.is_conditional_layer_name + .resize(self.layer_names.len(), false); + self.is_conditional_layer_name[start as usize..].fill(true); + } +} + +/// Whether a conditional name is the first one for its layer: no earlier name that is not conditional declares that layer, and no `@import` of `rules` with `layer(...)` and no condition does. +#[cold] +fn first_declares_a_layer_under_a_condition( + names: &[LayerName], + is_conditional: &[bool], + rules: &BundlerCssRuleList, +) -> bool { + // A trie of the layers that the file declares in every case. `children[&(node, segment)]` is the node of the layer `segment` inside the layer of `node`. Node 0 is no layer. + type Children = bun_collections::HashMap<(u32, &'static [u8]), u32>; + fn declare(children: &mut Children, name: &LayerName) { + let mut node = 0; + for segment in name.v.slice() { + let new_node = children.len() as u32 + 1; + let child = bun_core::handle_oom(children.get_or_put((node, *segment))); + if !child.found_existing { + *child.value_ptr = new_node; + } + node = *child.value_ptr; + } + } + fn is_declared(children: &Children, name: &LayerName) -> bool { + let mut node = 0; + for segment in name.v.slice() { + match children.get(&(node, *segment)) { + Some(&child) => node = child, + None => return false, + } + } + true + } + + let mut children = Children::new(); + for rule in rules.v.iter() { + if let CssRule::Import(import) = rule + && import.supports.is_none() + && import.media.always_matches() + && let Some(name) = import.layer.as_ref().and_then(|layer| layer.v.as_ref()) + { + declare(&mut children, name); + } + } + let is_conditional = is_conditional.iter().chain(core::iter::repeat(&false)); + for (name, &is_conditional) in names.iter().zip(is_conditional) { + if !is_conditional { + declare(&mut children, name); + } else if !is_declared(&children, name) { + return true; + } + } + false +} + // ───────────────────────────── AtRulePrelude ───────────────────────────── // // The leaf-module payload types (KeyframesName, PageSelector, ContainerName, @@ -1523,14 +1602,15 @@ mod rule_parsers { )) } - /// The block of `@media`, `@supports` or `@-moz-document`: a layer in it is declared only when the condition holds. + /// The block of `@media` (with its query), `@supports` or `@-moz-document`: a layer in it is declared only when the condition holds. fn parse_conditional_block( &mut self, input: &mut Parser, + media: Option<&MediaList>, ) -> CssResult> { - T::bump_conditional_depth(self.at_rule_parser, 1); + let first_layer_name = T::layer_name_count(self.at_rule_parser); let rules = self.parse_style_block(input); - T::bump_conditional_depth(self.at_rule_parser, -1); + T::on_conditional_block_end(self.at_rule_parser, first_layer_name, media); rules } @@ -1759,7 +1839,7 @@ mod rule_parsers { Ok(()) } AtRulePrelude::Media(query) => { - let rules = this.parse_conditional_block(input)?; + let rules = this.parse_conditional_block(input, Some(&query))?; this.rules .v .push(CssRule::Media(css_rules::media::MediaRule { @@ -1770,7 +1850,7 @@ mod rule_parsers { Ok(()) } AtRulePrelude::Supports(condition) => { - let rules = this.parse_conditional_block(input)?; + let rules = this.parse_conditional_block(input, None)?; this.rules .v .push(CssRule::Supports(css_rules::supports::SupportsRule { @@ -1844,7 +1924,7 @@ mod rule_parsers { Ok(()) } AtRulePrelude::MozDocument => { - let rules = this.parse_conditional_block(input)?; + let rules = this.parse_conditional_block(input, None)?; this.rules .v .push(CssRule::MozDocument(css_rules::document::MozDocumentRule { @@ -2614,8 +2694,7 @@ mod stylesheet_impl { import_records: core::ptr::null_mut(), layer_names: Vec::new(), layers_pre_import: None, - conditional_depth: 0, - has_conditional_layer_names: false, + is_conditional_layer_name: Vec::new(), anon_layer_count: 0, enclosing_layer: LayerName::default(), }; @@ -2711,12 +2790,13 @@ mod stylesheet_impl { let source_map_urls: Vec>> = vec![parser.current_source_map_url().map(Box::<[u8]>::from)]; + let has_conditional_layer_names = + P::has_conditional_layer_names(at_rule_parser, &rules); // Dispatch through the `CustomAtRuleParser::take_layer_names` hook // (default = empty; `BundlerAtRuleParser` overrides to move its list // out) so the accumulated layer ordering isn't silently dropped. let layer_names = P::take_layer_names(at_rule_parser); let layers_pre_import = P::take_layers_pre_import(at_rule_parser); - let has_conditional_layer_names = P::has_conditional_layer_names(at_rule_parser); Ok(( Self { @@ -2833,8 +2913,7 @@ mod stylesheet_impl { import_records: import_records_ptr.as_ptr(), layer_names: Vec::new(), layers_pre_import: None, - conditional_depth: 0, - has_conditional_layer_names: false, + is_conditional_layer_name: Vec::new(), anon_layer_count: 0, enclosing_layer: LayerName::default(), }; diff --git a/test/bundler/css-layer-order.ts b/test/bundler/css-layer-order.ts index e9aa13865d46..a4285517a584 100644 --- a/test/bundler/css-layer-order.ts +++ b/test/bundler/css-layer-order.ts @@ -31,6 +31,8 @@ export type GraphOptions = { leadingStatements?: boolean; /** Some layer blocks are empty. Default false. */ emptyBlocks?: boolean; + /** Some rules are inside `@media` or `@supports`. Default false. */ + conditions?: boolean; }; const names = ["a", "b", "c"]; @@ -41,6 +43,7 @@ export function generateGraph( options: GraphOptions = {}, ): { files: Record; entry: string } { const { cycles = false, anonymous = false, leadingStatements = true, emptyBlocks = false } = options; + const { conditions = false } = options; const rnd = rng(seed); const int = (n: number, from = rnd) => Math.floor(from() * n); const pick = (pool: string[], from = rnd) => pool[int(pool.length, from)]; @@ -49,7 +52,7 @@ export function generateGraph( // Each optional shape draws from streams of its own, one per file, so a seed keeps its base graph whatever the // options are. const own = (shape: number) => Array.from({ length: n }, (_, i) => rng(seed ^ Math.imul(shape * 8 + i, 0x5bd1e995))); - const [leading, cyclic, anon, empty] = [1, 2, 3, 4].map(own); + const [leading, cyclic, anon, empty, conditional] = [1, 2, 3, 4, 5].map(own); const imports: string[][] = Array.from({ length: n }, () => []); const addImport = (i: number, target: number, from = rnd) => { @@ -79,14 +82,26 @@ export function generateGraph( css += statement(leading[i]) + (leading[i]() < 0.4 ? statement(leading[i]) : ""); } css += imports[i].join(""); + let lastSupports = 0; for (let items = 1 + int(4); items > 0; items--) { if (emptyBlocks && empty[i]() < 0.3) css += `@layer ${outer(empty[i])} {}\n`; const kind = int(20); - if (kind < 5) css += statement(); - else if (kind < 13) css += `@layer ${outer()} { ${style()} }\n`; + let item: string; + if (kind < 5) item = statement(); + else if (kind < 13) item = `@layer ${outer()} { ${style()} }\n`; else if (kind < 18) { - css += `@layer ${outer()} { ${rnd() < 0.5 ? style() + " " : ""}@layer ${pick(names)} { ${style()} } }\n`; - } else css += style() + "\n"; + item = `@layer ${outer()} { ${rnd() < 0.5 ? style() + " " : ""}@layer ${pick(names)} { ${style()} } }\n`; + } else item = style() + "\n"; + const from = conditional[i]; + if (conditions && from() < 0.35) { + const media = from() < 0.7; + let number = 1 + int(2, from); + // The minifier drops the second of two adjacent `@supports` rules with one condition (#46733). + if (!media && number === lastSupports) number = 3 - number; + lastSupports = media ? 0 : number; + item = `@${media ? `media (min-width: ${number}px)` : `supports (top: ${number}px)`} { ${item.trim()} }\n`; + } else lastSupports = 0; + css += item; } files[stem(i) + ".css"] = css; } @@ -104,13 +119,23 @@ export type LayerEvaluation = { winner: string | undefined; order: Record at[0] + /\d+/.exec(prelude)?.[0]; +const conditionalBlock = /@(media|supports)\s*([^;{}]*)\{/g; -/** The graph as a browser sees it: every `@import` is followed. */ -export function evaluateSources(files: Record, entry: string): LayerEvaluation { +/** + * The graph as a browser sees it: every `@import` is followed. `state` has the conditions that hold: a block with + * another condition declares no layer and applies no rule. + */ +export function evaluateSources( + files: Record, + entry: string, + state: ReadonlySet = new Set(), +): LayerEvaluation { const order: Record = {}; const root: Layer = { name: "", path: "", children: [] }; const rules: { layer: Layer; id: string }[] = []; @@ -131,10 +156,14 @@ export function evaluateSources(files: Record, entry: string): L path.split(".").reduce((layer, name) => child(layer, name.trim()), from); const apply = (file: string, into: Layer, chain: string[]) => { - const stack = [into]; + // `undefined`: inside a block whose condition does not hold. + const stack: (Layer | undefined)[] = [into]; for (const [, at, prelude, open, selector, declarations] of files[file].replace(comment, "").matchAll(syntax)) { const top = stack[stack.length - 1]; - if (at === "import") { + if (!top) { + if (open === "{") stack.push(undefined); + else if (!at && selector === undefined) stack.pop(); + } else if (at === "import") { const [, target = "", layered, wrapper = ""] = importPrelude.exec(prelude) ?? []; const layer = layered ? descend(top, wrapper) : top; // An import that closes a cycle is not loaded, and neither is a missing file. Its layer is declared anyway. @@ -142,7 +171,8 @@ export function evaluateSources(files: Record, entry: string): L } else if (at === "layer" && open === ";") { for (const path of prelude.split(",")) descend(top, path); } else if (at) { - if (open === "{") stack.push(at === "layer" ? descend(top, prelude) : top); + const holds = (at !== "media" && at !== "supports") || state.has(condition(at, prelude)); + if (open === "{") stack.push(at === "layer" ? descend(top, prelude) : holds ? top : undefined); } else if (selector !== undefined) { // The minifier merges adjacent rules into one with several `--k`. The last one wins, as with separate rules. for (const [, id] of declarations.matchAll(/--k\s*:\s*([^;\s]+)/g)) rules.push({ layer: top, id }); @@ -163,8 +193,8 @@ export function evaluateSources(files: Record, entry: string): L return { winner: winner?.id, order }; } -export function evaluateBundle(css: string): LayerEvaluation { - return evaluateSources({ "bundle.css": css }, "bundle.css"); +export function evaluateBundle(css: string, state?: ReadonlySet): LayerEvaluation { + return evaluateSources({ "bundle.css": css }, "bundle.css", state); } /** Whether `problem` says that `.t` gets another rule. The other problems are about the order of layers. */ @@ -201,7 +231,19 @@ export async function checkGraph(bun: string, seed: number, options?: GraphOptio if (exitCode !== 0 || stderr) { return { seed, status: "skipped", problems: [`bun build (exit code ${exitCode}): ` + stderr.slice(0, 400)] }; } - const problems = compare(evaluateSources(files, entry), evaluateBundle(stdout)); + // One evaluation per state of the conditions that the sources have. + const all = Object.values(files).flatMap(css => + [...css.matchAll(conditionalBlock)].map(([, at, p]) => condition(at, p)), + ); + const conditions = [...new Set(all)].sort(); + const problems: string[] = []; + for (let bits = 0; bits < 1 << conditions.length; bits++) { + const state = new Set(conditions.filter((_, i) => bits & (1 << i))); + const where = conditions.length ? ` (conditions that hold: ${[...state].join(" ") || "none"})` : ""; + for (const problem of compare(evaluateSources(files, entry, state), evaluateBundle(stdout, state))) { + problems.push(problem + where); + } + } if (stdout.includes("@import")) problems.push("the bundle still has an @import"); return { seed, status: problems.length ? "wrong" : "ok", problems }; } diff --git a/test/bundler/esbuild/css.test.ts b/test/bundler/esbuild/css.test.ts index 039fd5240379..a68705b10947 100644 --- a/test/bundler/esbuild/css.test.ts +++ b/test/bundler/esbuild/css.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { bunExe } from "harness"; +import { readdirSync } from "node:fs"; import { join } from "node:path"; import { checkGraph } from "../css-layer-order"; import { itBundled } from "../expectBundled"; @@ -2660,6 +2661,36 @@ c { }, out: "@layer theme,theme;@layer base,theme;@layer theme;@media (prefers-color-scheme:dark){@layer theme{:root{--bg:black}}}:root{--bg:white}@layer theme{.t{color:red}}@layer base{.t{color:#00f}}", }, + // The same two inputs with no statement ahead of an "@import" in any file. + NameDeclaredOnlyUnderMediaAndNoStatement: { + files: { + "/entry.css": /* css */ ` + @import "./g.css"; + @import "./x.css"; + @import "./y.css"; + @import "./x.css"; + `, + "/g.css": `@media print { @layer a { .g { color: green } } }`, + "/x.css": `@layer a { .t { color: red } }`, + "/y.css": `@layer u { .t { color: blue } }`, + }, + out: "@media print{@layer a{.g{color:green}}}@layer a;@layer u{.t{color:#00f}}@layer a{.t{color:red}}", + }, + NameDeclaredOnlyUnderMediaInAFileImportedTwiceAndNoStatement: { + files: { + "/entry.css": /* css */ ` + @import "./tokens.css"; + @import "./other.css"; + @import "./tokens.css"; + `, + "/tokens.css": `@media (prefers-color-scheme: dark) { @layer theme { :root { --bg: black } } }`, + "/other.css": /* css */ ` + @layer base { .t { color: blue } } + @layer theme { .t { color: red } } + `, + }, + out: "@media (prefers-color-scheme:dark){@layer theme{:root{--bg:black}}}@layer base{.t{color:#00f}}@layer theme{.t{color:red}}@media (prefers-color-scheme:dark){@layer theme{:root{--bg:black}}}", + }, // A copy under "layer(w)" is kept when a later copy has no layer: its rules are in another layer. LayeredCopyBehindABareCopy: { files: { @@ -2798,6 +2829,144 @@ c { }); } + // Which file keeps its earlier copy. g.css is imported twice with other.css between the two, and entry.css has a + // statement ahead of its imports. A file that first declares a layer inside "@media", "@supports" or "@-moz-document" + // keeps its first copy. A file that declares the layer in every case first gets a list of its layers, as on main. + const otherLayers = "@layer base{.t{color:#00f}}@layer theme{.t{color:red}}"; + const importedTwiceBehindAStatement = (g: string, first: string, last: string, extra?: Record) => ({ + files: { + "/entry.css": /* css */ ` + @layer first; + @import "./g.css"; + @import "./other.css"; + @import "./g.css"; + `, + "/g.css": g, + "/other.css": /* css */ ` + @layer base { .t { color: blue } } + @layer theme { .t { color: red } } + `, + ...extra, + }, + out: "@layer first;" + first + otherLayers + last, + }); + const keptBehindAStatement = (g: string, printed: string) => importedTwiceBehindAStatement(g, printed, printed); + const firstDeclarationCases: Record; out: string }> = { + UnderMedia: keptBehindAStatement( + `@media print { @layer theme { .g { color: green } } }`, + "@media print{@layer theme{.g{color:green}}}", + ), + // "@media all" is no condition, but a condition inside it or around it is one. + UnderMediaAll: importedTwiceBehindAStatement( + `@media all { @layer theme { .g { color: green } } }`, + "@layer theme;", + "@layer theme{.g{color:green}}", + ), + UnderMediaInsideMediaAll: keptBehindAStatement( + `@media all { @media print { @layer theme { .g { color: green } } } }`, + "@media print{@layer theme{.g{color:green}}}", + ), + UnderSupportsInsideMediaAll: keptBehindAStatement( + `@media all { @supports (display: no-such-value) { @layer theme { .g { color: green } } } }`, + "@supports (display: no-such-value){@layer theme{.g{color:green}}}", + ), + UnderMediaAllInsideMedia: keptBehindAStatement( + `@media print { @media all { @layer theme { .g { color: green } } } }`, + "@media print{@layer theme{.g{color:green}}}", + ), + // The minifier removes "@media not all". A list would declare "theme", and the copy declares nothing, as the file. + UnderMediaThatNeverMatches: keptBehindAStatement( + /* css */ ` + @media not all { @layer theme { .g { color: green } } } + .g { color: black } + `, + ".g{color:#000}", + ), + OutsideFirst: importedTwiceBehindAStatement( + /* css */ ` + @layer theme { .g { color: green } } + @media print { @layer theme { .g { color: black } } } + `, + "@layer theme,theme;", + "@layer theme{.g{color:green}}@media print{@layer theme{.g{color:#000}}}", + ), + // "theme.dark" declares "theme" too. + OutsideFirstAsASublayerName: importedTwiceBehindAStatement( + /* css */ ` + @layer theme.dark { .g { color: green } } + @media print { @layer theme { .g { color: black } } } + `, + "@layer theme.dark,theme;", + "@layer theme.dark{.g{color:green}}@media print{@layer theme{.g{color:#000}}}", + ), + // "other.theme" declares "other" and "other.theme", and not "theme". + OutsideFirstAsASublayerOfAnotherLayer: keptBehindAStatement( + /* css */ ` + @layer other.theme { .g { color: green } } + @media print { @layer theme { .g { color: black } } } + `, + "@layer other.theme{.g{color:green}}@media print{@layer theme{.g{color:#000}}}", + ), + // The block declares "theme", and the rule inside it declares "theme.theme" only in print. + SublayerNamedAsItsParent: keptBehindAStatement( + /* css */ ` + @layer theme { + .g { color: green } + @media print { @layer theme { .g { color: black } } } + } + `, + "@layer theme{.g{color:green}@media print{@layer theme{.g{color:#000}}}}", + ), + // An "@import" of g.css with "layer(theme)" and no condition declares "theme" in every case. + ByAnImportOfTheFile: importedTwiceBehindAStatement( + /* css */ ` + @import "./a.css" layer(theme); + @media print { @layer theme { .g { color: green } } } + `, + "@layer theme;@layer theme;", + "@layer theme{.a{color:gray}}@media print{@layer theme{.g{color:green}}}", + { "/a.css": `.a { color: gray }` }, + ), + ByAnImportOfTheFileUnderMediaAll: importedTwiceBehindAStatement( + /* css */ ` + @import "./a.css" layer(theme) all; + @media print { @layer theme { .g { color: green } } } + `, + "@layer theme;@layer theme;", + "@layer theme{.a{color:gray}}@media print{@layer theme{.g{color:green}}}", + { "/a.css": `.a { color: gray }` }, + ), + ByAnImportOfTheFileUnderSupports: importedTwiceBehindAStatement( + /* css */ ` + @import "./a.css" layer(theme) supports(display: no-such-value); + @media print { @layer theme { .g { color: green } } } + `, + "@supports (display:no-such-value){@layer theme;}@media print{@layer theme{.g{color:green}}}", + "@supports (display:no-such-value){@layer theme{.a{color:gray}}}@media print{@layer theme{.g{color:green}}}", + { "/a.css": `.a { color: gray }` }, + ), + // A bare "layer" on the "@import" declares no name. + ByAnImportOfTheFileUnderABareLayer: importedTwiceBehindAStatement( + /* css */ ` + @import "./a.css" layer; + @media print { @layer theme { .g { color: green } } } + `, + "@media print{@layer theme{.g{color:green}}}", + "@layer{@layer theme{.a{color:gray}}}@media print{@layer theme{.g{color:green}}}", + { "/a.css": `@layer theme { .a { color: gray } }` }, + ), + }; + for (const [name, { files, out }] of Object.entries(firstDeclarationCases)) { + itBundled(`css/CSSAtLayerOrderFirstDeclaration${name}`, { + files, + outfile: "/out.css", + minifyWhitespace: true, + onAfterBundle(api) { + api.expectFile("/out.css").toBe(out + "\n"); + }, + }); + } + // A CSS module with "composes" cannot print inside a layer wrapper, so its copy under "layer(components)" // stays a list of layers, as on main. itBundled("css/CSSAtLayerOrderComposesModuleUnderLayerAndBare", { @@ -2861,6 +3030,11 @@ c { [3, {}], [543, {}], [2, { anonymous: true }], + [46, { conditions: true }], + [202, { conditions: true }], + [341, { conditions: true }], + [15, { conditions: true, cycles: true }], + [314, { conditions: true, anonymous: true }], ] as const) { test(`css/CSSAtLayerOrder: graph ${seed} ${JSON.stringify(options)}`, async () => { expect(await checkGraph(bunExe(), seed, options)).toEqual({ seed, status: "ok", problems: [] }); @@ -2967,6 +3141,633 @@ c { outfile: "/out.css", }); + // A layer that a file first declares inside "@media", "@supports" or "@-moz-document" is in the layer order only + // while that condition holds (css-cascade-5, "Layer Ordering"; WPT css/css-cascade/layer-media-query.html). A list + // of layer names cannot say that, so such a file does not stand for a list, and its earlier copy stays whole, also + // when no file has a statement ahead of an "@import". In Chrome, `.t` gets the same color from each `out` as from + // its source files. A `todo` has the right output and says why bun prints another. + type ConditionalLayerCase = { files: Record; out: string; todo?: string }; + // x.css is imported twice. The list for its first copy declares "a" ahead of "u", unless g.css declares "a". + const listBehind = (g: string, out: string): ConditionalLayerCase => ({ + files: { + "/entry.css": /* css */ ` + @import "./g.css"; + @import "./x.css"; + @import "./y.css"; + @import "./x.css"; + `, + "/g.css": g, + "/x.css": `@layer a { .t { color: red } }`, + "/y.css": `@layer u { .t { color: blue } }`, + }, + out: out + "@layer u{.t{color:#00f}}@layer a{.t{color:red}}", + }); + const other = "@layer base{.t{color:#00f}}@layer theme{.t{color:red}}"; + // g.css is imported twice, with other.css between the two. `first` is what bun prints for the first copy. + const importedTwice = ( + g: string, + first: string, + last: string, + extra?: Record, + ): ConditionalLayerCase => ({ + files: { + "/entry.css": /* css */ ` + @import "./g.css"; + @import "./other.css"; + @import "./g.css"; + `, + "/g.css": g, + "/other.css": /* css */ ` + @layer base { .t { color: blue } } + @layer theme { .t { color: red } } + `, + ...extra, + }, + out: first + other + last, + }); + // The first copy of g.css stays whole: g.css first declares a layer under a condition. + const keptCopy = (g: string, printed: string): ConditionalLayerCase => importedTwice(g, printed, printed); + const conditionalLayerCases: Record = { + ListBehindAFileWithTheLayerUnderMedia: listBehind( + `@media print { @layer a { .g { color: green } } }`, + "@media print{@layer a{.g{color:green}}}@layer a;", + ), + ListBehindAFileWithTheLayerUnderSupports: listBehind( + `@supports (display: no-such-value) { @layer a { .g { color: green } } }`, + "@supports (display: no-such-value){@layer a{.g{color:green}}}@layer a;", + ), + // "@media all" is no condition: g.css declares "a", and the list is not needed. + ListBehindAFileWithTheLayerUnderMediaAll: listBehind( + `@media all { @layer a { .g { color: green } } }`, + "@layer a{.g{color:green}}", + ), + Media: keptCopy( + `@media print { @layer theme { .g { color: green } } }`, + "@media print{@layer theme{.g{color:green}}}", + ), + Supports: keptCopy( + `@supports (display: no-such-value) { @layer theme { .g { color: green } } }`, + "@supports (display: no-such-value){@layer theme{.g{color:green}}}", + ), + // Chrome and Safari do not have this rule, so they declare no layer for it. + MozDocument: keptCopy( + `@-moz-document url-prefix() { @layer theme { .g { color: green } } }`, + "@-moz-document url-prefix(){@layer theme{.g{color:green}}}", + ), + Statement: keptCopy( + /* css */ ` + @media print { @layer theme, print; } + .g { color: green } + `, + "@media print{@layer theme,print;}.g{color:green}", + ), + // "base" is declared in every case, "base.theme" only in print. + Sublayer: keptCopy( + /* css */ ` + @layer base { + .g { color: green } + @media print { @layer theme { .g { color: black } } } + } + `, + "@layer base{.g{color:green}@media print{@layer theme{.g{color:#000}}}}", + ), + InsideAStyleRule: keptCopy( + `.g { @media print { @layer theme { color: green } } }`, + "@media print{@layer theme{.g{color:green}}}", + ), + InsideContainer: keptCopy( + `@container (min-width: 1px) { @media print { @layer theme { .g { color: green } } } }`, + "@container (width>=1px){@media print{@layer theme{.g{color:green}}}}", + ), + // On a screen g.css declares "print" ahead of "theme". + DeclaredAgainOutsideLater: keptCopy( + /* css */ ` + @media print { @layer theme { .g { color: green } } } + @layer print { .g { color: black } } + @layer theme { .g { color: gray } } + `, + "@media print{@layer theme{.g{color:green}}}@layer print{.g{color:#000}}@layer theme{.g{color:gray}}", + ), + // The "@import" declares "theme" only in print too. + ImportLayerUnderTheCondition: importedTwice( + /* css */ ` + @import "./a.css" layer(theme) print; + @media print { @layer theme { .g { color: green } } } + `, + "@media print{@layer theme;}@media print{@layer theme{.g{color:green}}}", + "@media print{@layer theme{.a{color:gray}}}@media print{@layer theme{.g{color:green}}}", + { "/a.css": `.a { color: gray }` }, + ), + // A list would declare "theme". The minifier removes "@media not all", so the copy declares nothing, as the file. + MediaThatNeverMatches: keptCopy( + /* css */ ` + @media not all { @layer theme { .g { color: green } } } + .g { color: black } + `, + ".g{color:#000}", + ), + MediaInsideAMediaThatNeverMatches: keptCopy( + /* css */ ` + @media not all { @media print { @layer theme { .g { color: green } } } } + @layer late { .g { color: black } } + `, + "@layer late{.g{color:#000}}", + ), + // In the next cases g.css declares "theme" in every case first, so a list of its layers stands for its first + // copy, as on main. + DeclaredOutsideFirst: importedTwice( + /* css */ ` + @layer theme { .g { color: green } } + @media print { @layer theme { .g { color: black } } } + `, + "@layer theme,theme;", + "@layer theme{.g{color:green}}@media print{@layer theme{.g{color:#000}}}", + ), + // "theme.dark" declares "theme" too. + SublayerDeclaredOutsideFirst: importedTwice( + /* css */ ` + @layer theme.dark { .g { color: green } } + @media print { @layer theme { .g { color: black } } } + `, + "@layer theme.dark,theme;", + "@layer theme.dark{.g{color:green}}@media print{@layer theme{.g{color:#000}}}", + ), + ImportLayerDeclaresIt: importedTwice( + /* css */ ` + @import "./a.css" layer(theme); + @media print { @layer theme { .g { color: green } } } + `, + "@layer theme;@layer theme;", + "@layer theme{.a{color:gray}}@media print{@layer theme{.g{color:green}}}", + { "/a.css": `.a { color: gray }` }, + ), + MediaAll: importedTwice( + `@media all { @layer theme { .g { color: green } } }`, + "@layer theme;", + "@layer theme{.g{color:green}}", + ), + // A browser that has these rules declares a layer inside them in every case. + Container: importedTwice( + `@container (min-width: 1px) { @layer theme { .g { color: green } } }`, + "@layer theme;", + "@container (width>=1px){@layer theme{.g{color:green}}}", + ), + Scope: importedTwice( + `@scope (.g) { @layer theme { .g { color: green } } }`, + "@layer theme;", + "@scope(.g){@layer theme{.g{color:green}}}", + ), + StartingStyle: importedTwice( + `@starting-style { @layer theme { .g { color: green } } }`, + "@layer theme;", + "@starting-style{@layer theme{.g{color:green}}}", + ), + StyleRule: importedTwice(`.g { @layer theme { color: green } }`, "@layer theme;", "@layer theme{.g{color:green}}"), + // Of the copies that a later copy replaces, only the first one stays. + ThreeCopies: { + files: { + "/entry.css": /* css */ ` + @import "./g.css"; + @import "./other.css"; + @import "./g.css"; + @import "./g.css"; + `, + "/g.css": `@media print { @layer theme { .g { color: green } } }`, + "/other.css": /* css */ ` + @layer base { .t { color: blue } } + @layer theme { .t { color: red } } + `, + }, + out: `@media print{@layer theme{.g{color:green}}}${other}@media print{@layer theme{.g{color:green}}}`, + }, + ImportUnderLayer: { + files: { + "/entry.css": /* css */ ` + @import "./g.css" layer(ui); + @import "./other.css" layer(ui); + @import "./g.css" layer(ui); + `, + "/g.css": `@media print { @layer theme { .g { color: green } } }`, + "/other.css": /* css */ ` + @layer base { .t { color: blue } } + @layer theme { .t { color: red } } + `, + }, + out: `@layer ui{@media print{@layer theme{.g{color:green}}}}@layer ui{${other}}@layer ui{@media print{@layer theme{.g{color:green}}}}`, + }, + ImportUnderMedia: { + files: { + "/entry.css": /* css */ ` + @import "./g.css" screen; + @import "./other.css"; + @import "./g.css" screen; + `, + "/g.css": `@supports (display: no-such-value) { @layer theme { .g { color: green } } }`, + "/other.css": /* css */ ` + @layer base { .t { color: blue } } + @layer theme { .t { color: red } } + `, + }, + out: `@media screen{@supports (display: no-such-value){@layer theme{.g{color:green}}}}${other}@media screen{@supports (display: no-such-value){@layer theme{.g{color:green}}}}`, + }, + // The copy under "layer(ui)" has its rules in other layers than the bare copies, so it stays too. + LayeredCopyBetweenBareCopies: { + files: { + "/entry.css": /* css */ ` + @import "./g.css"; + @import "./g.css" layer(ui); + @import "./late.css"; + @import "./g.css"; + `, + "/g.css": /* css */ ` + @media print { @layer theme { .g { color: green } } } + @layer base { .g { color: black } } + `, + "/late.css": /* css */ ` + @layer late { .t { color: blue } } + @layer ui { .t { color: red } } + `, + }, + out: "@media print{@layer theme{.g{color:green}}}@layer base{.g{color:#000}}@layer ui{@media print{@layer theme{.g{color:green}}}@layer base{.g{color:#000}}}@layer late{.t{color:#00f}}@layer ui{.t{color:red}}@media print{@layer theme{.g{color:green}}}@layer base{.g{color:#000}}", + }, + // The list of h.css under "layer(v)" stays. Without it, the copy of g.css would put "a" ahead of "b" inside "v". + LayeredCopiesOfTwoFiles: { + files: { + "/entry.css": /* css */ ` + @import "./h.css"; + @import "./g.css"; + @import "./f.css"; + @import "./f.css"; + `, + "/f.css": /* css */ ` + @import "./h.css" layer(v); + @import "./g.css" layer(v); + `, + "/g.css": `@media screen { @layer a { .t { color: blue } } }`, + "/h.css": `@layer b { .t { color: red } }`, + }, + out: "@layer b{.t{color:red}}@media screen{@layer a{.t{color:#00f}}}@layer v{@layer b;}@layer v{@media screen{@layer a{.t{color:#00f}}}}@layer v{@layer b{.t{color:red}}}@layer v{@media screen{@layer a{.t{color:#00f}}}}", + }, + // In the sources the first layer is the unnamed one of the first copy of f2.css, so `.t` is green. A list cannot + // declare an unnamed layer. The copies do. + UnnamedBlockWithImportant: { + files: { + "/entry.css": /* css */ ` + @import "./f1.css"; + @import "./f1.css"; + `, + "/f1.css": /* css */ ` + @import "./f2.css"; + @import "./f2.css" layer(v); + `, + "/f2.css": /* css */ ` + @layer { .t { color: green !important } } + @media (min-width: 1px) { @layer a { .t { color: blue !important } } } + `, + }, + out: "@layer{.t{color:green!important}}@media (min-width:1px){@layer a{.t{color:#00f!important}}}@layer v{@layer{.t{color:green!important}}@media (min-width:1px){@layer a{.t{color:#00f!important}}}}".repeat( + 2, + ), + }, + // "@namespace" is valid only ahead of every rule but "@import" and "@layer" statements. The kept copy is a block, + // as a list under import conditions is on main. + CopyAheadOfNamespace: { + todo: 'the copy of g.css is a block ahead of "@namespace", so a browser drops that rule (#46232 moves "@namespace" to the top)', + files: { + "/entry.css": /* css */ ` + @import "./g.css"; + @import "./ns.css"; + @import "./g.css"; + `, + "/g.css": `@media print { @layer theme { .g { color: green } } }`, + "/ns.css": /* css */ ` + @namespace svg url(http://www.w3.org/2000/svg); + @layer base { svg|a { color: blue } } + @layer theme { .t { color: red } } + `, + }, + out: '@namespace svg "http://www.w3.org/2000/svg";@media print{@layer theme{.g{color:green}}}@layer base{svg|a{color:#00f}}@layer theme{.t{color:red}}@media print{@layer theme{.g{color:green}}}', + }, + // The next four inputs are right on main. A file with a conditional layer turns on the handling of "layer(...)" + // for the whole import order, and the kept copies and lists of that handling are wrong here. + KeptCopyUnderABareLayer: { + todo: 'the copy of g.css under a bare "layer" prints as an unnamed layer of its own: the import order cannot tell two bare "layer" imports apart', + files: { + "/entry.css": /* css */ ` + @import "./y.css"; + @import "./lib.css" layer; + @import "./lib.css"; + `, + "/y.css": `@layer v, b;`, + "/lib.css": /* css */ ` + @import "./g.css"; + @layer b { .t { color: blue } } + `, + "/g.css": `@supports (display: grid) { @layer v { .t { color: red } } }`, + }, + out: "@layer v,b;@supports (display: grid){@layer v{.t{color:red}}}@layer b{.t{color:#00f}}", + }, + EmptyBlockInAKeptCopy: { + todo: 'the minifier removes the empty block "@layer a {}" from the kept copy of g.css (#46733 keeps it)', + files: { + "/entry.css": /* css */ ` + @import "./g.css"; + @import "./other.css"; + @import "./g.css"; + `, + "/g.css": /* css */ ` + @media print { @layer p { .g { color: green } } } + @layer a {} + `, + "/other.css": /* css */ ` + @layer b { .t { color: blue } } + @layer a { .t { color: red } } + `, + }, + out: "@layer p,a;@layer b{.t{color:#00f}}@layer a{.t{color:red}}@media print{@layer p{.g{color:green}}}", + }, + NamespaceBehindALayeredCopy: { + todo: 'with g.css in the order, the copy of x.css under "layer(a)" stays whole, and it is a block ahead of "@namespace" (#46232)', + files: { + "/entry.css": /* css */ ` + @import "./x.css" layer(a); + @import "./s.css"; + @import "./x.css"; + @import "./g.css"; + `, + "/x.css": `.x { color: red }`, + "/s.css": /* css */ ` + @namespace svg url(http://www.w3.org/2000/svg); + svg|a { color: lime } + @layer base { .t { color: blue } } + @layer theme { .t { color: red } } + `, + "/g.css": `@media print { @layer theme { .g { color: green } } }`, + }, + out: '@layer a;@namespace svg "http://www.w3.org/2000/svg";svg|a{color:#0f0}@layer base{.t{color:#00f}}@layer theme{.t{color:red}}.x{color:red}@media print{@layer theme{.g{color:green}}}', + }, + UnnamedBlockWithImportantAndAnotherFile: { + todo: 'with zz.css in the order, the list of f2.css under "layer(v)" stays between the bare list and the file, so "a" is declared ahead of the unnamed layer', + files: { + "/entry.css": /* css */ ` + @import "./f1.css"; + @import "./f1.css"; + @import "./zz.css"; + `, + "/f1.css": /* css */ ` + @import "./f2.css"; + @import "./f2.css" layer(v); + `, + "/f2.css": /* css */ ` + @layer { .t { color: green !important } } + @layer a { .t { color: blue !important } } + `, + "/zz.css": `@media print { @layer zz { .z { color: gray } } }`, + }, + out: "@layer{.t{color:green!important}}@layer a{.t{color:#00f!important}}@layer v{@layer{.t{color:green!important}}@layer a{.t{color:#00f!important}}}@media print{@layer zz{.z{color:gray}}}", + }, + }; + for (const [name, { files, out, todo }] of Object.entries(conditionalLayerCases)) { + itBundled(`css/CSSAtLayerConditional${name}`, { + todo: !!todo, + files, + outfile: "/out.css", + minifyWhitespace: true, + onAfterBundle(api) { + api.expectFile("/out.css").toBe(out + "\n"); + }, + }); + } + for (const name of ["ListBehindAFileWithTheLayerUnderMedia", "Media"]) { + const { files, out } = conditionalLayerCases[name]; + itBundled(`css/CSSAtLayerConditional${name}CLI`, { + backend: "cli", + files, + outfile: "/out.css", + minifyWhitespace: true, + onAfterBundle(api) { + api.expectFile("/out.css").toBe(out + "\n"); + }, + }); + } + + // A CSS module with "composes" prints only outside a wrapper. Its bare copy stays whole. Its copy under "layer(ui)" + // is a list, as on main, and that list declares "ui.theme" in every case. + { + const module = /* css */ ` + .q { border: 0 } + .s { composes: q; margin: 0 } + @media print { @layer theme { .s { padding: 0 } } } + `; + const printed = ".q{border:0}.s{margin:0;}@media print{@layer theme{.s{padding:0}}}"; + const withoutHashes = (css: string) => css.replace(/_[\w-]{6}(?=[{.])/g, ""); + itBundled("css/CSSAtLayerConditionalComposesModule", { + files: { + "/entry.css": /* css */ ` + @import "./m.module.css"; + @import "./other.css"; + @import "./m.module.css"; + `, + "/m.module.css": module, + "/other.css": /* css */ ` + @layer base { .t { color: blue } } + @layer theme { .t { color: red } } + `, + }, + outfile: "/out.css", + minifyWhitespace: true, + onAfterBundle(api) { + expect(withoutHashes(api.readFile("/out.css"))).toBe(printed + other + printed + "\n"); + }, + }); + itBundled("css/CSSAtLayerConditionalComposesModuleUnderLayer", { + todo: true, + files: { + "/entry.css": /* css */ ` + @import "./m.module.css" layer(ui); + @import "./other.css"; + @import "./m.module.css"; + `, + "/m.module.css": module, + "/other.css": /* css */ ` + @layer ui.base { .t { color: blue } } + @layer ui.theme { .t { color: red } } + `, + }, + outfile: "/out.css", + minifyWhitespace: true, + onAfterBundle(api) { + expect(withoutHashes(api.readFile("/out.css"))).toBe( + `@layer ui{${printed}}@layer ui.base{.t{color:#00f}}@layer ui.theme{.t{color:red}}${printed}\n`, + ); + }, + }); + } + + // An import order with external imports, a file with a conditional layer, and no statement ahead of an "@import". + // Only "@layer" statements may print ahead of the "@import" rules. Bun rejects an "@import" behind any other rule, + // so the second build throws if the first one printed a block there. + itBundled("css/CSSAtLayerConditionalWithExternalImports", { + files: { + "/entry.css": /* css */ ` + @import "./entry.css" layer(u); + @import "./entry.css" layer(w) screen; + @import "https://example.com/b.css"; + @import "https://example.com/a.css" layer(v); + @import "./g.css"; + @import "./other.css"; + @import "https://example.com/a.css"; + @import "./g.css"; + `, + "/g.css": `@media print { @layer theme { .g { color: green } } }`, + "/other.css": /* css */ ` + @layer base { .t { color: blue } } + @layer theme { .t { color: red } } + @layer u { .f { color: red } } + `, + }, + outfile: "/out.css", + minifyWhitespace: true, + async onAfterBundle(api) { + api + .expectFile("/out.css") + .toBe( + '@layer u;@import "https://example.com/b.css";@import "https://example.com/a.css";@media screen{@layer w;}' + + "@media print{@layer theme{.g{color:green}}}@layer base{.t{color:#00f}}@layer theme{.t{color:red}}@layer u{.f{color:red}}@media print{@layer theme{.g{color:green}}}\n", + ); + await Bun.build({ entrypoints: [api.outfile], outdir: api.join("again") }); + }, + }); + // The list that stands for the first copy of x.css stays behind the imports. + itBundled("css/CSSAtLayerConditionalListBehindExternalImports", { + files: { + "/entry.css": /* css */ ` + @import "./g.css"; + @import "https://example.com/b.css"; + @import "https://example.com/a.css" layer(v); + @import "./x.css"; + @import "./y.css"; + @import "https://example.com/a.css"; + @import "./x.css"; + `, + "/g.css": `@media print { @layer a { .g { color: green } } }`, + "/x.css": `@layer a { .t { color: red } }`, + "/y.css": `@layer u { .t { color: blue } }`, + }, + outfile: "/out.css", + minifyWhitespace: true, + async onAfterBundle(api) { + api + .expectFile("/out.css") + .toBe( + '@import "https://example.com/b.css";@import "https://example.com/a.css";@media print{@layer a{.g{color:green}}}@layer a;@layer u{.t{color:#00f}}@layer a{.t{color:red}}\n', + ); + await Bun.build({ entrypoints: [api.outfile], outdir: api.join("again") }); + }, + }); + + // Both entry points have y.css, print.css, x.css and both.css behind the first copy of a file. a.js needs the copy + // of print.css, which declares "x" only in print. b.js needs the list of x.css, which declares it in every case. + itBundled("css/CSSAtLayerConditionalTwoEntryPoints", { + files: { + "/a.js": /* js */ ` + import "./print.css"; + import "./y.css"; + import "./both.css"; + console.log("a"); + `, + "/b.js": /* js */ ` + import "./x.css"; + import "./y.css"; + import "./both.css"; + console.log("b"); + `, + "/print.css": `@media print { @layer x { .p { color: green } } }`, + "/x.css": `@layer x { .t { color: red } }`, + "/y.css": `@layer y { .t { color: blue } }`, + "/both.css": /* css */ ` + @import "./print.css"; + @import "./x.css"; + `, + }, + entryPoints: ["/a.js", "/b.js"], + outdir: "/out", + minifyWhitespace: true, + onAfterBundle(api) { + const files = "@layer y{.t{color:#00f}}@media print{@layer x{.p{color:green}}}@layer x{.t{color:red}}\n"; + api.expectFile("/out/a.css").toBe("@media print{@layer x{.p{color:green}}}" + files); + api.expectFile("/out/b.css").toBe("@layer x;" + files); + }, + }); + + // Right on main. The import orders of the two entry points differ only in import conditions, which the key of a + // CSS chunk leaves out (#46357 compares the orders). On main the orders also differ in a list of layers. Here the + // copy of f0.css under "layer(w)" stays a file, so the keys are equal and e1.js gets the style sheet of e0.js. + itBundled("css/CSSAtLayerConditionalTwoEntryPointsWithOneKey", { + todo: true, + files: { + "/e0.js": `import "./f0.css"; import "./f1.css";`, + "/e1.js": `import "./f1.css";`, + "/f0.css": /* css */ ` + @layer c { .t { color: red } } + @layer b { .t { color: blue } } + `, + "/f1.css": /* css */ ` + @import "./f0.css" print; + @import "./f0.css" layer(w); + @import "./f0.css"; + @media print { @layer a { .p { color: green } } } + @layer b { .t { color: green } } + `, + }, + entryPoints: ["/e0.js", "/e1.js"], + outdir: "/out", + minifyWhitespace: true, + onAfterBundle(api) { + const files = + "@layer c{.t{color:red}}@layer b{.t{color:#00f}}@media print{@layer a{.p{color:green}}}@layer b{.t{color:green}}\n"; + api.expectFile("/out/e0.css").toBe(files); + api.expectFile("/out/e1.css").toBe("@media print{@layer c,b;}@layer w{@layer c,b;}" + files); + }, + }); + + // The two style sheets of the page import tokens.css, so the page gets one import order with tokens.css in it + // twice. + itBundled("css/CSSAtLayerConditionalTwoStyleSheetsOfAPage", { + files: { + "/index.html": /* html */ ` + + + + + + +

text

+ + `, + "/a.css": /* css */ ` + @import "./tokens.css"; + @layer base { .t { color: blue } } + `, + "/b.css": /* css */ ` + @import "./tokens.css"; + @layer theme { .t { color: red } } + `, + "/tokens.css": `@media (prefers-color-scheme: dark) { @layer theme { :root { --bg: black } } }`, + }, + entryPoints: ["/index.html"], + outdir: "/out", + minifyWhitespace: true, + onAfterBundle(api) { + const css = readdirSync(api.outdir).filter(file => file.endsWith(".css")); + expect(css).toHaveLength(1); + const tokens = "@media (prefers-color-scheme:dark){@layer theme{:root{--bg:black}}}"; + api + .expectFile("/out/" + css[0]) + .toBe(`${tokens}@layer base{.t{color:#00f}}${tokens}@layer theme{.t{color:red}}\n`); + }, + }); + // This test mainly just makes sure that this scenario doesn't crash itBundled("css/CSSAndJavaScriptCodeSplittingESBuildIssue1064", { files: {