webapi: replace-all queues one combined mutation record

Fixes WPT /dom/nodes/MutationObserver-textContent.html (1/4) and the
remaining failures of ParentNode-replaceChildren.html (25/29): the DOM
"replace all" algorithm (Element.textContent setter,
ParentNode.replaceChildren) must queue a single tree mutation record
with all removedNodes and addedNodes (and null previous/next sibling).
We emitted one record per removed child plus one per added child, so
observers saw 2+ records where the spec requires exactly one.

Frame.removeNode/appendNode gain a notify_observers opt (default true);
Node.replaceChildren suppresses the per-node records and queues the
combined record itself. Removing an added child from its previous
parent still notifies that parent's observers separately, as the spec
requires.

Coverage:
- /dom/nodes/MutationObserver-textContent.html 1/4 -> 4/4 (fully green)
- /dom/nodes/ParentNode-replaceChildren.html 25/29 -> 29/29 (fully green)
- /dom/nodes/MutationObserver-childList.html 29/38 -> 30/38

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-11 13:57:31 +02:00
parent a13df6113e
commit 3a4bdaf4c6
2 changed files with 25 additions and 4 deletions

View File

@@ -2364,6 +2364,9 @@ pub fn dupeSSO(self: *Frame, value: []const u8) !String {
const RemoveNodeOpts = struct {
will_be_reconnected: bool,
// Set to false when the caller queues its own combined mutation record
// (e.g. replaceChildren's single "replace all" record).
notify_observers: bool = true,
};
pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpts) void {
// Capture siblings before removing
@@ -2398,7 +2401,7 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt
slotting.removalSteps(parent, child, self);
if (observers.hasMutationObservers(self)) {
if (opts.notify_observers and observers.hasMutationObservers(self)) {
const removed = [_]*Node{child};
observers.notifyChildListChange(self, parent, &.{}, &removed, previous_sibling, next_sibling);
}
@@ -2497,6 +2500,9 @@ const InsertNodeRelative = union(enum) {
const InsertNodeOpts = struct {
child_already_connected: bool = false,
adopting_to_new_document: bool = false,
// Set to false when the caller queues its own combined mutation record
// (e.g. replaceChildren's single "replace all" record).
notify_observers: bool = true,
};
pub fn insertNodeRelative(self: *Frame, parent: *Node, child: *Node, relative: InsertNodeRelative, opts: InsertNodeOpts) !void {
return self._insertNodeRelative(false, parent, child, relative, opts);
@@ -2590,7 +2596,9 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
}
}
self.notifyChildInserted(parent, child);
if (opts.notify_observers) {
self.notifyChildInserted(parent, child);
}
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.

View File

@@ -1282,10 +1282,19 @@ pub fn replaceChildren(self: *Node, nodes: []const NodeOrText, frame: *Frame) !v
frame.domChanged();
// Per the "replace all" algorithm, observers get one combined mutation
// record with all removed and added nodes, so per-node notification is
// suppressed here.
const notify = Frame.observers.hasMutationObservers(frame);
var removed: std.ArrayList(*Node) = .empty;
// Remove all existing children
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 });
}
// Append new children
@@ -1296,7 +1305,11 @@ pub fn replaceChildren(self: *Node, nodes: []const NodeOrText, frame: *Frame) !v
child_connected = child.isConnected();
frame.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected });
}
try frame.appendNode(self, child, .{ .child_already_connected = child_connected });
try frame.appendNode(self, child, .{ .child_already_connected = child_connected, .notify_observers = false });
}
if (notify and (removed.items.len > 0 or children_to_add.items.len > 0)) {
Frame.observers.notifyChildListChange(frame, self, children_to_add.items, removed.items, null, null);
}
}