Skip to content

PopperAnchor's dependency-less useEffect causes 'Maximum update depth exceeded' #3858

@nick-jonas

Description

@nick-jonas

Bug report

Current behavior

PopperAnchor has a useEffect with no dependency array that calls context.onAnchorChange() (which is setState) on every render:

React.useEffect(() => {
  context.onAnchorChange(virtualRef?.current || ref.current);
}); // ← no dependency array

When a page has 50+ Radix components that use Popper internally (Tooltip, Popover, DropdownMenu, HoverCard), each PopperAnchor calls setState during the passive effects phase on mount. React counts all of these against a single 50-update limit per commit cycle, causing:

Error: Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
React limits the number of nested updates to prevent infinite loops.

This is not an infinite loop — it's a cumulative depth issue from too many independent Popper instances mounting simultaneously. React Strict Mode (which double-invokes effects) makes this worse in development.

Expected behavior

PopperAnchor should not contribute to React's nested update depth counter on mount. The anchor element only needs to be set once when the DOM node is attached.

Proposed fix

Replace the dependency-less useEffect + setState with a callback ref:

const callbackRef = React.useCallback((node) => {
  ref.current = node;
  if (node) context.onAnchorChange(node);
}, [context.onAnchorChange]);
const composedRefs = useComposedRefs(forwardedRef, virtualRef ? ref : callbackRef);
React.useEffect(() => {
  if (virtualRef?.current) context.onAnchorChange(virtualRef.current);
}, [virtualRef, context.onAnchorChange]);

React calls callback refs during DOM attachment in the commit phase, which does not count toward the nested update limit. The useEffect is kept only for the virtualRef case.

We verified this fix works via a pnpm patch on @radix-ui/react-popper@1.2.7.

Reproduction

Any page with 50+ Radix Tooltip/Popover/DropdownMenu/HoverCard components mounting in a single render pass. In our case: a workbench page with a sidebar nav (~10 items with Tooltips), action buttons with Tooltips (~15), and an inline document editor with citation HoverCards (~30+).

Environment

  • @radix-ui/react-popper: 1.2.7
  • React 19.2.3
  • Verified in both development (StrictMode) and production builds

Your minimal, reproducible example

Mount 60 Radix Tooltip components in a single page. Observe "Maximum update depth exceeded" on initial render.

Relevant package versions

  • @radix-ui/react-tooltip: 1.2.7
  • @radix-ui/react-popper: 1.2.7
  • react: 19.2.3
  • react-dom: 19.2.3

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions