Skip to content

Commit 8961a25

Browse files
authored
Slot: Unwrap promises for async child elements (#3680)
Fix #3165 This changeset patches an issue with how slot components interact with lazy React components. In the case of a lazy component instance, the resulting promise must be consumed to render the desired component. Thank you @danielr18 for contributing the initial fix.
1 parent aedbdaf commit 8961a25

File tree

4 files changed

+109
-2
lines changed

4 files changed

+109
-2
lines changed

.changeset/purple-donuts-smell.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+
This changeset patches an issue with how slot components interact with lazy React components. In the case of a lazy component instance, the resulting promise must be consumed to render the desired component.

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,22 @@ exports[`given a Button with Slottable > without asChild > should render a butto
3535
</button>
3636
</div>
3737
`;
38+
39+
exports[`given a Slot with React lazy components > with a lazy component in Button with Slottable > should render a lazy link with icon on the left/right 1`] = `
40+
<div>
41+
<a
42+
href="https://radix-ui.com"
43+
>
44+
<span>
45+
left
46+
</span>
47+
Button
48+
<em>
49+
text
50+
</em>
51+
<span>
52+
right
53+
</span>
54+
</a>
55+
</div>
56+
`;

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,65 @@ describe.skip('given an Input', () => {
172172
});
173173
});
174174

175+
describe('given a Slot with React lazy components', () => {
176+
afterEach(cleanup);
177+
178+
describe('with a lazy component as child', () => {
179+
const LazyButton = React.lazy(() =>
180+
Promise.resolve({
181+
default: ({ children, ...props }: React.ComponentProps<'button'>) => (
182+
<button {...props}>{children}</button>
183+
),
184+
})
185+
);
186+
187+
it('should render the lazy component correctly', async () => {
188+
const handleClick = vi.fn();
189+
190+
render(
191+
<React.Suspense fallback={<div>Loading...</div>}>
192+
<Slot onClick={handleClick}>
193+
<LazyButton>Click me</LazyButton>
194+
</Slot>
195+
</React.Suspense>
196+
);
197+
198+
// Wait for lazy component to load
199+
await screen.findByRole('button');
200+
201+
fireEvent.click(screen.getByRole('button'));
202+
expect(handleClick).toHaveBeenCalledTimes(1);
203+
});
204+
});
205+
206+
describe('with a lazy component in Button with Slottable', () => {
207+
const LazyLink = React.lazy(() =>
208+
Promise.resolve({
209+
default: ({ children, ...props }: React.ComponentProps<'a'>) => (
210+
<a {...props}>{children}</a>
211+
),
212+
})
213+
);
214+
215+
it('should render a lazy link with icon on the left/right', async () => {
216+
const tree = render(
217+
<React.Suspense fallback={<div>Loading...</div>}>
218+
<Button iconLeft={<span>left</span>} iconRight={<span>right</span>} asChild>
219+
<LazyLink href="https://radix-ui.com">
220+
Button <em>text</em>
221+
</LazyLink>
222+
</Button>
223+
</React.Suspense>
224+
);
225+
226+
// Wait for lazy component to load
227+
await screen.findByRole('link');
228+
229+
expect(tree.container).toMatchSnapshot();
230+
});
231+
});
232+
});
233+
175234
type TriggerProps = React.ComponentProps<'button'> & { as: React.ElementType };
176235

177236
const Trigger = ({ as: Comp = 'button', ...props }: TriggerProps) => <Comp {...props} />;

packages/react/slot/src/slot.tsx

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

4+
declare module 'react' {
5+
interface ReactElement {
6+
$$typeof?: symbol | string;
7+
}
8+
}
9+
10+
const REACT_LAZY_TYPE = Symbol.for('react.lazy');
11+
12+
interface LazyReactElement extends React.ReactElement {
13+
$$typeof: typeof REACT_LAZY_TYPE;
14+
_payload: any;
15+
}
16+
417
/* -------------------------------------------------------------------------------------------------
518
* Slot
619
* -----------------------------------------------------------------------------------------------*/
@@ -9,10 +22,18 @@ interface SlotProps extends React.HTMLAttributes<HTMLElement> {
922
children?: React.ReactNode;
1023
}
1124

25+
function isLazyComponent(element: React.ReactNode): element is LazyReactElement {
26+
// has to be done in a roundabout way unless we want to add a dependency on react-is
27+
return React.isValidElement(element) && element.$$typeof === REACT_LAZY_TYPE;
28+
}
29+
1230
/* @__NO_SIDE_EFFECTS__ */ export function createSlot(ownerName: string) {
1331
const SlotClone = createSlotClone(ownerName);
1432
const Slot = React.forwardRef<HTMLElement, SlotProps>((props, forwardedRef) => {
15-
const { children, ...slotProps } = props;
33+
let { children, ...slotProps } = props;
34+
if (isLazyComponent(children)) {
35+
children = React.use(children._payload);
36+
}
1637
const childrenArray = React.Children.toArray(children);
1738
const slottable = childrenArray.find(isSlottable);
1839

@@ -65,7 +86,10 @@ interface SlotCloneProps {
6586

6687
/* @__NO_SIDE_EFFECTS__ */ function createSlotClone(ownerName: string) {
6788
const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
68-
const { children, ...slotProps } = props;
89+
let { children, ...slotProps } = props;
90+
if (isLazyComponent(children)) {
91+
children = React.use(children._payload);
92+
}
6993

7094
if (React.isValidElement(children)) {
7195
const childrenRef = getElementRef(children);

0 commit comments

Comments
 (0)