Skip to content

fix(slot): memoize composed ref in SlotClone to prevent infinite loop…#3835

Open
ishantmehta01 wants to merge 4 commits intoradix-ui:mainfrom
ishantmehta01:patch-1
Open

fix(slot): memoize composed ref in SlotClone to prevent infinite loop…#3835
ishantmehta01 wants to merge 4 commits intoradix-ui:mainfrom
ishantmehta01:patch-1

Conversation

@ishantmehta01
Copy link
Copy Markdown

… in React 19

Fixes #3799

Problem

In SlotClone, composeRefs(forwardedRef, childrenRef) is called inline during render, creating a new function identity on every render. In React 19, ref identity changes
trigger ref cleanup + re-attach. When a state setter is used as a ref (e.g., Tooltip's setTrigger), this causes an infinite loop:

  1. New composeRefs → React detects ref identity change
  2. React calls cleanup (setTrigger(null)) → state update → re-render
  3. Re-render creates another new composeRefs → goto 1
  4. "Maximum update depth exceeded"

Solution

Memoize the composed ref via React.useMemo so the ref identity stays stable across renders. The memoization depends on forwardedRef and childrenRef — it only recomputes
when the actual refs change, not on every render.

How this differs from #3804

PR #3804 switches to useComposedRefs, but comments there note that useComposedRefs itself is unstable when callers pass inline arrow functions. This approach memoizes
directly at the call site in SlotClone, which is the minimal change needed — it keeps composeRefs untouched and just stabilizes the ref identity where it matters.

Validation

Running in production on a large enterprise React 19 app since Feb 2026. Zero recurrence of the infinite loop across Tooltip, Select, Popover, and Dialog components.

Description

… in React 19

Fixes radix-ui#3799

  ## Problem

  In `SlotClone`, `composeRefs(forwardedRef, childrenRef)` is called inline during render, creating a new function identity on every render. In React 19, ref identity changes
  trigger ref cleanup + re-attach. When a state setter is used as a ref (e.g., Tooltip's `setTrigger`), this causes an infinite loop:

  1. New `composeRefs` → React detects ref identity change
  2. React calls cleanup (`setTrigger(null)`) → state update → re-render
  3. Re-render creates another new `composeRefs` → goto 1
  4. "Maximum update depth exceeded"

  ## Solution

  Memoize the composed ref via `React.useMemo` so the ref identity stays stable across renders. The memoization depends on `forwardedRef` and `childrenRef` — it only recomputes
  when the actual refs change, not on every render.

  ## How this differs from radix-ui#3804

  PR radix-ui#3804 switches to `useComposedRefs`, but comments there note that `useComposedRefs` itself is unstable when callers pass inline arrow functions. This approach memoizes
  directly at the call site in `SlotClone`, which is the minimal change needed — it keeps `composeRefs` untouched and just stabilizes the ref identity where it matters.

  ## Validation

  Running in production on a large enterprise React 19 app since Feb 2026. Zero recurrence of the infinite loop across Tooltip, Select, Popover, and Dialog components.
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 23, 2026

🦋 Changeset detected

Latest commit: 8e5438e

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

installHook.js:1 Error: Maximum update depth exceeded - React 19 + Radix

1 participant