Skip to content

Fix FocusScope restoreFocus crash when there is no focusable element to restore to - #10371

Open
Faithfinder wants to merge 1 commit into
adobe:mainfrom
Faithfinder:fix/focusscope-restore-null-node
Open

Fix FocusScope restoreFocus crash when there is no focusable element to restore to#10371
Faithfinder wants to merge 1 commit into
adobe:mainfrom
Faithfinder:fix/focusscope-restore-null-node

Conversation

@Faithfinder

Copy link
Copy Markdown

Fixes #7874. That issue was closed as stale pending "a small reproduction application" — the regression test below is that reproduction.

Summary

A FocusScope with restoreFocus can throw

TypeError: Cannot read properties of null (reading 'dispatchEvent')

when it unmounts and neither its nodeToRestore nor the nearest still-mounted ancestor scope has anything to restore focus to.

Root cause

When a restoreFocus scope unmounts, useRestoreFocus's layout effect cleanup schedules a requestAnimationFrame that tries to put focus back somewhere sensible. It makes two passes over a frozen clone of the focus scope tree:

  1. Walk up from the unmounting scope and, if some nodeToRestore is still connected to the DOM, restore focus to it.

  2. Fallback: otherwise walk up to the nearest scope still in the tree and focus its first focusable element:

    let node = getFirstInScope(treeNode.scopeRef.current, true);
    restoreFocusToElement(node);
    return;

getFirstInScope returns walker.nextNode() from a TreeWalker, which is null when 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, since getFocusableTreeWalker filters out non-visible elements and so a scope inside a display: none subtree yields nothing. In the original report the surviving scope was a single <div> inside a popover that had been closed.

The null then flows straight into restoreFocusToElement, which dereferences it:

function restoreFocusToElement(node: FocusableElement) {
  if (node.dispatchEvent(new CustomEvent(RESTORE_FOCUS_EVENT, {bubbles: true, cancelable: true}))) {

The as FocusableElement cast on getFirstInScope's return value hid this from the type checker. The only other caller already handles null correctly — focusFirstInScope routes it through focusElement, which no-ops on null — so this fallback was the single unguarded call site.

Fix

  • Give getFirstInScope an honest return type of FocusableElement | null. The cast was masking a value the DOM API genuinely returns, and correcting it makes the compiler point at the unguarded call site.
  • Guard the fallback. When the nearest in-tree scope has nothing focusable, skip it and keep walking up the tree rather than dereferencing 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 cases block in packages/react-aria/test/focus/FocusScope.test.js. It renders a restoreFocus scope inside an outer scope, then in a single commit unmounts the inner scope and removes the outer scope's only focusable element. That leaves nodeToRestore disconnected, so pass 1 finds nothing, and the fallback then lands on an outer scope with nothing focusable in it. On main the test fails with the exact TypeError from the issue; with this change it passes and focus correctly ends up on document.body.

yarn jest packages/react-aria/test/focus/ reports 68 passing, both with and without STRICT_MODE=1 VIRT_ON=1.

One thing deliberately left alone

The same fallback can also call getFirstInScope with an empty scope array: scopeRef.current is [] for a scope that renders no elements between its sentinels, and [] is truthy, so it passes the treeNode.scopeRef.current check and then throws on scope[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

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>
@Faithfinder Faithfinder reopened this Jul 27, 2026

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, thank you for figuring out that reproduction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FocusScope restoreFocus "Cannot read properties of null (reading 'dispatchEvent')"

2 participants