Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions dom/ranges/Range-deleteContents-mutation-order.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<!doctype html>
<title>Range.deleteContents() collapses range before mutations</title>
<link rel="author" title="Steven Obiajulu" href="https://github.com/stevenobiajulu">
<link rel="help" href="https://dom.spec.whatwg.org/#dom-range-deletecontents">
<meta name="assert" content="Range.deleteContents() sets range start and end before performing mutations, so the range remains valid even if script runs during removal.">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<div id="log"></div>
<script>
"use strict";

// Case 1: originalStartNode is an inclusive ancestor of originalEndNode.
// The range should collapse to (originalStartNode, originalStartOffset).
test(function() {
const div = document.createElement("div");
for (let i = 0; i < 5; i++) {
div.appendChild(document.createElement("span"));
}
// Range: (div, 2) to (div, 5) — covers children at indices 2, 3, 4.
const range = document.createRange();
range.setStart(div, 2);
range.setEnd(div, 5);

range.deleteContents();

assert_equals(range.startContainer, div, "startContainer should be div");
assert_equals(range.startOffset, 2, "startOffset should be 2");
assert_equals(range.endContainer, div, "endContainer should be div");
assert_equals(range.endOffset, 2, "endOffset should be 2");
assert_true(range.collapsed, "range should be collapsed");
assert_equals(div.childNodes.length, 2, "div should have 2 remaining children");
}, "deleteContents collapses range correctly when start is ancestor of end (element children)");

// Case 2: originalStartNode is NOT an inclusive ancestor of originalEndNode.
// The range should collapse to (referenceNode.parent, referenceNode.index + 1).
test(function() {
const div = document.createElement("div");
const referenceSpan = document.createElement("span");
const n1 = document.createElement("span");
const n2 = document.createElement("span");
const n3 = document.createElement("span");
const endSpan = document.createElement("span");
div.append(referenceSpan, n1, n2, n3, endSpan);
// Place start inside referenceSpan, end inside endSpan.
referenceSpan.appendChild(document.createTextNode("start"));
endSpan.appendChild(document.createTextNode("end"));

const range = document.createRange();
range.setStart(referenceSpan.firstChild, 2);
range.setEnd(endSpan.firstChild, 1);

range.deleteContents();

// newNode = div (parent of referenceSpan), newOffset = 1 (referenceSpan.index + 1).
// After removals of n1, n2, n3, the range should be auto-maintained to a valid position.
assert_true(range.collapsed, "range should be collapsed");
assert_equals(range.startContainer, div, "startContainer should be div");
// After removing n1, n2, n3 (which were at indices 1, 2, 3), and with
// live range pre-remove steps adjusting the offset, the final offset
// should be 1 (the original offset, since removed nodes were at index >= offset).
assert_equals(range.startOffset, 1, "startOffset should be 1");
}, "deleteContents collapses range correctly when start is not ancestor of end");

// Case 3: CharacterData start and end nodes (partial text deletion).
test(function() {
const div = document.createElement("div");
const startText = document.createTextNode("Hello");
const middleSpan = document.createElement("span");
const endText = document.createTextNode("World");
div.append(startText, middleSpan, endText);

const range = document.createRange();
range.setStart(startText, 2); // "He|llo"
range.setEnd(endText, 3); // "Wor|ld"

range.deleteContents();

assert_true(range.collapsed, "range should be collapsed");
// newNode = startText (originalStartNode is inclusive ancestor since it's the start text).
// Wait — startText is NOT an inclusive ancestor of endText. So we go to the "otherwise" branch.
// referenceNode walks up from startText. startText's parent is div, which IS an ancestor
// of endText. So referenceNode stays at startText.
// newNode = div, newOffset = startText.index + 1 = 1.
// After replace data of startText (truncates "Hello" to "He"), remove middleSpan,
// replace data of endText (truncates "World" to "ld"), the range should be valid.
assert_equals(range.startContainer, div, "startContainer should be div");
assert_equals(range.startOffset, 1, "startOffset should be 1");
assert_equals(startText.data, "He", "start text should be truncated");
assert_equals(endText.data, "ld", "end text should be truncated");
assert_equals(div.childNodes.length, 2, "middle span should be removed");
}, "deleteContents collapses range correctly with CharacterData boundary nodes");

// Case 4: Same CharacterData node (early return path — no mutation ordering issue).
test(function() {
const div = document.createElement("div");
const text = document.createTextNode("Hello World");
div.appendChild(text);

const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 7);

range.deleteContents();

assert_true(range.collapsed, "range should be collapsed");
assert_equals(range.startContainer, text, "startContainer should be the text node");
assert_equals(range.startOffset, 2, "startOffset should be 2");
assert_equals(text.data, "Heorld", "text data should have middle removed");
}, "deleteContents handles same CharacterData node correctly");

// --- Mutation during removal via custom element disconnectedCallback ---
//
// Structure: container > [before(0), trigger(1), inside(2), sentinel(3)]
// Range: (container, 1) → (container, 3)
//
// trigger's disconnectedCallback removes 'before' (at index 0, before the
// range start). This exercises live-range adjustment on the collapsed range.
//
// Collapse-before-mutations (current spec):
// Range collapses to (container, 1). Removals begin. When the callback
// removes 'before', live-range adjustment decrements startOffset to 0.
// Final: startOffset = 0.
//
// Collapse-after-mutations (old spec):
// Removals and callback happen first. Then the algorithm sets the range
// to the pre-computed (container, 1), overwriting any live-range
// adjustments. Final: startOffset = 1.

customElements.define("x-delete-remove-trigger", class extends HTMLElement {
disconnectedCallback() {
if (this._target && this._target.parentNode) {
this._target.remove();
}
}
});

test(function() {
const container = document.createElement("div");
document.body.appendChild(container);

const before = document.createElement("p");
const trigger = document.createElement("x-delete-remove-trigger");
const inside = document.createElement("p");
const sentinel = document.createElement("p");
container.append(before, trigger, inside, sentinel);
trigger._target = before;

const range = document.createRange();
range.setStart(container, 1);
range.setEnd(container, 3);

range.deleteContents();

assert_true(range.collapsed, "range should be collapsed");
assert_equals(range.startContainer, container, "startContainer");
assert_equals(range.startOffset, 0,
"startOffset adjusted to 0 after earlier sibling removed during operation");
assert_true(container.contains(sentinel), "sentinel not removed");

document.body.removeChild(container);
}, "deleteContents: range valid when disconnectedCallback removes earlier sibling");
</script>
156 changes: 156 additions & 0 deletions dom/ranges/Range-extractContents-mutation-order.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<!doctype html>
<title>Range.extractContents() collapses range before mutations</title>
<link rel="author" title="Steven Obiajulu" href="https://github.com/stevenobiajulu">
<link rel="help" href="https://dom.spec.whatwg.org/#concept-range-extract">
<meta name="assert" content="The extract algorithm sets range start and end before performing mutations, so the range remains valid even if script runs during removal.">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<div id="log"></div>
<script>
"use strict";

// Case 1: originalStartNode is an inclusive ancestor of originalEndNode.
// The range should collapse to (originalStartNode, originalStartOffset).
test(function() {
const div = document.createElement("div");
for (let i = 0; i < 5; i++) {
div.appendChild(document.createElement("span"));
}
// Range: (div, 2) to (div, 5) — covers children at indices 2, 3, 4.
const range = document.createRange();
range.setStart(div, 2);
range.setEnd(div, 5);

const fragment = range.extractContents();

assert_equals(range.startContainer, div, "startContainer should be div");
assert_equals(range.startOffset, 2, "startOffset should be 2");
assert_equals(range.endContainer, div, "endContainer should be div");
assert_equals(range.endOffset, 2, "endOffset should be 2");
assert_true(range.collapsed, "range should be collapsed");
assert_equals(div.childNodes.length, 2, "div should have 2 remaining children");
assert_equals(fragment.childNodes.length, 3, "fragment should contain 3 extracted children");
}, "extractContents collapses range correctly when start is ancestor of end (element children)");

// Case 2: originalStartNode is NOT an inclusive ancestor of originalEndNode.
// The range should collapse to (referenceNode.parent, referenceNode.index + 1).
test(function() {
const div = document.createElement("div");
const referenceSpan = document.createElement("span");
const n1 = document.createElement("span");
const n2 = document.createElement("span");
const n3 = document.createElement("span");
const endSpan = document.createElement("span");
div.append(referenceSpan, n1, n2, n3, endSpan);
referenceSpan.appendChild(document.createTextNode("start"));
endSpan.appendChild(document.createTextNode("end"));

const range = document.createRange();
range.setStart(referenceSpan.firstChild, 2);
range.setEnd(endSpan.firstChild, 1);

const fragment = range.extractContents();

assert_true(range.collapsed, "range should be collapsed");
assert_equals(range.startContainer, div, "startContainer should be div");
assert_equals(range.startOffset, 1, "startOffset should be 1");
assert_true(fragment.childNodes.length > 0, "fragment should have extracted content");
}, "extractContents collapses range correctly when start is not ancestor of end");

// Case 3: CharacterData start and end nodes (partial text extraction).
test(function() {
const div = document.createElement("div");
const startText = document.createTextNode("Hello");
const middleSpan = document.createElement("span");
const endText = document.createTextNode("World");
div.append(startText, middleSpan, endText);

const range = document.createRange();
range.setStart(startText, 2); // "He|llo"
range.setEnd(endText, 3); // "Wor|ld"

const fragment = range.extractContents();

assert_true(range.collapsed, "range should be collapsed");
assert_equals(range.startContainer, div, "startContainer should be div");
assert_equals(range.startOffset, 1, "startOffset should be 1");
assert_equals(startText.data, "He", "start text should be truncated");
assert_equals(endText.data, "ld", "end text should be truncated");
assert_equals(div.childNodes.length, 2, "middle span should be extracted");
assert_true(fragment.childNodes.length > 0, "fragment should contain extracted content");
}, "extractContents collapses range correctly with CharacterData boundary nodes");

// Case 4: Same CharacterData node (early return path — no mutation ordering issue).
test(function() {
const div = document.createElement("div");
const text = document.createTextNode("Hello World");
div.appendChild(text);

const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 7);

const fragment = range.extractContents();

assert_true(range.collapsed, "range should be collapsed");
assert_equals(range.startContainer, text, "startContainer should be the text node");
assert_equals(range.startOffset, 2, "startOffset should be 2");
assert_equals(text.data, "Heorld", "text data should have middle removed");
assert_equals(fragment.firstChild.data, "llo W", "fragment should contain extracted text");
}, "extractContents handles same CharacterData node correctly");

// --- Mutation during removal via custom element disconnectedCallback ---
//
// Structure: container > [before(0), trigger(1), inside(2), sentinel(3)]
// Range: (container, 1) → (container, 3)
//
// trigger's disconnectedCallback removes 'before' (at index 0, before the
// range start). This exercises live-range adjustment on the collapsed range.
//
// Collapse-before-mutations (current spec):
// Range collapses to (container, 1). Removals begin. When the callback
// removes 'before', live-range adjustment decrements startOffset to 0.
// Final: startOffset = 0.
//
// Collapse-after-mutations (old spec):
// Removals and callback happen first. Then the algorithm sets the range
// to the pre-computed (container, 1), overwriting any live-range
// adjustments. Final: startOffset = 1.

customElements.define("x-extract-remove-trigger", class extends HTMLElement {
disconnectedCallback() {
if (this._target && this._target.parentNode) {
this._target.remove();
}
}
});

test(function() {
const container = document.createElement("div");
document.body.appendChild(container);

const before = document.createElement("p");
const trigger = document.createElement("x-extract-remove-trigger");
const inside = document.createElement("p");
const sentinel = document.createElement("p");
container.append(before, trigger, inside, sentinel);
trigger._target = before;

const range = document.createRange();
range.setStart(container, 1);
range.setEnd(container, 3);

const fragment = range.extractContents();

assert_true(range.collapsed, "range should be collapsed");
assert_equals(range.startContainer, container, "startContainer");
assert_equals(range.startOffset, 0,
"startOffset adjusted to 0 after earlier sibling removed during operation");
assert_true(container.contains(sentinel), "sentinel not removed");
assert_false(
Array.from(fragment.childNodes).includes(sentinel),
"sentinel not in extracted fragment");

document.body.removeChild(container);
}, "extractContents: range valid when disconnectedCallback removes earlier sibling");
</script>
Loading