diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 582291a3..edb6baee 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -270,14 +270,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 811e7e5c..889e0d3c 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"); @@ -142,6 +143,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, @@ -2394,6 +2398,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| { @@ -2422,6 +2432,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 @@ -2569,6 +2584,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) @@ -2827,6 +2857,54 @@ 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); + ar.updateForCharacterDataReplace(target, offset, count, data_len); + } +} + +/// 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); + ar.updateForSplitText(target, new_node, offset, parent, node_index); + } +} + +/// 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); + ar.updateForNodeInsertion(parent, child_index); + } +} + +/// 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); + ar.updateForNodeRemoval(parent, child, child_index); + } +} + // 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..e766ac29 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, @@ -215,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/CData.zig b/src/browser/webapi/CData.zig index 13e075ad..4fb6de6f 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 { @@ -272,15 +271,20 @@ 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 { 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..66ce1c93 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) { + 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; }