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);
+ }
}
}