Skip to content

[popups] Fix return focus when reference disconnects#4655

Merged
atomiks merged 3 commits intomui:masterfrom
atomiks:codex/fix-disconnected-return-focus
Apr 27, 2026
Merged

[popups] Fix return focus when reference disconnects#4655
atomiks merged 3 commits intomui:masterfrom
atomiks:codex/fix-disconnected-return-focus

Conversation

@atomiks
Copy link
Copy Markdown
Contributor

@atomiks atomiks commented Apr 21, 2026

Opening a dialog from a menu item can disconnect the dialog's direct trigger when the menu closes. The focus manager was treating that disconnected reference as the default target and then giving up, so closing the dialog left focus on the body. This falls back to the last connected focus target instead, allowing focus to return to the root menu trigger.

Changes

  • Use the previously focused connected element when the direct floating reference has disconnected.
  • Add Chromium regression coverage for a detached dialog trigger rendered through a menu item.

@atomiks atomiks added component: menu Changes related to the menu component. type: bug It doesn't behave as expected. component: dialog Changes related to the dialog component. labels Apr 21, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 21, 2026

commit: d1ab1bc

@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented Apr 21, 2026

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+3B(0.00%) ▼-4B(0.00%)

Details of bundle changes

Performance

Total duration: 1,476.39 ms 🔺+130.91 ms(+9.7%) | Renders: 53 (+0) | Paint: 2,259.73 ms 🔺+185.20 ms(+8.9%)

Test Duration Renders
Tabs mount (200 instances) 283.27 ms 🔺+60.84 ms(+27.4%) 4 (+0)
Slider mount (300 instances) 184.05 ms 🔺+56.48 ms(+44.3%) 3 (+0)
Tooltip mount (300 contained roots) 79.93 ms 🔺+22.84 ms(+40.0%) 2 (+0)
Checkbox mount (500 instances) 81.25 ms 🔺+18.78 ms(+30.1%) 1 (+0)
Dialog mount (300 instances) 96.33 ms 🔺+16.16 ms(+20.2%) 2 (+0)

...and 7 more. View full report

Details of benchmark changes


Check out the code infra dashboard for more information about this PR.

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 21, 2026

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit d1ab1bc
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/69eea585eef1c800086239f1
😎 Deploy Preview https://deploy-preview-4655--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@atomiks atomiks marked this pull request as ready for review April 22, 2026 10:44
Copy link
Copy Markdown
Member

@flaviendelangle flaviendelangle left a comment

Choose a reason for hiding this comment

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

PR #4655 Review Summary

PR #4655 — [floating-ui] Fix return focus when reference disconnects · 2 files, +63/−3

Files changed:

What it does: Opening a dialog from a Menu.Item that also renders Dialog.Trigger causes the menu to close, which unmounts the dialog's direct trigger. FloatingFocusManager then treats that disconnected reference as the return-focus target and gives up, leaving focus on <body> after the dialog closes. The fix falls back to getPreviouslyFocusedElement() (a WeakRef list that already filters connected elements) when domReference?.isConnected is false. The same isConnected guard is applied to the non-boolean returnFocus branch, fixing the same latent bug there too.

Critical Issues

None.

(The test-analyzer flagged a potential JSX bracket mismatch in the new test as a blocker. I re-fetched the raw diff and verified: <Menu.Root> closes correctly before <Dialog.Root> opens as a sibling inside <React.Fragment>. False alarm — dismiss.)

Important Issues

Test coverage gap on the non-boolean returnFocus branchcode-reviewer · pr-test-analyzer

The diff also changes the non-boolean branch at FloatingFocusManager.tsx:790: const fallback = domReference || …domReference?.isConnected ? domReference : …. This fixes the same class of latent bug (disconnected domReference used as fallback when returnFocus is a ref/function that resolves nullish), but neither the added Dialog-level test nor the existing FloatingFocusManager tests at lines 1634-1700 or 347-453 exercise it. A small unit test in FloatingFocusManager.test.tsx passing returnFocus={someRef} (with someRef.current = null) and detaching the reference would cover it.

Suggestions

  • Combine the split waitFor pair (DialogRoot.test.tsx:1306-1311) into one waitFor that asserts both menu-closed and dialog-open. Avoids accidental-pass diagnostics if a regression interleaves the transitions. Low priority. — pr-test-analyzer
  • Commit/PR message could note the bonus fix in the non-boolean branch — it's not just the default-returnFocus=true case being fixed; the ref/function case gets the same isConnected guarantee. Worth mentioning for reviewers / changelog. — code-reviewer

Strengths

  • Surgical, minimally scoped 5-line fix. When domReference is connected (the common path), return value is unchanged — zero risk to happy paths. — code-reviewer
  • getReturnElement() is confirmed to be the single source of truth for return-focus targets in FloatingFocusManager.tsx. Other .focus() call sites (lines 510/519/533 in restoreFocus, 926/931/946/954 in focus guards) are orthogonal and unaffected. No other unfixed sites of the same class. — code-reviewer
  • Test uses realistic compositionMenu.Item render={<Dialog.Trigger handle={dialogHandle} />} — reproducing the production pattern rather than a synthetic unmount. Fidelity matches the reported bug exactly. — pr-test-analyzer
  • Test is not redundant with the existing FloatingFocusManager unit test at lines 1634-1700: that one protects the internal focus-fallback contract, this one protects the user-facing Menu-triggers-Dialog integration. Complementary coverage. — pr-test-analyzer
  • AGENTS.md compliance is clean: it.skipIf(isJSDOM) correct, no redundant flushMicrotasks, Vitest-only APIs, imports already present, file co-located with source, no useTimeout/useAnimationFrame/etc. rules triggered by the diff. — both agents
  • expect(menuTrigger).toHaveFocus() is tight enough — it fails for <body>, other elements, or null focus. No silent-pass risk. — pr-test-analyzer

Semantic-equivalence verification (boolean branch)

domReference state Pre-PR return Post-PR return Same?
connected domReference domReference yes
defined, disconnected null getPreviouslyFocusedElement() || null No — intentional fix
null/undefined getPreviouslyFocusedElement() gated by isConnected getPreviouslyFocusedElement() || null yes (fallback is already connected-or-undefined via internal clearDisconnectedPreviouslyFocusedElements)

Only divergence is the intended fix. No unintended drift.

Review scope

  • Ran code-reviewer and pr-test-analyzer in parallel.
  • Skipped silent-failure-hunter (no error-handling changes), comment-analyzer (no comments), type-design-analyzer (no new types), code-simplifier (diff is already minimal).

Recommended Action

  1. Not blocking. The fix is correct, narrowly scoped, and well tested at the Dialog integration layer. It's mergeable as-is.
  2. Nice-to-have follow-up: add a FloatingFocusManager unit test for the ref/function returnFocus branch with a detached reference, so both post-PR branches have explicit regression coverage. Could be a separate small PR.

Local verification commands

  • pnpm test:chromium DialogRoot --no-watch — runs the new regression test.
  • pnpm test:chromium FloatingFocusManager --no-watch — ensures existing return-focus tests still pass.
  • Manual smoke: deploy preview — open a menu-triggered dialog and confirm focus returns to the menu trigger on Escape.

@atomiks atomiks changed the title [floating-ui] Fix return focus when reference disconnects [popups] Fix return focus when reference disconnects Apr 26, 2026
@atomiks atomiks added scope: all components Widespread work has an impact on almost all components. and removed component: menu Changes related to the menu component. component: dialog Changes related to the dialog component. labels Apr 26, 2026
@atomiks atomiks merged commit 40497f0 into mui:master Apr 27, 2026
26 of 27 checks passed
@atomiks atomiks deleted the codex/fix-disconnected-return-focus branch April 27, 2026 00:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: all components Widespread work has an impact on almost all components. type: bug It doesn't behave as expected.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants