From 307e016aa5ca0f00cd447e3ef05328360df08736 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 7 May 2026 22:37:28 +0800 Subject: [PATCH] optimize elementFromPoint This adds 3 optimizations to elementFromPoint (which we've seen some sites can call frequently and, on those sites, it dominates benchmarks). 1. Enable the visibility cache. Might seen like pure overhead, since every node is visited once. But a node's visibility check includes its ancestors. 2. Instead of using getBoundingClientRectForVisible, use getElementDimensions. getBoundingClientRect has no context, so it has to calculate the element's x and y by walking [part of] the document. But in elementFromPoint we're already walking the DOM in order, so we have all the context we need for x and y. 3. Exit early. Once we're past the target y, no element can match. --- src/browser/webapi/Document.zig | 34 +++++++++++++++++++++++++----- src/browser/webapi/Element.zig | 2 +- src/browser/webapi/EventTarget.zig | 2 +- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index b18211f5..0093e6f5 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -560,20 +560,44 @@ pub fn replaceChildren(self: *Document, nodes: []const Node.NodeOrText, frame: * } pub fn elementFromPoint(self: *Document, x: f64, y: f64, frame: *Frame) !?*Element { - // Traverse document in depth-first order to find the topmost (last in document order) - // element that contains the point (x, y) + // DFS in document order; topmost = last visited element whose rect contains (x, y). + // + // Faux-layout shortcut: rect.top is calculateDocumentPosition × 5, which is + // monotonically increasing in document order. So we maintain a running + // preorder counter instead of calling calculateDocumentPosition per node + // (which itself is O(N)). Once the counter's y passes the query y, no + // later element can contain the point, and we can return. + // + // We also share a single VisibilityCache across all elements so the + // ancestor-walk inside isHidden gets amortized. var topmost: ?*Element = null; const root = self.asNode(); var stack: std.ArrayList(*Node) = .empty; try stack.append(frame.call_arena, root); + var visibility_cache: Element.VisibilityCache = .{}; + var preorder_index: f64 = 0; + while (stack.items.len > 0) { const node = stack.pop() orelse break; + const pos = preorder_index * 5.0; + + if (pos > y) { + // Monotonic: no later element has top <= y, so none can contain (x, y). + return topmost; + } + + preorder_index += 1; if (node.is(Element)) |element| { - if (element.checkVisibilityCached(null, frame)) { - const rect = element.getBoundingClientRectForVisible(frame); - if (x >= rect.getLeft() and x <= rect.getRight() and y >= rect.getTop() and y <= rect.getBottom()) { + if (element.checkVisibilityCached(&visibility_cache, frame)) { + const dims = element.getElementDimensions(frame); + // x and y both come from preorder position in our faux layout. + const left = pos; + const top = pos; + const right = pos + dims.width; + const bottom = pos + dims.height; + if (x >= left and x <= right and y >= top and y <= bottom) { topmost = element; } } diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 91f47758..e1bffe76 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1144,7 +1144,7 @@ pub fn checkVisibility(self: *Element, opts_: ?CheckVisibilityOpts, frame: *Fram }); } -fn getElementDimensions(self: *Element, frame: *Frame) struct { width: f64, height: f64 } { +pub fn getElementDimensions(self: *Element, frame: *Frame) struct { width: f64, height: f64 } { var width: f64 = 5.0; var height: f64 = 5.0; diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index f838ea29..4c18cd31 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -203,7 +203,7 @@ pub const JsApi = struct { const testing = @import("../../testing.zig"); test "WebApi: EventTarget" { - const filter: testing.LogFilter = .init(&.{.js, .event}); + const filter: testing.LogFilter = .init(&.{ .js, .event }); defer filter.deinit(); // we create thousands of these per frame. Nothing should bloat it.