diff --git a/.changeset/fix-aschild-rsc-lazy.md b/.changeset/fix-aschild-rsc-lazy.md new file mode 100644 index 0000000000..2d60bf1e3a --- /dev/null +++ b/.changeset/fix-aschild-rsc-lazy.md @@ -0,0 +1,7 @@ +--- +'@ark-ui/react': patch +--- + +Fix `asChild` rendering nothing when the child crosses the React Server Components boundary. React's Flight protocol can +hand the child over wrapped in `Symbol(react.lazy)`, which `isValidElement` rejects, so the factory bailed out and +rendered neither the child nor the element it stood in for. The lazy child is now unwrapped before its props are merged. diff --git a/packages/react/src/components/factory.test.tsx b/packages/react/src/components/factory.test.tsx index 9431f1788d..35452dfccc 100644 --- a/packages/react/src/components/factory.test.tsx +++ b/packages/react/src/components/factory.test.tsx @@ -1,6 +1,6 @@ -import { render, screen } from '@testing-library/react' +import { act, render, screen } from '@testing-library/react' import user from '@testing-library/user-event' -import { useCallback, useReducer } from 'react' +import { Suspense, type ReactElement, type ReactNode, isValidElement, useCallback, useReducer } from 'react' import { ark } from './factory.ts' const ComponentUnderTest = () => ( @@ -15,7 +15,7 @@ describe('Ark Factory', () => { it('should render only the child', () => { render() - expect(() => screen.getByTestId('parent')).toThrow() + expect(screen.queryByTestId('parent')).toBeNull() expect(screen.getByTestId('child')).toBeVisible() }) @@ -100,4 +100,124 @@ describe('Ark Factory', () => { expect(callbackRef).toHaveBeenCalledTimes(callsAfterMount) }) + + interface FlightChunk { + status: 'pending' | 'fulfilled' + value: ReactElement | null + listeners: (() => void)[] + then(onFulfill: () => void): void + } + + function createChunk(status: FlightChunk['status'], value: ReactElement | null): FlightChunk { + return { + status, + value, + listeners: [], + // biome-ignore lint/suspicious/noThenProperty: a Flight chunk is intentionally thenable + then(onFulfill) { + this.listeners.push(onFulfill) + }, + } + } + + function readChunk(chunk: FlightChunk) { + if (chunk.status === 'fulfilled') return chunk.value + throw chunk + } + + function toLazy(chunk: FlightChunk): ReactNode { + return { $$typeof: Symbol.for('react.lazy'), _payload: chunk, _init: readChunk } as unknown as ReactNode + } + + function createLazyChild(element: ReactElement): ReactNode { + return toLazy(createChunk('fulfilled', element)) + } + + function createPendingLazyChild(element: ReactElement) { + const chunk = createChunk('pending', null) + const resolve = () => { + chunk.status = 'fulfilled' + chunk.value = element + for (const listener of chunk.listeners) listener() + } + return [toLazy(chunk), resolve] as const + } + + it('should unwrap a react.lazy child from the RSC flight protocol', () => { + const lazyChild = createLazyChild( + + Ark UI + , + ) + + expect(isValidElement(lazyChild)).toBe(false) + + const { container } = render( + + {lazyChild} + , + ) + + expect(container.querySelector('div')).toBeNull() + + const child = screen.getByTestId('child') + expect(child).toBeVisible() + expect(child).toHaveClass('child parent') + expect(child).toHaveStyle({ background: 'red' }) + expect(child).toHaveStyle({ color: 'blue' }) + expect(child).toHaveTextContent('Ark UI') + }) + + it('should suspend until a pending react.lazy child resolves', async () => { + const [lazyChild, resolve] = createPendingLazyChild(Ark UI) + + render( + loading}> + + {lazyChild} + + , + ) + + expect(screen.getByTestId('fallback')).toBeVisible() + expect(screen.queryByTestId('child')).toBeNull() + + await act(async () => resolve()) + + const child = await screen.findByTestId('child') + expect(child).toHaveAttribute('data-part', 'parent') + expect(child).toHaveTextContent('Ark UI') + }) + + it('should compose refs through a react.lazy child', () => { + const parentRef = vi.fn() + const childRef = vi.fn() + + render( + + {createLazyChild()} + , + ) + + const child = screen.getByTestId('child') + expect(child).toHaveAttribute('data-part', 'parent') + expect(parentRef).toHaveBeenCalledWith(child) + expect(childRef).toHaveBeenCalledWith(child) + }) + + it('should leave non-lazy invalid children untouched', () => { + const { container } = render( + + + + , + ) + + expect(container.firstChild).toBeNull() + }) + + it('should render nothing when asChild has no valid child', () => { + const { container } = render(text) + expect(container.firstChild).toBeNull() + }) }) diff --git a/packages/react/src/components/factory.ts b/packages/react/src/components/factory.ts index 745baca4f0..ae96bd8308 100644 --- a/packages/react/src/components/factory.ts +++ b/packages/react/src/components/factory.ts @@ -43,12 +43,30 @@ function getRef(element: React.ReactElement) { return (element.props as { ref?: React.Ref | undefined }).ref || (element as any).ref } +const REACT_LAZY_TYPE = Symbol.for('react.lazy') + +function isLazyElement(children: React.ReactNode) { + return ( + typeof children === 'object' && children !== null && '$$typeof' in children && children.$$typeof === REACT_LAZY_TYPE + ) +} + +// Flight hands children across the RSC boundary wrapped in react.lazy: facebook/react#32392 +function getAsChild(children: React.ReactNode) { + if (isValidElement>(children)) { + return children + } + if (isLazyElement(children)) { + return Children.toArray(children).find(isValidElement>) + } + return undefined +} + const withAsChild = (Component: React.ElementType) => { const Comp = memo( forwardRef>((props, ref) => { const { asChild, children, ...restProps } = props - const onlyChild = - asChild && isValidElement>(children) ? Children.only(children) : undefined + const onlyChild = asChild ? getAsChild(children) : undefined const childRef = onlyChild ? getRef(onlyChild) : undefined const composedRef = useComposedRefs(ref, childRef)