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