From d23453ce454c280e26393d93f42b2a2d0bbbc181 Mon Sep 17 00:00:00 2001 From: egrs Date: Tue, 10 Mar 2026 19:59:04 +0100 Subject: [PATCH 1/7] 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; } From 7927ad8fcf7a833c1ff57ec763189faa9c3fdb02 Mon Sep 17 00:00:00 2001 From: egrs Date: Tue, 10 Mar 2026 20:27:05 +0100 Subject: [PATCH 2/7] route appendData through replaceData for spec compliance Per DOM spec, appendData(data) is defined as replaceData(length, 0, data). While the range update would be a no-op (offset=length, count=0), routing through replaceData ensures consistent code path and spec compliance. --- src/browser/webapi/CData.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index 5a74f87d..4fb6de6f 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -271,9 +271,9 @@ pub fn isEqualNode(self: *const CData, other: *const CData) bool { } pub fn appendData(self: *CData, data: []const u8, page: *Page) !void { - const old_value = self._data; - self._data = try String.concat(page.arena, &.{ self._data.str(), data }); - page.characterDataChange(self.asNode(), old_value); + // Per DOM spec, appendData(data) is replaceData(length, 0, data). + const length = self.getLength(); + try self.replaceData(length, 0, data, page); } pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void { From d2c55da6c95724cae61b836d0089adebf0ceda90 Mon Sep 17 00:00:00 2001 From: egrs Date: Wed, 11 Mar 2026 07:26:20 +0100 Subject: [PATCH 3/7] address review: move per-range logic to AbstractRange, simplify collapsed check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the per-range update logic from Page into AbstractRange methods (updateForCharacterDataReplace, updateForSplitText, updateForNodeInsertion, updateForNodeRemoval). Page now just iterates the list and delegates. Remove redundant start_container == end_container check in insertNode — collapsed already implies same container. --- src/browser/Page.zig | 75 ++---------------------- src/browser/webapi/AbstractRange.zig | 85 ++++++++++++++++++++++++++++ src/browser/webapi/Range.zig | 2 +- 3 files changed, 90 insertions(+), 72 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 95433ce1..d053f0ab 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -2907,23 +2907,7 @@ pub fn updateRangesForCharacterDataReplace(self: *Page, target: *Node, offset: u 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)); - } - } + ar.updateForCharacterDataReplace(target, offset, count, data_len); } } @@ -2936,25 +2920,7 @@ pub fn updateRangesForSplitText(self: *Page, target: *Node, new_node: *Node, off 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; - } + ar.updateForSplitText(target, new_node, offset, parent, node_index); } } @@ -2965,13 +2931,7 @@ pub fn updateRangesForNodeInsertion(self: *Page, parent: *Node, child_index: u32 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; - } + ar.updateForNodeInsertion(parent, child_index); } } @@ -2981,37 +2941,10 @@ pub fn updateRangesForNodeRemoval(self: *Page, parent: *Node, child: *Node, chil 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; - } + ar.updateForNodeRemoval(parent, child, child_index); } } -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/webapi/AbstractRange.zig b/src/browser/webapi/AbstractRange.zig index 231c635d..e766ac29 100644 --- a/src/browser/webapi/AbstractRange.zig +++ b/src/browser/webapi/AbstractRange.zig @@ -218,6 +218,91 @@ fn isInclusiveAncestorOf(potential_ancestor: *Node, node: *Node) bool { return isAncestorOf(potential_ancestor, node); } +/// Update this range's boundaries after a replaceData mutation on target. +/// All parameters are in UTF-16 code unit offsets. +pub fn updateForCharacterDataReplace(self: *AbstractRange, target: *Node, offset: u32, count: u32, data_len: u32) void { + if (self._start_container == target) { + if (self._start_offset > offset and self._start_offset <= offset + count) { + self._start_offset = offset; + } else if (self._start_offset > offset + count) { + // Use i64 intermediate to avoid u32 underflow when count > data_len + self._start_offset = @intCast(@as(i64, self._start_offset) + @as(i64, data_len) - @as(i64, count)); + } + } + + if (self._end_container == target) { + if (self._end_offset > offset and self._end_offset <= offset + count) { + self._end_offset = offset; + } else if (self._end_offset > offset + count) { + self._end_offset = @intCast(@as(i64, self._end_offset) + @as(i64, data_len) - @as(i64, count)); + } + } +} + +/// Update this range's boundaries after a splitText operation. +/// Steps 7b-7e of the DOM spec splitText algorithm. +pub fn updateForSplitText(self: *AbstractRange, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void { + // Step 7b: ranges on the original node with start > offset move to new node + if (self._start_container == target and self._start_offset > offset) { + self._start_container = new_node; + self._start_offset = self._start_offset - offset; + } + // Step 7c: ranges on the original node with end > offset move to new node + if (self._end_container == target and self._end_offset > offset) { + self._end_container = new_node; + self._end_offset = self._end_offset - offset; + } + // Step 7d: ranges on parent with start == node_index + 1 increment + if (self._start_container == parent and self._start_offset == node_index + 1) { + self._start_offset += 1; + } + // Step 7e: ranges on parent with end == node_index + 1 increment + if (self._end_container == parent and self._end_offset == node_index + 1) { + self._end_offset += 1; + } +} + +/// Update this range's boundaries after a node insertion. +pub fn updateForNodeInsertion(self: *AbstractRange, parent: *Node, child_index: u32) void { + if (self._start_container == parent and self._start_offset > child_index) { + self._start_offset += 1; + } + if (self._end_container == parent and self._end_offset > child_index) { + self._end_offset += 1; + } +} + +/// Update this range's boundaries after a node removal. +pub fn updateForNodeRemoval(self: *AbstractRange, parent: *Node, child: *Node, child_index: u32) void { + // Steps 4-5: ranges whose start/end is an inclusive descendant of child + // get moved to (parent, child_index). + if (isInclusiveDescendantOf(self._start_container, child)) { + self._start_container = parent; + self._start_offset = child_index; + } + if (isInclusiveDescendantOf(self._end_container, child)) { + self._end_container = parent; + self._end_offset = child_index; + } + + // Steps 6-7: ranges on parent at offsets > child_index get decremented. + if (self._start_container == parent and self._start_offset > child_index) { + self._start_offset -= 1; + } + if (self._end_container == parent and self._end_offset > child_index) { + self._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; +} + pub const JsApi = struct { pub const bridge = js.Bridge(AbstractRange); diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index a5789a64..66ce1c93 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -358,7 +358,7 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void { // 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) { + if (was_collapsed) { self._proto._end_offset = self._proto._start_offset + 1; } } From 625d424199340bd54ee13d97fb2d79e74d86451d Mon Sep 17 00:00:00 2001 From: egrs Date: Wed, 11 Mar 2026 07:27:39 +0100 Subject: [PATCH 4/7] remove ranges from live list on GC finalization Add a weak finalizer to Range that removes its linked list node from Page._live_ranges when V8 garbage-collects the JS Range object. This prevents the list from growing unboundedly and avoids iterating over stale entries during mutation updates. --- src/browser/webapi/Range.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index 66ce1c93..e3b8bc41 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -680,6 +680,10 @@ fn getContainerElement(self: *const Range) ?*Node.Element { return parent.is(Node.Element); } +pub fn deinit(self: *Range, _: bool, page: *Page) void { + page._live_ranges.remove(&self._proto._range_link); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Range); @@ -687,6 +691,8 @@ pub const JsApi = struct { pub const name = "Range"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(Range.deinit); }; // Constants for compareBoundaryPoints From 056b8bb53660883280c86439b90f93a7fb47df17 Mon Sep 17 00:00:00 2001 From: egrs Date: Wed, 11 Mar 2026 07:58:31 +0100 Subject: [PATCH 5/7] fix CI: store list pointer on AbstractRange to avoid Page type mismatch The bridge.finalizer resolves Page through its own module graph, which can differ from Range.zig's import in release builds. Store a pointer to the live_ranges list directly on AbstractRange so deinit can remove without accessing Page fields. --- src/browser/Factory.zig | 1 + src/browser/webapi/AbstractRange.zig | 1 + src/browser/webapi/Range.zig | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index b0da7a81..dbdc11ae 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -282,6 +282,7 @@ pub fn abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(chil ._start_container = doc, }; chain.setLeaf(1, child); + abstract_range._live_ranges = &page._live_ranges; page._live_ranges.append(&abstract_range._range_link); return chain.get(1); } diff --git a/src/browser/webapi/AbstractRange.zig b/src/browser/webapi/AbstractRange.zig index e766ac29..93bda1fa 100644 --- a/src/browser/webapi/AbstractRange.zig +++ b/src/browser/webapi/AbstractRange.zig @@ -35,6 +35,7 @@ _start_container: *Node, // Intrusive linked list node for tracking live ranges on the Page. _range_link: std.DoublyLinkedList.Node = .{}, +_live_ranges: *std.DoublyLinkedList = undefined, pub const Type = union(enum) { range: *Range, diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index e3b8bc41..213dea2a 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -680,8 +680,8 @@ fn getContainerElement(self: *const Range) ?*Node.Element { return parent.is(Node.Element); } -pub fn deinit(self: *Range, _: bool, page: *Page) void { - page._live_ranges.remove(&self._proto._range_link); +pub fn deinit(self: *Range, _: bool, _: *Page) void { + self._proto._live_ranges.remove(&self._proto._range_link); } pub const JsApi = struct { From 697a2834c23cd6da0491f1953be1c3f93342e9c2 Mon Sep 17 00:00:00 2001 From: egrs Date: Wed, 11 Mar 2026 08:04:51 +0100 Subject: [PATCH 6/7] Revert "fix CI: store list pointer on AbstractRange to avoid Page type mismatch" This reverts commit 056b8bb53660883280c86439b90f93a7fb47df17. --- src/browser/Factory.zig | 1 - src/browser/webapi/AbstractRange.zig | 1 - src/browser/webapi/Range.zig | 4 ++-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index dbdc11ae..b0da7a81 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -282,7 +282,6 @@ pub fn abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(chil ._start_container = doc, }; chain.setLeaf(1, child); - abstract_range._live_ranges = &page._live_ranges; page._live_ranges.append(&abstract_range._range_link); return chain.get(1); } diff --git a/src/browser/webapi/AbstractRange.zig b/src/browser/webapi/AbstractRange.zig index 93bda1fa..e766ac29 100644 --- a/src/browser/webapi/AbstractRange.zig +++ b/src/browser/webapi/AbstractRange.zig @@ -35,7 +35,6 @@ _start_container: *Node, // Intrusive linked list node for tracking live ranges on the Page. _range_link: std.DoublyLinkedList.Node = .{}, -_live_ranges: *std.DoublyLinkedList = undefined, pub const Type = union(enum) { range: *Range, diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index 213dea2a..e3b8bc41 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -680,8 +680,8 @@ fn getContainerElement(self: *const Range) ?*Node.Element { return parent.is(Node.Element); } -pub fn deinit(self: *Range, _: bool, _: *Page) void { - self._proto._live_ranges.remove(&self._proto._range_link); +pub fn deinit(self: *Range, _: bool, page: *Page) void { + page._live_ranges.remove(&self._proto._range_link); } pub const JsApi = struct { From 25c89c9940bd1241037fe13623cb2b87a11a5c7c Mon Sep 17 00:00:00 2001 From: egrs Date: Wed, 11 Mar 2026 08:04:53 +0100 Subject: [PATCH 7/7] Revert "remove ranges from live list on GC finalization" This reverts commit 625d424199340bd54ee13d97fb2d79e74d86451d. --- src/browser/webapi/Range.zig | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index e3b8bc41..66ce1c93 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -680,10 +680,6 @@ fn getContainerElement(self: *const Range) ?*Node.Element { return parent.is(Node.Element); } -pub fn deinit(self: *Range, _: bool, page: *Page) void { - page._live_ranges.remove(&self._proto._range_link); -} - pub const JsApi = struct { pub const bridge = js.Bridge(Range); @@ -691,8 +687,6 @@ pub const JsApi = struct { pub const name = "Range"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const weak = true; - pub const finalizer = bridge.finalizer(Range.deinit); }; // Constants for compareBoundaryPoints