Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-aschild-rsc-lazy.md
Original file line number Diff line number Diff line change
@@ -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.
126 changes: 123 additions & 3 deletions packages/react/src/components/factory.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
Expand All @@ -15,7 +15,7 @@ describe('Ark Factory', () => {
it('should render only the child', () => {
render(<ComponentUnderTest />)

expect(() => screen.getByTestId('parent')).toThrow()
expect(screen.queryByTestId('parent')).toBeNull()
expect(screen.getByTestId('child')).toBeVisible()
})

Expand Down Expand Up @@ -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(
<span data-testid="child" className="child" style={{ color: 'blue' }}>
Ark UI
</span>,
)

expect(isValidElement(lazyChild)).toBe(false)

const { container } = render(
<ark.div data-testid="parent" className="parent" style={{ background: 'red' }} asChild>
{lazyChild}
</ark.div>,
)

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(<span data-testid="child">Ark UI</span>)

render(
<Suspense fallback={<span data-testid="fallback">loading</span>}>
<ark.div data-part="parent" asChild>
{lazyChild}
</ark.div>
</Suspense>,
)

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(
<ark.div ref={parentRef} data-part="parent" asChild>
{createLazyChild(<span ref={childRef} data-testid="child" />)}
</ark.div>,
)

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(
<ark.div data-testid="parent" asChild>
<span data-testid="first" />
<span data-testid="second" />
</ark.div>,
)

expect(container.firstChild).toBeNull()
})

it('should render nothing when asChild has no valid child', () => {
const { container } = render(<ark.div asChild>text</ark.div>)
expect(container.firstChild).toBeNull()
})
})
22 changes: 20 additions & 2 deletions packages/react/src/components/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,30 @@ function getRef(element: React.ReactElement) {
return (element.props as { ref?: React.Ref<unknown> | 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<Record<string, unknown>>(children)) {
return children
}
if (isLazyElement(children)) {
return Children.toArray(children).find(isValidElement<Record<string, unknown>>)
}
return undefined
}

const withAsChild = (Component: React.ElementType) => {
const Comp = memo(
forwardRef<unknown, ArkPropsWithRef<typeof Component>>((props, ref) => {
const { asChild, children, ...restProps } = props
const onlyChild =
asChild && isValidElement<Record<string, unknown>>(children) ? Children.only(children) : undefined
const onlyChild = asChild ? getAsChild(children) : undefined
const childRef = onlyChild ? getRef(onlyChild) : undefined
const composedRef = useComposedRefs(ref, childRef)

Expand Down