webapi: spec-conformant script insertion/execution steps

Fixes 10 failing files under /dom/nodes/insertion-removing-steps/
(5/20 -> 15/20 files passing), which pin down when dynamically inserted
scripts execute:

- Scripts (and iframes/links/styles) appended to a DETACHED parent no
  longer activate; the ready work is gated on the node being connected,
  and runs later if the subtree gets inserted into the document.
- Inserting a subtree runs the ready work for its descendant elements
  too, in tree order (nodeIsReadySubtree), after the node is in place.
- Multi-node insertions (fragments, and ParentNode.append/prepend with
  several arguments, which now convert to a fragment per spec) run all
  the ready work only after every node is inserted: an earlier script
  observes its later siblings connected, and can remove one to prevent
  it from running.
- Inserting into a not-yet-started connected script runs the script's
  children-changed steps (once, with the full content) before the
  inserted nodes' own ready work, per whatwg/html#10188: the outer
  script executes before an inner one.
- Mutation records queue before any script runs, so an inserted script
  observes its own insertion record via takeRecords. The script
  manager's pre-execution microtask drain now only happens at a real
  checkpoint (empty JS stack): a script executed synchronously from a
  running script must not flush the queue mid-task.

The remaining files in the directory need meta-referrer /
meta-default-style / media <source> insertion steps and style/iframe
load interleavings.

Coverage: /dom/nodes/insertion-removing-steps/: 15/20 files passing
(10 newly); /dom/nodes and /dom/events regressions clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-12 15:41:27 +02:00
parent 741a443d70
commit dd97f46ef8
4 changed files with 99 additions and 26 deletions

View File

@@ -2523,9 +2523,7 @@ pub fn moveAllChildren(self: *Frame, source: *Node, parent: *Node, ref_node: ?*N
var it = source.childrenIterator();
while (it.next()) |child| {
if (notify) {
try moved.append(self.call_arena, child);
}
try moved.append(self.call_arena, child);
const child_was_connected = child.isConnected();
self.removeNode(source, child, .{ .will_be_reconnected = dest_connected, .notify_observers = false });
if (ref_node) |ref| {
@@ -2533,10 +2531,10 @@ pub fn moveAllChildren(self: *Frame, source: *Node, parent: *Node, ref_node: ?*N
parent,
child,
.{ .before = ref },
.{ .child_already_connected = child_was_connected, .notify_observers = false },
.{ .child_already_connected = child_was_connected, .notify_observers = false, .run_ready = false },
);
} else {
try self.appendNode(parent, child, .{ .child_already_connected = child_was_connected, .notify_observers = false });
try self.appendNode(parent, child, .{ .child_already_connected = child_was_connected, .notify_observers = false, .run_ready = false });
}
}
@@ -2544,6 +2542,21 @@ pub fn moveAllChildren(self: *Frame, source: *Node, parent: *Node, ref_node: ?*N
observers.notifyChildListChange(self, source, &.{}, moved.items, null, null);
observers.notifyChildListChange(self, parent, moved.items, &.{}, previous_sibling, ref_node);
}
// Nodes moved into a not-yet-started script run the script's
// children-changed steps first (whatwg/html#10188), then each inserted
// node's own ready work runs, in tree order, with everything in place.
if (parent.isConnected()) {
if (parent.is(Element.Html.Script)) |script| {
if (!script._executed) {
try self.nodeIsReady(false, parent);
}
}
}
for (moved.items) |child| {
try self.nodeIsReadySubtree(child);
}
}
const InsertNodeRelative = union(enum) {
@@ -2557,6 +2570,10 @@ const InsertNodeOpts = struct {
// Set to false when the caller queues its own combined mutation record
// (e.g. replaceChildren's single "replace all" record).
notify_observers: bool = true,
// Set to false for multi-node insertions (fragments): the caller runs
// the ready work itself once every node is in place, so an earlier
// script observes its later siblings already inserted.
run_ready: bool = true,
};
pub fn insertNodeRelative(self: *Frame, parent: *Node, child: *Node, relative: InsertNodeRelative, opts: InsertNodeOpts) !void {
return self._insertNodeRelative(false, parent, child, relative, opts);
@@ -2639,12 +2656,16 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
const parent_is_connected = parent.isConnected();
// nodeIsReady resolves the node's owning frame itself (only for the few node
// types that have ready work), so pass the incumbent `self`.
try self.nodeIsReady(false, child);
// Mutation records queue synchronously at insertion, before any script
// runs: an inserted script can observe its own record via takeRecords.
if (opts.notify_observers) {
self.notifyChildInserted(parent, child);
}
// Check if text was added to a script that hasn't started yet.
if (child._type == .cdata and parent_is_connected) {
// Inserting into a not-yet-started script runs the script's
// children-changed steps first, then the inserted nodes' own ready work
// (whatwg/html#10188: the outer script executes before an inner one).
if (opts.run_ready and parent_is_connected) {
if (parent.is(Element.Html.Script)) |script| {
if (!script._executed) {
try self.nodeIsReady(false, parent);
@@ -2652,8 +2673,10 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
}
}
if (opts.notify_observers) {
self.notifyChildInserted(parent, child);
// nodeIsReady resolves the node's owning frame itself (only for the few node
// types that have ready work), so pass the incumbent `self`.
if (opts.run_ready) {
try self.nodeIsReadySubtree(child);
}
if (opts.child_already_connected and !opts.adopting_to_new_document) {
@@ -2885,6 +2908,19 @@ fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, opts: F
}
}
// Runs the "ready" work for an inserted node and, when it's an element with
// children, for its descendants in tree order: appending a subtree
// containing scripts must execute them all, after the whole insertion.
fn nodeIsReadySubtree(self: *Frame, node: *Node) !void {
if (node._type != .element or node.firstChild() == null) {
return self.nodeIsReady(false, node);
}
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(node, .{});
while (tw.next()) |el| {
try self.nodeIsReady(false, el.asNode());
}
}
fn nodeIsReady(self: *Frame, comptime from_parser: bool, node: *Node) !void {
if ((comptime from_parser) and self._parse_mode == .fragment) {
if (self._fragment_scripts_runnable == false) {
@@ -2906,6 +2942,16 @@ fn nodeIsReady(self: *Frame, comptime from_parser: bool, node: *Node) !void {
// walk, so we only do it once we've matched a node type that has ready work
// (the common text/element insertion does nothing here). The parser inserts
// into its own document, so from_parser always uses `self`.
// Scripts, iframes, links and styles activate on becoming connected;
// appending them to a detached parent does nothing (they run/load later
// if the subtree gets inserted into the document).
if (comptime from_parser == false) {
switch (node._type) {
.element => if (!node.isConnected()) return,
else => {},
}
}
if (node.is(Element.Html.Script)) |script| {
if ((comptime from_parser == false) and script._src.len == 0) {
// Script was added via JavaScript without a src attribute.

View File

@@ -872,8 +872,13 @@ pub const Script = struct {
const local = &ls.local;
// Per spec, trigger any microtasks BEFORE execution in case the parser
// (e.g. via event callbacks) queued anything.
local.runMicrotasks();
// (e.g. via event callbacks) queued anything. Only at a microtask
// checkpoint though (empty JS stack): a script executing because a
// running script inserted it must not drain the queue mid-task -
// e.g. its own insertion record must survive for takeRecords().
if (frame.js.call_depth == 0) {
local.runMicrotasks();
}
// Handle importmap special case here: the content is a JSON containing imports.
// Multiple <script type="importmap"> elements merge with first-wins semantics.

View File

@@ -1064,21 +1064,11 @@ pub fn getChildren(self: *Element, frame: *Frame) !collections.NodeLive(.child_e
}
pub fn append(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !void {
const parent = self.asNode();
for (nodes) |node_or_text| {
const child = try node_or_text.toNode(frame);
_ = try parent.appendChild(child, frame);
}
return self.asNode().appendNodes(nodes, frame);
}
pub fn prepend(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !void {
const parent = self.asNode();
var i = nodes.len;
while (i > 0) {
i -= 1;
const child = try nodes[i].toNode(frame);
_ = try parent.insertBefore(child, parent.firstChild(), frame);
}
return self.asNode().prependNodes(nodes, frame);
}
pub fn moveBefore(self: *Element, node: js.Value, child: js.Value, frame: *Frame) !void {

View File

@@ -1436,6 +1436,38 @@ pub fn getElementsByClassName(self: *Node, class_name: []const u8, frame: *Frame
/// Shared implementation of replaceChildren for Element, Document, and DocumentFragment.
/// Validates all nodes, removes existing children, then appends new children.
// ParentNode.append/prepend with several nodes convert them into a fragment
// first, so the insertion happens as one operation: an earlier script must
// observe its later siblings inserted (and can remove them before they run).
pub fn appendNodes(self: *Node, nodes: []const NodeOrText, frame: *Frame) !void {
if (nodes.len == 1) {
const child = try nodes[0].toNode(frame);
_ = try self.appendChild(child, frame);
return;
}
const fragment = (try DocumentFragment.init(frame)).asNode();
for (nodes) |node_or_text| {
const child = try node_or_text.toNode(frame);
_ = try fragment.appendChild(child, frame);
}
_ = try self.appendChild(fragment, frame);
}
pub fn prependNodes(self: *Node, nodes: []const NodeOrText, frame: *Frame) !void {
const reference = self.firstChild();
if (nodes.len == 1) {
const child = try nodes[0].toNode(frame);
_ = try self.insertBefore(child, reference, frame);
return;
}
const fragment = (try DocumentFragment.init(frame)).asNode();
for (nodes) |node_or_text| {
const child = try node_or_text.toNode(frame);
_ = try fragment.appendChild(child, frame);
}
_ = try self.insertBefore(fragment, reference, frame);
}
pub fn replaceChildren(self: *Node, nodes: []const NodeOrText, frame: *Frame) !void {
// First pass: validate all nodes and collect them
// We need to collect because DocumentFragments contribute their children, not themselves