Skip to content

fix(focus-scope): defer MutationObserver focus recovery to prevent hijacking during Tab transitions#3870

Open
ralphholzmann wants to merge 1 commit into
radix-ui:mainfrom
ralphholzmann:fix/focus-scope-mutation-observer-race
Open

fix(focus-scope): defer MutationObserver focus recovery to prevent hijacking during Tab transitions#3870
ralphholzmann wants to merge 1 commit into
radix-ui:mainfrom
ralphholzmann:fix/focus-scope-mutation-observer-race

Conversation

@ralphholzmann

Copy link
Copy Markdown

Summary

Fixes a bug where focus is incorrectly hijacked to the FocusScope container during normal Tab key navigation when a React re-render coincides with the focus transition.

Root cause: When tabbing away from an input that triggers a state update on blur (e.g., react-hook-form validation), React's commit phase can remove and re-add DOM nodes via commitMutationEffectscommitDeletions. This fires the MutationObserver callback in handleMutations while document.activeElement is temporarily document.body — the browser is mid-transition between the old focused element and the next tabbable target. The synchronous focus(container) call intercepts this transition and moves focus to the FocusScope container (tabIndex: -1) instead of letting the browser complete the Tab to the next element.

Fix: Defer the document.activeElement === document.body check to requestAnimationFrame. This allows the browser to complete the focus transition first:

  • If activeElement has moved to the next tabbable element → no-op (correct behavior)
  • If activeElement is still document.body → the focused element was genuinely removed, so we correctly recover by focusing the container

Reproduction

  1. Open a Radix Dialog with trapped focus scope
  2. Add a form input that triggers a React re-render on blur (e.g., react-hook-form with onBlur validation or onChange in a blur handler)
  3. Tab through the form — when leaving the input, focus jumps to the dialog container div instead of the next tabbable element

Test plan

  • Tab forward through all elements in a trapped FocusScope — focus should move sequentially without jumping to the container
  • Tab forward through elements where an onBlur handler triggers a React re-render — focus should still move to the next element
  • Shift+Tab backward through all elements — same behavior
  • When a focused element is genuinely removed from the DOM (not a re-render), focus should still recover to the container
  • Focus trapping still works: clicking outside the scope should pull focus back
  • Loop behavior still works: tabbing past the last element wraps to the first (when loop={true})

🤖 Generated with Claude Code

…jacking during Tab transitions

When a React re-render removes and re-adds DOM nodes in response to a
blur event (e.g. react-hook-form's onBlur validation), the
MutationObserver fires while activeElement is temporarily document.body
— the browser is mid-transition between the old focused element and the
next tabbable target. The synchronous focus(container) call intercepts
this transition and incorrectly moves focus to the FocusScope container.

Deferring the check to requestAnimationFrame allows the browser to
complete the focus transition. If activeElement is still document.body
after the frame, the focused element was genuinely removed and we
correctly recover by focusing the container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented May 7, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 69b6d55

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant