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);