mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-08-01 02:06:17 -04:00
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:
@@ -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 {
|
||||
|
||||
203
src/browser/tests/css/layer_at_rule_cascade.html
Normal file
203
src/browser/tests/css/layer_at_rule_cascade.html
Normal 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>
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user