mui / mui/base-ui

[popover][menu] Tabbing out of a non-modal popup with no focusable content and no other tabbable page element throws `RangeError: Maximum call stack size exceeded`

Open
#5,715 0 comments 0 reactions 0 assignees View on GitHub
component: menu component: popover type: bug
Dominant language
TypeScript
Stars
10.9k
Forks
543
Avg merge
1d 20h
Merged PRs (30d)
101

Description

## 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:
```ts
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 for `document.body` and picks the next entry **modulo the list length**:
```ts
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:

```tsx
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import { Popover } from '@base-ui/react/popover';

function App() {
return (

Open



No focusable content here.






);
}

createRoot(document.getElementById('root')).render();
```

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.

Contributor guide

Open the contributing guide

Research direction

Start by reproducing the reduced Popover example, then read packages/react/src/utils/popups/useTriggerFocusGuards.ts and packages/react/src/floating-ui-react/utils/tabbable.ts, especially the guard handlers and bounded lookup helpers. Add regression coverage for an empty non-modal popup with no other tabbable page element. Done means pressing Tab closes the popup and moves focus safely without recursive focus events or a RangeError; verify the shared Menu path as appropriate.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
accessibility, frontend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
78/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.