From ad9bbef2b8cc15f93e8aed835705acbf37eae4dc Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 23 Jun 2026 08:12:29 +0800 Subject: [PATCH] webapi: moveBefore Add Element/DocuemntFragment/Document moveBefore. The API is relatively complicated compared to other node-moving APIs, so there are still some edge cases. But this implementation should be at a level where having the API fixes more things than it breaks. --- src/browser/CustomElementReactions.zig | 5 + src/browser/tests/node/move_before.html | 109 +++++++++++++++++++++ src/browser/webapi/Document.zig | 5 + src/browser/webapi/DocumentFragment.zig | 5 + src/browser/webapi/Element.zig | 5 + src/browser/webapi/Node.zig | 93 ++++++++++++++++++ src/browser/webapi/element/html/Custom.zig | 52 +++++++--- 7 files changed, 261 insertions(+), 13 deletions(-) create mode 100644 src/browser/tests/node/move_before.html diff --git a/src/browser/CustomElementReactions.zig b/src/browser/CustomElementReactions.zig index b14d77b31..79ef08090 100644 --- a/src/browser/CustomElementReactions.zig +++ b/src/browser/CustomElementReactions.zig @@ -110,6 +110,10 @@ pub fn enqueueConnected(self: *Self, frame: *Frame, element: *Element) !void { try self.route(frame, .{ .connected = element }); } +pub fn enqueueMove(self: *Self, frame: *Frame, element: *Element) !void { + try self.route(frame, .{ .move = element }); +} + pub fn enqueueDisconnected(self: *Self, frame: *Frame, element: *Element) !void { try self.route(frame, .{ .disconnected = element }); } @@ -143,6 +147,7 @@ pub fn enqueueAttributeChanged( pub const Reaction = union(enum) { connected: *Element, disconnected: *Element, + move: *Element, adopted: Adopted, attribute_changed: AttributeChanged, diff --git a/src/browser/tests/node/move_before.html b/src/browser/tests/node/move_before.html new file mode 100644 index 000000000..4f005b6e6 --- /dev/null +++ b/src/browser/tests/node/move_before.html @@ -0,0 +1,109 @@ + + +
+
+
+
+
+ + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 07cb80f6c..0b8dc17e5 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -619,6 +619,10 @@ pub fn replaceChildren(self: *Document, nodes: []const Node.NodeOrText, frame: * return self.asNode().replaceChildren(nodes, frame); } +pub fn moveBefore(self: *Document, node: js.Value, child: js.Value, frame: *Frame) !void { + return self.asNode().moveBefore(node, child, frame); +} + pub fn elementFromPoint(self: *Document, x: f64, y: f64, frame: *Frame) !?*Element { // DFS in document order; topmost = last visited element whose rect contains (x, y). // @@ -1213,6 +1217,7 @@ pub const JsApi = struct { pub const importNode = bridge.function(Document.importNode, .{ .dom_exception = true, .ce_reactions = true }); pub const append = bridge.function(Document.append, .{ .dom_exception = true, .ce_reactions = true }); pub const prepend = bridge.function(Document.prepend, .{ .dom_exception = true, .ce_reactions = true }); + pub const moveBefore = bridge.function(Document.moveBefore, .{ .dom_exception = true, .ce_reactions = true }); pub const replaceChildren = bridge.function(Document.replaceChildren, .{ .dom_exception = true, .ce_reactions = true }); pub const elementFromPoint = bridge.function(Document.elementFromPoint, .{}); pub const elementsFromPoint = bridge.function(Document.elementsFromPoint, .{}); diff --git a/src/browser/webapi/DocumentFragment.zig b/src/browser/webapi/DocumentFragment.zig index 313ceec9b..da09e9bbc 100644 --- a/src/browser/webapi/DocumentFragment.zig +++ b/src/browser/webapi/DocumentFragment.zig @@ -146,6 +146,10 @@ pub fn replaceChildren(self: *DocumentFragment, nodes: []const Node.NodeOrText, return self.asNode().replaceChildren(nodes, frame); } +pub fn moveBefore(self: *DocumentFragment, node: js.Value, child: js.Value, frame: *Frame) !void { + return self.asNode().moveBefore(node, child, frame); +} + pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, frame: *Frame) !void { const dump = @import("../dump.zig"); return dump.children(self.asNode(), .{ .shadow = .complete }, writer, frame); @@ -213,6 +217,7 @@ pub const JsApi = struct { pub const lastElementChild = bridge.accessor(DocumentFragment.lastElementChild, null, .{}); pub const append = bridge.function(DocumentFragment.append, .{ .dom_exception = true, .ce_reactions = true }); pub const prepend = bridge.function(DocumentFragment.prepend, .{ .dom_exception = true, .ce_reactions = true }); + pub const moveBefore = bridge.function(DocumentFragment.moveBefore, .{ .dom_exception = true, .ce_reactions = true }); pub const replaceChildren = bridge.function(DocumentFragment.replaceChildren, .{ .dom_exception = true, .ce_reactions = true }); pub const innerHTML = bridge.accessor(_getInnerHTML, _setInnerHTML, .{ .ce_reactions = true }); diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 42f00be7b..4fdeb7df8 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1045,6 +1045,10 @@ pub fn prepend(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !v } } +pub fn moveBefore(self: *Element, node: js.Value, child: js.Value, frame: *Frame) !void { + return self.asNode().moveBefore(node, child, frame); +} + pub fn before(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !void { const node = self.asNode(); const parent = node.parentNode() orelse return; @@ -2014,6 +2018,7 @@ pub const JsApi = struct { pub const remove = bridge.function(Element.remove, .{ .ce_reactions = true }); pub const append = bridge.function(Element.append, .{ .dom_exception = true, .ce_reactions = true }); pub const prepend = bridge.function(Element.prepend, .{ .dom_exception = true, .ce_reactions = true }); + pub const moveBefore = bridge.function(Element.moveBefore, .{ .dom_exception = true, .ce_reactions = true }); pub const before = bridge.function(Element.before, .{ .dom_exception = true, .ce_reactions = true }); pub const after = bridge.function(Element.after, .{ .dom_exception = true, .ce_reactions = true }); pub const firstElementChild = bridge.accessor(Element.firstElementChild, null, .{}); diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index b61bcda0c..af1e4395c 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -692,6 +692,99 @@ pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, frame: *Fra return old_child; } +// `node` and `child` are taken as raw js.Values rather than `*Node`/`?*Node` +// because both must be present, and `child` is nullable. +pub fn moveBefore(self: *Node, node_val: js.Value, child_val: js.Value, frame: *Frame) !void { + const node = try node_val.toZig(*Node); + const child: ?*Node = if (child_val.isNullOrUndefined()) null else try child_val.toZig(*Node); + + // parent must be a Document, DocumentFragment, or Element node. + switch (self._type) { + .document, .document_fragment, .element => {}, + else => return error.HierarchyError, + } + + if (node.contains(self)) { + return error.HierarchyError; + } + + if (self.getRootNode(.{ .composed = true }) != node.getRootNode(.{ .composed = true })) { + return error.HierarchyError; + } + + // node must be an Element or a CharacterData node. + switch (node._type) { + .element, .cdata => {}, + else => return error.HierarchyError, + } + + if (self._type == .document) { + switch (node._type) { + .cdata => |cd| { + if (cd._type == .text) { + // A Text node cannot be a child of a document. + return error.HierarchyError; + } + }, + .element => { + var it = self.childrenIterator(); + while (it.next()) |existing| { + if (existing._type == .element and existing != node) { + // A document can have at most one element child. + return error.HierarchyError; + } + } + }, + else => {}, + } + } + + if (child) |c| { + if (c._parent != self) { + // If child is non-null, its parent must be parent. + return error.NotFound; + } + } + + // Moving a node before itself is a relative no-op: the reference child + // becomes the node's own next sibling. + var ref = child; + if (ref) |r| { + if (r == node) { + ref = node.nextSibling(); + } + } + + frame.domChanged(); + + // selfand node share a root, so the connectedness won't change. This API + // should appear atomic as much as possible. We can skip the id-map + // management (because it won't change) and custom elements shouldn't fire + // disconnect/connected callbacks. But MutationObservers and ranges still + // fire + const connected = node.isConnected(); + + if (node._parent) |old_parent| { + frame.removeNode(old_parent, node, .{ .will_be_reconnected = connected }); + } + + if (ref) |r| { + try frame.insertNodeRelative(self, node, .{ .before = r }, .{ .child_already_connected = connected }); + } else { + try frame.appendNode(self, node, .{ .child_already_connected = connected }); + } + + if (connected) { + // Enqueue on a move callback (if we're connected) for any nested + // custom element + const TreeWalker = @import("TreeWalker.zig"); + var tw = TreeWalker.Full.Elements.init(node, .{}); + while (tw.next()) |el| { + Element.Html.Custom.enqueueMoveCallbackOnElement(el, frame); + } + } +} + pub fn getNodeValue(self: *const Node) ?String { return switch (self._type) { .cdata => |c| c.getData(), diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index 1e9130777..8880195c2 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); @@ -132,6 +133,20 @@ pub fn enqueueDisconnectedCallbackOnElement(element: *Element, frame: *Frame) vo }; } +// Enqueues an atomic-move reaction (moveBefore). Unlike connect/disconnect there +// is no dedup state to flip: a move always fires, and the element's connected +// state is unchanged by the move. +pub fn enqueueMoveCallbackOnElement(element: *Element, frame: *Frame) void { + if (element.is(Custom)) |custom| { + if (custom._definition == null) return; + } else { + if (frame.getCustomizedBuiltInDefinition(element) == null) return; + } + frame._ce_reactions.enqueueMove(frame, element) catch |err| { + log.warn(.bug, "ce_reactions enqueue fail", .{ .err = err }); + }; +} + pub fn enqueueAdoptedCallbackOnElement(element: *Element, old_document: *Document, new_document: *Document, frame: *Frame) void { if (element.is(Custom)) |custom| { if (custom._definition == null) return; @@ -163,37 +178,36 @@ pub fn fireReaction(reaction: Reaction, frame: *Frame) void { .connected => |el| { if (el.is(Custom)) |custom| { custom.invokeCallback("connectedCallback", .{}, frame); - } else if (frame.getCustomizedBuiltInDefinition(el)) |definition| { - invokeCallbackOnElement(el, definition, "connectedCallback", .{}, frame); + } else if (frame.getCustomizedBuiltInDefinition(el)) |_| { + invokeCallbackOnElement(el, "connectedCallback", .{}, frame); } }, .disconnected => |el| { if (el.is(Custom)) |custom| { custom.invokeCallback("disconnectedCallback", .{}, frame); - } else if (frame.getCustomizedBuiltInDefinition(el)) |definition| { - invokeCallbackOnElement(el, definition, "disconnectedCallback", .{}, frame); + } else if (frame.getCustomizedBuiltInDefinition(el)) |_| { + invokeCallbackOnElement(el, "disconnectedCallback", .{}, frame); } }, .adopted => |a| { if (a.element.is(Custom)) |custom| { custom.invokeCallback("adoptedCallback", .{ a.old_document, a.new_document }, frame); - } else if (frame.getCustomizedBuiltInDefinition(a.element)) |definition| { - invokeCallbackOnElement(a.element, definition, "adoptedCallback", .{ a.old_document, a.new_document }, frame); + } else if (frame.getCustomizedBuiltInDefinition(a.element)) |_| { + invokeCallbackOnElement(a.element, "adoptedCallback", .{ a.old_document, a.new_document }, frame); } }, .attribute_changed => |a| { if (a.element.is(Custom)) |custom| { custom.invokeCallback("attributeChangedCallback", .{ a.name, a.old_value, a.new_value, a.namespace }, frame); - } else if (frame.getCustomizedBuiltInDefinition(a.element)) |definition| { - invokeCallbackOnElement(a.element, definition, "attributeChangedCallback", .{ a.name, a.old_value, a.new_value, a.namespace }, frame); + } else if (frame.getCustomizedBuiltInDefinition(a.element)) |_| { + invokeCallbackOnElement(a.element, "attributeChangedCallback", .{ a.name, a.old_value, a.new_value, a.namespace }, frame); } }, + .move => |el| invokeCallbackOnElement(el, "move", .{}, frame), } } -fn invokeCallbackOnElement(element: *Element, definition: *CustomElementDefinition, comptime callback_name: [:0]const u8, args: anytype, frame: *Frame) void { - _ = definition; - +fn invokeCallbackOnElement(element: *Element, comptime callback_name: [:0]const u8, args: anytype, frame: *Frame) void { var ls: js.Local.Scope = undefined; frame.js.localScope(&ls); defer ls.deinit(); @@ -202,8 +216,20 @@ fn invokeCallbackOnElement(element: *Element, definition: *CustomElementDefiniti const js_val = ls.local.zigValueToJs(element, .{}) catch return; const js_element = js_val.toObject(); - // Call the callback method if it exists - js_element.callMethod(void, callback_name, args) catch return; + if (comptime std.mem.eql(u8, callback_name, "move") == false) { + // Call the callback method if it exists + js_element.callMethod(void, callback_name, args) catch {}; + return; + } + + // for "move", we call "connectedMoveCallback" if it exists, else we fallback + // to "disconnectedCallback" + "connectedCallback" + if (js_element.has("connectedMoveCallback")) { + js_element.callMethod(void, "connectedMoveCallback", .{}) catch return; + } else { + js_element.callMethod(void, "disconnectedCallback", .{}) catch return; + js_element.callMethod(void, "connectedCallback", .{}) catch return; + } } // Check if element has "is" attribute and attach customized built-in definition