Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions packages/react-aria/src/focus/FocusScope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down
46 changes: 46 additions & 0 deletions packages/react-aria/test/focus/FocusScope.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<FocusScope>
<div>
{showRestoreTarget && <button data-testid="restore-target">restore target</button>}
</div>
{show && (
<FocusScope restoreFocus autoFocus>
<button data-testid="inside">inside</button>
</FocusScope>
)}
</FocusScope>
);
}

let {getByTestId, rerender} = render(<Test show={false} showRestoreTarget />);
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(<Test show showRestoreTarget />);
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(<Test show={false} showRestoreTarget={false} />);
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);
Expand Down