#5715·base-ui

[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`

Author: berezin-pavelCreated Sep 12, 2026Updated Sep 18, 2026
Labelstype: bugcomponent: popover

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 exceeded

The 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:
    typescript
    let nextTabbable = getTabbableAfterElement(
      store.context.triggerFocusTargetRef.current || triggerElementRef.current,
    );
  • getTabbableAfterElementgetTabbableNearElement (packages/react/src/floating-ui-react/utils/tabbable.ts#L226-L244) builds the full tabbable list for document.body and picks the next entry modulo the list length:
    typescript
    const nextIndex = (index + dir + elementCount) % elementCount;
    return list[nextIndex];
  • FocusGuard elements 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 in document.body are 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 with getTabbableBeforeElement — 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:

typescript
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:

  1. Render the component above as the only content of the page (document.body must not contain any other button, link, input, or other tabbable node — only the trigger and, once opened, the popup).
  2. The popup opens (defaultOpen) and, because it has no focusable descendants, Base UI gives its container tabindex="-1" and moves initial focus onto it automatically.
  3. Press Tab once.
  4. The page throws RangeError: Maximum call stack size exceeded instead 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.