Skip to content

Commit f64d781

Browse files
authored
use stable composed refs in SlotClone (#3477)
1 parent db7a4c5 commit f64d781

File tree

3 files changed

+62
-4
lines changed

3 files changed

+62
-4
lines changed

.changeset/famous-socks-deny.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@radix-ui/react-slot': patch
3+
---
4+
5+
use stable composed refs in SlotClone

packages/react/slot/src/slot.test.tsx

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import React from 'react';
1+
import React, { useState } from 'react';
22
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
34
import { Slot, Slottable } from './slot';
45
import { afterEach, describe, it, beforeEach, vi, expect } from 'vitest';
56

@@ -139,6 +140,36 @@ describe('given a Button with Slottable', () => {
139140
});
140141
});
141142

143+
describe('given an Input', () => {
144+
const handleRef = vi.fn();
145+
146+
beforeEach(() => {
147+
handleRef.mockReset();
148+
});
149+
150+
afterEach(cleanup);
151+
152+
describe('without asChild', () => {
153+
it('should only call function refs once', async () => {
154+
render(<Input ref={handleRef} />);
155+
await userEvent.type(screen.getByRole('textbox'), 'foo');
156+
expect(handleRef).toHaveBeenCalledTimes(1);
157+
});
158+
});
159+
160+
describe('with asChild', () => {
161+
it('should only call function refs once', async () => {
162+
render(
163+
<Input asChild ref={handleRef}>
164+
<input />
165+
</Input>
166+
);
167+
await userEvent.type(screen.getByRole('textbox'), 'foo');
168+
expect(handleRef).toHaveBeenCalledTimes(1);
169+
});
170+
});
171+
});
172+
142173
type TriggerProps = React.ComponentProps<'button'> & { as: React.ElementType };
143174

144175
const Trigger = ({ as: Comp = 'button', ...props }: TriggerProps) => <Comp {...props} />;
@@ -160,3 +191,24 @@ const Button = React.forwardRef<
160191
</Comp>
161192
);
162193
});
194+
195+
const Input = React.forwardRef<
196+
React.ElementRef<'input'>,
197+
React.ComponentProps<'input'> & {
198+
asChild?: boolean;
199+
}
200+
>(({ asChild, children, ...props }, forwardedRef) => {
201+
const Comp = asChild ? Slot : 'input';
202+
const [value, setValue] = useState('');
203+
204+
return (
205+
<Comp
206+
{...props}
207+
onChange={(event) => setValue(event.target.value)}
208+
ref={forwardedRef}
209+
value={value}
210+
>
211+
{children}
212+
</Comp>
213+
);
214+
});

packages/react/slot/src/slot.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react';
2-
import { composeRefs } from '@radix-ui/react-compose-refs';
2+
import { useComposedRefs } from '@radix-ui/react-compose-refs';
33

44
/* -------------------------------------------------------------------------------------------------
55
* Slot
@@ -66,13 +66,14 @@ interface SlotCloneProps {
6666
/* @__NO_SIDE_EFFECTS__ */ function createSlotClone(ownerName: string) {
6767
const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
6868
const { children, ...slotProps } = props;
69+
const childrenRef = React.isValidElement(children) ? getElementRef(children) : undefined;
70+
const ref = useComposedRefs(childrenRef, forwardedRef);
6971

7072
if (React.isValidElement(children)) {
71-
const childrenRef = getElementRef(children);
7273
const props = mergeProps(slotProps, children.props as AnyProps);
7374
// do not pass ref to React.Fragment for React 19 compatibility
7475
if (children.type !== React.Fragment) {
75-
props.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
76+
props.ref = ref;
7677
}
7778
return React.cloneElement(children, props);
7879
}

0 commit comments

Comments
 (0)