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).
This commit is contained in:
egrs
2026-03-10 19:59:04 +01:00
parent 62f31ea24a
commit d23453ce45
8 changed files with 528 additions and 22 deletions

View File

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

View File

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

View File

@@ -0,0 +1,315 @@
<!DOCTYPE html>
<script src="testing.js"></script>
<script id=insertData_adjusts_range_offsets>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// range covers "cde"
// Insert "XX" at offset 1 (before range start)
text.insertData(1, 'XX');
// "aXXbcdef" — range should shift right by 2
testing.expectEqual(4, range.startOffset);
testing.expectEqual(7, range.endOffset);
testing.expectEqual(text, range.startContainer);
}
</script>
<script id=insertData_at_range_start>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Insert at exactly the start offset — should not shift start
text.insertData(2, 'YY');
// "abYYcdef" — start stays at 2, end shifts by 2
testing.expectEqual(2, range.startOffset);
testing.expectEqual(7, range.endOffset);
}
</script>
<script id=insertData_inside_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Insert inside the range
text.insertData(3, 'Z');
// "abcZdef" — start unchanged, end shifts by 1
testing.expectEqual(2, range.startOffset);
testing.expectEqual(6, range.endOffset);
}
</script>
<script id=insertData_after_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Insert after range end — no change
text.insertData(5, 'ZZ');
testing.expectEqual(2, range.startOffset);
testing.expectEqual(5, range.endOffset);
}
</script>
<script id=deleteData_before_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 3);
range.setEnd(text, 5);
// range covers "de"
// Delete "ab" (offset 0, count 2) — before range
text.deleteData(0, 2);
// "cdef" — range shifts left by 2
testing.expectEqual(1, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=deleteData_overlapping_range_start>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Delete from offset 1, count 2 — overlaps range start
text.deleteData(1, 2);
// "adef" — start clamped to offset(1), end adjusted
testing.expectEqual(1, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=deleteData_inside_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 1);
range.setEnd(text, 5);
// Delete inside range: offset 2, count 2
text.deleteData(2, 2);
// "abef" — start unchanged, end shifts by -2
testing.expectEqual(1, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=replaceData_adjusts_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Replace "cd" (offset 2, count 2) with "XXXX" (4 chars)
text.replaceData(2, 2, 'XXXX');
// "abXXXXef" — start clamped to 2, end adjusted by (4-2)=+2
testing.expectEqual(2, range.startOffset);
testing.expectEqual(7, range.endOffset);
}
</script>
<script id=splitText_moves_range_to_new_node>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 4);
range.setEnd(text, 6);
// range covers "ef"
const newText = text.splitText(3);
// text = "abc", newText = "def"
// Range was at (text, 4)-(text, 6), with offset > 3:
// start moves to (newText, 4-3=1), end moves to (newText, 6-3=3)
testing.expectEqual(newText, range.startContainer);
testing.expectEqual(1, range.startOffset);
testing.expectEqual(newText, range.endContainer);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=splitText_range_at_split_point>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 0);
range.setEnd(text, 3);
// range covers "abc"
const newText = text.splitText(3);
// text = "abc", newText = "def"
// Range end is at exactly the split offset — should stay on original node
testing.expectEqual(text, range.startContainer);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(text, range.endContainer);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=appendChild_does_not_affect_range>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
div.appendChild(p1);
div.appendChild(p2);
const range = document.createRange();
range.setStart(div, 0);
range.setEnd(div, 2);
// Appending should not affect range offsets (spec: no update for append)
const p3 = document.createElement('p');
div.appendChild(p3);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(2, range.endOffset);
}
</script>
<script id=insertBefore_shifts_range_offsets>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
div.appendChild(p1);
div.appendChild(p2);
const range = document.createRange();
range.setStart(div, 1);
range.setEnd(div, 2);
// Insert before p1 (index 0) — range offsets > 0 should increment
const span = document.createElement('span');
div.insertBefore(span, p1);
testing.expectEqual(2, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=removeChild_shifts_range_offsets>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
const p3 = document.createElement('p');
div.appendChild(p1);
div.appendChild(p2);
div.appendChild(p3);
const range = document.createRange();
range.setStart(div, 1);
range.setEnd(div, 3);
// Remove p1 (index 0) — offsets > 0 should decrement
div.removeChild(p1);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(2, range.endOffset);
}
</script>
<script id=removeChild_moves_range_from_descendant>
{
const div = document.createElement('div');
const p = document.createElement('p');
const text = document.createTextNode('hello');
p.appendChild(text);
div.appendChild(p);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 4);
// Remove p (which contains text) — range should move to (div, index_of_p)
div.removeChild(p);
testing.expectEqual(div, range.startContainer);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(div, range.endContainer);
testing.expectEqual(0, range.endOffset);
}
</script>
<script id=multiple_ranges_updated>
{
const text = document.createTextNode('abcdefgh');
const div = document.createElement('div');
div.appendChild(text);
const range1 = document.createRange();
range1.setStart(text, 1);
range1.setEnd(text, 3);
const range2 = document.createRange();
range2.setStart(text, 5);
range2.setEnd(text, 7);
// Insert at offset 0 — both ranges should shift
text.insertData(0, 'XX');
testing.expectEqual(3, range1.startOffset);
testing.expectEqual(5, range1.endOffset);
testing.expectEqual(7, range2.startOffset);
testing.expectEqual(9, range2.endOffset);
}
</script>
<script id=data_setter_updates_ranges>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Setting data replaces all content — range collapses to offset 0
text.data = 'new content';
testing.expectEqual(text, range.startContainer);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(text, range.endContainer);
testing.expectEqual(0, range.endOffset);
}
</script>

View File

@@ -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,

View File

@@ -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, &.{

View File

@@ -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 => {},

View File

@@ -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", .{});
}

View File

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