From fd260f19998169f1d93d0623b903d048ed46104e Mon Sep 17 00:00:00 2001 From: Steven Obiajulu Date: Mon, 23 Feb 2026 22:31:23 -0500 Subject: [PATCH 1/2] Add tests for Range collapse ordering in deleteContents/extractContents Verify that Range.deleteContents() and Range.extractContents() collapse the range to the correct position after the operation completes. Tests cover: - Element children fully contained (Case 1: start is ancestor of end) - Mixed ancestry with multiple contained nodes (Case 2) - CharacterData boundary nodes with partial text deletion - Same CharacterData node (early return path) These tests serve as conformance/non-regression tests for the spec fix that moves range collapse before mutations (whatwg/dom#1446). --- .../Range-deleteContents-mutation-order.html | 110 ++++++++++++++++++ .../Range-extractContents-mutation-order.html | 101 ++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 dom/ranges/Range-deleteContents-mutation-order.html create mode 100644 dom/ranges/Range-extractContents-mutation-order.html diff --git a/dom/ranges/Range-deleteContents-mutation-order.html b/dom/ranges/Range-deleteContents-mutation-order.html new file mode 100644 index 00000000000000..56e62df306d849 --- /dev/null +++ b/dom/ranges/Range-deleteContents-mutation-order.html @@ -0,0 +1,110 @@ + +Range.deleteContents() collapses range before mutations + + + + + +
+ diff --git a/dom/ranges/Range-extractContents-mutation-order.html b/dom/ranges/Range-extractContents-mutation-order.html new file mode 100644 index 00000000000000..01a4ca0da2e28c --- /dev/null +++ b/dom/ranges/Range-extractContents-mutation-order.html @@ -0,0 +1,101 @@ + +Range.extractContents() collapses range before mutations + + + + + +
+ From 941dea73230004b575c0c9535d4d10e0f0b461ba Mon Sep 17 00:00:00 2001 From: Steven Obiajulu Date: Wed, 11 Mar 2026 10:32:13 -0400 Subject: [PATCH 2/2] Add disconnectedCallback mutation tests for deleteContents/extractContents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address smaug's review: the existing tests only verified final collapse position without exercising synchronous script during removal. Add a test to each file that uses a custom element whose disconnectedCallback removes an earlier sibling mid-operation, verifying the range adjusts correctly. Tested in Chrome, Firefox, and Safari — all pass (startOffset = 0). --- .../Range-deleteContents-mutation-order.html | 52 ++++++++++++++++++ .../Range-extractContents-mutation-order.html | 55 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/dom/ranges/Range-deleteContents-mutation-order.html b/dom/ranges/Range-deleteContents-mutation-order.html index 56e62df306d849..dbfa0108631cf9 100644 --- a/dom/ranges/Range-deleteContents-mutation-order.html +++ b/dom/ranges/Range-deleteContents-mutation-order.html @@ -107,4 +107,56 @@ 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"); diff --git a/dom/ranges/Range-extractContents-mutation-order.html b/dom/ranges/Range-extractContents-mutation-order.html index 01a4ca0da2e28c..d79668583f2018 100644 --- a/dom/ranges/Range-extractContents-mutation-order.html +++ b/dom/ranges/Range-extractContents-mutation-order.html @@ -98,4 +98,59 @@ 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");