diff --git a/packages/react-aria/src/focus/FocusScope.tsx b/packages/react-aria/src/focus/FocusScope.tsx index 41114eeadcf..18379b34e3e 100644 --- a/packages/react-aria/src/focus/FocusScope.tsx +++ b/packages/react-aria/src/focus/FocusScope.tsx @@ -180,10 +180,8 @@ export function FocusScope(props: FocusScopeProps): JSX.Element { // Scope may have been re-parented. let parentScope = focusScopeTree.getTreeNode(scopeRef)?.parent?.scopeRef ?? null; - if ( - (scopeRef === activeScope || isAncestorScope(scopeRef, activeScope)) && - (!parentScope || focusScopeTree.getTreeNode(parentScope)) - ) { + // A descendant may remain active while an ancestor scope is unmounted. + if (scopeRef === activeScope && (!parentScope || focusScopeTree.getTreeNode(parentScope))) { activeScope = parentScope; } focusScopeTree.removeTreeNode(scopeRef); diff --git a/packages/react-aria/test/focus/FocusScope.browser.test.tsx b/packages/react-aria/test/focus/FocusScope.browser.test.tsx new file mode 100644 index 00000000000..d57e7480805 --- /dev/null +++ b/packages/react-aria/test/focus/FocusScope.browser.test.tsx @@ -0,0 +1,73 @@ +/* + * 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 {expect, it} from 'vitest'; +import {FocusScope} from '../../src/focus/FocusScope'; +import React, {useState} from 'react'; +import ReactDOM from 'react-dom'; +import {render} from 'vitest-browser-react'; +import {userEvent} from 'vitest/browser'; + +function Portal({children}: {children: React.ReactNode}) { + return ReactDOM.createPortal(children, document.body); +} + +function SiblingScopes() { + let [showFirst, setShowFirst] = useState(true); + let [showSecond, setShowSecond] = useState(false); + + let openSecond = () => { + setShowSecond(true); + setTimeout(() => setShowFirst(false), 50); + }; + + return ( + + + {showFirst && ( + + + + + + )} + {showSecond && ( + + + + + + + + )} + + ); +} + +it.each([ + {direction: 'forward', shift: false, expected: '2026'}, + {direction: 'reverse', shift: true, expected: 'Next month'} +])( + 'keeps $direction Tab navigation in a sibling scope after unmount', + async ({shift, expected}) => { + let {getByRole} = await render(); + + await userEvent.click(getByRole('button', {name: 'Choose date and time'})); + await expect + .element(getByRole('button', {name: 'Choose date and time'})) + .not.toBeInTheDocument(); + await expect.element(getByRole('button', {name: 'September'})).toHaveFocus(); + + await userEvent.tab({shift}); + await expect.element(getByRole('button', {name: expected})).toHaveFocus(); + } +); diff --git a/packages/react-aria/test/focus/FocusScope.test.js b/packages/react-aria/test/focus/FocusScope.test.js index e5ed0e1d3c4..098adbee3d6 100644 --- a/packages/react-aria/test/focus/FocusScope.test.js +++ b/packages/react-aria/test/focus/FocusScope.test.js @@ -1927,6 +1927,52 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(child3); }); + it('should keep focus contained in a sibling scope when the previous scope unmounts', async function () { + function Portal({children}) { + return ReactDOM.createPortal(children, document.body); + } + + function Test({showFirst, showSecond}) { + return ( + + + {showFirst && ( + + + + + + )} + {showSecond && ( + + + + + + + + )} + + ); + } + + let {getByTestId, rerender} = render(); + expect(document.activeElement).toBe(getByTestId('first')); + + rerender(); + expect(document.activeElement).toBe(getByTestId('second1')); + + rerender(); + expect(document.activeElement).toBe(getByTestId('second1')); + + expect(focusScopeTree.size).toBe(3); + await user.tab(); + expect(document.activeElement).toBe(getByTestId('second2')); + act(() => getByTestId('second1').focus()); + await user.tab({shift: true}); + expect(document.activeElement).toBe(getByTestId('second3')); + }); + it('should restore to the correct scope on unmount', async function () { function Test({show1, show2, show3}) { return (