From e96a3bfbfeddf2aad42c59c45fa6bcccffe95a05 Mon Sep 17 00:00:00 2001 From: Aurora Scharff Date: Wed, 18 Mar 2026 11:52:24 +0100 Subject: [PATCH 1/5] feat: add renderAsync for testing async React Server Components --- README.md | 48 +++++ src/__tests__/renderAsync.js | 344 +++++++++++++++++++++++++++++++++++ src/pure.js | 82 ++++++++- types/index.d.ts | 35 ++++ 4 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/renderAsync.js 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..4235194d --- /dev/null +++ b/src/__tests__/renderAsync.js @@ -0,0 +1,344 @@ +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('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..0ac07a91 100644 --- a/src/pure.js +++ b/src/pure.js @@ -317,6 +317,77 @@ 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. +async function resolveElement(element) { + if (element == null || typeof element !== 'object') { + return element + } + + if (Array.isArray(element)) { + return Promise.all(element.map(resolveElement)) + } + + if (!React.isValidElement(element)) { + return element + } + + if (typeof element.type === 'function' && isAsyncFunction(element.type)) { + const resolved = await element.type({...element.props}) + return resolveElement(resolved) + } + + const children = element.props?.children + if (children == null) { + return element + } + + const resolvedChildren = await resolveElement(children) + + if (resolvedChildren === children) { + return element + } + + if (Array.isArray(resolvedChildren)) { + return React.cloneElement(element, undefined, ...resolvedChildren) + } + return React.cloneElement(element, undefined, resolvedChildren) +} + +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 +429,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. */ From 45f5d83ffde92360e68840eff2b13268dc90cb78 Mon Sep 17 00:00:00 2001 From: Aurora Scharff Date: Wed, 18 Mar 2026 12:16:00 +0100 Subject: [PATCH 2/5] feat: enhance resolveElement to support async components in non-children props --- src/__tests__/renderAsync.js | 51 +++++++++++++++++++++++++++++ src/pure.js | 62 +++++++++++++++++++++++++++++++----- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/src/__tests__/renderAsync.js b/src/__tests__/renderAsync.js index 4235194d..d5233796 100644 --- a/src/__tests__/renderAsync.js +++ b/src/__tests__/renderAsync.js @@ -209,6 +209,57 @@ describe('renderAsync', () => { expect(screen.getByTestId('child')).toHaveTextContent('Child Content') }) + test('resolves async components passed as non-children props', async () => { + async function AsyncSidebar() { + return + } + + async function AsyncHeader() { + return
Header Content
+ } + + 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('returns standard render result properties', async () => { const {container, baseElement, debug, unmount, asFragment} = await renderAsync() diff --git a/src/pure.js b/src/pure.js index 0ac07a91..d24f3a6b 100644 --- a/src/pure.js +++ b/src/pure.js @@ -325,13 +325,16 @@ function isAsyncFunction(fn) { // 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)) { - return Promise.all(element.map(resolveElement)) + const resolved = await Promise.all(element.map(resolveElement)) + return resolved.some((r, i) => r !== element[i]) ? resolved : element } if (!React.isValidElement(element)) { @@ -343,21 +346,64 @@ async function resolveElement(element) { return resolveElement(resolved) } - const children = element.props?.children - if (children == null) { + return resolveElementProps(element) +} + +async function resolveElementProps(element) { + const props = element.props + if (props == null) { return element } - const resolvedChildren = await resolveElement(children) + let propsChanged = false + let childrenChanged = false + const newProps = {} + let resolvedChildren + + for (const key of Object.keys(props)) { + const value = props[key] + + if (key === 'children') { + resolvedChildren = await resolveElement(value) + childrenChanged = resolvedChildren !== value + continue + } + + // Only resolve values that are React elements to avoid inadvertently + // awaiting Promise-valued props (e.g. dataPromise for use()) + if (React.isValidElement(value)) { + const resolved = await resolveElement(value) + newProps[key] = resolved + if (resolved !== value) { + propsChanged = true + } + } else { + newProps[key] = value + } + } - if (resolvedChildren === children) { + if (!propsChanged && !childrenChanged) { return element } - if (Array.isArray(resolvedChildren)) { - return React.cloneElement(element, undefined, ...resolvedChildren) + // 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, undefined, resolvedChildren) + + return React.cloneElement(element, newProps) } async function renderAsync(ui, options) { From 4a4e74c369b6d4cdf9314fdf458cf1335cc19011 Mon Sep 17 00:00:00 2001 From: Aurora Scharff Date: Wed, 18 Mar 2026 12:31:18 +0100 Subject: [PATCH 3/5] style: format renderAsync source and tests with Prettier Made-with: Cursor --- src/__tests__/renderAsync.js | 88 ++++++++++++++++-------------------- src/pure.js | 6 ++- 2 files changed, 44 insertions(+), 50 deletions(-) diff --git a/src/__tests__/renderAsync.js b/src/__tests__/renderAsync.js index d5233796..689d453f 100644 --- a/src/__tests__/renderAsync.js +++ b/src/__tests__/renderAsync.js @@ -287,59 +287,51 @@ describe('renderAsync with use()', () => { dataPromise={Promise.resolve({message: 'loaded via use'})} />, ) - expect(screen.getByTestId('use-data')).toHaveTextContent( - 'loaded via use', - ) + 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( - , + testGateReact19('renders component using use() with list data', async () => { + function UseFetchComponent({itemsPromise}) { + const data = React.use(itemsPromise) + return ( +
    + {data.items.map(item => ( +
  • {item}
  • + ))} +
) - 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} - } + await renderAsync( + , + ) + const list = screen.getByTestId('use-list') + expect(list.children).toHaveLength(3) + }) - async function AsyncParentWithUseChild() { - const title = await Promise.resolve('Async Title') - return ( -
-

{title}

- -
- ) - } + testGateReact19('renders async parent with use()-based child', async () => { + function UseChild({textPromise}) { + const data = React.use(textPromise) + return {data.text} + } - await renderAsync() - expect(screen.getByTestId('async-use-parent')).toBeInTheDocument() - expect(screen.getByTestId('use-child')).toHaveTextContent('from use') - }, - ) + 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') @@ -368,9 +360,7 @@ describe('renderAsync with use()', () => { ) expect(screen.getByTestId('use-greeting')).toHaveTextContent('Hello Alice') - await rerender( - , - ) + await rerender() expect(screen.getByTestId('use-greeting')).toHaveTextContent('Hello Bob') }) diff --git a/src/pure.js b/src/pure.js index d24f3a6b..38812553 100644 --- a/src/pure.js +++ b/src/pure.js @@ -411,7 +411,11 @@ async function renderAsync(ui, options) { // 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) + const wrapped = React.createElement( + React.Suspense, + {fallback: null}, + resolvedUi, + ) let result await act(async () => { From 707a6a9570373253681119f246a5ec47673c4892 Mon Sep 17 00:00:00 2001 From: Aurora Scharff Date: Wed, 18 Mar 2026 12:46:32 +0100 Subject: [PATCH 4/5] fix: resolve CI failures (lint, coverage) in renderAsync - Refactor resolveElementProps to use Promise.all instead of await-in-loop (fixes no-await-in-loop lint error) - Wrap resolved values in {value, changed} objects so Promise.all does not inadvertently flatten Promise-valued props (e.g. dataPromise for use()) - Remove unreachable props == null guard (React elements always have props) - Add test for non-element objects in children to cover resolveElement safety branch (96.15% branch coverage, above 95% threshold) Made-with: Cursor --- src/__tests__/renderAsync.js | 15 ++++++++++ src/pure.js | 53 +++++++++++++++++------------------- 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/src/__tests__/renderAsync.js b/src/__tests__/renderAsync.js index 689d453f..77f648b1 100644 --- a/src/__tests__/renderAsync.js +++ b/src/__tests__/renderAsync.js @@ -260,6 +260,21 @@ describe('renderAsync', () => { expect(screen.getByTestId('content')).toHaveTextContent('Content') }) + 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() diff --git a/src/pure.js b/src/pure.js index 38812553..072d61bf 100644 --- a/src/pure.js +++ b/src/pure.js @@ -350,37 +350,34 @@ async function resolveElement(element) { } async function resolveElementProps(element) { - const props = element.props - if (props == null) { - return 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, + })) + } + return {value, changed: false} + }), + ) - let propsChanged = false - let childrenChanged = false + const propsChanged = results.some(r => r.changed) const newProps = {} - let resolvedChildren - - for (const key of Object.keys(props)) { - const value = props[key] - - if (key === 'children') { - resolvedChildren = await resolveElement(value) - childrenChanged = resolvedChildren !== value - continue - } + keys.forEach((key, i) => { + newProps[key] = results[i].value + }) - // Only resolve values that are React elements to avoid inadvertently - // awaiting Promise-valued props (e.g. dataPromise for use()) - if (React.isValidElement(value)) { - const resolved = await resolveElement(value) - newProps[key] = resolved - if (resolved !== value) { - propsChanged = true - } - } else { - newProps[key] = value - } - } + const children = element.props.children + const resolvedChildren = + children == null ? children : await resolveElement(children) + const childrenChanged = resolvedChildren !== children if (!propsChanged && !childrenChanged) { return element From 0618f8bfacc4642dcc2a7a211e8a0ed8029f5a93 Mon Sep 17 00:00:00 2001 From: Aurora Scharff Date: Thu, 19 Mar 2026 10:27:29 +0100 Subject: [PATCH 5/5] enhance resolveElement to handle async components in array-valued props --- src/__tests__/renderAsync.js | 26 ++++++++++++++++++++++++++ src/pure.js | 12 +++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/__tests__/renderAsync.js b/src/__tests__/renderAsync.js index 77f648b1..b761cd24 100644 --- a/src/__tests__/renderAsync.js +++ b/src/__tests__/renderAsync.js @@ -260,6 +260,32 @@ describe('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
      {tabs}
    + } + + 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)}
    diff --git a/src/pure.js b/src/pure.js index 072d61bf..4ed52937 100644 --- a/src/pure.js +++ b/src/pure.js @@ -343,7 +343,11 @@ async function resolveElement(element) { if (typeof element.type === 'function' && isAsyncFunction(element.type)) { const resolved = await element.type({...element.props}) - return resolveElement(resolved) + const resolvedElement = await resolveElement(resolved) + if (element.key != null && React.isValidElement(resolvedElement)) { + return React.cloneElement(resolvedElement, {key: element.key}) + } + return resolvedElement } return resolveElementProps(element) @@ -364,6 +368,12 @@ async function resolveElementProps(element) { 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} }), )