Merge pull request #1910 from lightpanda-io/css-improvements-perf2

Bucket stylesheet rules
This commit is contained in:
Karl Seguin
2026-03-19 14:39:55 +08:00
committed by GitHub

View File

@@ -41,14 +41,24 @@ pub const VisibilityCache = std.AutoHashMapUnmanaged(*Element, bool);
pub const PointerEventsCache = std.AutoHashMapUnmanaged(*Element, bool); pub const PointerEventsCache = std.AutoHashMapUnmanaged(*Element, bool);
// Tracks visibility-relevant CSS rules from <style> elements. // Tracks visibility-relevant CSS rules from <style> elements.
// Rules are bucketed by their rightmost selector part for fast lookup.
const StyleManager = @This(); const StyleManager = @This();
const Tag = Element.Tag;
const RuleList = std.ArrayList(VisibilityRule);
page: *Page, page: *Page,
arena: Allocator, arena: Allocator,
// Sorted in document order. Only ryles that we care about (display, visibility, ...) // Bucketed rules for fast lookup - keyed by rightmost selector part
rules: std.ArrayList(VisibilityRule) = .empty, id_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
class_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
tag_rules: std.AutoHashMapUnmanaged(Tag, RuleList) = .empty,
other_rules: RuleList = .empty, // universal, attribute, pseudo-class endings
// Document order counter for tie-breaking equal specificity
next_doc_order: u32 = 0,
// When true, rules need to be rebuilt // When true, rules need to be rebuilt
dirty: bool = false, dirty: bool = false,
@@ -90,10 +100,26 @@ fn rebuildIfDirty(self: *StyleManager) !void {
self.dirty = false; self.dirty = false;
errdefer self.dirty = true; errdefer self.dirty = true;
const id_rules_count = self.id_rules.count();
const class_rules_count = self.class_rules.count();
const tag_rules_count = self.tag_rules.count();
const other_rules_count = self.other_rules.items.len;
const item_count = self.rules.items.len;
self.page._session.arena_pool.resetRetain(self.arena); self.page._session.arena_pool.resetRetain(self.arena);
self.rules = try .initCapacity(self.arena, item_count);
self.next_doc_order = 0;
self.id_rules = .empty;
try self.id_rules.ensureUnusedCapacity(self.arena, id_rules_count);
self.class_rules = .empty;
try self.class_rules.ensureUnusedCapacity(self.arena, class_rules_count);
self.tag_rules = .empty;
try self.tag_rules.ensureUnusedCapacity(self.arena, tag_rules_count);
self.other_rules = try .initCapacity(self.arena, other_rules_count);
const sheets = self.page.document._style_sheets orelse return; const sheets = self.page.document._style_sheets orelse return;
for (sheets._sheets.items) |sheet| { for (sheets._sheets.items) |sheet| {
self.sheetAdded(sheet) catch |err| { self.sheetAdded(sheet) catch |err| {
@@ -193,27 +219,97 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
self.rebuildIfDirty() catch return false; self.rebuildIfDirty() catch return false;
for (self.rules.items) |rule| { // Track doc_order for tie-breaking (0 = inline style, which always wins)
// Skip rules that can't possibly update any property we care about var display_doc_order: u32 = 0;
const dominated = (rule.props.display_none == null or rule.specificity < display_spec) and var visibility_doc_order: u32 = 0;
(rule.props.visibility_hidden == null or rule.specificity < visibility_spec) and var opacity_doc_order: u32 = 0;
(rule.props.opacity_zero == null or rule.specificity < opacity_spec);
if (dominated) continue;
if (matchesSelector(el, rule.selector, self.page)) { // Helper to check a single rule
if (rule.props.display_none != null and rule.specificity >= display_spec) { const Ctx = struct {
display_none = rule.props.display_none; display_none: *?bool,
display_spec = rule.specificity; display_spec: *u32,
} display_doc_order: *u32,
if (rule.props.visibility_hidden != null and rule.specificity >= visibility_spec) { visibility_hidden: *?bool,
visibility_hidden = rule.props.visibility_hidden; visibility_spec: *u32,
visibility_spec = rule.specificity; visibility_doc_order: *u32,
} opacity_zero: *?bool,
if (rule.props.opacity_zero != null and rule.specificity >= opacity_spec) { opacity_spec: *u32,
opacity_zero = rule.props.opacity_zero; opacity_doc_order: *u32,
opacity_spec = rule.specificity; el: *Element,
page: *Page,
// Returns true if (spec, doc_order) beats (best_spec, best_doc_order)
fn beats(spec: u32, doc_order: u32, best_spec: u32, best_doc_order: u32) bool {
return spec > best_spec or (spec == best_spec and doc_order > best_doc_order);
}
fn checkRule(ctx: @This(), rule: VisibilityRule) void {
// Skip rules that can't possibly beat current best for any property
const dominated = (rule.props.display_none == null or !beats(rule.specificity, rule.doc_order, ctx.display_spec.*, ctx.display_doc_order.*)) and
(rule.props.visibility_hidden == null or !beats(rule.specificity, rule.doc_order, ctx.visibility_spec.*, ctx.visibility_doc_order.*)) and
(rule.props.opacity_zero == null or !beats(rule.specificity, rule.doc_order, ctx.opacity_spec.*, ctx.opacity_doc_order.*));
if (dominated) return;
if (matchesSelector(ctx.el, rule.selector, ctx.page)) {
if (rule.props.display_none != null and beats(rule.specificity, rule.doc_order, ctx.display_spec.*, ctx.display_doc_order.*)) {
ctx.display_none.* = rule.props.display_none;
ctx.display_spec.* = rule.specificity;
ctx.display_doc_order.* = rule.doc_order;
}
if (rule.props.visibility_hidden != null and beats(rule.specificity, rule.doc_order, ctx.visibility_spec.*, ctx.visibility_doc_order.*)) {
ctx.visibility_hidden.* = rule.props.visibility_hidden;
ctx.visibility_spec.* = rule.specificity;
ctx.visibility_doc_order.* = rule.doc_order;
}
if (rule.props.opacity_zero != null and beats(rule.specificity, rule.doc_order, ctx.opacity_spec.*, ctx.opacity_doc_order.*)) {
ctx.opacity_zero.* = rule.props.opacity_zero;
ctx.opacity_spec.* = rule.specificity;
ctx.opacity_doc_order.* = rule.doc_order;
}
} }
} }
};
const ctx = Ctx{
.display_none = &display_none,
.display_spec = &display_spec,
.display_doc_order = &display_doc_order,
.visibility_hidden = &visibility_hidden,
.visibility_spec = &visibility_spec,
.visibility_doc_order = &visibility_doc_order,
.opacity_zero = &opacity_zero,
.opacity_spec = &opacity_spec,
.opacity_doc_order = &opacity_doc_order,
.el = el,
.page = self.page,
};
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
if (self.id_rules.get(id)) |rules| {
for (rules.items) |rule| {
ctx.checkRule(rule);
}
}
}
if (el.getAttributeSafe(comptime .wrap("class"))) |class_attr| {
var it = std.mem.tokenizeAny(u8, class_attr, &std.ascii.whitespace);
while (it.next()) |class| {
if (self.class_rules.get(class)) |rules| {
for (rules.items) |rule| {
ctx.checkRule(rule);
}
}
}
}
if (self.tag_rules.get(el.getTag())) |rules| {
for (rules.items) |rule| {
ctx.checkRule(rule);
}
}
for (self.other_rules.items) |rule| {
ctx.checkRule(rule);
} }
return (display_none orelse false) or (visibility_hidden orelse false) or (opacity_zero orelse false); return (display_none orelse false) or (visibility_hidden orelse false) or (opacity_zero orelse false);
@@ -237,12 +333,13 @@ pub fn hasPointerEventsNone(self: *StyleManager, el: *Element, cache: ?*PointerE
const pe_none = self.elementHasPointerEventsNone(elem); const pe_none = self.elementHasPointerEventsNone(elem);
// Store in cache
if (cache) |c| { if (cache) |c| {
c.put(self.page.call_arena, elem, pe_none) catch {}; c.put(self.page.call_arena, elem, pe_none) catch {};
} }
if (pe_none) return true; if (pe_none) {
return true;
}
current = elem.parentElement(); current = elem.parentElement();
} }
@@ -253,30 +350,66 @@ pub fn hasPointerEventsNone(self: *StyleManager, el: *Element, cache: ?*PointerE
fn elementHasPointerEventsNone(self: *StyleManager, el: *Element) bool { fn elementHasPointerEventsNone(self: *StyleManager, el: *Element) bool {
const page = self.page; const page = self.page;
var result: ?bool = null;
var best_spec: u32 = 0;
// Check inline style first // Check inline style first
if (getInlineStyleProperty(el, .wrap("pointer-events"), page)) |property| { if (getInlineStyleProperty(el, .wrap("pointer-events"), page)) |property| {
if (property._value.eql(comptime .wrap("none"))) { if (property._value.eql(comptime .wrap("none"))) {
return true; return true;
} }
// Inline set to non-none - no stylesheet can override
return false; return false;
} }
// Check stylesheet rules // Check stylesheet rules
self.rebuildIfDirty() catch return false; self.rebuildIfDirty() catch return false;
for (self.rules.items) |rule| { var result: ?bool = null;
if (rule.props.pointer_events_none == null or rule.specificity < best_spec) { var best_spec: u32 = 0;
continue; var best_doc_order: u32 = 0;
// Helper to check a single rule
const checkRule = struct {
fn beats(spec: u32, doc_order: u32, b_spec: u32, b_doc_order: u32) bool {
return spec > b_spec or (spec == b_spec and doc_order > b_doc_order);
} }
if (matchesSelector(el, rule.selector, page)) { fn check(rule: VisibilityRule, res: *?bool, spec: *u32, doc_order: *u32, elem: *Element, p: *Page) void {
result = rule.props.pointer_events_none; if (rule.props.pointer_events_none == null or !beats(rule.specificity, rule.doc_order, spec.*, doc_order.*)) {
best_spec = rule.specificity; return;
}
if (matchesSelector(elem, rule.selector, p)) {
res.* = rule.props.pointer_events_none;
spec.* = rule.specificity;
doc_order.* = rule.doc_order;
}
} }
}.check;
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
if (self.id_rules.get(id)) |rules| {
for (rules.items) |rule| {
checkRule(rule, &result, &best_spec, &best_doc_order, el, page);
}
}
}
if (el.getAttributeSafe(comptime .wrap("class"))) |class_attr| {
var it = std.mem.tokenizeAny(u8, class_attr, &std.ascii.whitespace);
while (it.next()) |class| {
if (self.class_rules.get(class)) |rules| {
for (rules.items) |rule| {
checkRule(rule, &result, &best_spec, &best_doc_order, el, page);
}
}
}
}
if (self.tag_rules.get(el.getTag())) |rules| {
for (rules.items) |rule| {
checkRule(rule, &result, &best_spec, &best_doc_order, el, page);
}
}
for (self.other_rules.items) |rule| {
checkRule(rule, &result, &best_spec, &best_doc_order, el, page);
} }
return result orelse false; return result orelse false;
@@ -284,6 +417,7 @@ fn elementHasPointerEventsNone(self: *StyleManager, el: *Element) bool {
// Extracts visibility-relevant rules from a CSS rule. // Extracts visibility-relevant rules from a CSS rule.
// Creates one VisibilityRule per selector (not per selector list) so each has correct specificity. // Creates one VisibilityRule per selector (not per selector list) so each has correct specificity.
// Buckets rules by their rightmost selector part for fast lookup.
fn addRule(self: *StyleManager, style_rule: *CSSStyleRule) !void { fn addRule(self: *StyleManager, style_rule: *CSSStyleRule) !void {
const selector_text = style_rule._selector_text; const selector_text = style_rule._selector_text;
if (selector_text.len == 0) { if (selector_text.len == 0) {
@@ -306,15 +440,95 @@ fn addRule(self: *StyleManager, style_rule: *CSSStyleRule) !void {
// Create one rule per selector - each has its own specificity // Create one rule per selector - each has its own specificity
// e.g., "#id, .class { display: none }" becomes two rules with different specificities // e.g., "#id, .class { display: none }" becomes two rules with different specificities
for (selectors) |selector| { for (selectors) |selector| {
// Get the rightmost compound (last segment, or first if no segments)
const rightmost = if (selector.segments.len > 0)
selector.segments[selector.segments.len - 1].compound
else
selector.first;
// Find the bucketing key from rightmost compound
const bucket_key = getBucketKey(rightmost) orelse continue; // skip if dynamic pseudo-class
const rule = VisibilityRule{ const rule = VisibilityRule{
.props = props, .props = props,
.selector = selector, .selector = selector,
.specificity = computeSpecificity(selector), .specificity = computeSpecificity(selector),
.doc_order = self.next_doc_order,
}; };
try self.rules.append(self.arena, rule); self.next_doc_order += 1;
// Add to appropriate bucket
switch (bucket_key) {
.id => |id| {
const gop = try self.id_rules.getOrPut(self.arena, id);
if (!gop.found_existing) gop.value_ptr.* = .empty;
try gop.value_ptr.append(self.arena, rule);
},
.class => |class| {
const gop = try self.class_rules.getOrPut(self.arena, class);
if (!gop.found_existing) gop.value_ptr.* = .empty;
try gop.value_ptr.append(self.arena, rule);
},
.tag => |tag| {
const gop = try self.tag_rules.getOrPut(self.arena, tag);
if (!gop.found_existing) gop.value_ptr.* = .empty;
try gop.value_ptr.append(self.arena, rule);
},
.other => {
try self.other_rules.append(self.arena, rule);
},
}
} }
} }
const BucketKey = union(enum) {
id: []const u8,
class: []const u8,
tag: Tag,
other,
};
/// Returns the best bucket key for a compound selector, or null if it contains
/// a dynamic pseudo-class we should skip (hover, active, focus, etc.)
/// Priority: id > class > tag > other
fn getBucketKey(compound: Selector.Compound) ?BucketKey {
var best_key: BucketKey = .other;
for (compound.parts) |part| {
switch (part) {
.id => |id| {
best_key = .{ .id = id };
},
.class => |class| {
if (best_key != .id) {
best_key = .{ .class = class };
}
},
.tag => |tag| {
if (best_key == .other) {
best_key = .{ .tag = tag };
}
},
.tag_name => {
// Custom tag - put in other bucket (can't efficiently look up)
// Keep current best_key if we have something better
},
.pseudo_class => |pc| {
// Skip dynamic pseudo-classes - they depend on interaction state
switch (pc) {
.hover, .active, .focus, .focus_within, .focus_visible, .visited, .target => {
return null; // Skip this selector entirely
},
else => {},
}
},
.universal, .attribute => {},
}
}
return best_key;
}
/// Extracts visibility-relevant properties from a style declaration. /// Extracts visibility-relevant properties from a style declaration.
fn extractVisibilityProperties(style: *CSSStyleProperties) VisibilityProperties { fn extractVisibilityProperties(style: *CSSStyleProperties) VisibilityProperties {
var props = VisibilityProperties{}; var props = VisibilityProperties{};
@@ -416,8 +630,10 @@ const VisibilityRule = struct {
props: VisibilityProperties, props: VisibilityProperties,
// Packed specificity: (id_count << 20) | (class_count << 10) | element_count // Packed specificity: (id_count << 20) | (class_count << 10) | element_count
// Document order is implicit in array position - equal specificity with later position wins
specificity: u32, specificity: u32,
// Document order for tie-breaking equal specificity (higher = later in document)
doc_order: u32,
}; };
const CheckVisibilityOptions = struct { const CheckVisibilityOptions = struct {
@@ -569,3 +785,23 @@ test "StyleManager: computeSpecificity: pseudo-class (general)" {
}; };
try testing.expectEqual(1 << 10, computeSpecificity(selector)); try testing.expectEqual(1 << 10, computeSpecificity(selector));
} }
test "StyleManager: document order tie-breaking" {
// When specificity is equal, higher doc_order (later in document) wins
const beats = struct {
fn f(spec: u32, doc_order: u32, best_spec: u32, best_doc_order: u32) bool {
return spec > best_spec or (spec == best_spec and doc_order > best_doc_order);
}
}.f;
// Higher specificity always wins regardless of doc_order
try testing.expect(beats(2, 0, 1, 10));
try testing.expect(!beats(1, 10, 2, 0));
// Equal specificity: higher doc_order wins
try testing.expect(beats(1, 5, 1, 3)); // doc_order 5 > 3
try testing.expect(!beats(1, 3, 1, 5)); // doc_order 3 < 5
// Equal specificity and doc_order: no win
try testing.expect(!beats(1, 5, 1, 5));
}