diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 6adba3bbf..de20542e2 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -2558,14 +2558,13 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No } } - // The parser path does its own (limited) notification and connected-callback - // work, then returns. + // The parser path does its own (limited) connected-callback work, then + // returns. if (comptime from_parser) { - // Of the parser insertions, only fragment parses (innerHTML) mutate a - // live tree; the initial document parse suppresses notifications. - if (self._parse_mode == .fragment) { - self.notifyChildInserted(parent, child); - } + // No mutation records from parser insertions: the initial document + // parse never notifies, and fragment parses (innerHTML et al.) queue + // one combined "replace all" record at the call site (Node.setHTML) + // instead of one per inserted child. if (child.is(Element)) |el| { // Invoke connectedCallback for custom elements during parsing. @@ -2821,21 +2820,11 @@ fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, opts: F } node._children = first._children; - if (observers.hasMutationObservers(self)) { - var it = node.childrenIterator(); - while (it.next()) |child| { - child._parent = node; - // Notify mutation observers for each unwrapped child - const previous_sibling = child.previousSibling(); - const next_sibling = child.nextSibling(); - const added = [_]*Node{child}; - observers.notifyChildListChange(self, node, &added, &.{}, previous_sibling, next_sibling); - } - } else { - var it = node.childrenIterator(); - while (it.next()) |child| { - child._parent = node; - } + // No mutation records for the unwrapped children either; see the comment + // about fragment parses in _insertNodeRelative. + var it = node.childrenIterator(); + while (it.next()) |child| { + child._parent = node; } } diff --git a/src/browser/tests/mutation_observer/childlist.html b/src/browser/tests/mutation_observer/childlist.html index 77decbe08..ea93718f3 100644 --- a/src/browser/tests/mutation_observer/childlist.html +++ b/src/browser/tests/mutation_observer/childlist.html @@ -269,41 +269,18 @@ innerHtmlParent.innerHTML = 'New 1New 2New 3'; await state.done((mutations) => { - // innerHTML triggers mutations for both removals and additions - // With tri-state: from_parser=true + parse_mode=fragment -> mutations fire - // HTML wrapper element is filtered out, so: 3 removals + 3 additions = 6 - testing.expectEqual(6, mutations.length); + // Per the "replace all" algorithm, innerHTML queues a single mutation + // record combining all removed and added nodes. + testing.expectEqual(1, mutations.length); - // First 3: removals testing.expectEqual('childList', mutations[0].type); - testing.expectEqual(1, mutations[0].removedNodes.length); + testing.expectEqual(3, mutations[0].removedNodes.length); testing.expectEqual(oldChildren[0], mutations[0].removedNodes[0]); - testing.expectEqual(0, mutations[0].addedNodes.length); - - testing.expectEqual('childList', mutations[1].type); - testing.expectEqual(1, mutations[1].removedNodes.length); - testing.expectEqual(oldChildren[1], mutations[1].removedNodes[0]); - testing.expectEqual(0, mutations[1].addedNodes.length); - - testing.expectEqual('childList', mutations[2].type); - testing.expectEqual(1, mutations[2].removedNodes.length); - testing.expectEqual(oldChildren[2], mutations[2].removedNodes[0]); - testing.expectEqual(0, mutations[2].addedNodes.length); - - // Last 3: additions (unwrapped span elements) - testing.expectEqual('childList', mutations[3].type); - testing.expectEqual(0, mutations[3].removedNodes.length); - testing.expectEqual(1, mutations[3].addedNodes.length); - testing.expectEqual('SPAN', mutations[3].addedNodes[0].nodeName); - - testing.expectEqual('childList', mutations[4].type); - testing.expectEqual(0, mutations[4].removedNodes.length); - testing.expectEqual(1, mutations[4].addedNodes.length); - testing.expectEqual('SPAN', mutations[4].addedNodes[0].nodeName); - - testing.expectEqual('childList', mutations[5].type); - testing.expectEqual(0, mutations[5].removedNodes.length); - testing.expectEqual(1, mutations[5].addedNodes.length); - testing.expectEqual('SPAN', mutations[5].addedNodes[0].nodeName); + testing.expectEqual(oldChildren[1], mutations[0].removedNodes[1]); + testing.expectEqual(oldChildren[2], mutations[0].removedNodes[2]); + testing.expectEqual(3, mutations[0].addedNodes.length); + testing.expectEqual('SPAN', mutations[0].addedNodes[0].nodeName); + testing.expectEqual('SPAN', mutations[0].addedNodes[1].nodeName); + testing.expectEqual('SPAN', mutations[0].addedNodes[2].nodeName); }); diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index aeee38879..efcc7aaff 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -472,13 +472,34 @@ pub fn setOuterHTML(self: *Element, html: []const u8, frame: *Frame) !void { } frame.domChanged(); + + // Observers of the parent must see a single mutation record replacing + // this node with the parsed nodes. + const notify = Frame.observers.hasMutationObservers(frame); + const previous_sibling = node.previousSibling(); + const next_sibling = node.nextSibling(); + var added: std.ArrayList(*Node) = .empty; + if (html.len > 0) { const fragment = (try Node.DocumentFragment.init(frame)).asNode(); try frame.parseHtmlAsChildren(fragment, html); - try frame.insertAllChildrenBefore(fragment, parent, node); + const dest_connected = parent.isConnected(); + var it = fragment.childrenIterator(); + while (it.next()) |child| { + if (notify) { + try added.append(frame.call_arena, child); + } + frame.removeNode(fragment, child, .{ .will_be_reconnected = dest_connected, .notify_observers = false }); + try frame.insertNodeRelative(parent, child, .{ .before = node }, .{ .notify_observers = false }); + } } - frame.removeNode(parent, node, .{ .will_be_reconnected = false }); + frame.removeNode(parent, node, .{ .will_be_reconnected = false, .notify_observers = false }); + + if (notify) { + const removed = [_]*Node{node}; + Frame.observers.notifyChildListChange(frame, parent, added.items, &removed, previous_sibling, next_sibling); + } } pub fn getInnerHTML(self: *Element, writer: *std.Io.Writer, frame: *Frame) !void { diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index bbe5d1f9e..c5d2d3a26 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -1316,19 +1316,38 @@ pub fn replaceChildren(self: *Node, nodes: []const NodeOrText, frame: *Frame) !v /// Shared implementation in Element and DocumentFragment pub fn setHTML(self: *Node, html: []const u8, allow_declarative_shadow: bool, frame: *Frame) !void { frame.domChanged(); + + // Observers of this subtree get one combined "replace all" mutation + // record; per-node notification is suppressed for the removals here and + // for the parser insertions (fragment parsing never notifies). + const notify = Frame.observers.hasMutationObservers(frame); + var removed: std.ArrayList(*Node) = .empty; + var it = self.childrenIterator(); while (it.next()) |child| { - frame.removeNode(self, child, .{ .will_be_reconnected = false }); + if (notify) { + try removed.append(frame.call_arena, child); + } + frame.removeNode(self, child, .{ .will_be_reconnected = false, .notify_observers = false }); } - if (html.len == 0) { - return; + if (html.len > 0) { + if (allow_declarative_shadow) { + try frame.parseHtmlUnsafeAsChildren(self, html); + } else { + try frame.parseHtmlAsChildren(self, html); + } } - if (allow_declarative_shadow) { - try frame.parseHtmlUnsafeAsChildren(self, html); - } else { - try frame.parseHtmlAsChildren(self, html); + if (notify) { + var added: std.ArrayList(*Node) = .empty; + var child_it = self.childrenIterator(); + while (child_it.next()) |child| { + try added.append(frame.call_arena, child); + } + if (removed.items.len > 0 or added.items.len > 0) { + Frame.observers.notifyChildListChange(frame, self, added.items, removed.items, null, null); + } } }