Skip to content

Commit f0c454b

Browse files
fix(slot): memoize composed ref in SlotClone to prevent infinite loop 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.
1 parent 22473d1 commit f0c454b

1 file changed

Lines changed: 31 additions & 18 deletions

File tree

packages/react/slot/src/slot.tsx

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -98,24 +98,37 @@ interface SlotCloneProps {
9898
}
9999

100100
/* @__NO_SIDE_EFFECTS__ */ function createSlotClone(ownerName: string) {
101-
const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
102-
let { children, ...slotProps } = props;
103-
if (isLazyComponent(children) && typeof use === 'function') {
104-
children = use(children._payload);
105-
}
106-
107-
if (React.isValidElement(children)) {
108-
const childrenRef = getElementRef(children);
109-
const props = mergeProps(slotProps, children.props as AnyProps);
110-
// do not pass ref to React.Fragment for React 19 compatibility
111-
if (children.type !== React.Fragment) {
112-
props.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
113-
}
114-
return React.cloneElement(children, props);
115-
}
116-
117-
return React.Children.count(children) > 1 ? React.Children.only(null) : null;
118-
});
101+
const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
102+
let { children, ...slotProps } = props;
103+
if (isLazyComponent(children) && typeof use === 'function') {
104+
children = use(children._payload);
105+
}
106+
107+
// Memoize the composed ref to maintain stable identity across renders.
108+
// In React 19, ref identity changes trigger cleanup + re-attach cycles.
109+
// Without memoization, composeRefs creates a new function every render,
110+
// which causes infinite loops when a state setter is used as a ref
111+
// (e.g. Tooltip's setTrigger). See: https://github.com/radix-ui/primitives/issues/3799
112+
const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
113+
const composedRef = React.useMemo(
114+
() =>
115+
forwardedRef && childrenRef
116+
? composeRefs(forwardedRef, childrenRef)
117+
: forwardedRef || childrenRef || null,
118+
[forwardedRef, childrenRef]
119+
);
120+
121+
if (React.isValidElement(children)) {
122+
const props = mergeProps(slotProps, children.props as AnyProps);
123+
// do not pass ref to React.Fragment for React 19 compatibility
124+
if (children.type !== React.Fragment) {
125+
props.ref = composedRef;
126+
}
127+
return React.cloneElement(children, props);
128+
}
129+
130+
return React.Children.count(children) > 1 ? React.Children.only(null) : null;
131+
});
119132

120133
SlotClone.displayName = `${ownerName}.SlotClone`;
121134
return SlotClone;

0 commit comments

Comments
 (0)