commit fb45177212c8fba300fb2d968174e87e1dab6fd5
parent 66629a4928953f615e9d39186f3d8ccf2367b74d
Author: Stephanie Y Zhang <stephanie.zhang@microsoft.com>
Date: Thu, 30 Oct 2025 18:57:35 +0000
Bug 1996383 [wpt PR 55656] - [FormControlRange] Programmatic updates for value changes, a=testonly
Automatic update from web-platform-tests
[FormControlRange] Programmatic updates for value changes
This CL adds support for programmatic value mutations (e.g. .value= and
setRangeText()). User-driven live updates remain handled via
beforeinput/input snapshots from CL:7001481.
On programmatic changes, FormControlRange updates by diffing the old and
new values within the prior selection range. Full-value replacements
collapse to [0,0]; partial edits shift, contract, or collapse endpoints
around the edited region.
For setRangeText(), the automatic full-value diff is skipped so a
precise, span-bounded update can be emitted without duplicate updates.
Follow-up CLs will extend coverage to richer edit scenarios (e.g.
drag/drop, IME).
Explainer:
github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/FormControlRange/explainer.md
Low-Coverage-Reason: COVERAGE_UNDERREPORTED
Bug: 421421332
Change-Id: Ia8bda2e60196dcfc0ca63b90841243efb08055f2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7004105
Reviewed-by: Ana Sollano Kim <ansollan@microsoft.com>
Commit-Queue: Stephanie Zhang <stephanie.zhang@microsoft.com>
Reviewed-by: Mason Freed <masonf@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1535389}
--
wpt-commits: 515585b874b18eb985af1bbb9d6b4f2f2897ff46
wpt-pr: 55656
Diffstat:
3 files changed, 332 insertions(+), 8 deletions(-)
diff --git a/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-programmatic-updates.html b/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-programmatic-updates.html
@@ -0,0 +1,314 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<body></body>
+<script>
+'use strict';
+
+// Verifies that FormControlRange updates its start/end offsets for
+// programmatic text mutations mirroring DOM Range behavior in the controlโs
+// value space. Each test first exercises a DOM Range in a contenteditable div,
+// then performs the equivalent operation with FormControlRange to ensure
+// behavior parity.
+
+const controls = ['input', 'textarea'];
+
+function setup(control, value) {
+ document.body.innerHTML = control === 'input' ? '<input type="text">' : '<textarea></textarea>';
+ const element = document.body.firstElementChild;
+ element.value = value;
+ element.focus();
+ return element;
+}
+
+function makeFormControlRange(element, start, end) {
+ const range = new FormControlRange();
+ range.setFormControlRange(element, start, end);
+ return range;
+}
+
+function setupEditable(value) {
+ // Create a contenteditable div with a single text node.
+ const editable = document.createElement('div');
+ editable.setAttribute('contenteditable', 'true');
+ editable.textContent = value;
+ document.body.appendChild(editable);
+ const text = editable.firstChild;
+ return { editable, text };
+}
+
+function makeDomRange(text, start, end) {
+ // Construct a DOM Range in the given text node.
+ const r = document.createRange();
+ r.setStart(text, start);
+ r.setEnd(text, end);
+ return r;
+}
+
+controls.forEach(control => {
+ test(() => {
+ // DOM: full replace (shorter) collapses to [0,0].
+ const d1 = setupEditable('ABCDEFG');
+ const domRange1 = makeDomRange(d1.text, 2, 5);
+ d1.text.replaceData(0, 7, 'XY');
+ assert_equals(domRange1.startOffset, 0, 'DOM shorter: start collapsed to 0');
+ assert_equals(domRange1.endOffset, 0, 'DOM shorter: end collapsed to 0');
+
+ // FormControlRange: full .value replacement (shorter) collapses to [0,0].
+ let element = setup(control, 'ABCDEFG');
+ let range = makeFormControlRange(element, 2, 5);
+ element.value = 'XY';
+ assert_equals(range.startOffset, 0, 'FormControlRange shorter: start collapsed to 0');
+ assert_equals(range.endOffset, 0, 'FormControlRange shorter: end collapsed to 0');
+
+ // DOM: full replace (longer) collapses to [0,0].
+ const d2 = setupEditable('ABC');
+ const domRange2 = makeDomRange(d2.text, 1, 3);
+ d2.text.replaceData(0, 3, 'ABCDEFGHIJKLMNOP');
+ assert_equals(domRange2.startOffset, 0, 'DOM longer: start collapsed to 0');
+ assert_equals(domRange2.endOffset, 0, 'DOM longer: end collapsed to 0');
+
+ // FormControlRange: full .value replacement (longer) collapses to [0,0].
+ element = setup(control, 'ABC');
+ range = makeFormControlRange(element, 1, 3);
+ element.value = 'ABCDEFGHIJKLMNOP';
+ assert_equals(range.startOffset, 0, 'FormControlRange longer: start collapsed to 0');
+ assert_equals(range.endOffset, 0, 'FormControlRange longer: end collapsed to 0');
+ }, `Full replace collapses to start (shorter & longer) (${control})`);
+
+ test(() => {
+ // DOM: full replace when prior range spans whole old value; collapse to [0,0].
+ const d = setupEditable('ABCDE');
+ const domRange = makeDomRange(d.text, 0, 5);
+ d.text.replaceData(0, 5, 'VWXYZ');
+ assert_equals(domRange.startOffset, 0, 'DOM whole-old: start collapsed to 0');
+ assert_equals(domRange.endOffset, 0, 'DOM whole-old: end collapsed to 0');
+
+ // FormControlRange: same scenario.
+ const element = setup(control, 'ABCDE');
+ const range = makeFormControlRange(element, 0, 5);
+ element.value = 'VWXYZ';
+ assert_equals(range.startOffset, 0, 'FormControlRange whole-old: start collapsed to 0');
+ assert_equals(range.endOffset, 0, 'FormControlRange whole-old: end collapsed to 0');
+ }, `Full replace from whole-old range collapses to 0 (${control})`);
+
+ test(() => {
+ // DOM: full replace with equal length; collapse to [0,0].
+ const d = setupEditable('ABCDE');
+ const domRange = makeDomRange(d.text, 1, 4);
+ d.text.replaceData(0, 5, 'VWXYZ');
+ assert_equals(domRange.startOffset, 0, 'DOM equal-length: start collapsed to 0');
+ assert_equals(domRange.endOffset, 0, 'DOM equal-length: end collapsed to 0');
+
+ // FormControlRange: same scenario.
+ const element = setup(control, 'ABCDE');
+ const range = makeFormControlRange(element, 1, 4);
+ element.value = 'VWXYZ';
+ assert_equals(range.startOffset, 0, 'FormControlRange equal-length: start collapsed to 0');
+ assert_equals(range.endOffset, 0, 'FormControlRange equal-length: end collapsed to 0');
+ }, `Full replace (equal length) collapses to 0 (${control})`);
+
+ test(() => {
+ // DOM: replace [3,7) with "XX".
+ const d = setupEditable('0123456789');
+ const domRange = makeDomRange(d.text, 2, 8);
+ d.text.replaceData(3, 4, 'XX');
+ assert_equals(d.editable.textContent, '012XX789', 'DOM value reflects replace');
+ assert_equals(domRange.startOffset, 2, 'DOM start unchanged before replaced segment');
+ assert_equals(domRange.endOffset, 6, 'DOM end adjusted by net delta');
+
+ // FormControlRange: same replace via setRangeText.
+ const element = setup(control, '0123456789');
+ const range = makeFormControlRange(element, 2, 8);
+ element.setRangeText('XX', 3, 7);
+ assert_equals(element.value, '012XX789', 'FormControlRange value reflects setRangeText replace');
+ assert_equals(range.startOffset, 2, 'FormControlRange start unchanged before replaced segment');
+ assert_equals(range.endOffset, 6, 'FormControlRange end adjusted by net delta');
+ }, `Partial replacement adjusts end (${control})`);
+
+ test(() => {
+ // DOM: no mutation; range unchanged.
+ const d = setupEditable('HELLO');
+ const domRange = makeDomRange(d.text, 1, 4);
+ assert_equals(domRange.startOffset, 1, 'DOM start unchanged on no-op');
+ assert_equals(domRange.endOffset, 4, 'DOM end unchanged on no-op');
+
+ // FormControlRange: setting same value (no-op); range unchanged.
+ const element = setup(control, 'HELLO');
+ const range = makeFormControlRange(element, 1, 4);
+ element.value = 'HELLO';
+ assert_equals(range.startOffset, 1, 'FormControlRange start unchanged on no-op');
+ assert_equals(range.endOffset, 4, 'FormControlRange end unchanged on no-op');
+ }, `No-op leaves range unchanged (${control})`);
+
+ test(() => {
+ // DOM: insert before the range; shift both endpoints by +1.
+ const d = setupEditable('ABCDE');
+ const domRange = makeDomRange(d.text, 2, 4);
+ d.text.insertData(1, 'Q');
+ assert_equals(d.editable.textContent, 'AQBCDE', 'DOM value after insertion before range');
+ assert_equals(domRange.startOffset, 3, 'DOM start +1');
+ assert_equals(domRange.endOffset, 5, 'DOM end +1');
+
+ // FormControlRange: same insert via setRangeText.
+ const element = setup(control, 'ABCDE');
+ const range = makeFormControlRange(element, 2, 4);
+ element.setRangeText('Q', 1, 1);
+ assert_equals(element.value, 'AQBCDE', 'FormControlRange value after insertion before range');
+ assert_equals(range.startOffset, 3, 'FormControlRange start +1');
+ assert_equals(range.endOffset, 5, 'FormControlRange end +1');
+ assert_equals(range.toString(), 'CD', 'FormControlRange range text stable');
+ }, `Insertion before range shifts both endpoints (${control})`);
+
+ test(() => {
+ // DOM: delete [2,3) inside the range; end shrinks by 1.
+ const d = setupEditable('ABCDE');
+ const domRange = makeDomRange(d.text, 1, 5);
+ d.text.deleteData(2, 1);
+ assert_equals(d.editable.textContent, 'ABDE', 'DOM value after interior deletion');
+ assert_equals(domRange.startOffset, 1, 'DOM start unchanged');
+ assert_equals(domRange.endOffset, 4, 'DOM end -1');
+
+ // FormControlRange: same delete via setRangeText.
+ const element = setup(control, 'ABCDE');
+ const range = makeFormControlRange(element, 1, 5);
+ element.setRangeText('', 2, 3);
+ assert_equals(element.value, 'ABDE', 'FormControlRange value after interior deletion');
+ assert_equals(range.startOffset, 1, 'FormControlRange start unchanged');
+ assert_equals(range.endOffset, 4, 'FormControlRange end -1');
+ assert_equals(range.toString(), 'BDE', 'FormControlRange range reflects deletion');
+ }, `Interior deletion shrinks end (${control})`);
+
+ test(() => {
+ // DOM: replace before the range with net -2.
+ const d = setupEditable('ABCDEFGHIJ');
+ const domRange = makeDomRange(d.text, 7, 10);
+ d.text.replaceData(2, 3, 'Z');
+ assert_equals(d.editable.textContent, 'ABZFGHIJ', 'DOM value after before-range shrink');
+ assert_equals(domRange.startOffset, 5, 'DOM start -2');
+ assert_equals(domRange.endOffset, 8, 'DOM end -2');
+
+ // FormControlRange: same replacement via setRangeText.
+ const element = setup(control, 'ABCDEFGHIJ');
+ const range = makeFormControlRange(element, 7, 10);
+ element.setRangeText('Z', 2, 5);
+ assert_equals(element.value, 'ABZFGHIJ', 'FormControlRange value after before-range shrink');
+ assert_equals(range.startOffset, 5, 'FormControlRange start -2');
+ assert_equals(range.endOffset, 8, 'FormControlRange end -2');
+ assert_equals(range.toString(), 'HIJ', 'FormControlRange text unchanged');
+ }, `Before-range shrink shifts left (${control})`);
+
+ test(() => {
+ // DOM: edits after the range; unchanged.
+ const d = setupEditable('ABCDEFGHIJ');
+ const domRange = makeDomRange(d.text, 2, 5);
+ d.text.replaceData(7, 2, 'WXYZ');
+ assert_equals(d.editable.textContent, 'ABCDEFGWXYZJ', 'DOM value after after-range grow');
+ assert_equals(domRange.startOffset, 2, 'DOM start unchanged');
+ assert_equals(domRange.endOffset, 5, 'DOM end unchanged');
+
+ // FormControlRange: same replacement via setRangeText.
+ const element = setup(control, 'ABCDEFGHIJ');
+ const range = makeFormControlRange(element, 2, 5);
+ element.setRangeText('WXYZ', 7, 9);
+ assert_equals(element.value, 'ABCDEFGWXYZJ', 'FormControlRange value after after-range grow');
+ assert_equals(range.startOffset, 2, 'FormControlRange start unchanged');
+ assert_equals(range.endOffset, 5, 'FormControlRange end unchanged');
+ assert_equals(range.toString(), 'CDE', 'FormControlRange text unchanged');
+ }, `After-range grow leaves range unchanged (${control})`);
+
+ test(() => {
+ // DOM: superset replacement collapses to start of change.
+ const d = setupEditable('ABCDEFG');
+ const domRange = makeDomRange(d.text, 2, 5);
+ d.text.replaceData(1, 5, 'Q');
+ assert_equals(d.editable.textContent, 'AQG', 'DOM value after superset replacement');
+ assert_equals(domRange.startOffset, 1, 'DOM collapsed to change start');
+ assert_equals(domRange.endOffset, 1, 'DOM collapsed');
+
+ // FormControlRange: same replacement via setRangeText.
+ const element = setup(control, 'ABCDEFG');
+ const range = makeFormControlRange(element, 2, 5);
+ element.setRangeText('Q', 1, 6);
+ assert_equals(element.value, 'AQG', 'FormControlRange value after superset replacement');
+ assert_equals(range.startOffset, 1, 'FormControlRange collapsed to change start');
+ assert_equals(range.endOffset, 1, 'FormControlRange collapsed');
+ assert_true(range.collapsed, 'FormControlRange collapsed flag true');
+ }, `Superset replacement collapses to change start (${control})`);
+
+ test(() => {
+ // DOM: insert exactly at range.start; start unchanged, end advances.
+ const d = setupEditable('ABCDE');
+ const domRange = makeDomRange(d.text, 2, 4);
+ d.text.insertData(2, 'QQ');
+ assert_equals(d.editable.textContent, 'ABQQCDE', 'DOM value after insert at start boundary');
+ assert_equals(domRange.startOffset, 2, 'DOM start unchanged at boundary');
+ assert_equals(domRange.endOffset, 6, 'DOM end +2');
+
+ // FormControlRange: same insertion via setRangeText.
+ const element = setup(control, 'ABCDE');
+ const range = makeFormControlRange(element, 2, 4);
+ element.setRangeText('QQ', 2, 2);
+ assert_equals(element.value, 'ABQQCDE', 'FormControlRange value after insert at start boundary');
+ assert_equals(range.startOffset, 2, 'FormControlRange start unchanged at boundary');
+ assert_equals(range.endOffset, 6, 'FormControlRange end +2');
+ }, `Insert at range.start extends end (${control})`);
+
+ test(() => {
+ // DOM: insert exactly at range.end; both boundaries unchanged.
+ const d = setupEditable('ABCDE');
+ const domRange = makeDomRange(d.text, 2, 4);
+ d.text.insertData(4, 'QQ');
+ assert_equals(d.editable.textContent, 'ABCDQQE', 'DOM value after insert at end boundary');
+ assert_equals(domRange.startOffset, 2, 'DOM start unchanged');
+ assert_equals(domRange.endOffset, 4, 'DOM end unchanged at boundary');
+
+ // FormControlRange: same insertion via setRangeText.
+ const element = setup(control, 'ABCDE');
+ const range = makeFormControlRange(element, 2, 4);
+ element.setRangeText('QQ', 4, 4);
+ assert_equals(element.value, 'ABCDQQE', 'FormControlRange value after insert at end boundary');
+ assert_equals(range.startOffset, 2, 'FormControlRange start unchanged');
+ assert_equals(range.endOffset, 4, 'FormControlRange end unchanged at boundary');
+ }, `Insert at range.end leaves range unchanged (${control})`);
+
+ test(() => {
+ // DOM: replace [1,3) (emoji is 2 code units) with two emojis (4 code units).
+ const d = setupEditable('A๐BC');
+ const domRange = makeDomRange(d.text, 1, 4);
+ d.text.replaceData(1, 2, '๐๐');
+ assert_equals(domRange.startOffset, 1, 'DOM start unchanged inside growth');
+ assert_equals(domRange.endOffset, 6, 'DOM end +2 code units');
+
+ // FormControlRange: same replacement via setRangeText.
+ const element = setup(control, 'A๐BC');
+ const range = makeFormControlRange(element, 1, 4);
+ element.setRangeText('๐๐', 1, 3);
+ assert_equals(range.startOffset, 1, 'FormControlRange start unchanged inside growth');
+ assert_equals(range.endOffset, 6, 'FormControlRange end +2 code units');
+ assert_true(range.toString().length >= 3, 'FormControlRange range text non-empty after expansion');
+ }, `Surrogate pair expansion grows end (${control})`);
+
+ test(() => {
+ // DOM: chained interior edits accumulate deltas.
+ const d = setupEditable('012345');
+ const domRange = makeDomRange(d.text, 2, 5);
+ d.text.replaceData(3, 1, 'XX'); // [3,4) -> "XX"
+ d.text.deleteData(3, 2); // delete the "XX"
+ assert_equals(d.editable.textContent, '01245', 'DOM final value after chained edits');
+ assert_equals(domRange.startOffset, 2, 'DOM start unchanged across interior edits');
+ assert_equals(domRange.endOffset, 4, 'DOM end reflects cumulative delta');
+
+ // FormControlRange: same edits via setRangeText.
+ const element = setup(control, '012345');
+ const range = makeFormControlRange(element, 2, 5);
+ element.setRangeText('XX', 3, 4);
+ element.setRangeText('', 3, 5);
+ assert_equals(element.value, '01245', 'FormControlRange final value after chained edits');
+ assert_equals(range.startOffset, 2, 'FormControlRange start unchanged across interior edits');
+ assert_equals(range.endOffset, 4, 'FormControlRange end reflects cumulative delta');
+ assert_equals(range.toString(), '24', 'FormControlRange final range text');
+ }, `Chained interior edits cumulatively adjust range (${control})`);
+});
+</script>
diff --git a/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-range-updates.html b/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-range-updates.html
@@ -99,7 +99,11 @@ test(() => {
range.setFormControlRange(textarea, 1, 5);
assert_equals(range.toString(), "rigi");
+ // Full .value= replacement collapses the range to the start (0,0).
textarea.value = "Modified";
- assert_equals(range.toString(), "odif");
-}, "FormControlRange reflects value changes on same element.");
+ assert_true(range.collapsed, "range collapses on full-value replacement");
+ assert_equals(range.startOffset, 0);
+ assert_equals(range.endOffset, 0);
+ assert_equals(range.toString(), "");
+}, "FormControlRange collapses on full .value= replacement.");
</script>
diff --git a/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-toString.html b/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-toString.html
@@ -9,11 +9,13 @@ test(() => {
const range = new FormControlRange();
range.setFormControlRange(input, 0, 4);
- assert_equals(range.toString(), "Test");
-
+ // Full replacement collapses to start.
input.value = "New";
- assert_equals(range.toString(), "New");
-}, "FormControlRange toString() reflects current value.");
+ assert_true(range.collapsed, "range collapses on full replacement");
+ assert_equals(range.startOffset, 0);
+ assert_equals(range.endOffset, 0);
+ assert_equals(range.toString(), "");
+}, "FormControlRange collapses on full .value= replacement.");
test(() => {
document.body.innerHTML = '<input type="text" value="Hello">';
@@ -21,9 +23,13 @@ test(() => {
const range = new FormControlRange();
range.setFormControlRange(input, 1, 4);
+ // Full replacement (shorter) also collapses to start.
input.value = "Hi";
- assert_equals(range.toString(), "i");
-}, "FormControlRange toString() clamps to available length.");
+ assert_true(range.collapsed, "range collapses on full replacement (shorter)");
+ assert_equals(range.startOffset, 0);
+ assert_equals(range.endOffset, 0);
+ assert_equals(range.toString(), "");
+}, "FormControlRange collapses on full .value= replacement (shorter).");
test(() => {
document.body.innerHTML = '<textarea>LongText</textarea>';