From 9c17c24a8a2f27e8783ee8cce18819301a7d47c3 Mon Sep 17 00:00:00 2001 From: Dmitrii Kartashev Date: Mon, 27 Jul 2026 13:47:55 -0400 Subject: [PATCH] fix: guard null restore target in FocusScope restoreFocus getFirstInScope() returns the result of TreeWalker.nextNode(), which is null when the scope contains no focusable element, for example when its focusable content has been removed or hidden. The restoreFocus fallback passed that value straight into restoreFocusToElement(), throwing a TypeError on a null dereference. Correct the misleading return type and skip scopes with nothing to restore to, continuing up the focus scope tree instead. Co-Authored-By: Claude Opus 5 (1M context) --- packages/react-aria/src/focus/FocusScope.tsx | 13 ++++-- .../react-aria/test/focus/FocusScope.test.js | 46 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) 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);