Skip to content

[popups] Scope queued focus cancellation to its target element#5293

Open
m2na7 wants to merge 1 commit into
mui:masterfrom
m2na7:fix/enqueue-focus-scoped-cancel
Open

[popups] Scope queued focus cancellation to its target element#5293
m2na7 wants to merge 1 commit into
mui:masterfrom
m2na7:fix/enqueue-focus-scoped-cancel

Conversation

@m2na7

@m2na7 m2na7 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Fixes #5268

Summary

enqueueFocus keeps a single module-level rAF handle and unconditionally cancels it on entry, so concurrent callers cancel each other's queued focus regardless of target. On keyboard-open of a roving-focus popup, FloatingFocusManager's initial-focus call cancels the option focus queued by useListNavigation — the manager's shouldFocus guard is designed to yield when focus has already moved inside the popup, but the module-level cancel kills the very frame that would move it, so the guard never gets the chance.

Pending frames are now keyed per element (WeakMap<FocusableElement, number>), so a caller only replaces its own target's pending focus. With that in place, the initial-focus guard observes the item focus and yields as designed. When the manager's initial-focus fallback resolves to a different element than the active item (any tabbable rendered before the items), the old code left focus permanently on that element and list navigation dead — the new hook-level test pins that case. useListNavigation retires its own previous queued request before queueing focus for another item, preserving the previous last-wins behavior for consecutive queued requests. A null target is now a no-op instead of cancelling an unrelated caller's pending focus.

The existing TODO in useListNavigation.test.tsx ("the animation frame callback from enqueueFocus sometimes kicking in after the click … causes flakeyness") describes the same mechanism.

Behavior notes

  • Two same-frame enqueues for different elements now both fire in order (last scheduled wins, before paint); previously the earlier one was silently cancelled. Final focus is unchanged in every reachable call-site ordering.
  • Migrating this file to the shared AnimationFrame scheduler was evaluated and deliberately left out: it changes timing under JSDOM's rAF stub and breaks four JSDOM-only FloatingFocusManager tests. [internal] Refactor: remove raw requestAnimationFrame #1901 also left this file's raw rAF in place.

Tests

  • enqueueFocus.test.ts (new): the cancellation semantics unit-tested with faked frames — JSDOM's real cancelAnimationFrame doesn't cancel, so the suite runs on vi.useFakeTimers(). Two cases fail on main (cross-element cancel, null side effect); the rest pin the preserved behavior (same-element replacement, cancel-function guard, sync, shouldFocus), each verified to fail against a mutated implementation.
  • useListNavigation.test.tsx: keyboard-open focuses the active item when the manager's initial focus targets another tabbable (fails on main); consecutive queued requests skip intermediate items.
  • Full jsdom suites pass (floating-ui-react, Select, Menu, Combobox — 2,113 tests) and the same suites pass in the Chromium environment, where real rAF cancellation is exercised.

@pkg-pr-new

pkg-pr-new Bot commented Jul 21, 2026

Copy link
Copy Markdown

commit: f1b3069

@code-infra-dashboard

code-infra-dashboard Bot commented Jul 21, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+124B(+0.03%) 🔺+38B(+0.03%)

Details of bundle changes

Performance

Total duration: 1,289.07 ms +29.65 ms(+2.4%) | Renders: 78 (+0) | Paint: 2,010.98 ms +34.14 ms(+1.7%)

Test Duration Renders
Checkbox mount (500 instances) 83.83 ms 🔺+21.85 ms(+35.3%) 1 (+0)
Popover mount (300 instances) 79.17 ms 🔺+15.16 ms(+23.7%) 1 (+0)

13 tests within noise — details

Metric alarms

Test Metric Change
Checkbox mount (500 instances) bench:paint 🔺 +31.48 ms
Popover mount (300 instances) bench:paint 🔺 +22.29 ms

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

@netlify

netlify Bot commented Jul 21, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit f1b3069
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a5f4445e34fa30008501e99
😎 Deploy Preview https://deploy-preview-5293--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

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

@zannager zannager added the scope: all components Widespread work has an impact on almost all components. label Jul 24, 2026
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.

Projects

None yet

2 participants