From e29778d72bfdc592d2c1fe329a13cc4a1067431e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 17 Mar 2026 13:40:51 +0800 Subject: [PATCH] Introduce StyleManager A Page now has a StyleManager. The StyleManager currently answers two questions: 1 - Is an element hidden 2 - Does an element have pointer-events == none This is used in calls such as element.checkVisibility which, on some pages, can be called tens of thousands of times (often through other methods, like element.getBoundingClientRect). This _can_ be a bottleneck. The StyleManager keeps a list of rules. The rules include the selector, specificity, and properties that we care about. Rules in a stylesheet that contain no properties of interest are ignored. This is the first and likely most significant optimization. Presumably, most CSS rules don't have a display/visibility/opacity or pointer-events property. The list is rules is cached until stylesheets are modified or delete. When this happens, the StyleManager is flagged as "dirty" and rebuilt on-demand in the next query. This is our second major optimization. For now, to check if an element is visible, we still need to scan all rules. But having a pre-build subset of all the rules is a first step. The next step might be to optimize the matching, or possibly optimizing common cases (e.g. id and/or simple class selector) --- src/SemanticTree.zig | 18 +- src/browser/Page.zig | 18 + src/browser/StyleManager.zig | 572 ++++++++++++++++++ src/browser/interactive.zig | 4 +- src/browser/tests/css.html | 30 - .../tests/element/check_visibility.html | 226 +++++++ src/browser/webapi/Document.zig | 2 +- src/browser/webapi/Element.zig | 168 +---- .../webapi/css/CSSStyleDeclaration.zig | 63 +- src/browser/webapi/css/CSSStyleSheet.zig | 10 + src/browser/webapi/css/StyleSheetList.zig | 9 + src/browser/webapi/element/html/Style.zig | 5 +- 12 files changed, 922 insertions(+), 203 deletions(-) create mode 100644 src/browser/StyleManager.zig create mode 100644 src/browser/tests/element/check_visibility.html diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index b64f222d..95561c9f 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -45,8 +45,9 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}! log.err(.app, "listener map failed", .{ .err = err }); return error.WriteFailed; }; - var css_cache: Element.CssCache = .empty; - self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, &css_cache) catch |err| { + var visibility_cache: Element.VisibilityCache = .empty; + var pointer_events_cache: Element.PointerEventsCache = .empty; + self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, &visibility_cache, &pointer_events_cache) catch |err| { log.err(.app, "semantic tree json dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -59,8 +60,9 @@ pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!v log.err(.app, "listener map failed", .{ .err = err }); return error.WriteFailed; }; - var css_cache: Element.CssCache = .empty; - self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, &css_cache) catch |err| { + var visibility_cache: Element.VisibilityCache = .empty; + var pointer_events_cache: Element.PointerEventsCache = .empty; + self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, &visibility_cache, &pointer_events_cache) catch |err| { log.err(.app, "semantic tree text dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -84,7 +86,7 @@ const NodeData = struct { node_name: []const u8, }; -fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, css_cache: ?*Element.CssCache) !void { +fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, visibility_cache: ?*Element.VisibilityCache, pointer_events_cache: ?*Element.PointerEventsCache) !void { // 1. Skip non-content nodes if (node.is(Element)) |el| { const tag = el.getTag(); @@ -94,7 +96,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam if (tag == .datalist or tag == .option or tag == .optgroup) return; // Check visibility using the engine's checkVisibility which handles CSS display: none - if (!el.checkVisibilityCached(css_cache, self.page)) { + if (!el.checkVisibilityCached(visibility_cache, self.page)) { return; } @@ -135,7 +137,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam } if (el.is(Element.Html)) |html_el| { - if (interactive.classifyInteractivity(self.page, el, html_el, listener_targets, css_cache) != null) { + if (interactive.classifyInteractivity(self.page, el, html_el, listener_targets, pointer_events_cache) != null) { is_interactive = true; } } @@ -215,7 +217,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam } gop.value_ptr.* += 1; - try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, css_cache); + try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, visibility_cache, pointer_events_cache); } } diff --git a/src/browser/Page.zig b/src/browser/Page.zig index cb62cb31..e8df5ab0 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -35,6 +35,7 @@ const Factory = @import("Factory.zig"); const Session = @import("Session.zig"); const EventManager = @import("EventManager.zig"); const ScriptManager = @import("ScriptManager.zig"); +const StyleManager = @import("StyleManager.zig"); const Parser = @import("parser/Parser.zig"); @@ -143,6 +144,7 @@ _blob_urls: std.StringHashMapUnmanaged(*Blob) = .{}, /// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it. _to_load: std.ArrayList(*Element.Html) = .{}, +_style_manager: StyleManager, _script_manager: ScriptManager, // List of active live ranges (for mutation updates per DOM spec) @@ -268,6 +270,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void ._factory = factory, ._pending_loads = 1, // always 1 for the ScriptManager ._type = if (parent == null) .root else .frame, + ._style_manager = undefined, ._script_manager = undefined, ._event_manager = EventManager.init(session.page_arena, self), }; @@ -297,6 +300,9 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void ._visual_viewport = visual_viewport, }); + self._style_manager = try StyleManager.init(self); + errdefer self._style_manager.deinit(); + const browser = session.browser; self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self); errdefer self._script_manager.deinit(); @@ -353,6 +359,7 @@ pub fn deinit(self: *Page, abort_http: bool) void { } self._script_manager.deinit(); + self._style_manager.deinit(); session.releaseArena(self.call_arena); } @@ -2538,6 +2545,17 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts } Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self); + + // If a + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 53a0b07a..33e29952 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -586,7 +586,7 @@ pub fn elementFromPoint(self: *Document, x: f64, y: f64, page: *Page) !?*Element while (stack.items.len > 0) { const node = stack.pop() orelse break; if (node.is(Element)) |element| { - if (element.checkVisibility(page)) { + if (element.checkVisibilityCached(null, page)) { const rect = element.getBoundingClientRectForVisible(page); if (x >= rect.getLeft() and x <= rect.getRight() and y >= rect.getTop() and y <= rect.getBottom()) { topmost = element; diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index d2d61451..58cc168c 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -24,6 +24,7 @@ const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const StyleManager = @import("../StyleManager.zig"); const reflect = @import("../reflect.zig"); const Node = @import("Node.zig"); @@ -1041,135 +1042,32 @@ pub fn parentElement(self: *Element) ?*Element { return self._proto.parentElement(); } -const CSSStyleRule = @import("css/CSSStyleRule.zig"); -const StyleSheetList = @import("css/StyleSheetList.zig"); +/// Cache for visibility checks - re-exported from StyleManager for convenience. +pub const VisibilityCache = StyleManager.VisibilityCache; -pub const CssProperties = struct { - display_none: bool = false, - visibility_hidden: bool = false, - opacity_zero: bool = false, - pointer_events_none: bool = false, +/// Cache for pointer-events checks - re-exported from StyleManager for convenience. +pub const PointerEventsCache = StyleManager.PointerEventsCache; + +pub fn hasPointerEventsNone(self: *Element, cache: ?*PointerEventsCache, page: *Page) bool { + return page._style_manager.hasPointerEventsNone(self, cache); +} + +pub fn checkVisibilityCached(self: *Element, cache: ?*VisibilityCache, page: *Page) bool { + return !page._style_manager.isHidden(self, cache, .{}); +} + +const CheckVisibilityOpts = struct { + checkOpacity: bool = false, + opacityProperty: bool = false, + checkVisibilityCSS: bool = false, + visibilityProperty: bool = false, }; - -pub const CssCache = std.AutoHashMapUnmanaged(*Element, CssProperties); - -fn getCssProperties(el: *Element, doc_sheets: ?*StyleSheetList, cache: ?*CssCache, page: *Page) CssProperties { - if (cache) |c| { - if (c.get(el)) |props| return props; - } - - var props = CssProperties{}; - - // Check stylesheets first - if (doc_sheets) |sheets| { - for (0..sheets.length()) |i| { - const sheet = sheets.item(i) orelse continue; - const rules = sheet.getCssRules(page) catch continue; - for (0..rules.length()) |j| { - const rule = rules.item(j) orelse continue; - if (rule.is(CSSStyleRule)) |style_rule| { - const selector = style_rule.getSelectorText(); - if (el.matches(selector, page) catch false) { - const style = (style_rule.getStyle(page) catch continue).asCSSStyleDeclaration(); - - const display = style.getPropertyValue("display", page); - if (std.mem.eql(u8, display, "none")) { - props.display_none = true; - } else if (display.len > 0) { - props.display_none = false; - } - - const visibility = style.getPropertyValue("visibility", page); - if (std.mem.eql(u8, visibility, "hidden") or std.mem.eql(u8, visibility, "collapse")) { - props.visibility_hidden = true; - } else if (visibility.len > 0) { - props.visibility_hidden = false; - } - - const opacity = style.getPropertyValue("opacity", page); - if (std.mem.eql(u8, opacity, "0")) { - props.opacity_zero = true; - } else if (opacity.len > 0) { - props.opacity_zero = false; - } - - const pointer_events = style.getPropertyValue("pointer-events", page); - if (std.mem.eql(u8, pointer_events, "none")) { - props.pointer_events_none = true; - } else if (pointer_events.len > 0) { - props.pointer_events_none = false; - } - } - } - } - } - } - - // Check inline styles overrides - if (el.getOrCreateStyle(page) catch null) |style| { - const decl = style.asCSSStyleDeclaration(); - const display = decl.getPropertyValue("display", page); - if (std.mem.eql(u8, display, "none")) { - props.display_none = true; - } else if (display.len > 0) { - props.display_none = false; - } - - const visibility = decl.getPropertyValue("visibility", page); - if (std.mem.eql(u8, visibility, "hidden") or std.mem.eql(u8, visibility, "collapse")) { - props.visibility_hidden = true; - } else if (visibility.len > 0) { - props.visibility_hidden = false; - } - - const opacity = decl.getPropertyValue("opacity", page); - if (std.mem.eql(u8, opacity, "0")) { - props.opacity_zero = true; - } else if (opacity.len > 0) { - props.opacity_zero = false; - } - - const pointer_events = decl.getPropertyValue("pointer-events", page); - if (std.mem.eql(u8, pointer_events, "none")) { - props.pointer_events_none = true; - } else if (pointer_events.len > 0) { - props.pointer_events_none = false; - } - } - - if (cache) |c| { - c.put(page.call_arena, el, props) catch {}; - } - - return props; -} - -pub fn hasPointerEventsNone(self: *Element, cache: ?*CssCache, page: *Page) bool { - const doc_sheets = page.document.getStyleSheets(page) catch null; - var current: ?*Element = self; - while (current) |el| { - const props = el.getCssProperties(doc_sheets, cache, page); - if (props.pointer_events_none) return true; - current = el.parentElement(); - } - return false; -} - -pub fn checkVisibilityCached(self: *Element, cache: ?*CssCache, page: *Page) bool { - const doc_sheets = page.document.getStyleSheets(page) catch null; - var current: ?*Element = self; - - while (current) |el| { - const props = getCssProperties(el, doc_sheets, cache, page); - if (props.display_none or props.visibility_hidden or props.opacity_zero) return false; - current = el.parentElement(); - } - - return true; -} - -pub fn checkVisibility(self: *Element, page: *Page) bool { - return self.checkVisibilityCached(null, page); +pub fn checkVisibility(self: *Element, opts_: ?CheckVisibilityOpts, page: *Page) bool { + const opts = opts_ orelse CheckVisibilityOpts{}; + return !page._style_manager.isHidden(self, null, .{ + .check_opacity = opts.checkOpacity or opts.opacityProperty, + .check_visibility = opts.visibilityProperty or opts.checkVisibilityCSS, + }); } fn getElementDimensions(self: *Element, page: *Page) struct { width: f64, height: f64 } { @@ -1206,7 +1104,7 @@ fn getElementDimensions(self: *Element, page: *Page) struct { width: f64, height } pub fn getClientWidth(self: *Element, page: *Page) f64 { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return 0.0; } const dims = self.getElementDimensions(page); @@ -1214,7 +1112,7 @@ pub fn getClientWidth(self: *Element, page: *Page) f64 { } pub fn getClientHeight(self: *Element, page: *Page) f64 { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return 0.0; } const dims = self.getElementDimensions(page); @@ -1222,7 +1120,7 @@ pub fn getClientHeight(self: *Element, page: *Page) f64 { } pub fn getBoundingClientRect(self: *Element, page: *Page) DOMRect { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return .{ ._x = 0.0, ._y = 0.0, @@ -1252,7 +1150,7 @@ pub fn getBoundingClientRectForVisible(self: *Element, page: *Page) DOMRect { } pub fn getClientRects(self: *Element, page: *Page) ![]DOMRect { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return &.{}; } const rects = try page.call_arena.alloc(DOMRect, 1); @@ -1297,7 +1195,7 @@ pub fn getScrollWidth(self: *Element, page: *Page) f64 { } pub fn getOffsetHeight(self: *Element, page: *Page) f64 { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return 0.0; } const dims = self.getElementDimensions(page); @@ -1305,7 +1203,7 @@ pub fn getOffsetHeight(self: *Element, page: *Page) f64 { } pub fn getOffsetWidth(self: *Element, page: *Page) f64 { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return 0.0; } const dims = self.getElementDimensions(page); @@ -1313,14 +1211,14 @@ pub fn getOffsetWidth(self: *Element, page: *Page) f64 { } pub fn getOffsetTop(self: *Element, page: *Page) f64 { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return 0.0; } return calculateDocumentPosition(self.asNode()); } pub fn getOffsetLeft(self: *Element, page: *Page) f64 { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return 0.0; } return calculateSiblingPosition(self.asNode()); diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 13499033..d207c11a 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -77,10 +77,11 @@ pub fn item(self: *const CSSStyleDeclaration, index: u32) []const u8 { pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 { const normalized = normalizePropertyName(property_name, &page.buf); - const prop = self.findProperty(normalized) orelse { + const wrapped = String.wrap(normalized); + const prop = self.findProperty(wrapped) orelse { // Only return default values for computed styles if (self._is_computed) { - return getDefaultPropertyValue(self, normalized); + return getDefaultPropertyValue(self, wrapped); } return ""; }; @@ -89,7 +90,7 @@ pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const pub fn getPropertyPriority(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 { const normalized = normalizePropertyName(property_name, &page.buf); - const prop = self.findProperty(normalized) orelse return ""; + const prop = self.findProperty(.wrap(normalized)) orelse return ""; return if (prop._important) "important" else ""; } @@ -120,7 +121,7 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value: const normalized_value = try normalizePropertyValue(page.call_arena, normalized, value); // Find existing property - if (self.findProperty(normalized)) |existing| { + if (self.findProperty(.wrap(normalized))) |existing| { existing._value = try String.init(page.arena, normalized_value, .{}); existing._important = important; return; @@ -144,7 +145,7 @@ pub fn removeProperty(self: *CSSStyleDeclaration, property_name: []const u8, pag fn removePropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, page: *Page) ![]const u8 { const normalized = normalizePropertyName(property_name, &page.buf); - const prop = self.findProperty(normalized) orelse return ""; + const prop = self.findProperty(.wrap(normalized)) orelse return ""; // the value might not be on the heap (it could be inlined in the small string // optimization), so we need to dupe it. @@ -208,11 +209,11 @@ pub fn format(self: *const CSSStyleDeclaration, writer: *std.Io.Writer) !void { } } -fn findProperty(self: *const CSSStyleDeclaration, name: []const u8) ?*Property { +pub fn findProperty(self: *const CSSStyleDeclaration, name: String) ?*Property { var node = self._properties.first; while (node) |n| { const prop = Property.fromNodeLink(n); - if (prop._name.eqlSlice(name)) { + if (prop._name.eql(name)) { return prop; } node = n.next; @@ -617,26 +618,36 @@ fn isLengthProperty(name: []const u8) bool { return length_properties.has(name); } -fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, normalized_name: []const u8) []const u8 { - if (std.mem.eql(u8, normalized_name, "visibility")) { - return "visible"; +fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, name: String) []const u8 { + switch (name.len) { + 5 => { + if (name.eql(comptime .wrap("color"))) { + const element = self._element orelse return ""; + return getDefaultColor(element); + } + }, + 7 => { + if (name.eql(comptime .wrap("opacity"))) { + return "1"; + } + if (name.eql(comptime .wrap("display"))) { + const element = self._element orelse return ""; + return getDefaultDisplay(element); + } + }, + 10 => { + if (name.eql(comptime .wrap("visibility"))) { + return "visible"; + } + }, + 16 => { + if (name.eqlSlice("background-color")) { + // transparent + return "rgba(0, 0, 0, 0)"; + } + }, + else => {}, } - if (std.mem.eql(u8, normalized_name, "opacity")) { - return "1"; - } - if (std.mem.eql(u8, normalized_name, "display")) { - const element = self._element orelse return ""; - return getDefaultDisplay(element); - } - if (std.mem.eql(u8, normalized_name, "color")) { - const element = self._element orelse return ""; - return getDefaultColor(element); - } - if (std.mem.eql(u8, normalized_name, "background-color")) { - // transparent - return "rgba(0, 0, 0, 0)"; - } - return ""; } diff --git a/src/browser/webapi/css/CSSStyleSheet.zig b/src/browser/webapi/css/CSSStyleSheet.zig index aa8655f1..2b41fffa 100644 --- a/src/browser/webapi/css/CSSStyleSheet.zig +++ b/src/browser/webapi/css/CSSStyleSheet.zig @@ -69,12 +69,19 @@ pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, maybe_index: ?u32, pag const rules = try self.getCssRules(page); try rules.insert(index, style_rule._proto, page); + + // Notify StyleManager that rules have changed + page._style_manager.sheetModified(); + return index; } pub fn deleteRule(self: *CSSStyleSheet, index: u32, page: *Page) !void { const rules = try self.getCssRules(page); try rules.remove(index); + + // Notify StyleManager that rules have changed + page._style_manager.sheetModified(); } pub fn replace(self: *CSSStyleSheet, text: []const u8, page: *Page) !js.Promise { @@ -99,6 +106,9 @@ pub fn replaceSync(self: *CSSStyleSheet, text: []const u8, page: *Page) !void { try rules.insert(index, style_rule._proto, page); index += 1; } + + // Notify StyleManager that rules have changed + page._style_manager.sheetModified(); } pub const JsApi = struct { diff --git a/src/browser/webapi/css/StyleSheetList.zig b/src/browser/webapi/css/StyleSheetList.zig index 11efb45c..c0732c73 100644 --- a/src/browser/webapi/css/StyleSheetList.zig +++ b/src/browser/webapi/css/StyleSheetList.zig @@ -24,6 +24,15 @@ pub fn add(self: *StyleSheetList, sheet: *CSSStyleSheet, page: *Page) !void { try self._sheets.append(page.arena, sheet); } +pub fn remove(self: *StyleSheetList, sheet: *CSSStyleSheet) void { + for (self._sheets.items, 0..) |s, i| { + if (s == sheet) { + _ = self._sheets.orderedRemove(i); + return; + } + } +} + pub const JsApi = struct { pub const bridge = js.Bridge(StyleSheetList); diff --git a/src/browser/webapi/element/html/Style.zig b/src/browser/webapi/element/html/Style.zig index b6389d61..fc52cf38 100644 --- a/src/browser/webapi/element/html/Style.zig +++ b/src/browser/webapi/element/html/Style.zig @@ -106,7 +106,10 @@ pub fn getSheet(self: *Style, page: *Page) !?*CSSStyleSheet { pub fn styleAddedCallback(self: *Style, page: *Page) !void { // Force stylesheet initialization so rules are parsed immediately - _ = self.getSheet(page) catch null; + if (self.getSheet(page) catch null) |sheet| { + // Notify StyleManager about the new stylesheet + page._style_manager.sheetAdded(sheet) catch {}; + } // if we're planning on navigating to another page, don't trigger load event. if (page.isGoingAway()) {