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/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 4e9df2272..96963bec4 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -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 {