From 757c70b6db68bb881abe3e6ddab6deced0130a64 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 24 Apr 2026 17:26:39 +0800 Subject: [PATCH] Add adoptedCallback for CustomElements Fired when elements are moved from one document to another. I don't think this is really used much, but it helps pass a number of WPT cases. This required tweaking insertAdjacentHTML as it was creating a full document and trigger spurious callbacks in the new code. A DocumentFragment is now used instead. --- build.zig.zon | 4 +- src/browser/Frame.zig | 20 ++++++-- .../tests/custom_elements/adopted.html | 48 +++++++++++++++++++ src/browser/webapi/Node.zig | 12 +++-- src/browser/webapi/element/Html.zig | 39 +++------------ src/browser/webapi/element/html/Custom.zig | 14 ++++++ 6 files changed, 94 insertions(+), 43 deletions(-) create mode 100644 src/browser/tests/custom_elements/adopted.html diff --git a/build.zig.zon b/build.zig.zon index 01565f7f..c412c504 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/2785878461f2c07daec2aa87770dc8123d4c12db.tar.gz", - .hash = "v8-0.0.0-xddH61SJBACs_UKpNogg3DnocRLpgRzVp3XI6DbWiWlP", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/2785878461f2c07daec2aa87770dc8123d4c12db.tar.gz", + .hash = "v8-0.0.0-xddH61SJBACs_UKpNogg3DnocRLpgRzVp3XI6DbWiWlP", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 51a52279..99163e2f 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1668,11 +1668,17 @@ pub fn setNodeOwnerDocument(self: *Frame, node: *Node, owner: *Document) !void { } // Recursively sets the owner document for a node and all its descendants -pub fn adoptNodeTree(self: *Frame, node: *Node, new_owner: *Document) !void { +pub fn adoptNodeTree(self: *Frame, node: *Node, old_owner: *Document, new_owner: *Document) !void { try self.setNodeOwnerDocument(node, new_owner); + + // Per spec, adopted steps run on each element after its document is set. + if (node.is(Element)) |el| { + Element.Html.Custom.invokeAdoptedCallbackOnElement(el, old_owner, new_owner, self); + } + var it = node.childrenIterator(); while (it.next()) |child| { - try self.adoptNodeTree(child, new_owner); + try self.adoptNodeTree(child, old_owner, new_owner); } } @@ -2945,7 +2951,10 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No } if (opts.child_already_connected and !opts.adopting_to_new_document) { - // The child is already connected in the same document, we don't have to reconnect it + // The child is already connected in the same document, we don't have to reconnect it. + // On cross-document adoption the child has already fired + // disconnectedCallback against the old tree and must re-fire + // connectedCallback for the new tree, so we fall through. return; } @@ -2963,7 +2972,10 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No // Only invoke connectedCallback if the root child is transitioning from // disconnected to connected. When that happens, all descendants should also // get connectedCallback invoked (they're becoming connected as a group). - const should_invoke_connected = parent_is_connected and !opts.child_already_connected; + // Cross-document adoption also counts as a transition: the element fired + // disconnectedCallback against the old tree during removeNode and must + // now fire connectedCallback against the new tree. + const should_invoke_connected = parent_is_connected and (!opts.child_already_connected or opts.adopting_to_new_document); var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(child, .{}); while (tw.next()) |el| { diff --git a/src/browser/tests/custom_elements/adopted.html b/src/browser/tests/custom_elements/adopted.html new file mode 100644 index 00000000..80dbc72d --- /dev/null +++ b/src/browser/tests/custom_elements/adopted.html @@ -0,0 +1,48 @@ + + + + + diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 593d9f2b..1c69fae4 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -241,12 +241,16 @@ pub fn appendChild(self: *Node, child: *Node, frame: *Frame) !*Node { if (child._parent) |parent| { // we can signal removeNode that the child will remain connected // (when it's appended to self) so that it can be a bit more efficient. - frame.removeNode(parent, child, .{ .will_be_reconnected = self.isConnected() }); + // But on cross-document moves the child must fully disconnect from the + // source document (firing disconnectedCallback) before adoption. + frame.removeNode(parent, child, .{ + .will_be_reconnected = self.isConnected() and !adopting_to_new_document, + }); } // Adopt the node tree if moving between documents if (adopting_to_new_document) { - try frame.adoptNodeTree(child, parent_owner); + try frame.adoptNodeTree(child, child_owner.?, parent_owner); } try frame.appendNode(self, child, .{ @@ -591,14 +595,14 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, frame: *Fra const adopting_to_new_document = child_owner != null and child_owner.? != parent_owner; frame.domChanged(); - const will_be_reconnected = self.isConnected(); + const will_be_reconnected = self.isConnected() and !adopting_to_new_document; if (new_node._parent) |parent| { frame.removeNode(parent, new_node, .{ .will_be_reconnected = will_be_reconnected }); } // Adopt the node tree if moving between documents if (adopting_to_new_document) { - try frame.adoptNodeTree(new_node, parent_owner); + try frame.adoptNodeTree(new_node, child_owner.?, parent_owner); } try frame.insertNodeRelative( diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 0eb4cb19..58d63f63 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -293,42 +293,15 @@ pub fn insertAdjacentHTML( html: []const u8, frame: *Frame, ) !void { - - // Create a new HTMLDocument. - const doc = try frame._factory.document(@import("../HTMLDocument.zig"){ - ._proto = undefined, - }); - const doc_node = doc.asNode(); - - const arena = try frame.getArena(.medium, "HTML.insertAdjacentHTML"); - defer frame.releaseArena(arena); - - const Parser = @import("../../parser/Parser.zig"); - var parser = Parser.init(arena, doc_node, frame); - parser.parse(html); - - // Check if there's parsing error. - if (parser.err) |_| { - return error.Invalid; - } - - // The parser wraps content in a document structure: - // - Typical: ...... - // - Head-only: (no body) - // - Empty/comments: May have no element at all - const html_node = doc_node.firstChild() orelse return; + const DocumentFragment = @import("../DocumentFragment.zig"); + const fragment = (try DocumentFragment.init(frame)).asNode(); + try frame.parseHtmlAsChildren(fragment, html); const target_node, const prev_node = try self.asElement().asNode().findAdjacentNodes(position); - // Iterate through all children of (typically and/or ) - // and insert their children (not the containers themselves) into the target. - // This handles both body content AND head-only elements like , , etc. - var html_children = html_node.childrenIterator(); - while (html_children.next()) |container| { - var iter = container.childrenIterator(); - while (iter.next()) |child_node| { - _ = try target_node.insertBefore(child_node, prev_node, frame); - } + var iter = fragment.childrenIterator(); + while (iter.next()) |child_node| { + _ = try target_node.insertBefore(child_node, prev_node, frame); } } diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index ed8fd58e..630312f9 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -24,6 +24,7 @@ const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); +const Document = @import("../../Document.zig"); const HtmlElement = @import("../Html.zig"); const CustomElementDefinition = @import("../../CustomElementDefinition.zig"); @@ -74,6 +75,19 @@ pub fn invokeAttributeChangedCallback(self: *Custom, name: String, old_value: ?S self.invokeCallback("attributeChangedCallback", .{ name, old_value, new_value, namespace }, frame); } +pub fn invokeAdoptedCallback(self: *Custom, old_document: *Document, new_document: *Document, frame: *Frame) void { + self.invokeCallback("adoptedCallback", .{ old_document, new_document }, frame); +} + +pub fn invokeAdoptedCallbackOnElement(element: *Element, old_document: *Document, new_document: *Document, frame: *Frame) void { + if (element.is(Custom)) |custom| { + custom.invokeAdoptedCallback(old_document, new_document, frame); + return; + } + const definition = frame.getCustomizedBuiltInDefinition(element) orelse return; + invokeCallbackOnElement(element, definition, "adoptedCallback", .{ old_document, new_document }, frame); +} + pub fn invokeConnectedCallbackOnElement(comptime from_parser: bool, element: *Element, frame: *Frame) !void { // Autonomous custom element if (element.is(Custom)) |custom| {