Repository: lightpanda-io/browser
Pull request #587: cdp: dispatch DOM.setChildNodes event for search results
Author: login: krichprollsch
Created: 2025-04-30T13:57:12Z | Merged: 2025-05-05T06:56:04Z



Diff:
diff --git a/src/browser/dom/element.zig b/src/browser/dom/element.zig
index 3aeac4bd3..d6cff74d6 100644
--- a/src/browser/dom/element.zig
+++ b/src/browser/dom/element.zig
@@ -99,7 +99,8 @@ pub const Element = struct {
     }
 
     pub fn get_attributes(self: *parser.Element) !*parser.NamedNodeMap {
-        return try parser.nodeGetAttributes(parser.elementToNode(self));
+        // An element must have non-nil attributes.
+        return try parser.nodeGetAttributes(parser.elementToNode(self)) orelse unreachable;
     }
 
     pub fn get_innerHTML(self: *parser.Element, state: *SessionState) ![]const u8 {
diff --git a/src/browser/dump.zig b/src/browser/dump.zig
index 4c4e79967..01669036b 100644
--- a/src/browser/dump.zig
+++ b/src/browser/dump.zig
@@ -38,18 +38,18 @@ pub fn writeNode(node: *parser.Node, writer: anytype) anyerror!void {
             try writer.writeAll(tag);
 
             // write the attributes
-            const map = try parser.nodeGetAttributes(node);
-            const ln = try parser.namedNodeMapGetLength(map);
-            var i: u32 = 0;
-            while (i < ln) {
-                const attr = try parser.namedNodeMapItem(map, i) orelse break;
-                try writer.writeAll(" ");
-                try writer.writeAll(try parser.attributeGetName(attr));
-                try writer.writeAll("=\"");
-                const attribute_value = try parser.attributeGetValue(attr) orelse "";
-                try writeEscapedAttributeValue(writer, attribute_value);
-                try writer.writeAll("\"");
-                i += 1;
+            const _map = try parser.nodeGetAttributes(node);
+            if (_map) |map| {
+                const ln = try parser.namedNodeMapGetLength(map);
+                for (0..ln) |i| {
+                    const attr = try parser.namedNodeMapItem(map, @intCast(i)) orelse break;
+                    try writer.writeAll(" ");
+                    try writer.writeAll(try parser.attributeGetName(attr));
+                    try writer.writeAll("=\"");
+                    const attribute_value = try parser.attributeGetValue(attr) orelse "";
+                    try writeEscapedAttributeValue(writer, attribute_value);
+                    try writer.writeAll("\"");
+                }
             }
 
             try writer.writeAll(">");
diff --git a/src/browser/netsurf.zig b/src/browser/netsurf.zig
index df5ab1473..f7050d605 100644
--- a/src/browser/netsurf.zig
+++ b/src/browser/netsurf.zig
@@ -1338,11 +1338,11 @@ pub fn nodeHasAttributes(node: *Node) !bool {
     return res;
 }
 
-pub fn nodeGetAttributes(node: *Node) !*NamedNodeMap {
+pub fn nodeGetAttributes(node: *Node) !?*NamedNodeMap {
     var res: ?*NamedNodeMap = undefined;
     const err = nodeVtable(node).dom_node_get_attributes.?(node, &res);
     try DOMErr(err);
-    return res.?;
+    return res;
 }
 
 pub fn nodeGetNamespace(node: *Node) !?[]const u8 {
diff --git a/src/cdp/Node.zig b/src/cdp/Node.zig
index a7a6b6588..44b8c9f21 100644
--- a/src/cdp/Node.zig
+++ b/src/cdp/Node.zig
@@ -29,6 +29,7 @@ const Node = @This();
 
 id: Id,
 _node: *parser.Node,
+set_child_nodes_event: bool,
 
 // Whenever we send a node to the client, we register it here for future lookup.
 // We maintain a node -> id and id -> node lookup.
@@ -85,6 +86,7 @@ pub const Registry = struct {
         node.* = .{
             ._node = n,
             .id = id,
+            .set_child_nodes_event = false,
         };
 
         node_lookup_gop.value_ptr.* = node;
@@ -218,7 +220,7 @@ pub const Writer = struct {
 
     fn toJSON(self: *const Writer, w: anytype) !void {
         try w.beginObject();
-        try writeCommon(self.node, false, w);
+        try self.writeCommon(self.node, false, w);
 
         {
             var registry = self.registry;
@@ -232,7 +234,7 @@ pub const Writer = struct {
                 const child = (try parser.nodeListItem(child_nodes, @intCast(i))) orelse break;
                 const child_node = try registry.register(child);
                 try w.beginObject();
-                try writeCommon(child_node, true, w);
+                try self.writeCommon(child_node, true, w);
                 try w.endObject();
                 i += 1;
             }
@@ -245,7 +247,7 @@ pub const Writer = struct {
         try w.endObject();
     }
 
-    fn writeCommon(node: *const Node, include_child_count: bool, w: anytype) !void {
+    fn writeCommon(self: *const Writer, node: *const Node, include_child_count: bool, w: anytype) !void {
         try w.objectField("nodeId");
         try w.write(node.id);
 
@@ -254,9 +256,24 @@ pub const Writer = struct {
 
         const n = node._node;
 
-        // TODO:
-        // try w.objectField("parentId");
-        // try w.write(pid);
+        if (try parser.nodeParentNode(n)) |p| {
+            const parent_node = try self.registry.register(p);
+            try w.objectField("parentId");
+            try w.write(parent_node.id);
+        }
+
+        const _map = try parser.nodeGetAttributes(n);
+        if (_map) |map| {
+            const attr_count = try parser.namedNodeMapGetLength(map);
+            try w.objectField("attributes");
+            try w.beginArray();
+            for (0..attr_count) |i| {
+                const attr = try parser.namedNodeMapItem(map, @intCast(i)) orelse continue;
+                try w.write(try parser.attributeGetName(attr));
+                try w.write(try parser.attributeGetValue(attr) orelse continue);
+            }
+            try w.endArray();
+        }
 
         try w.objectField("nodeType");
         try w.write(@intFromEnum(try parser.nodeType(n)));
@@ -461,6 +478,7 @@ test "cdp Node: Writer" {
                 .xmlVersion = "",
                 .compatibilityMode = "NoQuirksMode",
                 .isScrollable = false,
+                .parentId = 1,
             }, .{
                 .nodeId = 3,
                 .backendNodeId = 3,
@@ -474,6 +492,7 @@ test "cdp Node: Writer" {
                 .xmlVersion = "",
                 .compatibilityMode = "NoQuirksMode",
                 .isScrollable = false,
+                .parentId = 1,
             } },
         }, json);
     }
diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig
index ceb9bc93b..c37f59cba 100644
--- a/src/cdp/domains/dom.zig
+++ b/src/cdp/domains/dom.zig
@@ -76,12 +76,74 @@ fn performSearch(cmd: anytype) !void {
 
     const search = try bc.node_search_list.create(list.nodes.items);
 
+    // dispatch setChildNodesEvents to inform the client of the subpart of node
+    // tree covering the results.
+    try dispatchSetChildNodes(cmd, list.nodes.items);
+
     return cmd.sendResult(.{
         .searchId = search.name,
         .resultCount = @as(u32, @intCast(search.node_ids.len)),
     }, .{});
 }
 
+// dispatchSetChildNodes send the setChildNodes event for the whole DOM tree
+// hierarchy of each nodes.
+// We dispatch event in the reverse order: from the top level to the direct parents.
+// We should dispatch a node only if it has never been sent.
+fn dispatchSetChildNodes(cmd: anytype, nodes: []*parser.Node) !void {
+    const arena = cmd.arena;
+    const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
+    const session_id = bc.session_id orelse return error.SessionIdNotLoaded;
+
+    var parents: std.ArrayListUnmanaged(*Node) = .{};
+    for (nodes) |_n| {
+        var n = _n;
+        while (true) {
+            const p = try parser.nodeParentNode(n) orelse break;
+
+            // Register the node.
+            const node = try bc.node_registry.register(p);
+            if (node.set_child_nodes_event) break;
+            try parents.append(arena, node);
+            n = p;
+        }
+    }
+
+    const plen = parents.items.len;
+    if (plen == 0) return;
+
+    var i: usize = plen;
+    // We're going to iterate in reverse order from how we added them.
+    // This ensures that we're emitting the tree of nodes top-down.
+    while (i > 0) {
+        i -= 1;
+        const node = parents.items[i];
+        // Although our above loop won't add an already-sent node to `parents`
+        // this can still be true because two nodes can share the same parent node
+        // so we might have just sent the node a previous iteration of this loop
+        if (node.set_child_nodes_event) continue;
+
+        node.set_child_nodes_event = true;
+
+        // If the node has no parent, it's the root node.
+        // We don't dispatch event for it because we assume the root node is
+        // dispatched via the DOM.getDocument command.
+        const p = try parser.nodeParentNode(node._node) orelse {
+            continue;
+        };
+
+        // Retrieve the parent from the registry.
+        const parent_node = try bc.node_registry.register(p);
+
+        try cmd.sendEvent("DOM.setChildNodes", .{
+            .parentId = parent_node.id,
+            .nodes = .{bc.nodeWriter(node, .{})},
+        }, .{
+            .session_id = session_id,
+        });
+    }
+}
+
 // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-discardSearchResults
 fn discardSearchResults(cmd: anytype) !void {
     const params = (try cmd.params(struct {
diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig
index 045fa7eea..fc19d3af6 100644
--- a/src/cdp/testing.zig
+++ b/src/cdp/testing.zig
@@ -120,6 +120,7 @@ const TestContext = struct {
         }
 
         if (opts.html) |html| {
+            if (bc.session_id == null) bc.session_id = "SID-X";
             parser.deinit();
             try parser.init();
             const page = try bc.session.createPage();


Reviews:
user: login: karlseguin
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-30T14:46:44Z
user: login: karlseguin
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-02T22:45:44Z

Review comments:
diff_hunk: @@ -76,6 +76,23 @@ fn performSearch(cmd: anytype) !void {
 
     const search = try bc.node_search_list.create(list.nodes.items);
 
+    // dispatch setChildNodesEvents to inform the client of the subpart of node
+    // tree covering the results.
+    for (list.nodes.items) |n| {
+        // retrieve the node's parent
+        if (try parser.nodeParentNode(n)) |p| {
+            // Register the parent and send the node.
+            const parent_node = try bc.node_registry.register(p);
+            const node = bc.node_registry.lookup_by_node.get(n) orelse unreachable;
+            // Should-we return one DOM.setChildNodes event per parentId
+            // containing all its children in the nodes array?
path: src/cdp/domains/dom.zig
user: login: karlseguin
body: Yes.

If it's something we'll need to do often, we could expand the `NodeWriter`. It already takes a placeholder option, so it could be something like:

```
.nodes = bc.nodeWriter(parent_node, .{.mode = .child_list})},
```


created_at: 2025-04-30T14:43:57Z
author_association: COLLABORATOR
diff_hunk: @@ -76,12 +76,69 @@ fn performSearch(cmd: anytype) !void {
 
     const search = try bc.node_search_list.create(list.nodes.items);
 
+    // dispatch setChildNodesEvents to inform the client of the subpart of node
+    // tree covering the results.
+    try dispatchSetChildNodes(cmd, list.nodes.items);
+
     return cmd.sendResult(.{
         .searchId = search.name,
         .resultCount = @as(u32, @intCast(search.node_ids.len)),
     }, .{});
 }
 
+// dispatchSetChildNodes send the setChildNodes event for the whole DOM tree
+// hierarchy of each nodes.
+// We dispatch event in the reverse order: from the top level to the direct parents.
+// We should dispatch a node only if it has never been sent.
+fn dispatchSetChildNodes(cmd: anytype, nodes: []*parser.Node) !void {
+    const arena = cmd.arena;
+    const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
+    const session_id = bc.session_id orelse return error.SessionIdNotLoaded;
+
+    var parents: std.ArrayListUnmanaged(*Node) = .{};
+    for (nodes) |_n| {
+        var n = _n;
+        while (true) {
+            const p = try parser.nodeParentNode(n) orelse break;
+
+            // Register the node.
+            const node = try bc.node_registry.register(p);
+            if (node.set_child_nodes_event) break;
+            try parents.append(arena, node);
+            n = p;
+        }
+    }
+
+    const plen = parents.items.len;
+    if (plen == 0) return;
+
+    var i: usize = plen;
+    while (i > 0) {
+        i -= 1;
+        const node = parents.items[i];
+        if (node.set_child_nodes_event) continue;
path: src/cdp/domains/dom.zig
user: login: karlseguin
body: ```suggestion
        // Although our above loop won't add an already-sent node to `parents`
        // this can still be true because two nodes can share the same parent node
        // so we might have just sent the node a previous iteration of this loop
        if (node.set_child_nodes_event) continue;
```
created_at: 2025-05-02T22:39:31Z
author_association: COLLABORATOR
diff_hunk: @@ -76,12 +76,69 @@ fn performSearch(cmd: anytype) !void {
 
     const search = try bc.node_search_list.create(list.nodes.items);
 
+    // dispatch setChildNodesEvents to inform the client of the subpart of node
+    // tree covering the results.
+    try dispatchSetChildNodes(cmd, list.nodes.items);
+
     return cmd.sendResult(.{
         .searchId = search.name,
         .resultCount = @as(u32, @intCast(search.node_ids.len)),
     }, .{});
 }
 
+// dispatchSetChildNodes send the setChildNodes event for the whole DOM tree
+// hierarchy of each nodes.
+// We dispatch event in the reverse order: from the top level to the direct parents.
+// We should dispatch a node only if it has never been sent.
+fn dispatchSetChildNodes(cmd: anytype, nodes: []*parser.Node) !void {
+    const arena = cmd.arena;
+    const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
+    const session_id = bc.session_id orelse return error.SessionIdNotLoaded;
+
+    var parents: std.ArrayListUnmanaged(*Node) = .{};
+    for (nodes) |_n| {
+        var n = _n;
+        while (true) {
+            const p = try parser.nodeParentNode(n) orelse break;
+
+            // Register the node.
+            const node = try bc.node_registry.register(p);
+            if (node.set_child_nodes_event) break;
+            try parents.append(arena, node);
+            n = p;
+        }
+    }
+
+    const plen = parents.items.len;
+    if (plen == 0) return;
+
+    var i: usize = plen;
+    while (i > 0) {
path: src/cdp/domains/dom.zig
user: login: karlseguin
body: ```suggestion
    // We're going to iterate in reverse order from how we added them.
    // This ensures that we're emitting the tree of nodes top-down.
    while (i > 0) {
```
created_at: 2025-05-02T22:41:51Z
author_association: COLLABORATOR

PR comments:
user: login: krichprollsch
created_at: 2025-05-01T17:47:34Z
body: We have to dispatch the `setChildNodes` event for the whole hierarchy, starting from the root until the nodes.
I tried an implementation but I have a leak error with the unit tests.
author_association: MEMBER
user: login: karlseguin
created_at: 2025-05-02T02:34:47Z
body: > We have to dispatch the `setChildNodes` event for the whole hierarchy, starting from the root until the nodes. I tried an implementation but I have a leak error with the unit tests.

Really? I was pretty sure it was just the node's sibling, or, maybe more accurately, the node's parent children.

I don't see a leak, both in the CI and locally, it's an `unreachable` which is being hit. 

```zig
const parent_node = bc.node_registry.lookup_by_node.get(p) orelse unreachable;
```

I don't think there's any reason to assume the parent has been seen by the registry before. Always better to use `register`, which does a `getOrPut`

```zig
const node = try bc.node_registry.register(parser.documentToNode(doc));
```

After that, you'll get a crash because the session_id isn't set. I would do 2 things here:

1 - 
Be defensive and add a check after loading `bc.

```zig
var bc = ...
const session_id = bc.session_id orelse return error.SessionIdNotLoaded;
```

2 - Change the cdp/testing.zig `loadBrowserContext` and set a default session_id if there's `html`:

```zig
// existing if statement
if (opts.html) |html| {
   // add
   if (bc.session_id == null) bc.session_id = "SID-X";
   ..
}
```
  

author_association: COLLABORATOR
user: login: krichprollsch
created_at: 2025-05-02T20:27:04Z
body: The issue is fixed. I added a bool into `cdp.Node` to know if a node has been already dispatched.

> Really? I was pretty sure it was just the node's sibling, or, maybe more accurately, the node's parent children.

Yes it is as far as I can see. ..
author_association: MEMBER