Skip to content

fix(react): stabilize composed ref identities to end React 19 render loop (#3963)#3967

Merged
chaance merged 1 commit into
radix-ui:mainfrom
nakisgr:fix/composed-ref-stability-react19
Jun 15, 2026
Merged

fix(react): stabilize composed ref identities to end React 19 render loop (#3963)#3967
chaance merged 1 commit into
radix-ui:mainfrom
nakisgr:fix/composed-ref-stability-react19

Conversation

@nakisgr

@nakisgr nakisgr commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Follows up on #3899, which stabilized Slot's composed ref to fix the React 19 infinite re-render loop. The same unstable-identity pattern still exists in several other packages' own code, and under React 19's ref-cleanup semantics each reproduces the same Maximum update depth exceeded crash.

useComposedRefs is React.useCallback(composeRefs(...refs), refs). When a member of refs is an inline arrow (or the composition is a bare composeRefs(...) in render), the composed callback gets a new identity every render. React 19 detaches/re-attaches the ref on every commit; any composed state-setter ref toggles null -> node each cycle, scheduling another render — an infinite loop.

Changes

Same principle as #3899 — give the composed refs a stable identity:

  • Pass state setters directly (useComposedRefs(forwardedRef, setX)), matching existing usage like select.tsx onTriggerChange:
    • react-dismissable-layer: DismissableLayersetNode
    • react-focus-scope: FocusScopesetContainer
    • react-popper: PopperContentsetContent
    • react-scroll-area: ScrollAreasetScrollArea, ScrollbarsetScrollbar, ThumbscrollbarContext.onThumbChange (already stabilized via useCallbackRef)
    • react-select: SelectContentImpl / SelectItemAlignedPositionsetContent, SelectItemTextsetItemTextNode
  • Hoist render-inline composeRefs(...) into useComposedRefs above the JSX:
    • react-dropdown-menu: DropdownMenuTrigger
    • react-menu: MenuSubTrigger (composed directly with the onTriggerChange state setter)
  • Wrap closures that capture per-render values in useCallbackRef before composing (they take extra args, so can't be passed directly):
    • react-select: SelectItem (value, disabled) and SelectItemText (value, disabled)

Notes

Closes #3963.

@changeset-bot

changeset-bot Bot commented Jun 15, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 20c8435

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@chaance chaance merged commit 71a7122 into radix-ui:main Jun 15, 2026
1 of 2 checks passed
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.

[React 19] Unstable composed-ref identities in several primitives still cause "Maximum update depth exceeded" after the Slot fix (#3899)

2 participants