From 329516fe8c25841f716b517d646fff6246b9deea Mon Sep 17 00:00:00 2001 From: Thomas Gazagnaire Date: Sat, 19 Sep 2026 21:41:46 +0200 Subject: [PATCH 1/4] rule_order: canonicalise layer declarations to one normal form Two sheets that establish the same layer order in the same places project to two different canonical strings, because the optimizer folds an empty layer block into a trailing declaration while the pin logic splits a leading multi-name statement. The canonical projection then reports the two render-equal sheets as a difference; Tailwind's preflight, whose layers are declared in one statement, reads apart from tw's block-by-block sheet. The projection is a canonical form, so it must be confluent: one output per class of sheets that render alike. Read each block as runs separated by a barrier, a statement that is not a layer itself but declares a layer below it, such as an @media holding an @layer or an @import with a layer, whose contribution depends on its condition. Within a run, write every layer name the run introduces, each a.b path expanded to a and a.b, once as one `@layer ;` statement at the run's first name-bearing construct, drop the run's other declarations and empty blocks, and put its style-only blocks in rank order. The order is unchanged and nothing crosses a barrier. The fuzz suite pins the two properties: respellings of one order converge, and projecting the projection changes nothing. --- fuzz/fuzz.ml | 1 + fuzz/fuzz_rule_order.ml | 142 +++++++++++++++++++++++ fuzz/fuzz_rule_order.mli | 4 + lib/rule_order.ml | 175 ++++++++++++++++++++++++++++- test/cli/diff_canonical_residual.t | 4 +- test/test_rule_order.ml | 109 +++++++++--------- 6 files changed, 373 insertions(+), 62 deletions(-) create mode 100644 fuzz/fuzz_rule_order.ml create mode 100644 fuzz/fuzz_rule_order.mli diff --git a/fuzz/fuzz.ml b/fuzz/fuzz.ml index 312e32494..3209044fe 100644 --- a/fuzz/fuzz.ml +++ b/fuzz/fuzz.ml @@ -21,6 +21,7 @@ let () = Fuzz_inline.suite; Fuzz_stylesheet.suite; Fuzz_optimize.suite; + Fuzz_rule_order.suite; Fuzz_css.suite; Fuzz_supports.suite; Fuzz_font_face.suite; diff --git a/fuzz/fuzz_rule_order.ml b/fuzz/fuzz_rule_order.ml new file mode 100644 index 000000000..7633e9fd3 --- /dev/null +++ b/fuzz/fuzz_rule_order.ml @@ -0,0 +1,142 @@ +(** Fuzz tests for the canonical projection's layer normal form. + + The projection is a canonical form: it must be confluent, so two sheets that + establish the same layer order and hold the same rules project to one + string, and idempotent, so projecting its own output changes nothing. These + cases generate a layer order, spell it several ways (one statement, + per-layer statements, blocks alone), and check the projection brings them + together. *) + +open Cascade +open Alcobar + +let byte buf i = + if String.length buf = 0 then 0 else Char.code buf.[i mod String.length buf] + +let pick xs buf i = List.nth xs (byte buf i mod List.length xs) +let idents = [| "a"; "b"; "c"; "d" |] +let ident buf i = pick (Array.to_list idents) buf i + +(* A layer name: one ident, a dotted path of two, or a single ident that holds + an escaped dot and so is not the same layer as the path. *) +let name buf i = + match byte buf i mod 3 with + | 0 -> [ ident buf (i + 1) ] + | 1 -> [ ident buf (i + 1); ident buf (i + 2) ] + | _ -> [ ident buf (i + 1) ^ "." ^ ident buf (i + 2) ] + +let layer_names buf = + let n = 1 + (byte buf 0 mod 4) in + let rec go i acc = + if i >= n then List.rev acc + else + let name = name buf (i + 1) in + let acc = + if List.exists (Stylesheet.equal_layer_name name) acc then acc + else name :: acc + in + go (i + 1) acc + in + go 0 [] + +(* The CSS spelling of a layer name: [a.b] is the sublayer [b] of [a], and an + ident that holds a dot is spelled with it escaped. *) +let spell name = + let escape s = String.concat "\\." (String.split_on_char '.' s) in + String.concat "." (List.map escape name) + +let has_block buf i = byte buf (i + 40) land 1 = 0 + +let body buf i = + if byte buf (i + 60) land 1 = 0 then "x{top:0}" else "x{color:red}" + +(* One [@layer a,b;] statement naming every layer, then a block per layer that + has one. *) +let sheet_one_statement buf names = + let statement = "@layer " ^ String.concat "," (List.map spell names) ^ ";" in + let blocks = + List.mapi + (fun i name -> + if has_block buf i then "@layer " ^ spell name ^ "{" ^ body buf i ^ "}" + else "") + names + in + statement ^ String.concat "" blocks + +(* Per-layer statements for the layers that have no block, blocks for the rest, + the same order. *) +let sheet_per_name buf names = + String.concat "" + (List.mapi + (fun i name -> + if has_block buf i then "@layer " ^ spell name ^ "{" ^ body buf i ^ "}" + else "@layer " ^ spell name ^ ";") + names) + +let canonical css = + match Css.of_string ~strict:false css with + | Ok { stylesheet; _ } -> + Css.statements stylesheet |> Rule_order.canonicalize + |> Pp.to_string ~minify:true Stylesheet.pp_stylesheet + | Error e -> + failf "layer sheet did not parse (%s): %S" (Error.to_string e) css + +let test_layer_respellings_converge buf = + let names = layer_names buf in + let one = canonical (sheet_one_statement buf names) in + let per = canonical (sheet_per_name buf names) in + if one <> per then + failf "two spellings of one layer order project apart:\n %S\n %S" one per + +let test_layer_projection_is_idempotent buf = + let names = layer_names buf in + let once = canonical (sheet_one_statement buf names) in + let twice = canonical once in + if once <> twice then + failf "projecting the projection changed it:\n %S\n %S" once twice + +(* The order a name list establishes, with each [a.b] path expanded to [a] then + [a.b]: reversing [a.b, a] establishes the same order as [a, a.b]. *) +let established_order names = + let rec go seen = function + | [] -> List.rev seen + | name :: rest -> + let rec prefixes acc cur = function + | [] -> List.rev acc + | x :: tl -> + let cur = cur @ [ x ] in + prefixes (cur :: acc) cur tl + in + let seen = + List.fold_left + (fun seen prefix -> + if List.exists (Stylesheet.equal_layer_name prefix) seen then seen + else prefix :: seen) + seen (prefixes [] [] name) + in + go seen rest + in + go [] names + +let test_reversed_order_stays_distinct buf = + let names = layer_names buf in + let reversed = List.rev names in + if + List.length names > 1 + && established_order names <> established_order reversed + then + let forward = canonical (sheet_per_name buf names) in + let backward = canonical (sheet_per_name buf reversed) in + if forward = backward then + failf "two layer orders project together: %S" forward + +let suite = + ( "rule_order", + [ + test_case "layer respellings converge" [ bytes ] + test_layer_respellings_converge; + test_case "layer projection is idempotent" [ bytes ] + test_layer_projection_is_idempotent; + test_case "reversed layer order stays distinct" [ bytes ] + test_reversed_order_stays_distinct; + ] ) diff --git a/fuzz/fuzz_rule_order.mli b/fuzz/fuzz_rule_order.mli new file mode 100644 index 000000000..c92c9097a --- /dev/null +++ b/fuzz/fuzz_rule_order.mli @@ -0,0 +1,4 @@ +(** Fuzz tests for the canonical projection's layer normal form. *) + +val suite : string * Alcobar.test_case list +(** [suite] declares the layer-normal-form fuzz cases. *) diff --git a/lib/rule_order.ml b/lib/rule_order.ml index 509262a65..34ef3204c 100644 --- a/lib/rule_order.ml +++ b/lib/rule_order.ml @@ -1354,6 +1354,178 @@ let sort_layer_blocks (stmts : statement list) : statement list = let sorted = go stmts in if !moved then fold_layer_pins sorted else stmts +(* A sheet may name its layers in one [@layer a, b;] statement, in per-layer + statements, or in the blocks themselves. Two sheets that establish the same + layer order in the same places reach two different canonical forms, because + the optimizer folds an empty layer block into a trailing declaration while + the pin logic splits a leading multi-name statement, and the comparison then + reports the two render-equal sheets as different. This is the normal form + that removes the dependence on the spelling. + + A block's statements are read as runs separated by a barrier: a statement + that is not itself a layer construct but declares a layer below it, such as + an [@media] holding an [@layer] or an [@import] with a layer. A barrier's + contribution to the order depends on its condition, so no name crosses one. + Within a barrier-free run, every layer name the run introduces, each [a.b] + path expanded to its prefixes [a] then [a.b], is written once in + first-appearance order as one [@layer ;] statement at the run's first + name-bearing construct. The run's other layer statements and empty layer + blocks go, since the statement carries their only effect. Nothing moves past + a barrier and the names keep first-appearance order, so the order the sheet + renders with is unchanged, and a nested layer is normalized inside the block + that holds it. *) + +(* The prefixes a layer path declares, outermost first: [a.b] declares [a] then + [a.b], and an ident that holds an escaped dot is one name with no prefixes to + split. *) +let layer_name_prefixes (name : layer_name) : layer_name list = + let rec go acc cur = function + | [] -> List.rev acc + | x :: rest -> + let cur = cur @ [ x ] in + go (cur :: acc) cur rest + in + go [] [] name + +(* The layer names a run introduces, each path expanded, in first-appearance + order and deduplicated. *) +let run_layer_names (run : statement list) : layer_name list = + let seen = ref [] in + let add name = + List.iter + (fun prefix -> + if not (List.exists (Stylesheet.equal_layer_name prefix) !seen) then + seen := !seen @ [ prefix ]) + (layer_name_prefixes name) + in + let names_of stmt = + match stmt with + | Layer (Some name, _) -> [ name ] + | Layer_decl names -> names + | Import rule -> ( + match Stylesheet.import_layer_name rule with + | Some (_ :: _ as name) -> [ name ] + | Some [] | None -> []) + | _ -> [] + in + List.iter (fun stmt -> List.iter add (names_of stmt)) run; + !seen + +(* Put a run's style-only layer blocks into rank order. A block holding a nested + layer keeps its place, since the sublayer it holds contributes in source + order; moving it would change which of two writes to the sublayer wins. *) +let sort_layer_blocks_by_rank (names : layer_name list) (stmts : statement list) + : statement list = + let rank name = + let rec go i = function + | [] -> max_int + | x :: rest -> + if Stylesheet.equal_layer_name x name then i else go (i + 1) rest + in + go 0 names + in + let name = function Layer (Some name, _) -> name | _ -> [] in + let sortable = function + | Layer (Some _, body) -> List.for_all style_only body + | _ -> false + in + let rec go = function + | [] -> [] + | block :: _ as stmts when sortable block -> + let rec take acc = function + | block :: rest when sortable block -> take (block :: acc) rest + | rest -> (List.rev acc, rest) + in + let blocks, rest = take [] stmts in + List.stable_sort + (fun a b -> Int.compare (rank (name a)) (rank (name b))) + blocks + @ go rest + | stmt :: rest -> stmt :: go rest + in + go stmts + +(* Rewrite one barrier-free run to its normal form: one [@layer ;] + statement at the run's first name-bearing construct, the other declarations + and empty layer blocks gone, the style-only blocks in rank order. *) +type layer_verdict = Keep | Drop | Other + +let canonical_layer_run (run : statement list) : statement list = + let names = run_layer_names run in + if names = [] then run + else + let empty block = + List.for_all + (function + | Rule { declarations = []; nested = []; _ } -> true | _ -> false) + block + in + let verdict = function + | Layer (Some _, block) -> if empty block then Drop else Keep + | Layer_decl (_ :: _) -> Drop + | Import rule -> ( + match Stylesheet.import_layer_name rule with + | Some (_ :: _) -> Keep + | Some [] | None -> Other) + | _ -> Other + in + let emitted = ref false in + let out = ref [] in + let emit () = + if not !emitted then ( + emitted := true; + out := Layer_decl names :: !out) + in + List.iter + (fun stmt -> + match verdict stmt with + | Keep -> + emit (); + out := stmt :: !out + | Drop -> emit () + | Other -> out := stmt :: !out) + run; + sort_layer_blocks_by_rank names (List.rev !out) + +(* A statement that is a layer construct itself, and one whose transitive + content declares a layer, the second being a barrier for the run around + it. *) +let is_layer_construct = function + | Layer (Some _, _) | Layer_decl (_ :: _) -> true + | _ -> false + +let rec statement_declares_layer stmt = + is_layer_construct stmt + || (match stmt with + | Import rule -> ( + match Stylesheet.import_layer_name rule with + | Some (_ :: _) -> true + | Some [] | None -> false) + | _ -> false) + || Stylesheet.fold_statements + (fun acc child -> acc || statement_declares_layer child) + false + (Stylesheet.statement_children stmt) + +let is_barrier stmt = + (not (is_layer_construct stmt)) && statement_declares_layer stmt + +let rec canonical_layer_declarations (stmts : statement list) : statement list = + let stmts = + List.map + (Stylesheet.map_statement_children canonical_layer_declarations) + stmts + in + let rec go run acc = function + | [] -> List.rev_append (canonical_layer_run (List.rev run)) acc + | stmt :: rest when is_barrier stmt -> + go [] + (stmt :: List.rev_append (canonical_layer_run (List.rev run)) acc) + rest + | stmt :: rest -> go (stmt :: run) acc rest + in + List.rev (go [] [] stmts) + (* Media Queries 4 sec. 2.3 makes [all] the identity media type, so the Level 3 [not all and (X)] is the Level 4 [not (X)]; sec. 4.2 gives [min-X]/[max-X] and the range form one meaning, and a lower bound met by an upper bound one @@ -1483,4 +1655,5 @@ let canonicalize ?(lossless = false) ?(enforce_spec = false) ?judge in (* The runs above leave layer blocks side by side, which is where their order can be read. *) - sort_layer_blocks (if !changed then result else normalized) + canonical_layer_declarations + (sort_layer_blocks (if !changed then result else normalized)) diff --git a/test/cli/diff_canonical_residual.t b/test/cli/diff_canonical_residual.t index 7b7491da9..b30e86749 100644 --- a/test/cli/diff_canonical_residual.t +++ b/test/cli/diff_canonical_residual.t @@ -22,8 +22,8 @@ the report shows the two canonical forms with exit code 1. --- pinned.css +++ unpinned.css @@ position 7 @@ - -@layer a;@layer b{x{top:0}} - +@layer b{x{top:0}} + -@layer a,b;@layer b{x{top:0}} + +@layer b;@layer b{x{top:0}} ^ [1] diff --git a/test/test_rule_order.ml b/test/test_rule_order.ml index 357293d0b..d7fa66216 100644 --- a/test/test_rule_order.ml +++ b/test/test_rule_order.ml @@ -223,71 +223,68 @@ let media_conflict_keeps_source_order () = let layer_blocks_stay_put () = (* [@layer] order pins cascade-layer priority at first occurrence; layer - blocks are barriers. *) + blocks are barriers. The projection writes the order out as one statement, + so the statement and the blocks name the same order and project + together. *) let css = "@layer b{.y{color:blue}}@layer a{.x{color:red}}" in Alcotest.(check string) "layer blocks keep source order" - (render (statements css)) - (canonical css) + "@layer b,a;@layer b{.y{color:blue}}@layer a{.x{color:red}}" (canonical css) let pinned_layer_blocks_take_the_layer_order () = - (* CSS Cascade 5 sec. 6.1 sorts by layer before order of appearance, and a pin - fixes the layer order, so blocks written out of it read in it. A body of - style rules under [@starting-style] cascades by layer the same way and - moves too, across unlayered rules, [@property] and [@keyframes]; a block - declaring a sublayer inside it does not move. *) + (* CSS Cascade 5 sec. 6.1 sorts by layer before order of appearance, so the + projection writes the layer order out as one statement and leaves the + blocks to sit in it. A body of style rules under [@starting-style] cascades + by layer the same way and moves too, across unlayered rules. *) Alcotest.(check string) "starting-style body sorted" - "x{color:red}@layer a{y{color:blue}}@layer b{@starting-style{x{opacity:0}}}" + "x{color:red}@layer a,b;@layer a{y{color:blue}}@layer \ + b{@starting-style{x{opacity:0}}}" (canonical "@layer a,b;@layer b{@starting-style{x{opacity:0}}}x{color:red}@layer \ a{y{color:blue}}"); Alcotest.(check string) "a block holding a sublayer stays" - "@layer a;@layer b{@layer c{x{color:red}}}@layer a{y{color:blue}}" + "@layer a,b;@layer b{@layer c;@layer c{x{color:red}}}@layer \ + a{y{color:blue}}" (canonical "@layer a,b;@layer b{@layer c{x{color:red}}}@layer a{y{color:blue}}") let redundant_layer_pin_folds () = (* CSS Cascade 5 sec. 6.4.3: cascade layers are sorted by the order in which - they first are declared, so a pin gives its layer a position the very next - block would have given it anyway. Dropping it leaves the order alone, so - the two spellings are one sheet and the projection brings them together. *) + they first are declared. The projection states that order once, so the pin + and the block that repeats it reach one form. *) Alcotest.(check string) - "a pin the following block repeats folds away" "@layer a{x{color:red}}" + "a pin the following block repeats reaches one form" + "@layer a;@layer a{x{color:red}}" (canonical "@layer a;@layer a{x{color:red}}") let order_changing_layer_pin_is_kept () = (* Sec. 6.4.3 read the other way: here the pins are the only thing putting [a] - before [b], since [b]'s block comes first. Dropping [@layer a;] alone would - reverse the two layers and change what the sheet renders, so the order it - fixes stays. The projection writes that order out with [a]'s block first, - since sec. 6.1 sorts the two blocks by layer whichever comes first, and the - pin then repeats what the blocks declare. *) + before [b], since [b]'s block comes first. The projection states that + order, so [a] stays first and the pinned and unpinned sheets stay apart. *) let pinned = "@layer a;@layer b;@layer b{x{color:red}}@layer a{y{color:blue}}" in let unpinned = "@layer b{x{color:red}}@layer a{y{color:blue}}" in Alcotest.(check string) "the order the pin fixes stays" - "@layer a{y{color:blue}}@layer b{x{color:red}}" (canonical pinned); + "@layer a,b;@layer a{y{color:blue}}@layer b{x{color:red}}" + (canonical pinned); Alcotest.(check bool) "pinned and unpinned sheets stay distinct" true (canonical pinned <> canonical unpinned) let leading_layer_pins_matching_the_blocks_fold () = (* The shape a generator writes: every layer named up front, then the blocks - in that same order. Sec. 6.4.3 has the blocks declare that order on their - own, so the whole pin goes. One name reads as needed only while a later one - is still there to be weighed against it, so the fold has to settle rather - than sweep once. *) + in that same order. The projection states that order either way. *) Alcotest.(check string) - "pins repeating the block order fold away" - "@layer a{x{color:red}}@layer b{y{color:blue}}" + "pins repeating the block order reach one form" + "@layer a,b;@layer a{x{color:red}}@layer b{y{color:blue}}" (canonical "@layer a,b;@layer a{x{color:red}}@layer b{y{color:blue}}"); Alcotest.(check string) - "written as two statements they fold the same way" - "@layer a{x{color:red}}@layer b{y{color:blue}}" + "written as two statements they reach the same form" + "@layer a,b;@layer a{x{color:red}}@layer b{y{color:blue}}" (canonical "@layer a;@layer b;@layer a{x{color:red}}@layer b{y{color:blue}}"); Alcotest.(check bool) "the reversed pin order is another sheet and stays" true @@ -295,28 +292,25 @@ let leading_layer_pins_matching_the_blocks_fold () = <> canonical "@layer a{x{color:red}}@layer b{y{color:blue}}") let layer_pin_folds_one_name_not_the_statement () = - (* Sec. 6.4.4.2: [@layer a, b;] declares two layers in that order. Only [b] - repeats the order the blocks already give, so the statement survives with - [a] alone rather than going away whole. The projection then writes the - blocks in that order, which is what [a] fixed, and the pin goes too, so the - two spellings of the one order project together. *) + (* Sec. 6.4.4.2: [@layer a, b;] declares two layers in that order. The + projection writes the full order it reads, so the statement and the blocks + reach one form whatever spelling put [a] before [b]. *) Alcotest.(check string) "the order the pin gives, written by the blocks" - "@layer a{y{color:blue}}@layer b{x{color:red}}" + "@layer a,b;@layer a{y{color:blue}}@layer b{x{color:red}}" (canonical "@layer a,b;@layer b{x{color:red}}@layer a{y{color:blue}}"); Alcotest.(check string) "a pin kept for a name no block declares" - "@layer a,c;@layer a{y{color:blue}}@layer b{x{color:red}}" + "@layer a,c,b;@layer a{y{color:blue}}@layer b{x{color:red}}" (canonical "@layer a,c,b;@layer b{x{color:red}}@layer a{y{color:blue}}") let layer_pin_names_are_ident_lists () = (* Sec. 6.4.2: [a.b] is the sublayer [b] of [a] and declares [a] on the way, - so a pin naming [a] repeats what the sublayer block declares first. An - escaped dot makes one ident instead, a layer neither block declares, and - that pin is the whole of what puts that layer in the order. *) + so the order names [a] then [a.b]. An escaped dot makes one ident instead, + a layer of its own. *) Alcotest.(check string) - "a pin the sublayer block declares first folds away" - "@layer a.b{x{color:red}}" + "a sublayer names its parent on the way" + "@layer a,a.b;@layer a.b{x{color:red}}" (canonical "@layer a;@layer a.b{x{color:red}}"); Alcotest.(check bool) "a pin naming an ident that holds a dot stays" true @@ -325,35 +319,32 @@ let layer_pin_names_are_ident_lists () = let unblocked_layer_pin_is_kept () = (* A pin whose layer never gets a block of its own is the only declaration of - that layer, and sec. 6.4.3 sorts it before every layer declared after it. - Dropping it would take a position out of the order. *) + that layer, and sec. 6.4.3 sorts it before every layer declared after + it. *) Alcotest.(check string) - "a pin with no block of its own stays" "@layer a;@layer b{x{color:red}}" + "a pin with no block of its own stays" "@layer a,b;@layer b{x{color:red}}" (canonical "@layer a;@layer b{x{color:red}}") let layer_pin_over_conditional_declaration_is_kept () = (* Sec. 6.4.3: a layer declared inside a conditional group rule contributes to - the order only when the condition holds, so what the [@media] puts between - the pin and the block cannot be read here. Without the pin the [@media] may - declare [a] first and push it before [b], so the pin stays. *) + the order only when the condition holds, so no name is hoisted across the + [@media]; the run before it keeps the pin and the run after it states its + own order. *) let css = "@layer a;@media print{@layer a{x{color:red}}}@layer \ b{y{color:blue}}@layer a{z{color:green}}" in Alcotest.(check string) "a pin over an unreadable position stays" - (render (statements css)) + "@layer a;@media print{@layer a;@layer a{x{color:red}}}@layer b,a;@layer \ + b{y{color:blue}}@layer a{z{color:green}}" (canonical css) let layer_pin_over_conditional_layer_is_the_only_position () = (* The test above leaves an [@layer b] block between the pin and the block that repeats it, and that named position keeps the pin whatever the [@media] contributes. Here the conditional block is the only thing between - them, so whether it raises a position at all is the whole question: with a - layer inside it sec. 6.4.3 puts something unreadable between [a] and [a], - and the pin is what says which comes first; with only plain rules inside it - the pin says a second time what the block after it already says. The two - sheets differ in nothing else. *) + them, so whether it raises a position at all is the whole question. *) let over_a_layer = "@layer a;@media print{@layer q{x{color:red}}}@layer a{y{color:blue}}" in @@ -362,11 +353,12 @@ let layer_pin_over_conditional_layer_is_the_only_position () = in Alcotest.(check string) "a pin over a block declaring a layer stays" - (render (statements over_a_layer)) + "@layer a;@media print{@layer q;@layer q{x{color:red}}}@layer a;@layer \ + a{y{color:blue}}" (canonical over_a_layer); Alcotest.(check string) "the same pin over a block declaring none folds" - "@media print{x{color:red}}@layer a{y{color:blue}}" + "@media print{x{color:red}}@layer a;@layer a{y{color:blue}}" (canonical over_plain_rules) let layer_pin_over_import_is_kept () = @@ -376,8 +368,7 @@ let layer_pin_over_import_is_kept () = let css = "@layer a;@import url(x.css);@layer a{y{color:red}}" in Alcotest.(check string) "a pin over an import stays" - (render (statements css)) - (canonical css) + "@layer a;@import\"x.css\";@layer a{y{color:red}}" (canonical css) let layer_pin_fold_is_idempotent () = (* The projection is a projection: what it emits is already canonical. *) @@ -389,12 +380,12 @@ let layer_pin_fold_is_idempotent () = let block_with_layer_content_is_barrier () = (* A conditional block is only reorderable when its transitive content is - plain rules; a nested [@layer] pins it in place. *) + plain rules; a nested [@layer] pins it in place, and the run inside states + its own order. *) let css = "@media print{@layer a{.x{color:red}}}.b{margin:0}" in Alcotest.(check string) "media wrapping a layer stays put" - (render (statements css)) - (canonical css) + "@media print{@layer a;@layer a{.x{color:red}}}.b{margin:0}" (canonical css) let hoisted_group_converges_with_inline () = (* A shared declaration hoisted into a selector-list group is the same From 6b1e893e1c3e2960d136461e2932d35bef19a9db Mon Sep 17 00:00:00 2001 From: Thomas Gazagnaire Date: Sat, 19 Sep 2026 21:42:42 +0200 Subject: [PATCH 2/4] changes: canonicalise layer declarations to one normal form --- CHANGES.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 52d7c9f52..b13787b86 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -737,6 +737,14 @@ to lose a whole rule over one bad piece. Both are gone. ### Canonical diff +- The projection canonicalises layer declarations to one form, so two sheets + that establish the same layer order in the same places compare equal however + they spell it. A single `@layer a, b;`, per-layer `@layer a;` statements and + the blocks alone all reach one `@layer ;` per conditional-free run, + each `a.b` path expanding to `a` then `a.b`; nothing is hoisted across a + conditional group that declares a layer, so the order is preserved. This is + what made Tailwind's preflight, whose layers are declared in one statement, + report against tw's block-by-block sheet (#1275). - `--diff=canonical` drops an `@supports` block whose guard is false however a browser answers it, reading a parenthesised `` term as the false CSS Conditional 3 sec. 6.1 makes it: Tailwind's `@supports From e63c7384ea76b597895db7bdf76ded024067020b Mon Sep 17 00:00:00 2001 From: Thomas Gazagnaire Date: Sat, 19 Sep 2026 21:47:32 +0200 Subject: [PATCH 3/4] test: pin layer declaration forms converging --- test/test_css_compare.ml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/test_css_compare.ml b/test/test_css_compare.ml index a8dff235a..d3f3b0545 100644 --- a/test/test_css_compare.ml +++ b/test/test_css_compare.ml @@ -1004,6 +1004,31 @@ let canonical_ignores_how_the_layer_order_is_written () = (equal "@layer a.b{.p{color:red}}@layer a{@layer b{.p{color:blue}}}" "@layer a{@layer b{.p{color:blue}}}@layer a.b{.p{color:red}}") +(* The projection is confluent for the layer declaration forms: a single [@layer + a, b;] statement, per-layer statements and an empty block, and the blocks + alone all establish the same order and compare equal. A dotted name expands + to its prefixes on the way, and a conditional group that declares a layer is + a barrier no name is hoisted across. The shape is Tailwind's preflight, whose + layers are declared in one statement, against a sheet that declares them + block by block. *) +let canonical_layer_declaration_forms_converge () = + let equal a b = Cascade_diff.Css_compare.equal ~mode:`Canonical a b in + Alcotest.(check bool) + "one statement against per-layer statements and an empty block" true + (equal + "@layer theme,base,components,utilities;@layer \ + theme{.x{color:red}}@layer base{.y{color:blue}}@layer utilities{}" + "@layer theme{.x{color:red}}@layer base{.y{color:blue}}@layer \ + components;@layer utilities{}"); + Alcotest.(check bool) + "a dotted name expands to its prefixes" true + (equal "@layer a.b;@layer a.b{x{top:0}}" + "@layer a;@layer a.b;@layer a.b{x{top:0}}"); + Alcotest.(check bool) + "a conditional barrier keeps the order apart" false + (equal "@layer a;@media print{@layer b{x{top:0}}}" + "@media print{@layer b{x{top:0}}}@layer a;") + (* Two rules under a condition and its exact negation never apply together: Media Queries 4 sec. 3.4 has [not] negate the whole query, CSS Conditional 3 sec. 6.1 the whole [@supports] condition, and CSS Conditional 5 sec. 7.2 @@ -2861,6 +2886,8 @@ let suite = canonical_ignores_layer_block_position; Alcotest.test_case "canonical ignores how the layer order is written" `Quick canonical_ignores_how_the_layer_order_is_written; + Alcotest.test_case "canonical layer declaration forms converge" `Quick + canonical_layer_declaration_forms_converge; Alcotest.test_case "canonical ignores order under exclusive conditions" `Quick canonical_ignores_order_under_exclusive_conditions; ] ) From 5396b80b5e7ee1b130f201c05462b8d06f5ce2a1 Mon Sep 17 00:00:00 2001 From: Thomas Gazagnaire Date: Sat, 19 Sep 2026 21:58:55 +0200 Subject: [PATCH 4/4] rule_order: avoid a polymorphic comparison in the layer normal form --- fuzz/fuzz_rule_order.ml | 4 ++- lib/rule_order.ml | 70 ++++++++++++++++++++--------------------- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/fuzz/fuzz_rule_order.ml b/fuzz/fuzz_rule_order.ml index 7633e9fd3..ad3f6aca7 100644 --- a/fuzz/fuzz_rule_order.ml +++ b/fuzz/fuzz_rule_order.ml @@ -123,7 +123,9 @@ let test_reversed_order_stays_distinct buf = let reversed = List.rev names in if List.length names > 1 - && established_order names <> established_order reversed + && not + (List.equal (List.equal String.equal) (established_order names) + (established_order reversed)) then let forward = canonical (sheet_per_name buf names) in let backward = canonical (sheet_per_name buf reversed) in diff --git a/lib/rule_order.ml b/lib/rule_order.ml index 34ef3204c..e784f24c4 100644 --- a/lib/rule_order.ml +++ b/lib/rule_order.ml @@ -1451,41 +1451,41 @@ let sort_layer_blocks_by_rank (names : layer_name list) (stmts : statement list) type layer_verdict = Keep | Drop | Other let canonical_layer_run (run : statement list) : statement list = - let names = run_layer_names run in - if names = [] then run - else - let empty block = - List.for_all - (function - | Rule { declarations = []; nested = []; _ } -> true | _ -> false) - block - in - let verdict = function - | Layer (Some _, block) -> if empty block then Drop else Keep - | Layer_decl (_ :: _) -> Drop - | Import rule -> ( - match Stylesheet.import_layer_name rule with - | Some (_ :: _) -> Keep - | Some [] | None -> Other) - | _ -> Other - in - let emitted = ref false in - let out = ref [] in - let emit () = - if not !emitted then ( - emitted := true; - out := Layer_decl names :: !out) - in - List.iter - (fun stmt -> - match verdict stmt with - | Keep -> - emit (); - out := stmt :: !out - | Drop -> emit () - | Other -> out := stmt :: !out) - run; - sort_layer_blocks_by_rank names (List.rev !out) + match run_layer_names run with + | [] -> run + | names -> + let empty block = + List.for_all + (function + | Rule { declarations = []; nested = []; _ } -> true | _ -> false) + block + in + let verdict = function + | Layer (Some _, block) -> if empty block then Drop else Keep + | Layer_decl (_ :: _) -> Drop + | Import rule -> ( + match Stylesheet.import_layer_name rule with + | Some (_ :: _) -> Keep + | Some [] | None -> Other) + | _ -> Other + in + let emitted = ref false in + let out = ref [] in + let emit () = + if not !emitted then ( + emitted := true; + out := Layer_decl names :: !out) + in + List.iter + (fun stmt -> + match verdict stmt with + | Keep -> + emit (); + out := stmt :: !out + | Drop -> emit () + | Other -> out := stmt :: !out) + run; + sort_layer_blocks_by_rank names (List.rev !out) (* A statement that is a layer construct itself, and one whose transitive content declares a layer, the second being a barrier for the run around