From dc6ba6586598a4e1de8d7d1ed2aee601cc094105 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Thu, 18 Sep 2025 00:49:53 -0400 Subject: [PATCH 1/2] 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. --- .../slot/src/__snapshots__/slot.test.tsx.snap | 19 ++++++ packages/react/slot/src/slot.test.tsx | 59 +++++++++++++++++++ packages/react/slot/src/slot.tsx | 28 ++++++++- 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/packages/react/slot/src/__snapshots__/slot.test.tsx.snap b/packages/react/slot/src/__snapshots__/slot.test.tsx.snap index 4f76924864..11d7b7f561 100644 --- a/packages/react/slot/src/__snapshots__/slot.test.tsx.snap +++ b/packages/react/slot/src/__snapshots__/slot.test.tsx.snap @@ -35,3 +35,22 @@ exports[`given a Button with Slottable > without asChild > should render a butto `; + +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`] = ` +
+ + + left + + Button + + text + + + right + + +
+`; diff --git a/packages/react/slot/src/slot.test.tsx b/packages/react/slot/src/slot.test.tsx index b7fae1389a..d3f7770789 100644 --- a/packages/react/slot/src/slot.test.tsx +++ b/packages/react/slot/src/slot.test.tsx @@ -172,6 +172,65 @@ describe.skip('given an Input', () => { }); }); +describe('given a Slot with React lazy components', () => { + afterEach(cleanup); + + describe('with a lazy component as child', () => { + const LazyButton = React.lazy(() => + Promise.resolve({ + default: ({ children, ...props }: React.ComponentProps<'button'>) => ( + + ), + }) + ); + + it('should render the lazy component correctly', async () => { + const handleClick = vi.fn(); + + render( + Loading...}> + + Click me + + + ); + + // Wait for lazy component to load + await screen.findByRole('button'); + + fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + }); + + describe('with a lazy component in Button with Slottable', () => { + const LazyLink = React.lazy(() => + Promise.resolve({ + default: ({ children, ...props }: React.ComponentProps<'a'>) => ( + {children} + ), + }) + ); + + it('should render a lazy link with icon on the left/right', async () => { + const tree = render( + Loading...}> + + + ); + + // Wait for lazy component to load + await screen.findByRole('link'); + + expect(tree.container).toMatchSnapshot(); + }); + }); +}); + type TriggerProps = React.ComponentProps<'button'> & { as: React.ElementType }; const Trigger = ({ as: Comp = 'button', ...props }: TriggerProps) => ; diff --git a/packages/react/slot/src/slot.tsx b/packages/react/slot/src/slot.tsx index 7e31d66545..2b582bfdbe 100644 --- a/packages/react/slot/src/slot.tsx +++ b/packages/react/slot/src/slot.tsx @@ -1,6 +1,19 @@ import * as React from 'react'; import { composeRefs } from '@radix-ui/react-compose-refs'; +declare module 'react' { + interface ReactElement { + $$typeof?: symbol | string; + } +} + +const REACT_LAZY_TYPE = Symbol.for('react.lazy'); + +interface LazyReactElement extends React.ReactElement { + $$typeof: typeof REACT_LAZY_TYPE; + _payload: any; +} + /* ------------------------------------------------------------------------------------------------- * Slot * -----------------------------------------------------------------------------------------------*/ @@ -9,10 +22,18 @@ interface SlotProps extends React.HTMLAttributes { children?: React.ReactNode; } +function isLazyComponent(element: React.ReactNode): element is LazyReactElement { + // has to be done in a roundabout way unless we want to add a dependency on react-is + return React.isValidElement(element) && element.$$typeof === REACT_LAZY_TYPE; +} + /* @__NO_SIDE_EFFECTS__ */ export function createSlot(ownerName: string) { const SlotClone = createSlotClone(ownerName); const Slot = React.forwardRef((props, forwardedRef) => { - const { children, ...slotProps } = props; + let { children, ...slotProps } = props; + if (isLazyComponent(children)) { + children = React.use(children._payload); + } const childrenArray = React.Children.toArray(children); const slottable = childrenArray.find(isSlottable); @@ -65,7 +86,10 @@ interface SlotCloneProps { /* @__NO_SIDE_EFFECTS__ */ function createSlotClone(ownerName: string) { const SlotClone = React.forwardRef((props, forwardedRef) => { - const { children, ...slotProps } = props; + let { children, ...slotProps } = props; + if (isLazyComponent(children)) { + children = React.use(children._payload); + } if (React.isValidElement(children)) { const childrenRef = getElementRef(children); From 0f0cab1d236105bfa584e2ab35bb0944e6a4a4fb Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Thu, 18 Sep 2025 00:52:44 -0400 Subject: [PATCH 2/2] chore: add changeset --- .changeset/purple-donuts-smell.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/purple-donuts-smell.md diff --git a/.changeset/purple-donuts-smell.md b/.changeset/purple-donuts-smell.md new file mode 100644 index 0000000000..3e52366599 --- /dev/null +++ b/.changeset/purple-donuts-smell.md @@ -0,0 +1,5 @@ +--- +'@radix-ui/react-slot': patch +--- + +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.