From d4de695d565fe6b87667f0210992d16689dc30d1 Mon Sep 17 00:00:00 2001 From: Jolse Maginnis Date: Thu, 25 Jun 2026 09:23:03 +1000 Subject: [PATCH 1/6] 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/6] 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/6] 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 => } From 88df3cc734fb54ec5ffb21a9dc11387921159388 Mon Sep 17 00:00:00 2001 From: Jolse Maginnis Date: Mon, 29 Jun 2026 10:00:38 +1000 Subject: [PATCH 4/6] test: move DateField focus-steal regression test to jsdom The selectionchange focus steal is Firefox-specific browser behaviour, but the logic the fix changed -- whether the handler calls Selection.collapse onto the segment while another element is focused -- is environment independent. A real browser can't reproduce the steal through synthetic events anyway, so replace the browser test with a focused jsdom unit test that stubs window.getSelection to model the stale Firefox anchor and asserts collapse is not called. Co-Authored-By: Claude Opus 4.8 --- .../test/DateField.browser.test.tsx | 79 ------------------- .../test/DateField.test.js | 28 +++++++ 2 files changed, 28 insertions(+), 79 deletions(-) delete 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 deleted file mode 100644 index 0e99552fc92..00000000000 --- a/packages/react-aria-components/test/DateField.browser.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/* - * 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 {afterEach, expect, it, vi} from 'vitest'; -import {CalendarDate} from '@internationalized/date'; -import {DateField, DateInput, DateSegment} from '../src/DateField'; -import React from 'react'; -import {render} from 'vitest-browser-react'; -import {userEvent} from 'vitest/browser'; - -function DateFieldWithSibling() { - return ( - <> - - - {segment => } - - - ); -} - -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 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 -// -// 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 button = container.querySelector('[data-testid="sibling"]') as HTMLButtonElement; - let segments = [...container.querySelectorAll('[role="spinbutton"]')] as HTMLElement[]; - let lastSegment = segments[segments.length - 1]; - - // Genuinely move focus to a sibling element, so getActiveElement() !== the segment. - await userEvent.click(button); - expect(document.activeElement).toBe(button); - - // 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); - - document.dispatchEvent(new Event('selectionchange')); - - // Without the fix the handler calls selection.collapse(segment) here — the focus steal on Firefox. - expect(collapse).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(button); -}); diff --git a/packages/react-aria-components/test/DateField.test.js b/packages/react-aria-components/test/DateField.test.js index 43d0e94ff00..cbe98cb187f 100644 --- a/packages/react-aria-components/test/DateField.test.js +++ b/packages/react-aria-components/test/DateField.test.js @@ -562,4 +562,32 @@ describe('DateField', () => { expect(segements[1]).toHaveTextContent('dd'); expect(segements[2]).toHaveTextContent('yyyy'); }); + + // Regression test for #10259: in Firefox a stale selection anchor can remain inside a segment + // after focus moves away, and the selectionchange handler would collapse onto it, stealing focus. + it('does not collapse the selection onto a segment while another element is focused', () => { + let {getByRole} = render( + <> + + + + {segment => } + + + ); + + let button = getByRole('button'); + let segment = within(getByRole('group')).getAllByRole('spinbutton').at(-1); + act(() => button.focus()); + expect(document.activeElement).toBe(button); + + let collapse = jest.fn(); + jest.spyOn(window, 'getSelection').mockReturnValue({anchorNode: segment.firstChild, collapse}); + act(() => { + document.dispatchEvent(new Event('selectionchange')); + }); + + expect(collapse).not.toHaveBeenCalled(); + jest.restoreAllMocks(); + }); }); From 396445d9786c5d8c189ecdd37eb3b8cf9d95eb00 Mon Sep 17 00:00:00 2001 From: Jolse Maginnis Date: Mon, 29 Jun 2026 10:02:59 +1000 Subject: [PATCH 5/6] chore: trim comment on selectionchange focus-steal guard Co-Authored-By: Claude Opus 4.8 --- packages/react-aria/src/datepicker/useDateSegment.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/react-aria/src/datepicker/useDateSegment.ts b/packages/react-aria/src/datepicker/useDateSegment.ts index f2db9783f4e..963fc4659ff 100644 --- a/packages/react-aria/src/datepicker/useDateSegment.ts +++ b/packages/react-aria/src/datepicker/useDateSegment.ts @@ -264,10 +264,8 @@ 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(); - // 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. + // Only collapse while focused, otherwise a stale anchor left in the segment (e.g. on Firefox) + // steals focus back into it on selectionchange. See #10259. if ( selection?.anchorNode && nodeContains(ref.current, selection?.anchorNode) && From 6a30b5d77a6616411e8fdbef10ebbeeb1f439801 Mon Sep 17 00:00:00 2001 From: Jolse Maginnis Date: Wed, 1 Jul 2026 13:18:34 +1000 Subject: [PATCH 6/6] ci: retrigger CI (s2-docs parcel-resolver panic) Co-Authored-By: Claude Opus 4.8