fix(slot): use memoized useComposedRefs in SlotClone to prevent React 19 infinite loops#3804
Open
yannisgu wants to merge 2 commits intoradix-ui:mainfrom
Open
fix(slot): use memoized useComposedRefs in SlotClone to prevent React 19 infinite loops#3804yannisgu wants to merge 2 commits intoradix-ui:mainfrom
yannisgu wants to merge 2 commits intoradix-ui:mainfrom
Conversation
…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 detectedLatest commit: 12278ef The changes in this PR will be included in the next version bump. This PR includes changesets to release 44 packages
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
This PR was generated by an AI agent (Claude Code). Human verification of the fix and its implications is still outstanding.
Summary
SlotClonenow usesuseComposedRefs()(memoized viaReact.useCallback) instead of rawcomposeRefs()to compose forwarded and children refsReproduction
Minimal repro repo: https://github.com/yannisgu/radix-tanstack-form-react19-repro
Problem
SlotClonecallscomposeRefs(forwardedRef, childrenRef)directly on every render, creating a new function each time. In React 19,setRef()returnsref(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'sonTriggerChange, orCheckbox'suseSize).This state update during commit causes a re-render, which creates another new
composeRefsfunction, 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'suseFieldlayout effect with no dependency array), but can occur with any component that has effects or state updates during commit.Root cause chain
SlotClonecallscomposeRefs()→ new function identity every rendersetRef(ref, value)returnsref(value)→ React 19 treats it as cleanup refonTriggerChange) → re-renderFix
Replace raw
composeRefs()withuseComposedRefs(), which wraps the call inReact.useCallback. This was already the intended pattern —useComposedRefsexists for exactly this purpose but wasn't used inSlotClone.Affected components
Any Radix component using
Slot/asChildthat 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