diff --git a/packages/react-aria/src/focus/FocusScope.tsx b/packages/react-aria/src/focus/FocusScope.tsx index ad4f0a427a3..41114eeadcf 100644 --- a/packages/react-aria/src/focus/FocusScope.tsx +++ b/packages/react-aria/src/focus/FocusScope.tsx @@ -538,7 +538,7 @@ function focusElement(element: FocusableElement | null, scroll = false) { } } -function getFirstInScope(scope: Element[], tabbable = true) { +function getFirstInScope(scope: Element[], tabbable = true): FocusableElement | null { let sentinel = scope[0].previousElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable}, scope); @@ -553,7 +553,8 @@ function getFirstInScope(scope: Element[], tabbable = true) { nextNode = walker.nextNode(); } - return nextNode as FocusableElement; + // TreeWalker.nextNode() returns null when the scope contains no focusable element. + return nextNode as FocusableElement | null; } function focusFirstInScope(scope: Element[], tabbable: boolean = true) { @@ -811,8 +812,12 @@ function useRestoreFocus( ) { // oxlint-disable-next-line react-hooks/exhaustive-deps let node = getFirstInScope(treeNode.scopeRef.current, true); - restoreFocusToElement(node); - return; + // The scope may have nothing focusable in it, e.g. if its focusable + // content was removed or hidden. Keep walking up in that case. + if (node) { + restoreFocusToElement(node); + return; + } } treeNode = treeNode.parent; } diff --git a/packages/react-aria/test/focus/FocusScope.test.js b/packages/react-aria/test/focus/FocusScope.test.js index 39dd0d8be39..e5c3a50b3b2 100644 --- a/packages/react-aria/test/focus/FocusScope.test.js +++ b/packages/react-aria/test/focus/FocusScope.test.js @@ -2096,6 +2096,52 @@ describe('FocusScope', function () { }); }); describe('node to restore edge cases', () => { + it('does not throw when there is no focusable element to restore focus to', function () { + function Test({show, showRestoreTarget}) { + return ( + // The outer scope stays mounted and always contains the wrapper div, so + // it is never an empty scope, but once showRestoreTarget is false it + // holds no focusable element for the restore fallback to find. + +
+ {showRestoreTarget && } +
+ {show && ( + + + + )} +
+ ); + } + + let {getByTestId, rerender} = render(); + let restoreTarget = getByTestId('restore-target'); + act(() => { + restoreTarget.focus(); + }); + expect(document.activeElement).toBe(restoreTarget); + + // Mount the restoreFocus scope. autoFocus moves focus inside it, and the + // restore target is captured as its nodeToRestore. + rerender(); + act(() => { + jest.runAllTimers(); + }); + expect(document.activeElement).toBe(getByTestId('inside')); + + // Unmount the scope and remove the restore target in the same commit, so + // nodeToRestore is disconnected and the fallback walks up to the outer + // scope, which now has nothing focusable in it. + rerender(); + act(() => { + jest.runAllTimers(); + }); + + // There was nothing to restore to, so focus is left on the body. + expect(document.activeElement).toBe(document.body); + }); + it('tracks node to restore if the node to restore was removed in another part of the tree', async () => { function Test() { let [showMenu, setShowMenu] = useState(false);