From d23453ce454c280e26393d93f42b2a2d0bbbc181 Mon Sep 17 00:00:00 2001 From: egrs Date: Tue, 10 Mar 2026 19:59:04 +0100 Subject: [PATCH] update live ranges after CharacterData and DOM mutations Per DOM spec, all live ranges must have their boundary offsets updated when CharacterData content changes (insertData, deleteData, replaceData, splitText) or when nodes are inserted/removed from the tree. Track live ranges via an intrusive linked list on Page. After each mutation, iterate and adjust start/end offsets per the spec algorithms. Also fix Range.deleteContents loop that read _end_offset on each iteration (now decremented by the range update), and Range.insertNode that double-incremented _end_offset for non-collapsed ranges. Route textContent, nodeValue, and data setters through replaceData so range updates fire consistently. Fixes 9 WPT test files (all now 100%): Range-mutations-insertData, deleteData, replaceData, splitText, appendChild, insertBefore, removeChild, appendData, dataChange (~1330 new passing subtests). --- src/browser/Factory.zig | 6 +- src/browser/Page.zig | 145 ++++++++++++ src/browser/tests/range_mutations.html | 315 +++++++++++++++++++++++++ src/browser/webapi/AbstractRange.zig | 3 + src/browser/webapi/CData.zig | 30 ++- src/browser/webapi/Node.zig | 9 +- src/browser/webapi/Range.zig | 24 +- src/browser/webapi/cdata/Text.zig | 18 +- 8 files changed, 528 insertions(+), 22 deletions(-) create mode 100644 src/browser/tests/range_mutations.html diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index cbc2170d..b0da7a81 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -273,14 +273,16 @@ pub fn abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(chil const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(allocator); const doc = page.document.asNode(); - chain.set(0, AbstractRange{ + const abstract_range = chain.get(0); + abstract_range.* = AbstractRange{ ._type = unionInit(AbstractRange.Type, chain.get(1)), ._end_offset = 0, ._start_offset = 0, ._end_container = doc, ._start_container = doc, - }); + }; chain.setLeaf(1, child); + page._live_ranges.append(&abstract_range._range_link); return chain.get(1); } diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 014ebb62..95433ce1 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -54,6 +54,7 @@ const Performance = @import("webapi/Performance.zig"); const Screen = @import("webapi/Screen.zig"); const VisualViewport = @import("webapi/VisualViewport.zig"); const PerformanceObserver = @import("webapi/PerformanceObserver.zig"); +const AbstractRange = @import("webapi/AbstractRange.zig"); const MutationObserver = @import("webapi/MutationObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); @@ -143,6 +144,9 @@ _to_load: std.ArrayList(*Element.Html) = .{}, _script_manager: ScriptManager, +// List of active live ranges (for mutation updates per DOM spec) +_live_ranges: std.DoublyLinkedList = .{}, + // List of active MutationObservers _mutation_observers: std.DoublyLinkedList = .{}, _mutation_delivery_scheduled: bool = false, @@ -2434,6 +2438,12 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts const previous_sibling = child.previousSibling(); const next_sibling = child.nextSibling(); + // Capture child's index before removal for live range updates (DOM spec remove steps 4-7) + const child_index_for_ranges: ?u32 = if (self._live_ranges.first != null) + parent.getChildIndex(child) + else + null; + const children = parent._children.?; switch (children.*) { .one => |n| { @@ -2462,6 +2472,11 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts child._parent = null; child._child_link = .{}; + // Update live ranges for removal (DOM spec remove steps 4-7) + if (child_index_for_ranges) |idx| { + self.updateRangesForNodeRemoval(parent, child, idx); + } + // Handle slot assignment removal before mutation observers if (child.is(Element)) |el| { // Check if the parent was a shadow host @@ -2609,6 +2624,21 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod } child._parent = parent; + // Update live ranges for insertion (DOM spec insert step 6). + // For .before/.after the child was inserted at a specific position; + // ranges on parent with offsets past that position must be incremented. + // For .append no range update is needed (spec: "if child is non-null"). + if (self._live_ranges.first != null) { + switch (relative) { + .append => {}, + .before, .after => { + if (parent.getChildIndex(child)) |idx| { + self.updateRangesForNodeInsertion(parent, idx); + } + }, + } + } + // Tri-state behavior for mutations: // 1. from_parser=true, parse_mode=document -> no mutations (initial document parse) // 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions) @@ -2867,6 +2897,121 @@ pub fn childListChange( } } +// --- Live range update methods (DOM spec §4.2.3, §4.2.4, §4.7, §4.8) --- + +/// Update all live ranges after a replaceData mutation on a CharacterData node. +/// Per DOM spec: insertData = replaceData(offset, 0, data), +/// deleteData = replaceData(offset, count, ""). +/// All parameters are in UTF-16 code unit offsets. +pub fn updateRangesForCharacterDataReplace(self: *Page, target: *Node, offset: u32, count: u32, data_len: u32) void { + var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; + while (it) |link| : (it = link.next) { + const ar: *AbstractRange = @fieldParentPtr("_range_link", link); + + if (ar._start_container == target) { + if (ar._start_offset > offset and ar._start_offset <= offset + count) { + ar._start_offset = offset; + } else if (ar._start_offset > offset + count) { + // Use i64 intermediate to avoid u32 underflow when count > data_len + ar._start_offset = @intCast(@as(i64, ar._start_offset) + @as(i64, data_len) - @as(i64, count)); + } + } + + if (ar._end_container == target) { + if (ar._end_offset > offset and ar._end_offset <= offset + count) { + ar._end_offset = offset; + } else if (ar._end_offset > offset + count) { + ar._end_offset = @intCast(@as(i64, ar._end_offset) + @as(i64, data_len) - @as(i64, count)); + } + } + } +} + +/// Update all live ranges after a splitText operation. +/// Steps 7b-7e of the DOM spec splitText algorithm. +/// Steps 7d-7e complement (not overlap) updateRangesForNodeInsertion: +/// the insert update handles offsets > child_index, while 7d/7e handle +/// offsets == node_index+1 (these are equal values but with > vs == checks). +pub fn updateRangesForSplitText(self: *Page, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void { + var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; + while (it) |link| : (it = link.next) { + const ar: *AbstractRange = @fieldParentPtr("_range_link", link); + + // Step 7b: ranges on the original node with start > offset move to new node + if (ar._start_container == target and ar._start_offset > offset) { + ar._start_container = new_node; + ar._start_offset = ar._start_offset - offset; + } + // Step 7c: ranges on the original node with end > offset move to new node + if (ar._end_container == target and ar._end_offset > offset) { + ar._end_container = new_node; + ar._end_offset = ar._end_offset - offset; + } + // Step 7d: ranges on parent with start == node_index + 1 increment + if (ar._start_container == parent and ar._start_offset == node_index + 1) { + ar._start_offset += 1; + } + // Step 7e: ranges on parent with end == node_index + 1 increment + if (ar._end_container == parent and ar._end_offset == node_index + 1) { + ar._end_offset += 1; + } + } +} + +/// Update all live ranges after a node insertion. +/// Per DOM spec insert algorithm step 6: only applies when inserting before a +/// non-null reference node. +pub fn updateRangesForNodeInsertion(self: *Page, parent: *Node, child_index: u32) void { + var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; + while (it) |link| : (it = link.next) { + const ar: *AbstractRange = @fieldParentPtr("_range_link", link); + + if (ar._start_container == parent and ar._start_offset > child_index) { + ar._start_offset += 1; + } + if (ar._end_container == parent and ar._end_offset > child_index) { + ar._end_offset += 1; + } + } +} + +/// Update all live ranges after a node removal. +/// Per DOM spec remove algorithm steps 4-7. +pub fn updateRangesForNodeRemoval(self: *Page, parent: *Node, child: *Node, child_index: u32) void { + var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; + while (it) |link| : (it = link.next) { + const ar: *AbstractRange = @fieldParentPtr("_range_link", link); + + // Steps 4-5: ranges whose start/end is an inclusive descendant of child + // get moved to (parent, child_index). + if (isInclusiveDescendantOf(ar._start_container, child)) { + ar._start_container = parent; + ar._start_offset = child_index; + } + if (isInclusiveDescendantOf(ar._end_container, child)) { + ar._end_container = parent; + ar._end_offset = child_index; + } + + // Steps 6-7: ranges on parent at offsets > child_index get decremented. + if (ar._start_container == parent and ar._start_offset > child_index) { + ar._start_offset -= 1; + } + if (ar._end_container == parent and ar._end_offset > child_index) { + ar._end_offset -= 1; + } + } +} + +fn isInclusiveDescendantOf(node: *Node, potential_ancestor: *Node) bool { + var current: ?*Node = node; + while (current) |n| { + if (n == potential_ancestor) return true; + current = n.parentNode(); + } + return false; +} + // TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '') pub fn parseHtmlAsChildren(self: *Page, node: *Node, html: []const u8) !void { const previous_parse_mode = self._parse_mode; diff --git a/src/browser/tests/range_mutations.html b/src/browser/tests/range_mutations.html new file mode 100644 index 00000000..3e4efc7e --- /dev/null +++ b/src/browser/tests/range_mutations.html @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/AbstractRange.zig b/src/browser/webapi/AbstractRange.zig index 5f3edc31..231c635d 100644 --- a/src/browser/webapi/AbstractRange.zig +++ b/src/browser/webapi/AbstractRange.zig @@ -33,6 +33,9 @@ _start_offset: u32, _end_container: *Node, _start_container: *Node, +// Intrusive linked list node for tracking live ranges on the Page. +_range_link: std.DoublyLinkedList.Node = .{}, + pub const Type = union(enum) { range: *Range, // TODO: static_range: *StaticRange, diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index 13e075ad..5a74f87d 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -37,7 +37,7 @@ _data: String = .empty, /// Count UTF-16 code units in a UTF-8 string. /// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair), /// everything else produces 1. -fn utf16Len(data: []const u8) usize { +pub fn utf16Len(data: []const u8) usize { var count: usize = 0; var i: usize = 0; while (i < data.len) { @@ -232,14 +232,13 @@ pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void { } /// JS bridge wrapper for `data` setter. -/// Handles [LegacyNullToEmptyString]: null → setData(null) → "". -/// Passes everything else (including undefined) through V8 toString, -/// so `undefined` becomes the string "undefined" per spec. +/// Per spec, setting .data runs replaceData(0, this.length, value), +/// which includes live range updates. +/// Handles [LegacyNullToEmptyString]: null → "" per spec. pub fn _setData(self: *CData, value: js.Value, page: *Page) !void { - if (value.isNull()) { - return self.setData(null, page); - } - return self.setData(try value.toZig([]const u8), page); + const new_value: []const u8 = if (value.isNull()) "" else try value.toZig([]const u8); + const length = self.getLength(); + try self.replaceData(0, length, new_value, page); } pub fn format(self: *const CData, writer: *std.io.Writer) !void { @@ -281,6 +280,11 @@ pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16); + // Update live ranges per DOM spec replaceData steps (deleteData = replaceData with data="") + const length = self.getLength(); + const effective_count: u32 = @intCast(@min(count, length - offset)); + page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, 0); + const old_data = self._data; const old_value = old_data.str(); if (range.start == 0) { @@ -299,6 +303,10 @@ pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void { const byte_offset = try utf16OffsetToUtf8(self._data.str(), offset); + + // Update live ranges per DOM spec replaceData steps (insertData = replaceData with count=0) + page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), 0, @intCast(utf16Len(data))); + const old_value = self._data; const existing = old_value.str(); self._data = try String.concat(page.arena, &.{ @@ -312,6 +320,12 @@ pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !v pub fn replaceData(self: *CData, offset: usize, count: usize, data: []const u8, page: *Page) !void { const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16); + + // Update live ranges per DOM spec replaceData steps + const length = self.getLength(); + const effective_count: u32 = @intCast(@min(count, length - offset)); + page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, @intCast(utf16Len(data))); + const old_value = self._data; const existing = old_value.str(); self._data = try String.concat(page.arena, &.{ diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 15541491..7bfa7cca 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -293,7 +293,8 @@ pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void { } return el.replaceChildren(&.{.{ .text = data }}, page); }, - .cdata => |c| c._data = try page.dupeSSO(data), + // Per spec, setting textContent on CharacterData runs replaceData(0, length, value) + .cdata => |c| try c.replaceData(0, c.getLength(), data, page), .document => {}, .document_type => {}, .document_fragment => |frag| { @@ -612,7 +613,11 @@ pub fn getNodeValue(self: *const Node) ?String { pub fn setNodeValue(self: *const Node, value: ?String, page: *Page) !void { switch (self._type) { - .cdata => |c| try c.setData(if (value) |v| v.str() else null, page), + // Per spec, setting nodeValue on CharacterData runs replaceData(0, length, value) + .cdata => |c| { + const new_value: []const u8 = if (value) |v| v.str() else ""; + try c.replaceData(0, c.getLength(), new_value, page); + }, .attribute => |attr| try attr.setValue(value, page), .element => {}, .document => {}, diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index f0c904a6..a5789a64 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -322,6 +322,11 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void { const container = self._proto._start_container; const offset = self._proto._start_offset; + // Per spec: if range is collapsed, end offset should extend to include + // the inserted node. Capture before insertion since live range updates + // in the insert path will adjust non-collapsed ranges automatically. + const was_collapsed = self._proto.getCollapsed(); + if (container.is(Node.CData)) |_| { // If container is a text node, we need to split it const parent = container.parentNode() orelse return error.InvalidNodeType; @@ -351,9 +356,10 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void { _ = try container.insertBefore(node, ref_child, page); } - // Update range to be after the inserted node - if (self._proto._start_container == self._proto._end_container) { - self._proto._end_offset += 1; + // Per spec step 11: if range was collapsed, extend end to include inserted node. + // Non-collapsed ranges are already handled by the live range update in the insert path. + if (was_collapsed and self._proto._start_container == self._proto._end_container) { + self._proto._end_offset = self._proto._start_offset + 1; } } @@ -375,9 +381,12 @@ pub fn deleteContents(self: *Range, page: *Page) !void { ); page.characterDataChange(self._proto._start_container, old_value); } else { - // Delete child nodes in range - var offset = self._proto._start_offset; - while (offset < self._proto._end_offset) : (offset += 1) { + // Delete child nodes in range. + // Capture count before the loop: removeChild triggers live range + // updates that decrement _end_offset on each removal. + const count = self._proto._end_offset - self._proto._start_offset; + var i: u32 = 0; + while (i < count) : (i += 1) { if (self._proto._start_container.getChildAt(self._proto._start_offset)) |child| { _ = try self._proto._start_container.removeChild(child, page); } @@ -717,3 +726,6 @@ const testing = @import("../../testing.zig"); test "WebApi: Range" { try testing.htmlRunner("range.html", .{}); } +test "WebApi: Range mutations" { + try testing.htmlRunner("range_mutations.html", .{}); +} diff --git a/src/browser/webapi/cdata/Text.zig b/src/browser/webapi/cdata/Text.zig index 5eb096f3..e7a338b5 100644 --- a/src/browser/webapi/cdata/Text.zig +++ b/src/browser/webapi/cdata/Text.zig @@ -43,16 +43,26 @@ pub fn splitText(self: *Text, offset: usize, page: *Page) !*Text { const new_node = try page.createTextNode(new_data); const new_text = new_node.as(Text); - const old_data = data[0..byte_offset]; - try self._proto.setData(old_data, page); - - // If this node has a parent, insert the new node right after this one const node = self._proto.asNode(); + + // Per DOM spec splitText: insert first (step 7a), then update ranges (7b-7e), + // then truncate original node (step 8). if (node.parentNode()) |parent| { const next_sibling = node.nextSibling(); _ = try parent.insertBefore(new_node, next_sibling, page); + + // splitText-specific range updates (steps 7b-7e) + if (parent.getChildIndex(node)) |node_index| { + page.updateRangesForSplitText(node, new_node, @intCast(offset), parent, node_index); + } } + // Step 8: truncate original node via replaceData(offset, count, ""). + // Use replaceData instead of setData so live range updates fire + // (matters for detached text nodes where steps 7b-7e were skipped). + const length = self._proto.getLength(); + try self._proto.replaceData(offset, length - offset, "", page); + return new_text; }