[popover] Tabbing out of a non-modal popup with no focusable content and no other tabbable page element throws `RangeError: Maximum call stack size exceeded`
Current behavior
When a non-modal Popover.Popup (the default; modal={false}) contains no focusable descendants, and the page has no other tabbable element besides the trigger itself, pressing Tab once while focus is inside the popup crashes the page with:
RangeError: Maximum call stack size exceededThe crash comes from infinite mutual recursion between the trigger's two focus-guard handlers in useTriggerFocusGuards:
handleFocusTargetFocus(packages/react/src/utils/popups/useTriggerFocusGuards.ts#L62-L92) closes the popup and looks for the next tabbable element with:let nextTabbable = getTabbableAfterElement( store.context.triggerFocusTargetRef.current || triggerElementRef.current, );getTabbableAfterElement→getTabbableNearElement(packages/react/src/floating-ui-react/utils/tabbable.ts#L226-L244) builds the full tabbable list fordocument.bodyand picks the next entry modulo the list length:const nextIndex = (index + dir + elementCount) % elementCount; return list[nextIndex];FocusGuardelements are themselves tabbable (tabIndex: 0,packages/react/src/utils/FocusGuard.tsx#L26-L30), so once the popup is closed and its own two "inside" guards have unmounted, the only tabbable elements left indocument.bodyare the trigger's own guards and the trigger button — three elements total. Asking for "the tabbable element after the last one" wraps around to the first one instead of returning nothing, so the trigger's after-guard resolves to the trigger's own pre-guard.- Focusing the pre-guard runs
handlePreFocusGuardFocus(useTriggerFocusGuards.ts#L44-L60), which does the mirror-image lookup withgetTabbableBeforeElement— which wraps around the same three-element list back to the after-guard, and focuses it. - Each of these
.focus()calls synchronously re-enters the other handler (DOM focus event handlers run synchronously inside.focus()), so the two handlers call each other with no termination condition, growing the native call stack until it overflows.
Both handlers already know they can be called again on the same trigger — handleFocusTargetFocus guards its own while loop that walks forward from getTabbableAfterElement with getNextTabbable (which is index-clamped, not modulo) and a break when the same element repeats — but that loop only starts after the modulo wraparound has already produced the wrong first nextTabbable, so the guard never gets a chance to run: the recursion actually happens across handlers, one call each, not inside the loop.
Expected behavior
Tabbing out of an empty non-modal popup on a page with no other tabbable elements should close the popup and move focus somewhere sane (document body, or simply stay on/after the trigger) without throwing. getTabbableAfterElement/getTabbableBeforeElement returning a different trigger's own guard as "the next tabbable element" looks like the root cause — a bounded, non-modular list walk (matching what getNextTabbable/getPreviousTabbable already do) would stop this handoff from looping back into the same trigger's guards.
Reproducible example
No dependency on any app code — this is the entire page:
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import { Popover } from '@base-ui/react/popover';
function App() {
return (
<Popover.Root modal={false} defaultOpen>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner>
<Popover.Popup>
<p>No focusable content here.</p>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
createRoot(document.getElementById('root')).render(<App />);Steps to reproduce:
- Render the component above as the only content of the page (
document.bodymust not contain any other button, link, input, or other tabbable node — only the trigger and, once opened, the popup). - The popup opens (
defaultOpen) and, because it has no focusable descendants, Base UI gives its containertabindex="-1"and moves initial focus onto it automatically. - Press Tab once.
- The page throws
RangeError: Maximum call stack size exceededinstead of closing the popup and moving focus on.
Observed on the page above, served by Vite 8.2 in development, React 19.2.4, Chrome (headless, driven by Playwright). Before the keypress the only tabbable nodes in the document are the trigger button and Base UI's own focus guards ([data-base-ui-focus-guard]), and document.activeElement is the popup container (div[role="dialog"], tabindex="-1"). A single Tab produces:
RangeError: Maximum call stack size exceeded.
at handleFocusTargetFocus (@base-ui_react_popover.js:6559:120)
at executeDispatch (react-dom_client.js:9139:5)
at runWithFiberInDEV (react-dom_client.js:850:66)
at processDispatchQueue (react-dom_client.js:9165:27)
at react-dom_client.js:9452:5
at batchedUpdates$1 (react-dom_client.js:2039:37)
at dispatchEventForPluginEventSystem (react-dom_client.js:9238:4)
at dispatchEvent (react-dom_client.js:11317:29)
at dispatchDiscreteEvent (react-dom_client.js:11299:56)
at handlePreFocusGuardFocus (@base-ui_react_popover.js:6555:55)— the two guard handlers calling each other, as described above. The error is raised nine times in a row as React re-runs the handler; afterwards the popup is closed ([data-open] and role="dialog" are gone from the DOM) and document.activeElement has fallen back to document.body.
The same crash is expected with Menu in place of Popover, since Menu.Trigger shares useTriggerFocusGuards with Popover.Trigger; that variant was not run.
Base UI version
1.7.0
Which browser are you using?
Chrome
Which OS are you using?
macOS
Which assistive tech are you using (if applicable)?
None
Additional context
The crash was first hit in an application (Vite 8.2, React 19.2.4, Chrome) rather than in the snippet above; the snippet is a reduction of that case, arrived at by following the code path described in Current behavior, and then confirmed by running it.
Related but not a duplicate: #5538 ("[Popover] openOnHover does not open on keyboard focus and causes reopening after dismissal", closed as incomplete) also reports RangeError: Maximum call stack size exceeded thrown from FloatingFocusManager, but under a different, unsupported usage pattern (manually calling setOpen(true) from the trigger's onFocus while openOnHover is set), with a focusable link inside the popup. That is a different code path from the one described here — this report's popup has no focusable content at all, and no custom onFocus handling is involved, only default Tab navigation.
Source: mui/base-ui