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.
This commit is contained in:
Karl Seguin
2026-04-24 17:26:39 +08:00
parent 2c4179f1ad
commit 757c70b6db
6 changed files with 94 additions and 43 deletions

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<body>
<script src="../testing.js"></script>
<script id="adopted">
{
let calls = [];
class MyElement extends HTMLElement {
connectedCallback() { calls.push('connected'); }
adoptedCallback(oldDoc, newDoc) {
calls.push('adopted');
calls.push(oldDoc === document);
calls.push(newDoc === otherDoc);
}
disconnectedCallback() { calls.push('disconnected'); }
}
customElements.define('my-adopted-element', MyElement);
const otherDoc = document.implementation.createHTMLDocument('other');
// Case 1: adopting a detached element into another document
{
const el = document.createElement('my-adopted-element');
calls = [];
otherDoc.body.appendChild(el);
testing.expectEqual('adopted,true,true,connected', calls.join(','));
}
// Case 2: moving a connected element into another document fires
// disconnected -> adopted -> connected in order.
{
const el = document.createElement('my-adopted-element');
document.body.appendChild(el);
calls = [];
otherDoc.body.appendChild(el);
testing.expectEqual('disconnected,adopted,true,true,connected', calls.join(','));
}
// Case 3: appending into the same document does NOT fire adopted.
{
const el = document.createElement('my-adopted-element');
calls = [];
document.body.appendChild(el);
testing.expectEqual('connected', calls.join(','));
}
}
</script>
</body>

View File

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

View File

@@ -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: <html><head>...</head><body>...</body></html>
// - Head-only: <html><head><meta></head></html> (no body)
// - Empty/comments: May have no <html> 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 <html> (typically <head> and/or <body>)
// and insert their children (not the containers themselves) into the target.
// This handles both body content AND head-only elements like <meta>, <title>, 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);
}
}

View File

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