From d4de695d565fe6b87667f0210992d16689dc30d1 Mon Sep 17 00:00:00 2001 From: Jolse Maginnis Date: Thu, 25 Jun 2026 09:23:03 +1000 Subject: [PATCH 1/3] fix(@react-aria/datepicker): don't steal focus into a date segment from selectionchange while another element is focused In Firefox, the caret inside a sibling is not reflected in the document selection, so a stale anchor can remain inside a date segment after focus moves away. useDateSegment's document-level 'selectionchange' listener then calls Selection.collapse(segment), and in Firefox collapsing onto a contentEditable node moves focus to it -- stealing focus into the segment while the user types in a neighbouring input. Gate the collapse on the segment actually being the active element. This preserves the Android-Chrome composition behaviour the handler was written for (which only applies while the segment is focused) and removes the cross-element focus steal. Fixes #10259 --- .../test/DateField.browser.test.tsx | 60 +++++++++++++++++++ .../src/datepicker/useDateSegment.ts | 10 +++- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 packages/react-aria-components/test/DateField.browser.test.tsx diff --git a/packages/react-aria-components/test/DateField.browser.test.tsx b/packages/react-aria-components/test/DateField.browser.test.tsx new file mode 100644 index 00000000000..41e4c6366e2 --- /dev/null +++ b/packages/react-aria-components/test/DateField.browser.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {CalendarDate} from '@internationalized/date'; +import {DateField, DateInput, DateSegment} from '../src/DateField'; +import {expect, it} from 'vitest'; +import React from 'react'; +import {render} from 'vitest-browser-react'; +import {userEvent} from 'vitest/browser'; + +function DateFieldWithSiblingInput() { + return ( + <> + + + {segment => } + + + ); +} + +// Regression test for a Firefox-only focus steal. useDateSegment installs a document-level +// 'selectionchange' listener that re-collapses the window selection onto the segment whenever +// the selection anchor is inside it. In Firefox the caret inside an is not reflected +// in the document selection, so a stale anchor remains parked in a segment after focus moves to +// a sibling input; collapsing onto the contentEditable segment then steals focus back into it. +// See https://github.com/adobe/react-spectrum/issues/10259 +it('does not steal focus into a date segment when typing in a sibling input', async () => { + let {container} = await render(); + + let input = container.querySelector('[data-testid="sibling"]') as HTMLInputElement; + let segments = [...container.querySelectorAll('[role="spinbutton"]')] as HTMLElement[]; + let lastSegment = segments[segments.length - 1]; + let initialSegmentText = lastSegment.textContent; + + // Focus a date segment first so its onFocus handler parks the document selection inside it. + await userEvent.click(lastSegment); + expect(document.activeElement).toBe(lastSegment); + + // Move focus to the sibling input and type. The stale selection anchor (still in the segment + // on Firefox) must not cause the segment to grab focus back on the resulting selectionchange. + await userEvent.click(input); + expect(document.activeElement).toBe(input); + + await userEvent.keyboard('123'); + + // Focus must remain in the input and the digits must land there, not in the segment. + expect(document.activeElement).toBe(input); + expect(input.value).toBe('123'); + expect(lastSegment.textContent).toBe(initialSegmentText); +}); diff --git a/packages/react-aria/src/datepicker/useDateSegment.ts b/packages/react-aria/src/datepicker/useDateSegment.ts index 9119e2b7d76..f2db9783f4e 100644 --- a/packages/react-aria/src/datepicker/useDateSegment.ts +++ b/packages/react-aria/src/datepicker/useDateSegment.ts @@ -264,7 +264,15 @@ export function useDateSegment( // Otherwise, when tapping on a segment in Android Chrome and then entering text, // composition events will be fired that break the DOM structure and crash the page. let selection = window.getSelection(); - if (selection?.anchorNode && nodeContains(ref.current, selection?.anchorNode)) { + // Only collapse while this segment is actually focused. In Firefox, the selection inside a + // sibling text input is not reflected in the document selection, so a stale anchor can remain + // inside a segment after focus moves away; collapsing onto the contentEditable segment there + // steals focus back into it (e.g. while typing in a neighbouring input). See #10259. + if ( + selection?.anchorNode && + nodeContains(ref.current, selection?.anchorNode) && + getActiveElement() === ref.current + ) { selection.collapse(ref.current); } }); From d8046dbe7d0e2ff1c61bc3373a0ca107053758d7 Mon Sep 17 00:00:00 2001 From: Jolse Maginnis Date: Thu, 25 Jun 2026 17:07:18 +1000 Subject: [PATCH 2/3] test: make DateField selectionchange focus-steal test fail without the fix The previous test relied on a stale selection anchor surviving inside the segment after focus moved to a sibling input, but Chromium/WebKit collapse the document selection on focus change so the guarded branch was never reached -- the test passed with and without the fix. Reproduce the guarded precondition deterministically instead: keep genuine focus on a sibling element (so getActiveElement() !== the segment) and stub window.getSelection for a single selectionchange so the anchor is inside the segment, then assert the handler does not call Selection.collapse onto it. Now fails in every browser without the active-element guard. Co-Authored-By: Claude Opus 4.8 --- .../test/DateField.browser.test.tsx | 63 ++++++++++++------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/packages/react-aria-components/test/DateField.browser.test.tsx b/packages/react-aria-components/test/DateField.browser.test.tsx index 41e4c6366e2..57aaaa4aac3 100644 --- a/packages/react-aria-components/test/DateField.browser.test.tsx +++ b/packages/react-aria-components/test/DateField.browser.test.tsx @@ -10,17 +10,17 @@ * governing permissions and limitations under the License. */ +import {afterEach, expect, it, vi} from 'vitest'; import {CalendarDate} from '@internationalized/date'; import {DateField, DateInput, DateSegment} from '../src/DateField'; -import {expect, it} from 'vitest'; import React from 'react'; import {render} from 'vitest-browser-react'; import {userEvent} from 'vitest/browser'; -function DateFieldWithSiblingInput() { +function DateFieldWithSibling() { return ( <> - + {segment => } @@ -28,33 +28,50 @@ function DateFieldWithSiblingInput() { ); } +afterEach(() => { + vi.restoreAllMocks(); +}); + // Regression test for a Firefox-only focus steal. useDateSegment installs a document-level -// 'selectionchange' listener that re-collapses the window selection onto the segment whenever -// the selection anchor is inside it. In Firefox the caret inside an is not reflected -// in the document selection, so a stale anchor remains parked in a segment after focus moves to -// a sibling input; collapsing onto the contentEditable segment then steals focus back into it. +// 'selectionchange' listener that re-collapses the window selection onto the segment whenever the +// selection anchor is inside it. In Firefox the caret inside a sibling is not reflected in +// the document selection, so a stale anchor remains parked in a segment after focus moves away; +// collapsing onto the contentEditable segment there steals focus back into it (e.g. while typing +// in a neighbouring input). The fix gates the collapse on the segment being the active element. // See https://github.com/adobe/react-spectrum/issues/10259 -it('does not steal focus into a date segment when typing in a sibling input', async () => { - let {container} = await render(); +// +// The natural reproduction is Firefox-specific and impossible to set up faithfully through the +// test harness: in Chromium/WebKit focusing another element collapses the document selection, so +// the stale anchor never survives, and re-parking the real selection onto the contentEditable +// segment triggers the very focus steal we're guarding against. Instead we reproduce the guarded +// precondition deterministically — a selection anchored inside the segment while a *different* +// element genuinely holds focus — by stubbing window.getSelection for the single selectionchange, +// and assert on the guarded behaviour itself: the handler must not collapse the selection onto the +// segment. This fails in every browser without the fix. +it('does not collapse the selection onto a date segment while another element is focused', async () => { + let {container} = await render(); - let input = container.querySelector('[data-testid="sibling"]') as HTMLInputElement; + let button = container.querySelector('[data-testid="sibling"]') as HTMLButtonElement; let segments = [...container.querySelectorAll('[role="spinbutton"]')] as HTMLElement[]; let lastSegment = segments[segments.length - 1]; - let initialSegmentText = lastSegment.textContent; - // Focus a date segment first so its onFocus handler parks the document selection inside it. - await userEvent.click(lastSegment); - expect(document.activeElement).toBe(lastSegment); + // Genuinely move focus to a sibling element, so getActiveElement() !== the segment. + await userEvent.click(button); + expect(document.activeElement).toBe(button); - // Move focus to the sibling input and type. The stale selection anchor (still in the segment - // on Firefox) must not cause the segment to grab focus back on the resulting selectionchange. - await userEvent.click(input); - expect(document.activeElement).toBe(input); + // Present the Firefox state: a stale selection anchor still inside the segment. Stubbing + // window.getSelection keeps real focus untouched (collapsing onto the contentEditable segment + // for real would itself steal focus on Firefox, defeating the setup). + let collapse = vi.fn(); + let staleSelection = { + anchorNode: lastSegment.firstChild ?? lastSegment, + collapse + } as unknown as Selection; + vi.spyOn(window, 'getSelection').mockReturnValue(staleSelection); - await userEvent.keyboard('123'); + document.dispatchEvent(new Event('selectionchange')); - // Focus must remain in the input and the digits must land there, not in the segment. - expect(document.activeElement).toBe(input); - expect(input.value).toBe('123'); - expect(lastSegment.textContent).toBe(initialSegmentText); + // Without the fix the handler calls selection.collapse(segment) here — the focus steal on Firefox. + expect(collapse).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(button); }); From 5ce73025b6357a0d9e39207e960a03f5a1d25e3d Mon Sep 17 00:00:00 2001 From: Jolse Maginnis Date: Thu, 25 Jun 2026 17:23:30 +1000 Subject: [PATCH 3/3] chore: fix formatting in DateField.browser.test.tsx Co-Authored-By: Claude Opus 4.8 --- .../react-aria-components/test/DateField.browser.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/test/DateField.browser.test.tsx b/packages/react-aria-components/test/DateField.browser.test.tsx index 57aaaa4aac3..0e99552fc92 100644 --- a/packages/react-aria-components/test/DateField.browser.test.tsx +++ b/packages/react-aria-components/test/DateField.browser.test.tsx @@ -20,7 +20,9 @@ import {userEvent} from 'vitest/browser'; function DateFieldWithSibling() { return ( <> - + {segment => }