webapi: innerHTML/outerHTML queue one combined mutation record

Fixes WPT /dom/nodes/MutationObserver-inner-outer.html (0/3 -> 3/3):
setting innerHTML must queue a single "replace all" mutation record
(all removed children + all parsed children), and setting outerHTML a
single record replacing the element with the parsed nodes. We emitted
one record per removed child plus one per inserted child.

- Node.setHTML suppresses per-node records (removals via the new
  notify_observers opt, insertions by making fragment parsing never
  notify) and queues the combined record itself. Parsing still targets
  the element directly so html5ever keeps the right fragment context
  (e.g. raw-text content of <script>).
- Fragment parsing no longer notifies observers per inserted child:
  every other fragment-parse target is a detached DocumentFragment
  where notification is a no-op.
- Element.setOuterHTML moves the parsed nodes in with notification
  suppressed and queues one record with removedNodes=[the element],
  addedNodes=parsed children and the element's siblings.
- The unit test mutation_observer/childlist.html expected the old
  6-record innerHTML behavior; updated to the spec's single record
  (matches Chrome).

Coverage: /dom/nodes/MutationObserver-inner-outer.html 0/3 -> 3/3
(fully green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-11 14:10:31 +02:00
parent 3a4bdaf4c6
commit ce06c1c5dd
4 changed files with 70 additions and 64 deletions

View File

@@ -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;
}
}

View File

@@ -269,41 +269,18 @@
innerHtmlParent.innerHTML = '<span>New 1</span><span>New 2</span><span>New 3</span>';
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);
});
</script>

View File

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

View File

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