Merge pull request #3628 from lightpanda-io/iterative-axnode

mem: AXNode tree/query walks iterative, isIgnore linear
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-26 06:12:45 +08:00
commit 989ad39a47
1 file changed
+170 -75
+170 -75
View File
@@ -80,15 +80,17 @@ pub const Writer = struct {
}
fn toJSON(self: *const Writer, w: anytype) !void {
const cache_arena = try self.frame.getArena(.medium, "AXNode.IgnoreCache");
defer cache_arena.release();
var ignore_cache: IgnoreCache = .{ .allocator = cache_arena.allocator() };
try w.beginArray();
if (self.filter != null) {
try self.walkQuery(self.root.dom, false, w);
try self.walkQuery(self.root.dom, &ignore_cache, w);
} else {
const root = AXNode.fromNode(self.root.dom);
const root_hidden = if (self.root.dom.is(DOMNode.Element)) |el| isHidden(el, self.frame, .{}) else false;
if (try self.writeNode(self.root.id, root, false, root_hidden, w)) {
try self.writeNodeChildren(root, false, w);
}
try self.writeTree(root, root_hidden, &ignore_cache, w);
}
return w.endArray();
}
@@ -118,28 +120,17 @@ pub const Writer = struct {
try w.write(s);
}
fn writeNodeChildren(self: *const Writer, parent: AXNode, in_aria_hidden: bool, w: anytype) !void {
// Add ListMarker for listitem elements
if (parent.dom.is(DOMNode.Element)) |parent_el| {
if (parent_el.getTag() == .li) {
try self.writeListMarker(parent.dom, w);
}
}
const child_in_aria_hidden = in_aria_hidden or blk: {
const parent_el = parent.dom.is(DOMNode.Element) orelse break :blk false;
break :blk hasAriaHiddenTrue(parent_el);
};
var it = parent.dom.childrenIterator();
const ignore_text = ignoreText(parent.dom);
while (it.next()) |dom_node| {
fn writeTree(self: *const Writer, root: AXNode, root_hidden: bool, ignore_cache: *IgnoreCache, w: anytype) !void {
var walker: Walker = .init(root.dom);
var descend = try self.writeNode(self.root.id, root, false, root_hidden, ignore_cache, w);
while (walker.next(descend)) |dom_node| {
descend = false;
switch (dom_node._type) {
.cdata => {
if (dom_node.is(DOMNode.CData.Text) == null) {
continue;
}
if (ignore_text) {
if (ignoreText(dom_node._parent.?)) {
continue;
}
},
@@ -147,8 +138,8 @@ pub const Writer = struct {
// Prune hidden subtrees entirely (display:none,
// visibility:hidden, aria-hidden, hidden, inert). Matches
// Chromium: these elements aren't exposed to the AX tree.
const child_el = dom_node.as(DOMNode.Element);
if (child_in_aria_hidden or isHidden(child_el, self.frame, .{ .ancestors = false })) {
const el = dom_node.as(DOMNode.Element);
if (walker.inAriaHidden() or isHidden(el, self.frame, .{ .ancestors = false })) {
continue;
}
},
@@ -156,10 +147,7 @@ pub const Writer = struct {
}
const node = try self.registry.register(dom_node);
const axn = AXNode.fromNode(node.dom);
if (try self.writeNode(node.id, axn, child_in_aria_hidden, false, w)) {
try self.writeNodeChildren(axn, child_in_aria_hidden, w);
}
descend = try self.writeNode(node.id, .fromNode(dom_node), walker.inAriaHidden(), false, ignore_cache, w);
}
}
@@ -495,8 +483,7 @@ pub const Writer = struct {
}
// write a node. returns true if children must be written.
fn writeNode(self: *const Writer, id: u32, axn: AXNode, in_aria_hidden: bool, hidden: bool, w: anytype) !bool {
// ignore empty texts
fn writeNode(self: *const Writer, id: u32, axn: AXNode, in_aria_hidden: bool, hidden: bool, ignore_cache: *IgnoreCache, w: anytype) !bool {
try w.beginObject();
try w.objectField("nodeId");
@@ -511,7 +498,7 @@ pub const Writer = struct {
try w.objectField("role");
try self.writeAXValue(.{ .role = resolved.role }, w);
const ignore = axn.isIgnore(self.frame, in_aria_hidden, hidden);
const ignore = try axn.isIgnore(self.frame, in_aria_hidden, hidden, ignore_cache);
try w.objectField("ignored");
try w.write(ignore);
@@ -591,7 +578,7 @@ pub const Writer = struct {
}
// Skip hidden element children so childIds matches the
// subtree-pruning done in writeNodeChildren.
// subtree-pruning done in writeTree.
if (child.is(DOMNode.Element)) |child_el| {
if (child_in_aria_hidden or isHidden(child_el, self.frame, .{ .ancestors = false })) {
continue;
@@ -606,6 +593,14 @@ pub const Writer = struct {
try w.endObject();
if (write_children) {
if (n.is(DOMNode.Element)) |el| {
if (el.getTag() == .li) {
try self.writeListMarker(n, w);
}
}
}
return write_children;
}
@@ -648,25 +643,21 @@ pub const Writer = struct {
// Query-mode walk. Visits every node under `root` (including AX-ignored
// ones, per the queryAXTree spec) and defers emission to emitMatch.
fn walkQuery(self: *const Writer, node: *DOMNode, in_aria_hidden: bool, w: anytype) !void {
const axn = AXNode.fromNode(node);
try self.emitMatch(axn, in_aria_hidden, w);
// <head>, <script>, <style> never expose AX content — skip recursion.
if (axn.ignoreChildren()) return;
const child_in_aria_hidden = in_aria_hidden or blk: {
const parent_el = node.is(DOMNode.Element) orelse break :blk false;
break :blk hasAriaHiddenTrue(parent_el);
};
var it = node.childrenIterator();
while (it.next()) |child| {
switch (child._type) {
fn walkQuery(self: *const Writer, root: *DOMNode, ignore_cache: *IgnoreCache, w: anytype) !void {
var walker: Walker = .init(root);
const root_axn: AXNode = .fromNode(root);
try self.emitMatch(root_axn, false, ignore_cache, w);
// <head>, <script>, <style> never expose AX content — skip their children.
var descend = !root_axn.ignoreChildren();
while (walker.next(descend)) |node| {
descend = false;
switch (node._type) {
.element, .cdata => {},
else => continue,
}
try self.walkQuery(child, child_in_aria_hidden, w);
const axn: AXNode = .fromNode(node);
try self.emitMatch(axn, walker.inAriaHidden(), ignore_cache, w);
descend = !axn.ignoreChildren();
}
}
@@ -674,7 +665,7 @@ pub const Writer = struct {
// the queryAXTree flat-match shape: nodeId, backendDOMNodeId, ignored,
// role, name, plus empty properties / childIds (clients fetch full
// properties via getFullAXTree on a matched nodeId).
fn emitMatch(self: *const Writer, axn: AXNode, in_aria_hidden: bool, w: anytype) !void {
fn emitMatch(self: *const Writer, axn: AXNode, in_aria_hidden: bool, ignore_cache: *IgnoreCache, w: anytype) !void {
const filter = self.filter.?;
const resolved = self.resolveRole(axn) catch return;
@@ -689,7 +680,7 @@ pub const Writer = struct {
const node = try self.registry.register(axn.dom);
const hidden = if (axn.dom.is(DOMNode.Element)) |el| isHidden(el, self.frame, .{}) else false;
const ignored = axn.isIgnore(self.frame, in_aria_hidden, hidden);
const ignored = try axn.isIgnore(self.frame, in_aria_hidden, hidden, ignore_cache);
try w.beginObject();
@@ -1131,6 +1122,49 @@ fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer) !void {
}
}
/// Pre-order walk counting aria-hidden ancestors (root included), which
/// TreeWalker can't: it leaves subtrees without saying so.
const Walker = struct {
root: *DOMNode,
current: *DOMNode,
aria_hidden_ancestors: u32 = 0,
fn init(root: *DOMNode) Walker {
return .{ .root = root, .current = root };
}
/// `descend` false skips the current node's children.
fn next(self: *Walker, descend: bool) ?*DOMNode {
if (descend) {
if (self.current.firstChild()) |child| {
if (isAriaHidden(self.current)) self.aria_hidden_ancestors += 1;
self.current = child;
return child;
}
}
var node = self.current;
while (node != self.root) {
if (node.nextSibling()) |sibling| {
self.current = sibling;
return sibling;
}
node = node._parent.?;
if (isAriaHidden(node)) self.aria_hidden_ancestors -= 1;
}
return null;
}
fn inAriaHidden(self: *const Walker) bool {
return self.aria_hidden_ancestors > 0;
}
fn isAriaHidden(node: *DOMNode) bool {
const el = node.is(DOMNode.Element) orelse return false;
return hasAriaHiddenTrue(el);
}
};
fn hasAriaHiddenTrue(elt: *DOMNode.Element) bool {
if (elt.getAttributeInterned("aria-hidden")) |value| {
return std.mem.eql(u8, value, "true");
@@ -1322,17 +1356,62 @@ fn ignoreChildren(self: AXNode) bool {
};
}
// `hidden` comes from the caller: the tree walk prunes, so its children never
// are; the root and the query walk probe the whole chain.
fn isIgnore(self: AXNode, frame: *Frame, in_aria_hidden: bool, hidden: bool) bool {
const node = self.dom;
const role_attr = self.role_attr;
// A generic container's answer depends on the ones below it; without a memo,
// a walk asking about every node of a deep chain is O(depth²).
const IgnoreCache = struct {
allocator: std.mem.Allocator,
map: std.AutoHashMapUnmanaged(*DOMNode, bool) = .empty,
/// Keyed on the node alone: never reached under aria-hidden (ignoreSelf
/// says .ignored). `root` isn't cached, walks ask about each node once.
fn isGenericIgnored(self: *IgnoreCache, root: *DOMNode, frame: *Frame) !bool {
if (self.map.get(root)) |ignored| return ignored;
var tw = TreeWalker.FullExcludeSelf.init(root, .{});
const exposed = while (tw.next()) |node| {
const node_hidden = if (node.is(DOMNode.Element)) |el| isHidden(el, frame, .{ .ancestors = false }) else false;
switch (AXNode.fromNode(node).ignoreSelf(false, node_hidden)) {
.ignored => tw.skipChildren(),
.exposed => break node,
.generic => {
// Until an exposed node turns up under it.
const gop = try self.map.getOrPut(self.allocator, node);
if (!gop.found_existing) {
gop.value_ptr.* = true;
} else if (gop.value_ptr.*) {
tw.skipChildren();
} else {
break node;
}
},
}
} else return true;
var node = exposed._parent.?;
while (node != root) : (node = node._parent.?) {
self.map.getPtr(node).?.* = false;
}
return false;
}
};
/// `hidden` comes from the caller: the tree walk prunes, so its children never
/// are; the root and the query walk probe the whole chain.
fn isIgnore(self: AXNode, frame: *Frame, in_aria_hidden: bool, hidden: bool, cache: *IgnoreCache) !bool {
return switch (self.ignoreSelf(in_aria_hidden, hidden)) {
.ignored => true,
.exposed => false,
.generic => cache.isGenericIgnored(self.dom, frame),
};
}
/// isIgnore for `self` alone, leaving generic containers to the caller.
fn ignoreSelf(self: AXNode, in_aria_hidden: bool, hidden: bool) enum { ignored, exposed, generic } {
// Don't ignore non-Element node: CData, Document...
const elt = node.is(DOMNode.Element) orelse return in_aria_hidden;
const elt = self.dom.is(DOMNode.Element) orelse return if (in_aria_hidden) .ignored else .exposed;
// Ignore non-HTML elements: svg...
if (elt._type != .html) {
return true;
return .ignored;
}
const tag = elt.getTag();
@@ -1341,37 +1420,37 @@ fn isIgnore(self: AXNode, frame: *Frame, in_aria_hidden: bool, hidden: bool) boo
.script, .style, .meta, .link, .title, .base, .head, .noscript,
.template, .param, .source, .track, .datalist, .col, .colgroup, .html,
.body
=> return true,
=> return .ignored,
// zig fmt: on
.img => {
// Check for empty decorative images
const alt_ = elt.getAttributeInterned("alt");
if (alt_ == null or alt_.?.len == 0) {
return true;
return .ignored;
}
},
.input => {
// Check for hidden inputs
const input = elt.as(DOMNode.Element.Html.Input);
if (input._input_type == .hidden) {
return true;
return .ignored;
}
},
else => {},
}
if (role_attr) |role| {
if (self.role_attr) |role| {
if (std.ascii.eqlIgnoreCase(role, "none") or std.ascii.eqlIgnoreCase(role, "presentation")) {
return true;
return .ignored;
}
}
if (in_aria_hidden) {
return true;
return .ignored;
}
if (hidden) {
return true;
return .ignored;
}
// Generic containers with no semantic value
@@ -1381,21 +1460,11 @@ fn isIgnore(self: AXNode, frame: *Frame, in_aria_hidden: bool, hidden: bool) boo
const has_aria_labelledby = elt.hasAttributeSafe(.wrap("aria-labelledby"));
if (!has_role and !has_aria_label and !has_aria_labelledby) {
// Check if it has any non-ignored children.
var it = node.childrenIterator();
while (it.next()) |child| {
const axn = AXNode.fromNode(child);
const child_hidden = if (child.is(DOMNode.Element)) |child_el| isHidden(child_el, frame, .{ .ancestors = false }) else false;
if (!axn.isIgnore(frame, in_aria_hidden, child_hidden)) {
return false;
}
}
return true;
return .generic;
}
}
return false;
return .exposed;
}
pub fn getRole(self: AXNode) ![]const u8 {
@@ -2037,3 +2106,29 @@ test "AXNode: writer prunes children when root is hidden" {
try testing.expect(std.mem.indexOf(u8, json, "under-display-none") == null);
try testing.expect(std.mem.indexOf(u8, json, "\"childIds\":[]") != null);
}
test "AXNode: generic containers share memoized ignore answers" {
const frame = try testing.base.createFrame();
defer testing.base.test_session.closeAllPages();
const root = try frame.window._document.createElement("div", null, frame);
// #b holds only a decorative image; the text under #d exposes #c and #d.
try root.setInnerHTML(
\\<div id="a"><div id="b"><img></div><div id="c"><span id="d">text</span></div></div>
, frame);
var cache: IgnoreCache = .{ .allocator = testing.arena_allocator };
const expected = [_]struct { []const u8, bool }{ .{ "#a", false }, .{ "#b", true }, .{ "#c", false }, .{ "#d", false } };
for (expected) |e| {
const el = (try root.querySelector(e[0], frame)).?;
try testing.expectEqual(e[1], try AXNode.fromNode(el.asNode()).isIgnore(frame, false, false, &cache));
}
// Filled by #a's scan.
try testing.expectEqual(3, cache.map.count());
var fresh: IgnoreCache = .{ .allocator = testing.arena_allocator };
for (expected[1..]) |e| {
const el = (try root.querySelector(e[0], frame)).?;
try testing.expectEqual(e[1], try AXNode.fromNode(el.asNode()).isIgnore(frame, false, false, &fresh));
}
}