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.
This commit is contained in:
Karl Seguin
2026-05-07 22:37:28 +08:00
parent 61497ffe3a
commit 307e016aa5
3 changed files with 31 additions and 7 deletions

View File

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

View File

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

View File

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