diff --git a/README.md b/README.md
index 7e18d5dd..ce18ff04 100644
--- a/README.md
+++ b/README.md
@@ -366,6 +366,54 @@ test('handles server exceptions', async () => {
> to declaratively mock API communication in your tests instead of stubbing
> `window.fetch`, or relying on third-party adapters.
+### Async Server Components
+
+If you need to test `async function` components (React Server Components) or
+components that use React 19's `use()` API, use `renderAsync`:
+
+```jsx
+import {renderAsync, screen} from '@testing-library/react'
+
+// Async server component
+async function Greeting({userId}) {
+ const user = await db.getUser(userId)
+ return
Hello {user.name}
+}
+
+test('renders async server component', async () => {
+ await renderAsync( )
+ expect(screen.getByRole('heading')).toHaveTextContent('Hello Alice')
+})
+```
+
+`renderAsync` pre-resolves `async function` components in the element tree
+before passing them to React's client-side renderer, and wraps the result in a
+Suspense boundary with `act()` so that components using `use()` with promises
+also work:
+
+```jsx
+function UserProfile({userPromise}) {
+ const user = React.use(userPromise)
+ return {user.name}
+}
+
+test('renders component using use()', async () => {
+ await renderAsync(
+ ,
+ )
+ expect(screen.getByText('Alice')).toBeInTheDocument()
+})
+```
+
+`renderAsync` returns the same result as `render`, except `rerender` is async:
+
+```jsx
+const {rerender} = await renderAsync( )
+await rerender( )
+```
+
+Server-only APIs (cookies, headers, etc.) must be mocked in your test setup.
+
### More Examples
> We're in the process of moving examples to the
diff --git a/src/__tests__/renderAsync.js b/src/__tests__/renderAsync.js
new file mode 100644
index 00000000..b761cd24
--- /dev/null
+++ b/src/__tests__/renderAsync.js
@@ -0,0 +1,426 @@
+import * as React from 'react'
+import {renderAsync, screen} from '../'
+
+const isReact19 = React.version.startsWith('19.')
+
+const testGateReact19 = isReact19 ? test : test.skip
+
+async function AsyncHelloWorld() {
+ return Hello World
+}
+
+async function AsyncGreeting({name}) {
+ return Hello {name}
+}
+
+async function AsyncDataLoader() {
+ const data = await Promise.resolve({items: ['a', 'b', 'c']})
+ return (
+
+ {data.items.map(item => (
+ {item}
+ ))}
+
+ )
+}
+
+async function AsyncChild() {
+ return Child Content
+}
+
+async function AsyncParent() {
+ return (
+
+ )
+}
+
+async function AsyncDeeplyNested() {
+ return (
+
+ )
+}
+
+async function AsyncLevel2() {
+ return (
+
+ )
+}
+
+async function AsyncLevel3() {
+ return Deep Content
+}
+
+async function AsyncSiblings() {
+ return (
+
+ )
+}
+
+function SyncWrapper({children}) {
+ return {children}
+}
+
+async function AsyncWithSyncWrapper() {
+ return (
+
+
+
+ )
+}
+
+async function AsyncWithFragment() {
+ return (
+ <>
+ A
+ B
+ >
+ )
+}
+
+async function AsyncThatThrows() {
+ throw new Error('Server component error')
+}
+
+describe('renderAsync', () => {
+ test('renders a simple async component', async () => {
+ await renderAsync( )
+ expect(screen.getByTestId('hello')).toHaveTextContent('Hello World')
+ })
+
+ test('renders an async component with props', async () => {
+ await renderAsync( )
+ expect(screen.getByTestId('greeting')).toHaveTextContent('Hello Alice')
+ })
+
+ test('renders an async component that awaits data', async () => {
+ await renderAsync( )
+ const list = screen.getByTestId('list')
+ expect(list.children).toHaveLength(3)
+ expect(list).toHaveTextContent('abc')
+ })
+
+ test('resolves nested async components', async () => {
+ await renderAsync( )
+ expect(screen.getByTestId('parent')).toBeInTheDocument()
+ expect(screen.getByTestId('child')).toHaveTextContent('Child Content')
+ })
+
+ test('resolves deeply nested async components', async () => {
+ await renderAsync( )
+ expect(screen.getByTestId('level-1')).toBeInTheDocument()
+ expect(screen.getByTestId('level-2')).toBeInTheDocument()
+ expect(screen.getByTestId('level-3')).toHaveTextContent('Deep Content')
+ })
+
+ test('resolves sibling async components', async () => {
+ await renderAsync( )
+ expect(screen.getByTestId('siblings').children).toHaveLength(2)
+ })
+
+ test('resolves async component passed as children to sync wrapper', async () => {
+ await renderAsync( )
+ expect(screen.getByTestId('wrapper')).toBeInTheDocument()
+ expect(screen.getByTestId('child')).toHaveTextContent('Child Content')
+ })
+
+ test('resolves async components inside fragments', async () => {
+ await renderAsync( )
+ expect(screen.getByTestId('frag-a')).toHaveTextContent('A')
+ expect(screen.getByTestId('frag-b')).toHaveTextContent('B')
+ })
+
+ test('works with sync components (passthrough)', async () => {
+ function SyncComponent() {
+ return Sync Content
+ }
+ await renderAsync( )
+ expect(screen.getByTestId('sync')).toHaveTextContent('Sync Content')
+ })
+
+ test('works with plain HTML elements', async () => {
+ await renderAsync(Plain
)
+ expect(screen.getByTestId('plain')).toHaveTextContent('Plain')
+ })
+
+ test('supports rerender with async components', async () => {
+ const {rerender} = await renderAsync( )
+ expect(screen.getByTestId('greeting')).toHaveTextContent('Hello Alice')
+
+ await rerender( )
+ expect(screen.getByTestId('greeting')).toHaveTextContent('Hello Bob')
+ })
+
+ test('propagates errors from async components', async () => {
+ await expect(renderAsync( )).rejects.toThrow(
+ 'Server component error',
+ )
+ })
+
+ test('supports render options (container)', async () => {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ await renderAsync( , {container})
+
+ expect(container.querySelector('[data-testid="hello"]')).toHaveTextContent(
+ 'Hello World',
+ )
+
+ document.body.removeChild(container)
+ })
+
+ test('supports wrapper option', async () => {
+ const Wrapper = ({children}) => (
+ {children}
+ )
+
+ await renderAsync( , {wrapper: Wrapper})
+
+ expect(screen.getByTestId('test-wrapper')).toBeInTheDocument()
+ expect(screen.getByTestId('hello')).toHaveTextContent('Hello World')
+ })
+
+ test('async component with mixed sync and async children', async () => {
+ function SyncChild() {
+ return Sync
+ }
+
+ async function MixedParent() {
+ return (
+
+ )
+ }
+
+ await renderAsync( )
+ expect(screen.getByTestId('mixed')).toBeInTheDocument()
+ expect(screen.getByTestId('sync-child')).toHaveTextContent('Sync')
+ expect(screen.getByTestId('child')).toHaveTextContent('Child Content')
+ })
+
+ test('resolves async components passed as non-children props', async () => {
+ async function AsyncSidebar() {
+ return Sidebar Content
+ }
+
+ async function AsyncHeader() {
+ return
+ }
+
+ function Layout({sidebar, header, children}) {
+ return (
+
+ {header}
+
+
{children}
+
+ )
+ }
+
+ await renderAsync(
+ } header={ }>
+ Main
+ ,
+ )
+ expect(screen.getByTestId('layout')).toBeInTheDocument()
+ expect(screen.getByTestId('async-sidebar')).toHaveTextContent(
+ 'Sidebar Content',
+ )
+ expect(screen.getByTestId('async-header')).toHaveTextContent(
+ 'Header Content',
+ )
+ expect(screen.getByTestId('main-content')).toHaveTextContent('Main')
+ })
+
+ test('resolves async component in Suspense fallback prop', async () => {
+ async function AsyncFallback() {
+ return Resolved Fallback
+ }
+
+ function SyncContent() {
+ return Content
+ }
+
+ await renderAsync(
+ }>
+
+ ,
+ )
+ expect(screen.getByTestId('content')).toHaveTextContent('Content')
+ })
+
+ test('resolves async components in array-valued props', async () => {
+ async function AsyncTab({label}) {
+ return {label}
+ }
+
+ function SyncTab({label}) {
+ return {label}
+ }
+
+ function TabBar({tabs}) {
+ return
+ }
+
+ await renderAsync(
+ ,
+ ,
+ ]}
+ />,
+ )
+ expect(screen.getByTestId('tab-bar')).toBeInTheDocument()
+ expect(screen.getByTestId('tab-async')).toHaveTextContent('async')
+ expect(screen.getByTestId('tab-sync')).toHaveTextContent('sync')
+ })
+
+ test('passes through non-element objects in children unchanged', async () => {
+ function Container({children}) {
+ return {String(children)}
+ }
+
+ const plainObj = {
+ toString() {
+ return 'stringified'
+ },
+ }
+
+ await renderAsync(React.createElement(Container, null, plainObj))
+ expect(screen.getByTestId('container')).toHaveTextContent('stringified')
+ })
+
+ test('returns standard render result properties', async () => {
+ const {container, baseElement, debug, unmount, asFragment} =
+ await renderAsync( )
+
+ expect(container).toBeInstanceOf(HTMLElement)
+ expect(baseElement).toBe(document.body)
+ expect(typeof debug).toBe('function')
+ expect(typeof unmount).toBe('function')
+ expect(typeof asFragment).toBe('function')
+ expect(asFragment()).toBeInstanceOf(DocumentFragment)
+ })
+})
+
+describe('renderAsync with use()', () => {
+ testGateReact19(
+ 'renders component that calls use() with a promise prop',
+ async () => {
+ function UseDataLoader({dataPromise}) {
+ const data = React.use(dataPromise)
+ return {data.message}
+ }
+
+ await renderAsync(
+ ,
+ )
+ expect(screen.getByTestId('use-data')).toHaveTextContent('loaded via use')
+ },
+ )
+
+ testGateReact19('renders component using use() with list data', async () => {
+ function UseFetchComponent({itemsPromise}) {
+ const data = React.use(itemsPromise)
+ return (
+
+ {data.items.map(item => (
+ {item}
+ ))}
+
+ )
+ }
+
+ await renderAsync(
+ ,
+ )
+ const list = screen.getByTestId('use-list')
+ expect(list.children).toHaveLength(3)
+ })
+
+ testGateReact19('renders async parent with use()-based child', async () => {
+ function UseChild({textPromise}) {
+ const data = React.use(textPromise)
+ return {data.text}
+ }
+
+ async function AsyncParentWithUseChild() {
+ const title = await Promise.resolve('Async Title')
+ return (
+
+
{title}
+
+
+ )
+ }
+
+ await renderAsync( )
+ expect(screen.getByTestId('async-use-parent')).toBeInTheDocument()
+ expect(screen.getByTestId('use-child')).toHaveTextContent('from use')
+ })
+
+ testGateReact19('renders use() with context', async () => {
+ const ThemeContext = React.createContext('light')
+
+ function ThemeReader() {
+ const theme = React.use(ThemeContext)
+ return {theme}
+ }
+
+ const Wrapper = ({children}) => (
+ {children}
+ )
+
+ await renderAsync( , {wrapper: Wrapper})
+ expect(screen.getByTestId('theme')).toHaveTextContent('dark')
+ })
+
+ testGateReact19('supports rerender with use() components', async () => {
+ function UseGreeting({namePromise}) {
+ const name = React.use(namePromise)
+ return Hello {name}
+ }
+
+ const {rerender} = await renderAsync(
+ ,
+ )
+ expect(screen.getByTestId('use-greeting')).toHaveTextContent('Hello Alice')
+
+ await rerender( )
+ expect(screen.getByTestId('use-greeting')).toHaveTextContent('Hello Bob')
+ })
+
+ testGateReact19(
+ 'renders use() component wrapped in explicit Suspense',
+ async () => {
+ function SlowComponent({dataPromise}) {
+ const data = React.use(dataPromise)
+ return {data.value}
+ }
+
+ const dataPromise = Promise.resolve({value: 'resolved'})
+
+ await renderAsync(
+ Loading...}>
+
+ ,
+ )
+ expect(screen.getByTestId('slow')).toHaveTextContent('resolved')
+ },
+ )
+})
diff --git a/src/pure.js b/src/pure.js
index 0f9c487d..4ed52937 100644
--- a/src/pure.js
+++ b/src/pure.js
@@ -317,6 +317,134 @@ function cleanup() {
mountedContainers.clear()
}
+function isAsyncFunction(fn) {
+ return Object.prototype.toString.call(fn) === '[object AsyncFunction]'
+}
+
+// Recursively resolve async function components (React Server Components)
+// in the element tree before passing to React's client-side renderer.
+// This replicates the implicit use() behavior that React's server renderer
+// provides for async components but which the client renderer does not support.
+// Walks all props (not just children) so async components passed as e.g.
+// sidebar, fallback, or header props are also resolved.
+async function resolveElement(element) {
+ if (element == null || typeof element !== 'object') {
+ return element
+ }
+
+ if (Array.isArray(element)) {
+ const resolved = await Promise.all(element.map(resolveElement))
+ return resolved.some((r, i) => r !== element[i]) ? resolved : element
+ }
+
+ if (!React.isValidElement(element)) {
+ return element
+ }
+
+ if (typeof element.type === 'function' && isAsyncFunction(element.type)) {
+ const resolved = await element.type({...element.props})
+ const resolvedElement = await resolveElement(resolved)
+ if (element.key != null && React.isValidElement(resolvedElement)) {
+ return React.cloneElement(resolvedElement, {key: element.key})
+ }
+ return resolvedElement
+ }
+
+ return resolveElementProps(element)
+}
+
+async function resolveElementProps(element) {
+ const keys = Object.keys(element.props).filter(k => k !== 'children')
+
+ // Resolve React-element-valued props in parallel.
+ // Wrap results in {value, changed} objects so Promise.all does not
+ // inadvertently flatten Promise-valued props (e.g. dataPromise for use()).
+ const results = await Promise.all(
+ keys.map(key => {
+ const value = element.props[key]
+ if (React.isValidElement(value)) {
+ return resolveElement(value).then(resolved => ({
+ value: resolved,
+ changed: resolved !== value,
+ }))
+ }
+ if (Array.isArray(value) && value.some(React.isValidElement)) {
+ return resolveElement(value).then(resolved => ({
+ value: resolved,
+ changed: resolved !== value,
+ }))
+ }
+ return {value, changed: false}
+ }),
+ )
+
+ const propsChanged = results.some(r => r.changed)
+ const newProps = {}
+ keys.forEach((key, i) => {
+ newProps[key] = results[i].value
+ })
+
+ const children = element.props.children
+ const resolvedChildren =
+ children == null ? children : await resolveElement(children)
+ const childrenChanged = resolvedChildren !== children
+
+ if (!propsChanged && !childrenChanged) {
+ return element
+ }
+
+ // Spread children as separate arguments to cloneElement to preserve
+ // positional identity and avoid "missing key" warnings
+ if (childrenChanged) {
+ if (Array.isArray(resolvedChildren)) {
+ return React.cloneElement(
+ element,
+ propsChanged ? newProps : undefined,
+ ...resolvedChildren,
+ )
+ }
+ return React.cloneElement(
+ element,
+ propsChanged ? newProps : undefined,
+ resolvedChildren,
+ )
+ }
+
+ return React.cloneElement(element, newProps)
+}
+
+async function renderAsync(ui, options) {
+ const resolvedUi = await resolveElement(ui)
+
+ // Wrap in Suspense so components using use() with Promises suspend
+ // correctly, and use async act() to flush all pending suspensions.
+ const wrapped = React.createElement(
+ React.Suspense,
+ {fallback: null},
+ resolvedUi,
+ )
+
+ let result
+ await act(async () => {
+ result = render(wrapped, options)
+ })
+
+ return {
+ ...result,
+ rerender: async rerenderUi => {
+ const resolvedRerenderUi = await resolveElement(rerenderUi)
+ const wrappedRerender = React.createElement(
+ React.Suspense,
+ {fallback: null},
+ resolvedRerenderUi,
+ )
+ await act(async () => {
+ result.rerender(wrappedRerender)
+ })
+ },
+ }
+}
+
function renderHook(renderCallback, options = {}) {
const {initialProps, ...renderOptions} = options
@@ -358,6 +486,15 @@ function renderHook(renderCallback, options = {}) {
// just re-export everything from dom-testing-library
export * from '@testing-library/dom'
-export {render, renderHook, cleanup, act, fireEvent, getConfig, configure}
+export {
+ render,
+ renderAsync,
+ renderHook,
+ cleanup,
+ act,
+ fireEvent,
+ getConfig,
+ configure,
+}
/* eslint func-name-matching:0 */
diff --git a/types/index.d.ts b/types/index.d.ts
index 439dddbf..66049035 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -271,6 +271,41 @@ export function renderHook<
options?: RenderHookOptions | undefined,
): RenderHookResult
+export type RenderAsyncResult<
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+> = Omit, 'rerender'> & {
+ rerender: (ui: React.ReactNode) => Promise
+}
+
+/**
+ * Render async React Server Components and components using `use()` by
+ * resolving async function components in the element tree and wrapping
+ * the result in a Suspense boundary with `act()`.
+ *
+ * Handles:
+ * - `async function` server components (including nested/deeply nested)
+ * - Components that call `use(promise)` for data loading
+ * - Mixed trees of async server components, `use()`-based components,
+ * and regular client components
+ *
+ * Server-only APIs (cookies, headers, etc.) must be mocked in your test
+ * setup.
+ */
+export function renderAsync<
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+>(
+ ui: React.ReactNode,
+ options: RenderOptions,
+): Promise>
+export function renderAsync(
+ ui: React.ReactNode,
+ options?: Omit | undefined,
+): Promise
+
/**
* Unmounts React trees that were mounted with render.
*/