Fix FocusScope restoreFocus crash when there is no focusable element to restore to - #10371
Open
Faithfinder wants to merge 1 commit into
Open
Fix FocusScope restoreFocus crash when there is no focusable element to restore to#10371Faithfinder wants to merge 1 commit into
Faithfinder wants to merge 1 commit into
Conversation
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) <noreply@anthropic.com>
snowystinger
approved these changes
Jul 28, 2026
snowystinger
left a comment
Member
There was a problem hiding this comment.
Awesome, thank you for figuring out that reproduction.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #7874. That issue was closed as stale pending "a small reproduction application" — the regression test below is that reproduction.
Summary
A
FocusScopewithrestoreFocuscan throwwhen it unmounts and neither its
nodeToRestorenor the nearest still-mounted ancestor scope has anything to restore focus to.Root cause
When a
restoreFocusscope unmounts,useRestoreFocus's layout effect cleanup schedules arequestAnimationFramethat tries to put focus back somewhere sensible. It makes two passes over a frozen clone of the focus scope tree:Walk up from the unmounting scope and, if some
nodeToRestoreis still connected to the DOM, restore focus to it.Fallback: otherwise walk up to the nearest scope still in the tree and focus its first focusable element:
getFirstInScopereturnswalker.nextNode()from aTreeWalker, which isnullwhen the scope contains no focusable element — after both the tabbable pass and the focusable fallback pass. That is a legitimate state: it happens whenever the scope's focusable content has been removed, or is hidden, sincegetFocusableTreeWalkerfilters out non-visible elements and so a scope inside adisplay: nonesubtree yields nothing. In the original report the surviving scope was a single<div>inside a popover that had been closed.The
nullthen flows straight intorestoreFocusToElement, which dereferences it:The
as FocusableElementcast ongetFirstInScope's return value hid this from the type checker. The only other caller already handles null correctly —focusFirstInScoperoutes it throughfocusElement, which no-ops onnull— so this fallback was the single unguarded call site.Fix
getFirstInScopean honest return type ofFocusableElement | null. The cast was masking a value the DOM API genuinely returns, and correcting it makes the compiler point at the unguarded call site.null. If no ancestor has anything focusable either, focus is left on the document body, which is the correct outcome when there is genuinely nothing to restore to.Test
Adds a regression test to the
node to restore edge casesblock inpackages/react-aria/test/focus/FocusScope.test.js. It renders arestoreFocusscope inside an outer scope, then in a single commit unmounts the inner scope and removes the outer scope's only focusable element. That leavesnodeToRestoredisconnected, so pass 1 finds nothing, and the fallback then lands on an outer scope with nothing focusable in it. Onmainthe test fails with the exactTypeErrorfrom the issue; with this change it passes and focus correctly ends up ondocument.body.yarn jest packages/react-aria/test/focus/reports 68 passing, both with and withoutSTRICT_MODE=1 VIRT_ON=1.One thing deliberately left alone
The same fallback can also call
getFirstInScopewith an emptyscopearray:scopeRef.currentis[]for a scope that renders no elements between its sentinels, and[]is truthy, so it passes thetreeNode.scopeRef.currentcheck and then throws onscope[0].previousElementSibling. That is a different crash from the one reported here, so I kept this PR to the null case. Happy to fold a guard for it into this PR if you'd prefer.🤖 Generated with Claude Code