Skip to content

fix(slot): use memoized useComposedRefs in SlotClone to prevent React 19 infinite loops#3804

Open
yannisgu wants to merge 2 commits intoradix-ui:mainfrom
yannisgu:fix/slot-clone-stable-composed-refs
Open

fix(slot): use memoized useComposedRefs in SlotClone to prevent React 19 infinite loops#3804
yannisgu wants to merge 2 commits intoradix-ui:mainfrom
yannisgu:fix/slot-clone-stable-composed-refs

Conversation

@yannisgu
Copy link

@yannisgu yannisgu commented Feb 5, 2026

Note

This PR was generated by an AI agent (Claude Code). Human verification of the fix and its implications is still outstanding.

Summary

  • SlotClone now uses useComposedRefs() (memoized via React.useCallback) instead of raw composeRefs() to compose forwarded and children refs
  • This keeps the composed ref callback identity stable across renders, preventing React 19's ref unmount/remount cycle from triggering infinite loops

Reproduction

Minimal repro repo: https://github.com/yannisgu/radix-tanstack-form-react19-repro

git clone https://github.com/yannisgu/radix-tanstack-form-react19-repro
cd radix-tanstack-form-react19-repro/radix-select-tanstack-form
pnpm install && pnpm dev
# Open http://localhost:5173 — tab freezes

Problem

SlotClone calls composeRefs(forwardedRef, childrenRef) directly on every render, creating a new function each time. In React 19, setRef() returns ref(value), which React treats as a cleanup function. When React sees a new callback ref with cleanup on each render, it unmounts the old ref (calling cleanup) and mounts the new one — triggering any state setters passed as refs (e.g. SelectTrigger's onTriggerChange, or Checkbox's useSize).

This state update during commit causes a re-render, which creates another new composeRefs function, which triggers another unmount/remount — an infinite synchronous loop that freezes the browser tab.

The bug is particularly severe when combined with libraries that trigger re-renders during the commit phase (e.g. @tanstack/react-form's useField layout effect with no dependency array), but can occur with any component that has effects or state updates during commit.

Root cause chain

  1. SlotClone calls composeRefs() → new function identity every render
  2. setRef(ref, value) returns ref(value) → React 19 treats it as cleanup ref
  3. New ref identity + cleanup → React unmounts old ref, mounts new ref
  4. Ref mount triggers state setter (e.g. onTriggerChange) → re-render
  5. Re-render → step 1 → infinite loop

Fix

Replace raw composeRefs() with useComposedRefs(), which wraps the call in React.useCallback. This was already the intended pattern — useComposedRefs exists for exactly this purpose but wasn't used in SlotClone.

Affected components

Any Radix component using Slot/asChild that passes a state setter as a ref:

  • @radix-ui/react-select (SelectTrigger + onTriggerChange)
  • @radix-ui/react-checkbox (useSize state setter)
  • @radix-ui/react-popover, @radix-ui/react-tooltip, @radix-ui/react-dialog, etc.

Fixes #3799

…ite loops

SlotClone previously called composeRefs() directly, creating a new ref
callback function on every render. In React 19, callback refs can return
cleanup functions, and when composeRefs' internal setRef returns ref(value),
React treats the composed ref as having cleanup semantics. Combined with a
new ref identity every render, React unmounts the old ref and mounts the
new one each render cycle.

When a state setter is passed as one of the refs (e.g. SelectTrigger's
onTriggerChange, or Checkbox's useSize), the ref unmount/remount triggers a
state update during React's commit phase. If anything else also triggers a
re-render during commit (e.g. @tanstack/react-form's useField layout
effect), this creates an infinite synchronous loop that freezes the browser
tab.

Fix: use useComposedRefs (which wraps composeRefs in React.useCallback) to
memoize the composed ref callback, keeping its identity stable across
renders and preventing the unmount/remount cycle.

Fixes radix-ui#3799
@changeset-bot
Copy link

changeset-bot bot commented Feb 5, 2026

🦋 Changeset detected

Latest commit: 12278ef

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

This PR includes changesets to release 44 packages
Name Type
@radix-ui/react-slot Patch
@radix-ui/react-alert-dialog Patch
@radix-ui/react-collection Patch
@radix-ui/react-dialog Patch
@radix-ui/react-menu Patch
@radix-ui/react-popover Patch
@radix-ui/react-primitive Patch
radix-ui Patch
@radix-ui/react-select Patch
@radix-ui/react-tooltip Patch
@radix-ui/react-accordion Patch
@radix-ui/react-menubar Patch
@radix-ui/react-navigation-menu Patch
@radix-ui/react-one-time-password-field Patch
@radix-ui/react-roving-focus Patch
@radix-ui/react-slider Patch
@radix-ui/react-toast Patch
@radix-ui/react-context-menu Patch
@radix-ui/react-dropdown-menu Patch
@radix-ui/react-announce Patch
@radix-ui/react-arrow Patch
@radix-ui/react-aspect-ratio Patch
@radix-ui/react-avatar Patch
@radix-ui/react-checkbox Patch
@radix-ui/react-collapsible Patch
@radix-ui/react-dismissable-layer Patch
@radix-ui/react-focus-scope Patch
@radix-ui/react-form Patch
@radix-ui/react-hover-card Patch
@radix-ui/react-label Patch
@radix-ui/react-password-toggle-field Patch
@radix-ui/react-popper Patch
@radix-ui/react-portal Patch
@radix-ui/react-progress Patch
@radix-ui/react-radio-group Patch
@radix-ui/react-scroll-area Patch
@radix-ui/react-separator Patch
@radix-ui/react-switch Patch
@radix-ui/react-tabs Patch
@radix-ui/react-toggle-group Patch
@radix-ui/react-toggle Patch
@radix-ui/react-toolbar Patch
@radix-ui/react-visually-hidden Patch
@radix-ui/react-accessible-icon Patch

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