Merge pull request #2849 from lightpanda-io/perf/selector-parse-cache

perf(css): cache parsed selectors per frame
This commit is contained in:
Karl Seguin
2026-07-02 12:22:13 +08:00
committed by GitHub
3 changed files with 106 additions and 30 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -51,22 +51,69 @@ pub fn parseLeaky(arena: Allocator, input: []const u8) !Parsed {
return .{ .selectors = try Parser.parseList(arena, input) };
}
pub fn querySelector(root: *Node, input: []const u8, frame: *Frame) !?*Node.Element {
const parsed = try parseLeaky(frame.call_arena, input);
return parsed.query(root, frame);
/// One-off synthesized selectors use the `*Uncached` variants instead.
fn cachedParse(frame: *Frame, input: []const u8) ![]const Selector {
return frame._session.browser.selector_cache.parse(input);
}
pub fn querySelectorAll(root: *Node, input: []const u8, frame: *Frame) !*List {
if (input.len == 0) {
return error.SyntaxError;
/// 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 };
}
const arena = try frame.getArena(.small, "querySelectorAll");
errdefer frame.releaseArena(arena);
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;
}
// 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;
const selectors = try Parser.parseList(arena, input);
for (selectors) |selector| {
try List.collect(arena, root, selector, &nodes, frame);
}
@@ -79,38 +126,61 @@ 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 {
if (input.len == 0) {
return error.SyntaxError;
}
const arena = frame.call_arena;
const selectors = try Parser.parseList(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 {
return matchesAny(try cachedParse(frame, input), el, scope.asNode(), frame);
}
/// 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;
}
const parsed = Parsed{ .selectors = try Parser.parseList(arena, input) };
return parsed.query(root, frame);
}
const arena = frame.call_arena;
const selectors = try Parser.parseList(arena, input);
for (selectors) |selector| {
if (List.matches(el.asNode(), selector, scope.asNode(), frame)) {
return true;
}
pub fn querySelectorAllUncached(root: *Node, input: []const u8, frame: *Frame) !*List {
if (input.len == 0) {
return error.SyntaxError;
}
return false;
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 {