Skip to content

Commit 45f5d83

Browse files
committed
feat: enhance resolveElement to support async components in non-children props
1 parent e96a3bf commit 45f5d83

2 files changed

Lines changed: 105 additions & 8 deletions

File tree

src/__tests__/renderAsync.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,57 @@ describe('renderAsync', () => {
209209
expect(screen.getByTestId('child')).toHaveTextContent('Child Content')
210210
})
211211

212+
test('resolves async components passed as non-children props', async () => {
213+
async function AsyncSidebar() {
214+
return <nav data-testid="async-sidebar">Sidebar Content</nav>
215+
}
216+
217+
async function AsyncHeader() {
218+
return <header data-testid="async-header">Header Content</header>
219+
}
220+
221+
function Layout({sidebar, header, children}) {
222+
return (
223+
<div data-testid="layout">
224+
{header}
225+
<aside>{sidebar}</aside>
226+
<main>{children}</main>
227+
</div>
228+
)
229+
}
230+
231+
await renderAsync(
232+
<Layout sidebar={<AsyncSidebar />} header={<AsyncHeader />}>
233+
<div data-testid="main-content">Main</div>
234+
</Layout>,
235+
)
236+
expect(screen.getByTestId('layout')).toBeInTheDocument()
237+
expect(screen.getByTestId('async-sidebar')).toHaveTextContent(
238+
'Sidebar Content',
239+
)
240+
expect(screen.getByTestId('async-header')).toHaveTextContent(
241+
'Header Content',
242+
)
243+
expect(screen.getByTestId('main-content')).toHaveTextContent('Main')
244+
})
245+
246+
test('resolves async component in Suspense fallback prop', async () => {
247+
async function AsyncFallback() {
248+
return <div data-testid="resolved-fallback">Resolved Fallback</div>
249+
}
250+
251+
function SyncContent() {
252+
return <div data-testid="content">Content</div>
253+
}
254+
255+
await renderAsync(
256+
<React.Suspense fallback={<AsyncFallback />}>
257+
<SyncContent />
258+
</React.Suspense>,
259+
)
260+
expect(screen.getByTestId('content')).toHaveTextContent('Content')
261+
})
262+
212263
test('returns standard render result properties', async () => {
213264
const {container, baseElement, debug, unmount, asFragment} =
214265
await renderAsync(<AsyncHelloWorld />)

src/pure.js

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -325,13 +325,16 @@ function isAsyncFunction(fn) {
325325
// in the element tree before passing to React's client-side renderer.
326326
// This replicates the implicit use() behavior that React's server renderer
327327
// provides for async components but which the client renderer does not support.
328+
// Walks all props (not just children) so async components passed as e.g.
329+
// sidebar, fallback, or header props are also resolved.
328330
async function resolveElement(element) {
329331
if (element == null || typeof element !== 'object') {
330332
return element
331333
}
332334

333335
if (Array.isArray(element)) {
334-
return Promise.all(element.map(resolveElement))
336+
const resolved = await Promise.all(element.map(resolveElement))
337+
return resolved.some((r, i) => r !== element[i]) ? resolved : element
335338
}
336339

337340
if (!React.isValidElement(element)) {
@@ -343,21 +346,64 @@ async function resolveElement(element) {
343346
return resolveElement(resolved)
344347
}
345348

346-
const children = element.props?.children
347-
if (children == null) {
349+
return resolveElementProps(element)
350+
}
351+
352+
async function resolveElementProps(element) {
353+
const props = element.props
354+
if (props == null) {
348355
return element
349356
}
350357

351-
const resolvedChildren = await resolveElement(children)
358+
let propsChanged = false
359+
let childrenChanged = false
360+
const newProps = {}
361+
let resolvedChildren
362+
363+
for (const key of Object.keys(props)) {
364+
const value = props[key]
365+
366+
if (key === 'children') {
367+
resolvedChildren = await resolveElement(value)
368+
childrenChanged = resolvedChildren !== value
369+
continue
370+
}
371+
372+
// Only resolve values that are React elements to avoid inadvertently
373+
// awaiting Promise-valued props (e.g. dataPromise for use())
374+
if (React.isValidElement(value)) {
375+
const resolved = await resolveElement(value)
376+
newProps[key] = resolved
377+
if (resolved !== value) {
378+
propsChanged = true
379+
}
380+
} else {
381+
newProps[key] = value
382+
}
383+
}
352384

353-
if (resolvedChildren === children) {
385+
if (!propsChanged && !childrenChanged) {
354386
return element
355387
}
356388

357-
if (Array.isArray(resolvedChildren)) {
358-
return React.cloneElement(element, undefined, ...resolvedChildren)
389+
// Spread children as separate arguments to cloneElement to preserve
390+
// positional identity and avoid "missing key" warnings
391+
if (childrenChanged) {
392+
if (Array.isArray(resolvedChildren)) {
393+
return React.cloneElement(
394+
element,
395+
propsChanged ? newProps : undefined,
396+
...resolvedChildren,
397+
)
398+
}
399+
return React.cloneElement(
400+
element,
401+
propsChanged ? newProps : undefined,
402+
resolvedChildren,
403+
)
359404
}
360-
return React.cloneElement(element, undefined, resolvedChildren)
405+
406+
return React.cloneElement(element, newProps)
361407
}
362408

363409
async function renderAsync(ui, options) {

0 commit comments

Comments
 (0)