From 05b6eaf82c39e15af97441b7df4a9818adeba0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 30 Jun 2026 16:14:02 +0200 Subject: [PATCH 1/4] perf(css): cache parsed selectors per frame querySelector/querySelectorAll/matches/closest re-parsed the selector string on every call. nodeDetails' SelectorPath fires dozens of these per element while synthesizing a unique selector (often the same string twice back-to-back), and page JS that queries in loops paid the same repeated parse. Add a frame-lifetime cache (frame._selector_cache) keyed by the selector string. The parsed AST borrows slices of its input, so on a miss the key is duped into frame.arena and parsed against that owned copy; both share the frame's lifetime and are bulk-freed on navigation. A 1024-entry cap bounds growth from SelectorPath's one-off synthetic selectors, falling back to a per-call arena parse beyond it. StyleManager rule parsing is left untouched (parsed once per rebuild into its own arena). --- src/browser/Frame.zig | 5 +++ src/browser/webapi/selector/Selector.zig | 47 +++++++++++++++--------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 4f0207366..d8d290c0a 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -52,6 +52,7 @@ const VisualViewport = @import("webapi/VisualViewport.zig"); const AbstractRange = @import("webapi/AbstractRange.zig"); const Worker = @import("webapi/Worker.zig"); const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig"); +const Selector = @import("webapi/selector/Selector.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const SubmitEvent = @import("webapi/event/SubmitEvent.zig"); @@ -120,6 +121,10 @@ _attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attri // that actually access these features via JavaScript, saving 24 bytes per element. _element_styles: Element.StyleLookup = .empty, _element_datasets: Element.DatasetLookup = .empty, + +// Keyed by selector string. The parsed AST borrows slices of the key, so both +// live on `arena` (frame lifetime). See Selector.cachedParse. +_selector_cache: std.StringHashMapUnmanaged([]const Selector.Selector) = .empty, _element_class_lists: Element.ClassListLookup = .empty, _element_rel_lists: Element.RelListLookup = .empty, _element_shadow_roots: Element.ShadowRootLookup = .empty, diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 4e9df2272..dbe54d8b3 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -51,22 +51,43 @@ pub fn parseLeaky(arena: Allocator, input: []const u8) !Parsed { return .{ .selectors = try Parser.parseList(arena, input) }; } +// SelectorPath synthesizes many one-off selectors, so past the cap we parse +// into a per-call arena rather than grow the frame-lifetime cache unbounded. +const max_cache_entries = 1024; + +/// The parsed AST borrows slices of its input, so on a miss the key is duped +/// into `frame.arena` and parsed against that copy; both share the frame's +/// lifetime. `fallback` backs the parse once the cache is full. +fn cachedParse(frame: *Frame, fallback: Allocator, input: []const u8) ![]const Selector { + if (input.len == 0) { + return error.SyntaxError; + } + if (frame._selector_cache.get(input)) |selectors| { + return selectors; + } + if (frame._selector_cache.count() >= max_cache_entries) { + return Parser.parseList(fallback, input); + } + + const arena = frame.arena; + const owned = try arena.dupe(u8, input); + const selectors = try Parser.parseList(arena, owned); + try frame._selector_cache.put(arena, owned, selectors); + return selectors; +} + pub fn querySelector(root: *Node, input: []const u8, frame: *Frame) !?*Node.Element { - const parsed = try parseLeaky(frame.call_arena, input); + const parsed = Parsed{ .selectors = try cachedParse(frame, frame.call_arena, input) }; return parsed.query(root, frame); } pub fn querySelectorAll(root: *Node, input: []const u8, frame: *Frame) !*List { - if (input.len == 0) { - return error.SyntaxError; - } - const arena = try frame.getArena(.small, "querySelectorAll"); errdefer frame.releaseArena(arena); var nodes: std.AutoArrayHashMapUnmanaged(*Node, void) = .empty; - const selectors = try Parser.parseList(arena, input); + const selectors = try cachedParse(frame, arena, input); for (selectors) |selector| { try List.collect(arena, root, selector, &nodes, frame); } @@ -80,12 +101,7 @@ pub fn querySelectorAll(root: *Node, input: []const u8, frame: *Frame) !*List { } pub fn matches(el: *Node.Element, input: []const u8, frame: *Frame) !bool { - if (input.len == 0) { - return error.SyntaxError; - } - - const arena = frame.call_arena; - const selectors = try Parser.parseList(arena, input); + const selectors = try cachedParse(frame, frame.call_arena, input); for (selectors) |selector| { if (List.matches(el.asNode(), selector, el.asNode(), frame)) { @@ -98,12 +114,7 @@ pub fn matches(el: *Node.Element, input: []const u8, frame: *Frame) !bool { // Like matches, but allows the caller to specify a scope node distinct from el. // Used by closest() so that :scope always refers to the original context element. pub fn matchesWithScope(el: *Node.Element, input: []const u8, scope: *Node.Element, frame: *Frame) !bool { - if (input.len == 0) { - return error.SyntaxError; - } - - const arena = frame.call_arena; - const selectors = try Parser.parseList(arena, input); + const selectors = try cachedParse(frame, frame.call_arena, input); for (selectors) |selector| { if (List.matches(el.asNode(), selector, scope.asNode(), frame)) { From 1ee8f8def76bd955262424c6e0a99fd17b263b0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 1 Jul 2026 08:13:31 +0200 Subject: [PATCH 2/4] perf(css): drop selector-cache cap; bypass cache for one-off selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the arbitrary 1024-entry cap with an explicit split: the public querySelector/querySelectorAll/matches/closest entry points cache (page scripts, waitForSelector — selectors that recur), while SelectorPath's synthesized one-off candidates use new *Uncached variants that parse into a transient arena. The cache now only ever holds genuinely-reused selectors, so it needs no size bound. --- src/browser/SelectorPath.zig | 6 +- src/browser/webapi/selector/Selector.zig | 91 ++++++++++++++---------- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/src/browser/SelectorPath.zig b/src/browser/SelectorPath.zig index fd0fc7091..1750728be 100644 --- a/src/browser/SelectorPath.zig +++ b/src/browser/SelectorPath.zig @@ -162,14 +162,14 @@ fn siblingMatches(self: SelectorPath, el: *Element, sel: []const u8) bool { const parent = el.parentElement() orelse return false; var child = parent.firstElementChild(); while (child) |c| : (child = c.nextElementSibling()) { - if (c != el and (Selector.matches(c, sel, self.frame) catch false)) return true; + if (c != el and (Selector.matchesUncached(self.arena, c, sel, self.frame) catch false)) return true; } return false; } fn matchCount(self: SelectorPath, candidate: []const u8) usize { const root = self.frame.window._document.asNode(); - const list = Selector.querySelectorAll(root, candidate, self.frame) catch return 0; + const list = Selector.querySelectorAllUncached(root, candidate, self.frame) catch return 0; defer list.deinit(self.frame._page); return list.getLength(); } @@ -206,7 +206,7 @@ fn isPlainAttrValue(value: []const u8) bool { /// first-match against the document. fn isFirstMatch(self: SelectorPath, target: *Element, candidate: []const u8) bool { const root = self.frame.window._document.asNode(); - const first = (Selector.querySelector(root, candidate, self.frame) catch return false) orelse return false; + const first = (Selector.querySelectorUncached(self.arena, root, candidate, self.frame) catch return false) orelse return false; return first == target; } diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index dbe54d8b3..9a3b9d3c2 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -51,43 +51,27 @@ pub fn parseLeaky(arena: Allocator, input: []const u8) !Parsed { return .{ .selectors = try Parser.parseList(arena, input) }; } -// SelectorPath synthesizes many one-off selectors, so past the cap we parse -// into a per-call arena rather than grow the frame-lifetime cache unbounded. -const max_cache_entries = 1024; - -/// The parsed AST borrows slices of its input, so on a miss the key is duped -/// into `frame.arena` and parsed against that copy; both share the frame's -/// lifetime. `fallback` backs the parse once the cache is full. -fn cachedParse(frame: *Frame, fallback: Allocator, input: []const u8) ![]const Selector { +/// Parse `input` via the frame's selector cache. The parsed AST borrows slices +/// of its input, so on a miss the key is duped into `frame.arena` and parsed +/// against that copy; both share the frame's lifetime. Only for selectors that +/// recur (page scripts, waitForSelector) — one-off synthesized selectors use the +/// `*Uncached` variants so they don't pollute a frame-lifetime cache. +fn cachedParse(frame: *Frame, input: []const u8) ![]const Selector { if (input.len == 0) { return error.SyntaxError; } if (frame._selector_cache.get(input)) |selectors| { return selectors; } - if (frame._selector_cache.count() >= max_cache_entries) { - return Parser.parseList(fallback, input); - } - const arena = frame.arena; - const owned = try arena.dupe(u8, input); - const selectors = try Parser.parseList(arena, owned); - try frame._selector_cache.put(arena, owned, selectors); + const owned = try frame.arena.dupe(u8, input); + const selectors = try Parser.parseList(frame.arena, owned); + try frame._selector_cache.put(frame.arena, owned, selectors); return selectors; } -pub fn querySelector(root: *Node, input: []const u8, frame: *Frame) !?*Node.Element { - const parsed = Parsed{ .selectors = try cachedParse(frame, frame.call_arena, input) }; - return parsed.query(root, frame); -} - -pub fn querySelectorAll(root: *Node, input: []const u8, frame: *Frame) !*List { - const arena = try frame.getArena(.small, "querySelectorAll"); - errdefer frame.releaseArena(arena); - +fn collectAll(arena: Allocator, selectors: []const Selector, root: *Node, frame: *Frame) !*List { var nodes: std.AutoArrayHashMapUnmanaged(*Node, void) = .empty; - - const selectors = try cachedParse(frame, arena, input); for (selectors) |selector| { try List.collect(arena, root, selector, &nodes, frame); } @@ -100,28 +84,63 @@ pub fn querySelectorAll(root: *Node, input: []const u8, frame: *Frame) !*List { return list; } -pub fn matches(el: *Node.Element, input: []const u8, frame: *Frame) !bool { - const selectors = try cachedParse(frame, frame.call_arena, input); - +fn matchesAny(selectors: []const Selector, el: *Node.Element, scope: *Node, frame: *Frame) bool { for (selectors) |selector| { - if (List.matches(el.asNode(), selector, el.asNode(), frame)) { + if (List.matches(el.asNode(), selector, scope, frame)) { return true; } } return false; } +pub fn querySelector(root: *Node, input: []const u8, frame: *Frame) !?*Node.Element { + const parsed = Parsed{ .selectors = try cachedParse(frame, input) }; + return parsed.query(root, frame); +} + +pub fn querySelectorAll(root: *Node, input: []const u8, frame: *Frame) !*List { + const arena = try frame.getArena(.small, "querySelectorAll"); + errdefer frame.releaseArena(arena); + return collectAll(arena, try cachedParse(frame, input), root, frame); +} + +pub fn matches(el: *Node.Element, input: []const u8, frame: *Frame) !bool { + return matchesAny(try cachedParse(frame, input), el, el.asNode(), frame); +} + // Like matches, but allows the caller to specify a scope node distinct from el. // Used by closest() so that :scope always refers to the original context element. pub fn matchesWithScope(el: *Node.Element, input: []const u8, scope: *Node.Element, frame: *Frame) !bool { - const selectors = try cachedParse(frame, frame.call_arena, input); + return matchesAny(try cachedParse(frame, input), el, scope.asNode(), frame); +} - for (selectors) |selector| { - if (List.matches(el.asNode(), selector, scope.asNode(), frame)) { - return true; - } +// Uncached counterparts for one-off selectors (SelectorPath's synthesized +// candidates): parse into `arena` instead of the frame cache. querySelectorAll +// has no arena parameter — it parses into the same pooled arena that backs the +// returned List. + +pub fn querySelectorUncached(arena: Allocator, root: *Node, input: []const u8, frame: *Frame) !?*Node.Element { + if (input.len == 0) { + return error.SyntaxError; } - return false; + const parsed = Parsed{ .selectors = try Parser.parseList(arena, input) }; + return parsed.query(root, frame); +} + +pub fn querySelectorAllUncached(root: *Node, input: []const u8, frame: *Frame) !*List { + if (input.len == 0) { + return error.SyntaxError; + } + const arena = try frame.getArena(.small, "querySelectorAllUncached"); + errdefer frame.releaseArena(arena); + return collectAll(arena, try Parser.parseList(arena, input), root, frame); +} + +pub fn matchesUncached(arena: Allocator, el: *Node.Element, input: []const u8, frame: *Frame) !bool { + if (input.len == 0) { + return error.SyntaxError; + } + return matchesAny(try Parser.parseList(arena, input), el, el.asNode(), frame); } pub fn classAttributeContains(class_attr: []const u8, class_name: []const u8) bool { From c04d316f8a2e084f402897b7677d82612a311fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 1 Jul 2026 13:44:48 +0200 Subject: [PATCH 3/4] perf(css): move selector cache to Browser with bounded eviction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback: public querySelector doesn't guarantee reuse, so the cache must be bounded regardless of the SelectorPath bypass. Move it off the Frame (where it was wiped every navigation and unbounded) onto the Browser, since a parsed selector references no Frame/Context — entries are now shared across the browser's pages and survive navigation. Selector.Cache is a StringArrayHashMap with per-entry arenas (so eviction can free an individual entry, which a shared arena can't) and FIFO eviction of the oldest entry past a capacity. The SelectorPath *Uncached bypass stays. --- src/browser/Browser.zig | 6 +++ src/browser/Frame.zig | 5 -- src/browser/webapi/selector/Selector.zig | 68 +++++++++++++++++++----- 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index 62652b1d5..23926ccf6 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -25,6 +25,7 @@ const Notification = @import("../Notification.zig"); const js = @import("js/js.zig"); const Page = @import("Page.zig"); const Session = @import("Session.zig"); +const Selector = @import("webapi/selector/Selector.zig"); const Viewport = @import("Viewport.zig"); const HttpClient = @import("HttpClient.zig"); const PermissionState = @import("webapi/Permissions.zig").State; @@ -44,6 +45,9 @@ allocator: Allocator, arena_pool: *ArenaPool, http_client: HttpClient, +// Shared across pages, survives navigation. See Selector.Cache. +selector_cache: Selector.Cache, + // Permission state set via CDP Browser.grantPermissions / setPermission / // resetPermissions, keyed by permission name (e.g. "geolocation"). Read back // by navigator.permissions.query(). Scoped to the Browser so it persists @@ -109,6 +113,7 @@ pub fn init(self: *Browser, app: *App, opts: InitOpts, cdp: ?*CDP) !void { .http_client = undefined, .page_pool = std.heap.MemoryPool(Page).init(allocator), .fc_identity_pool = .init(allocator), + .selector_cache = .init(allocator), }; try self.http_client.init(allocator, &app.network, cdp); } @@ -123,6 +128,7 @@ pub fn deinit(self: *Browser) void { self.http_client.deinit(); self.clearPermissions(); self.permissions.deinit(self.allocator); + self.selector_cache.deinit(); } // Set (or overwrite) the stored state for a permission. The name is duped into diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index d8d290c0a..4f0207366 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -52,7 +52,6 @@ const VisualViewport = @import("webapi/VisualViewport.zig"); const AbstractRange = @import("webapi/AbstractRange.zig"); const Worker = @import("webapi/Worker.zig"); const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig"); -const Selector = @import("webapi/selector/Selector.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const SubmitEvent = @import("webapi/event/SubmitEvent.zig"); @@ -121,10 +120,6 @@ _attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attri // that actually access these features via JavaScript, saving 24 bytes per element. _element_styles: Element.StyleLookup = .empty, _element_datasets: Element.DatasetLookup = .empty, - -// Keyed by selector string. The parsed AST borrows slices of the key, so both -// live on `arena` (frame lifetime). See Selector.cachedParse. -_selector_cache: std.StringHashMapUnmanaged([]const Selector.Selector) = .empty, _element_class_lists: Element.ClassListLookup = .empty, _element_rel_lists: Element.RelListLookup = .empty, _element_shadow_roots: Element.ShadowRootLookup = .empty, diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 9a3b9d3c2..0fae181cb 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -51,24 +51,66 @@ pub fn parseLeaky(arena: Allocator, input: []const u8) !Parsed { return .{ .selectors = try Parser.parseList(arena, input) }; } -/// Parse `input` via the frame's selector cache. The parsed AST borrows slices -/// of its input, so on a miss the key is duped into `frame.arena` and parsed -/// against that copy; both share the frame's lifetime. Only for selectors that -/// recur (page scripts, waitForSelector) — one-off synthesized selectors use the -/// `*Uncached` variants so they don't pollute a frame-lifetime cache. +/// One-off synthesized selectors use the `*Uncached` variants instead. fn cachedParse(frame: *Frame, input: []const u8) ![]const Selector { - if (input.len == 0) { - return error.SyntaxError; + return frame._session.browser.selector_cache.parse(input); +} + +/// On the Browser because a parsed selector references no Frame/Context, so +/// entries survive navigation. Per-entry arena so eviction can free one entry. +pub const Cache = struct { + // Caps retained memory, not correctness; oldest entry evicted on overflow. + const max_entries = 1024; + + allocator: Allocator, + map: std.StringArrayHashMapUnmanaged(Entry) = .empty, + + const Entry = struct { + arena: std.heap.ArenaAllocator, + selectors: []const Selector, + }; + + pub fn init(allocator: Allocator) Cache { + return .{ .allocator = allocator }; } - if (frame._selector_cache.get(input)) |selectors| { + + pub fn deinit(self: *Cache) void { + for (self.map.values()) |*entry| { + entry.arena.deinit(); + } + self.map.deinit(self.allocator); + } + + fn parse(self: *Cache, input: []const u8) ![]const Selector { + if (input.len == 0) { + return error.SyntaxError; + } + if (self.map.get(input)) |entry| { + return entry.selectors; + } + + // The AST borrows slices of its input, so dupe the key into the arena. + var arena = std.heap.ArenaAllocator.init(self.allocator); + errdefer arena.deinit(); + const entry_arena = arena.allocator(); + const owned = try entry_arena.dupe(u8, input); + const selectors = try Parser.parseList(entry_arena, owned); + + if (self.map.count() >= max_entries) { + self.evictOldest(); + } + try self.map.put(self.allocator, owned, .{ .arena = arena, .selectors = selectors }); return selectors; } - const owned = try frame.arena.dupe(u8, input); - const selectors = try Parser.parseList(frame.arena, owned); - try frame._selector_cache.put(frame.arena, owned, selectors); - return selectors; -} + // Insertion order is preserved, so index 0 is the oldest. + fn evictOldest(self: *Cache) void { + const key = self.map.keys()[0]; + var arena = self.map.values()[0].arena; + std.debug.assert(self.map.orderedRemove(key)); + arena.deinit(); + } +}; fn collectAll(arena: Allocator, selectors: []const Selector, root: *Node, frame: *Frame) !*List { var nodes: std.AutoArrayHashMapUnmanaged(*Node, void) = .empty; From 6f7bde9939b34faeb8f7b0c1d4fe42a61f5cbf15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 1 Jul 2026 14:05:10 +0200 Subject: [PATCH 4/4] perf(css): tidy uncached-variants doc comment --- src/browser/webapi/selector/Selector.zig | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 0fae181cb..96963bec4 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -156,11 +156,9 @@ pub fn matchesWithScope(el: *Node.Element, input: []const u8, scope: *Node.Eleme return matchesAny(try cachedParse(frame, input), el, scope.asNode(), frame); } -// Uncached counterparts for one-off selectors (SelectorPath's synthesized -// candidates): parse into `arena` instead of the frame cache. querySelectorAll -// has no arena parameter — it parses into the same pooled arena that backs the -// returned List. - +/// Uncached counterparts for one-off selectors (SelectorPath): parse into +/// `arena` instead of caching. querySelectorAllUncached takes no arena — it uses +/// the pooled arena backing its List. pub fn querySelectorUncached(arena: Allocator, root: *Node, input: []const u8, frame: *Frame) !?*Node.Element { if (input.len == 0) { return error.SyntaxError;