css: apply @layer block rules to the cascade

Rules wrapped in @layer blocks (named, dotted sub-layer names, or
anonymous) were dropped from the cascade: StyleManager.parseSheet applied
top-level style rules and recursed into @media only, so @layer fell into
the silent else arm on both the _css_rules path and the <style> text path.
@layer blocks now flatten into the cascade like a matching @media body,
recursing into nested @media/@layer in both directions under the shared
MAX_AT_RULE_NESTING depth cap. The statement form (@layer a, b;) declares
ordering only and stays inert. Layer-priority ordering (css-cascade-5
section 6.4) is deliberately out of scope for this change: flattening with
the existing specificity + source-order tie-breaking fixes the common
failure mode of layered rules vanishing entirely (e.g. Tailwind v4 emits
its whole compiled output inside @layer, so .hidden { display: none }
never applied and checkVisibility()/getComputedStyle() reported hidden
elements as visible).

cssRules-inserted layer rules (insertRule/replaceSync) get their own
CSSRule.Type variant so the cascade pass can recognize them; their
JS-visible type stays 0 per CSSOM section 6.4.1 (no legacy code assigned),
matching Chrome's CSSLayerBlockRule.

Closes #2718
This commit is contained in:
Navid EMAD
2026-06-12 04:50:38 +02:00
parent 2664670947
commit 2ed49ecb8d
5 changed files with 282 additions and 36 deletions

View File

@@ -76,11 +76,11 @@ pub fn deinit(self: *StyleManager) void {
self.frame.releaseArena(self.arena);
}
/// Hard cap on `@media` nesting depth. CSS Nesting allows arbitrarily-deep
/// Hard cap on `@media` / `@layer` nesting depth. CSS allows arbitrarily-deep
/// at-rule nesting; without a cap a hostile inline stylesheet could blow the
/// Zig stack via mutually-recursive `applyMediaAtRule` frames. 32 is well
/// past anything seen in the wild.
const MAX_MEDIA_NESTING: u8 = 32;
/// Zig stack via the mutually-recursive `applyMediaAtRule` / `applyLayerAtRule`
/// / `applyInnerRules` frames. 32 is well past anything seen in the wild.
const MAX_AT_RULE_NESTING: u8 = 32;
fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
if (sheet._css_rules) |css_rules| {
@@ -91,6 +91,9 @@ fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
// `insertRule` / `replaceSync` participates in the cascade
// when its query matches the viewport.
.media => try self.applyMediaAtRule(rule._text, 0),
// `@layer` blocks flatten into the cascade (see
// applyLayerAtRule for the deliberate scope).
.layer => try self.applyLayerAtRule(rule._text, 0),
else => {},
}
}
@@ -105,12 +108,15 @@ fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
switch (parsed_rule) {
.style => |s| try self.addRawRule(s.selector, s.block),
.at_rule => |a| {
// Only `@media` participates in the cascade here. Other
// at-rules (`@keyframes`, `@supports`, `@font-face`, …)
// don't carry top-level declarations relevant to the
// visibility filter and stay skipped as before.
// Only `@media` and `@layer` participate in the cascade
// here. Other at-rules (`@keyframes`, `@supports`,
// `@font-face`, …) don't carry top-level declarations
// relevant to the visibility filter and stay skipped as
// before.
if (std.ascii.eqlIgnoreCase(a.keyword, "media")) {
try self.applyMediaAtRule(a.text, 0);
} else if (std.ascii.eqlIgnoreCase(a.keyword, "layer")) {
try self.applyLayerAtRule(a.text, 0);
}
},
}
@@ -123,45 +129,74 @@ fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
/// lived at the top level. Non-matching queries silently drop the inner
/// rules. Inline-only by design: external `<link rel="stylesheet">` is out
/// of scope for the headless engine.
fn applyMediaAtRule(self: *StyleManager, text: []const u8, depth: u8) !void {
if (depth >= MAX_MEDIA_NESTING) return;
fn applyMediaAtRule(self: *StyleManager, text: []const u8, depth: u8) Allocator.Error!void {
if (depth >= MAX_AT_RULE_NESTING) return;
// text shape: `@media <query> { <inner> }` for well-formed input.
// `CssParser.RulesIterator.consumeAtRule` always emits a span starting
// at `@`; for unclosed blocks it runs to EOF, so the closing `}` is
// located explicitly rather than assumed to be the final byte.
if (text.len < @as(usize, "@media".len) + 2) return;
if (!std.ascii.startsWithIgnoreCase(text, "@media")) return;
const rest = text["@media".len..];
// Use a comment-aware brace finder; a `/* { */` in the prelude would
// otherwise split the rule at the wrong place. The inner block's
// contents are re-parsed by CssParser below, which has its own trivia
// handling, so only this outer boundary needs the special-case scan.
const open = indexOfOpenBraceSkippingComments(rest) orelse return;
// Search only past the opening brace — the matching `}` lives there, and
// any returned position is naturally `> open` (since `rest[open] == '{'`).
const close = open + (std.mem.lastIndexOfScalar(u8, rest[open..], '}') orelse return);
const query = std.mem.trim(u8, rest[0..open], &std.ascii.whitespace);
const inner = rest[open + 1 .. close];
const block = atRuleBlock(text, "@media") orelse return;
const query = std.mem.trim(u8, block.prelude, &std.ascii.whitespace);
if (!MediaQuery.matches(query, self.frame._page.getViewport())) return;
try self.applyInnerRules(block.body, depth + 1);
}
/// Apply an `@layer` block rule by parsing its inner block into the cascade
/// as if its rules lived at the top level, recursing into nested `@media` /
/// `@layer`. Cascade-layer priority ordering (css-cascade-5 §6.4) is
/// deliberately not implemented: layers are flattened and ties keep breaking
/// on specificity + document order. Handles named blocks (including dotted
/// sub-layer names like `@layer theme.dark`) and anonymous blocks; the
/// statement form (`@layer a, b;`) declares ordering only and carries no
/// block (`atRuleBlock` returns null), so it applies nothing.
fn applyLayerAtRule(self: *StyleManager, text: []const u8, depth: u8) Allocator.Error!void {
if (depth >= MAX_AT_RULE_NESTING) return;
// The layer-name prelude is intentionally ignored — layers are flattened.
const block = atRuleBlock(text, "@layer") orelse return;
try self.applyInnerRules(block.body, depth + 1);
}
/// Parse a conditional at-rule's inner block, adding style rules to the
/// cascade and recursing into nested `@media` / `@layer`. `depth` is the
/// nesting depth of the *nested* at-rules (callers pass their own depth + 1).
fn applyInnerRules(self: *StyleManager, inner: []const u8, depth: u8) Allocator.Error!void {
var it = CssParser.parseStylesheet(inner);
while (it.next()) |nested_rule| {
switch (nested_rule) {
.style => |s| try self.addRawRule(s.selector, s.block),
.at_rule => |nested| {
if (std.ascii.eqlIgnoreCase(nested.keyword, "media")) {
try self.applyMediaAtRule(nested.text, depth + 1);
try self.applyMediaAtRule(nested.text, depth);
} else if (std.ascii.eqlIgnoreCase(nested.keyword, "layer")) {
try self.applyLayerAtRule(nested.text, depth);
}
},
}
}
}
/// Split a block at-rule into its prelude and inner block: for
/// `@media <query> { <body> }` returns `.{ .prelude = "<query>", .body = "<body>" }`.
/// Returns `null` when `text` doesn't start with `keyword`, or when it carries
/// no block — notably the statement form (`@layer a, b;`), which declares
/// ordering only.
///
/// `CssParser.RulesIterator.consumeAtRule` always emits a span starting at `@`;
/// for unclosed blocks it runs to EOF, so the closing `}` is located explicitly
/// rather than assumed to be the final byte. The opening `{` is found with a
/// comment-aware scan so a `/* { */` in the prelude doesn't split the rule at
/// the wrong place; the block's own trivia is handled by the CssParser re-parse
/// in `applyInnerRules`, so only this outer boundary needs the special-case scan.
fn atRuleBlock(text: []const u8, keyword: []const u8) ?struct { prelude: []const u8, body: []const u8 } {
if (!std.ascii.startsWithIgnoreCase(text, keyword)) return null;
const rest = text[keyword.len..];
const open = indexOfOpenBraceSkippingComments(rest) orelse return null;
// Search only past the opening brace — the matching `}` lives there, and
// any returned position is naturally `> open` (since `rest[open] == '{'`).
const close = open + (std.mem.lastIndexOfScalar(u8, rest[open..], '}') orelse return null);
return .{ .prelude = rest[0..open], .body = rest[open + 1 .. close] };
}
/// Find the first `{` in `s` that is not inside a CSS `/* ... */` comment.
/// An unclosed comment returns `null` (treat the whole rule as malformed).
fn indexOfOpenBraceSkippingComments(s: []const u8) ?usize {

View File

@@ -0,0 +1,203 @@
<!DOCTYPE html>
<head>
<script src="../testing.js"></script>
</head>
<!--
`@layer` block rules (CSS Cascade 5) are flattened into the cascade: the
inner rules participate exactly as if they were written at the top level,
recursing into nested `@media` / `@layer`. Layer-priority ordering
(css-cascade-5 §6.4) is deliberately not implemented — ties keep breaking
on specificity + document order. The statement form (`@layer a, b;`)
declares ordering only and carries no block, so it is inert.
-->
<style>
/* Named layer block — inner rules join the cascade. */
@layer utilities {
#hidden-by-layer { display: none; }
}
/* Anonymous layer block. */
@layer {
#hidden-by-anon-layer { display: none; }
}
/* Dotted sub-layer name in the prelude. */
@layer theme.dark {
#hidden-by-dotted-layer { display: none; }
}
/* Statement form: declares layer order, carries no block. It must be
inert AND must not abort parsing of the rest of the sheet. */
@layer theme, base, components, utilities;
#hidden-after-statement { display: none; }
/* Matching @media nested inside @layer — applies. */
@layer responsive {
@media (min-width: 600px) {
#hidden-media-in-layer { display: none; }
}
}
/* Non-matching @media nested inside @layer — dropped. */
@layer responsive {
@media print {
#not-hidden-print-in-layer { display: none; }
}
}
/* @layer nested inside @media. */
@media screen {
@layer inner {
#hidden-layer-in-media { display: none; }
}
}
/* @layer nested inside @layer. */
@layer outer {
@layer inner {
#hidden-nested-layer { display: none; }
}
}
/* Utility-style !important declaration inside a layer. */
@layer utilities {
#hidden-important { display: none !important; }
}
/* visibility:hidden inside a layer. */
@layer utilities {
#layer-visibility-hidden { visibility: hidden; }
}
</style>
<body>
<div id="hidden-by-layer">named</div>
<div id="hidden-by-anon-layer">anonymous</div>
<div id="hidden-by-dotted-layer">dotted</div>
<div id="hidden-after-statement">after-statement</div>
<div id="hidden-media-in-layer">media-in-layer</div>
<div id="not-hidden-print-in-layer">print-in-layer</div>
<div id="hidden-layer-in-media">layer-in-media</div>
<div id="hidden-nested-layer">nested-layer</div>
<div id="hidden-important">important</div>
<div id="layer-visibility-hidden">visibility</div>
<div id="dyn-target">dyn-target</div>
<div id="dyn2-target">dyn2-target</div>
<div id="deep-nest-target">deep-nest-target</div>
<style id="dyn"></style>
<style id="dyn2"></style>
<style id="deep-nest-style"></style>
</body>
<script id=layer_named_block_hides>
{
testing.expectFalse($('#hidden-by-layer').checkVisibility());
// getComputedStyle consumes the same cascade.
testing.expectEqual('none', getComputedStyle($('#hidden-by-layer')).display);
}
</script>
<script id=layer_anonymous_block_hides>
{
testing.expectFalse($('#hidden-by-anon-layer').checkVisibility());
}
</script>
<script id=layer_dotted_name_hides>
{
testing.expectFalse($('#hidden-by-dotted-layer').checkVisibility());
}
</script>
<script id=layer_statement_is_inert>
{
// The statement form has nothing to apply, and rules after it still parse.
testing.expectFalse($('#hidden-after-statement').checkVisibility());
}
</script>
<script id=layer_media_in_layer>
{
// Matching query inside a layer applies; non-matching is dropped.
testing.expectFalse($('#hidden-media-in-layer').checkVisibility());
testing.expectTrue($('#not-hidden-print-in-layer').checkVisibility());
}
</script>
<script id=layer_in_media>
{
testing.expectFalse($('#hidden-layer-in-media').checkVisibility());
}
</script>
<script id=layer_nested_layers>
{
testing.expectFalse($('#hidden-nested-layer').checkVisibility());
}
</script>
<script id=layer_important_declaration>
{
testing.expectFalse($('#hidden-important').checkVisibility());
}
</script>
<script id=layer_visibility_hidden>
{
// checkVisibility() defaults to display-only; visibilityProperty opts in.
testing.expectTrue($('#layer-visibility-hidden').checkVisibility());
testing.expectFalse(
$('#layer-visibility-hidden').checkVisibility({ visibilityProperty: true })
);
}
</script>
<script id=layer_dynamic_insertRule>
{
// The cssRules path (insertRule) must recurse into @layer too.
const sheet = $('#dyn').sheet;
const index = sheet.insertRule(
'@layer utilities { #dyn-target { display: none; } }', 0
);
testing.expectEqual(0, index);
testing.expectFalse($('#dyn-target').checkVisibility());
}
</script>
<script id=layer_cssRules_surface>
{
// @layer rules postdate the legacy numeric CSSRule type constants, so
// `type` is 0 (CSSOM §6.4.1) — same as Chrome's CSSLayerBlockRule.
const sheet = $('#dyn').sheet;
const rule = sheet.cssRules[0];
testing.expectEqual(0, rule.type);
}
</script>
<script id=layer_statement_insertRule>
{
// Statement form through insertRule: surfaced via cssRules, applies nothing.
const sheet = $('#dyn').sheet;
const index = sheet.insertRule('@layer theme, base;', 0);
testing.expectEqual(0, index);
testing.expectEqual(0, sheet.cssRules[0].type);
}
</script>
<script id=layer_replaceSync>
{
// The cssRules path also fires through replaceSync.
$('#dyn2').sheet.replaceSync(
'@layer utilities { #dyn2-target { display: none; } }'
);
testing.expectFalse($('#dyn2-target').checkVisibility());
}
</script>
<script id=layer_deep_nesting_capped>
{
// 50 levels of `@layer a { ... }` around a `display:none` rule. The depth
// guard (MAX_AT_RULE_NESTING in StyleManager.zig) must short-circuit
// before the Zig stack is exhausted — and the inner rule sits beyond the
// cap, so the element stays visible.
let css = '';
for (let i = 0; i < 50; i++) css += '@layer a { ';
css += '#deep-nest-target { display: none; }';
for (let i = 0; i < 50; i++) css += ' }';
$('#deep-nest-style').sheet.replaceSync(css);
testing.expectTrue($('#deep-nest-target').checkVisibility());
}
</script>

View File

@@ -256,7 +256,7 @@
<script id=cascade_deep_nesting_capped>
{
// 50 levels of `@media screen { ... }` around a `display:none` rule. The
// depth guard (MAX_MEDIA_NESTING in StyleManager.zig) must short-circuit
// depth guard (MAX_AT_RULE_NESTING in StyleManager.zig) must short-circuit
// before the Zig stack is exhausted — and the inner rule sits beyond the
// cap, so the element stays visible.
let css = '';

View File

@@ -23,6 +23,7 @@ pub const Type = union(enum) {
font_feature_values: void,
viewport: void,
region_style: void,
layer: void,
unknown: void,
};
@@ -63,10 +64,12 @@ pub fn initAtRule(rule_type: Type, text: []const u8, frame: *Frame) !*CSSRule {
}
pub fn getType(self: *const CSSRule) u16 {
if (self._type == .unknown) {
return 0;
}
return @as(u16, @intFromEnum(std.meta.activeTag(self._type))) + 1;
return switch (self._type) {
// `@layer` rules postdate the legacy numeric type constants, so
// their `type` is 0 (CSSOM §6.4.1) — same as unknown at-rules.
.layer, .unknown => 0,
else => @as(u16, @intFromEnum(std.meta.activeTag(self._type))) + 1,
};
}
pub fn getCssText(self: *const CSSRule, _: *Frame) []const u8 {

View File

@@ -147,6 +147,7 @@ fn atRuleTypeFor(keyword_with_prefix: []const u8) CSSRule.Type {
if (eql(keyword, "font-feature-values")) return .font_feature_values;
if (eql(keyword, "viewport")) return .viewport;
if (eql(keyword, "document")) return .document;
if (eql(keyword, "layer")) return .layer;
return .unknown;
}
@@ -219,3 +220,7 @@ test "WebApi: CSSStyleSheet" {
defer filter.deinit();
try testing.htmlRunner("css/stylesheet.html", .{});
}
test "WebApi: layer @-rule cascade" {
try testing.htmlRunner("css/layer_at_rule_cascade.html", .{});
}