Skip to content

Commit 1ba3a97

Browse files
committed
Add async render APIs
1 parent 3dcd8a9 commit 1ba3a97

File tree

4 files changed

+306
-1
lines changed

4 files changed

+306
-1
lines changed

src/__tests__/renderAsync.js

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as React from 'react'
2+
import {act, renderAsync} from '../'
3+
4+
test('async data requires async APIs', async () => {
5+
const {promise, resolve} = Promise.withResolvers()
6+
7+
function Component() {
8+
const value = React.use(promise)
9+
return <div>{value}</div>
10+
}
11+
12+
const {container} = await renderAsync(
13+
<React.Suspense fallback="loading...">
14+
<Component />
15+
</React.Suspense>,
16+
)
17+
18+
expect(container).toHaveTextContent('loading...')
19+
20+
await act(async () => {
21+
resolve('Hello, Dave!')
22+
})
23+
24+
expect(container).toHaveTextContent('Hello, Dave!')
25+
})

src/act-compat.js

+14
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,22 @@ function withGlobalActEnvironment(actImplementation) {
8282

8383
const act = withGlobalActEnvironment(reactAct)
8484

85+
async function actAsync(scope) {
86+
const previousActEnvironment = getIsReactActEnvironment()
87+
setIsReactActEnvironment(true)
88+
try {
89+
// React.act isn't async yet so we need to force it.
90+
return await reactAct(async () => {
91+
scope()
92+
})
93+
} finally {
94+
setIsReactActEnvironment(previousActEnvironment)
95+
}
96+
}
97+
8598
export default act
8699
export {
100+
actAsync,
87101
setIsReactActEnvironment as setReactActEnvironment,
88102
getIsReactActEnvironment,
89103
}

src/pure.js

+188-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
configure as configureDTL,
88
} from '@testing-library/dom'
99
import act, {
10+
actAsync,
1011
getIsReactActEnvironment,
1112
setReactActEnvironment,
1213
} from './act-compat'
@@ -196,6 +197,64 @@ function renderRoot(
196197
}
197198
}
198199

200+
async function renderRootAsync(
201+
ui,
202+
{baseElement, container, hydrate, queries, root, wrapper: WrapperComponent},
203+
) {
204+
await actAsync(() => {
205+
if (hydrate) {
206+
root.hydrate(
207+
strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)),
208+
container,
209+
)
210+
} else {
211+
root.render(
212+
strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)),
213+
container,
214+
)
215+
}
216+
})
217+
218+
return {
219+
container,
220+
baseElement,
221+
debug: (el = baseElement, maxLength, options) =>
222+
Array.isArray(el)
223+
? // eslint-disable-next-line no-console
224+
el.forEach(e => console.log(prettyDOM(e, maxLength, options)))
225+
: // eslint-disable-next-line no-console,
226+
console.log(prettyDOM(el, maxLength, options)),
227+
unmount: async () => {
228+
await actAsync(() => {
229+
root.unmount()
230+
})
231+
},
232+
rerender: async rerenderUi => {
233+
await renderRootAsync(rerenderUi, {
234+
container,
235+
baseElement,
236+
root,
237+
wrapper: WrapperComponent,
238+
})
239+
// Intentionally do not return anything to avoid unnecessarily complicating the API.
240+
// folks can use all the same utilities we return in the first place that are bound to the container
241+
},
242+
asFragment: () => {
243+
/* istanbul ignore else (old jsdom limitation) */
244+
if (typeof document.createRange === 'function') {
245+
return document
246+
.createRange()
247+
.createContextualFragment(container.innerHTML)
248+
} else {
249+
const template = document.createElement('template')
250+
template.innerHTML = container.innerHTML
251+
return template.content
252+
}
253+
},
254+
...getQueriesForElement(baseElement, queries),
255+
}
256+
}
257+
199258
function render(
200259
ui,
201260
{
@@ -258,6 +317,68 @@ function render(
258317
})
259318
}
260319

320+
function renderAsync(
321+
ui,
322+
{
323+
container,
324+
baseElement = container,
325+
legacyRoot = false,
326+
queries,
327+
hydrate = false,
328+
wrapper,
329+
} = {},
330+
) {
331+
if (legacyRoot && typeof ReactDOM.render !== 'function') {
332+
const error = new Error(
333+
'`legacyRoot: true` is not supported in this version of React. ' +
334+
'If your app runs React 19 or later, you should remove this flag. ' +
335+
'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.',
336+
)
337+
Error.captureStackTrace(error, render)
338+
throw error
339+
}
340+
341+
if (!baseElement) {
342+
// default to document.body instead of documentElement to avoid output of potentially-large
343+
// head elements (such as JSS style blocks) in debug output
344+
baseElement = document.body
345+
}
346+
if (!container) {
347+
container = baseElement.appendChild(document.createElement('div'))
348+
}
349+
350+
let root
351+
// eslint-disable-next-line no-negated-condition -- we want to map the evolution of this over time. The root is created first. Only later is it re-used so we don't want to read the case that happens later first.
352+
if (!mountedContainers.has(container)) {
353+
const createRootImpl = legacyRoot ? createLegacyRoot : createConcurrentRoot
354+
root = createRootImpl(container, {hydrate, ui, wrapper})
355+
356+
mountedRootEntries.push({container, root})
357+
// we'll add it to the mounted containers regardless of whether it's actually
358+
// added to document.body so the cleanup method works regardless of whether
359+
// they're passing us a custom container or not.
360+
mountedContainers.add(container)
361+
} else {
362+
mountedRootEntries.forEach(rootEntry => {
363+
// Else is unreachable since `mountedContainers` has the `container`.
364+
// Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries`
365+
/* istanbul ignore else */
366+
if (rootEntry.container === container) {
367+
root = rootEntry.root
368+
}
369+
})
370+
}
371+
372+
return renderRootAsync(ui, {
373+
container,
374+
baseElement,
375+
queries,
376+
hydrate,
377+
wrapper,
378+
root,
379+
})
380+
}
381+
261382
function cleanup() {
262383
mountedRootEntries.forEach(({root, container}) => {
263384
act(() => {
@@ -271,6 +392,21 @@ function cleanup() {
271392
mountedContainers.clear()
272393
}
273394

395+
async function cleanupAsync() {
396+
for (const {root, container} of mountedRootEntries) {
397+
// eslint-disable-next-line no-await-in-loop -- act calls can't overlap
398+
await actAsync(() => {
399+
root.unmount()
400+
})
401+
if (container.parentNode === document.body) {
402+
document.body.removeChild(container)
403+
}
404+
}
405+
406+
mountedRootEntries.length = 0
407+
mountedContainers.clear()
408+
}
409+
274410
function renderHook(renderCallback, options = {}) {
275411
const {initialProps, ...renderOptions} = options
276412

@@ -310,8 +446,59 @@ function renderHook(renderCallback, options = {}) {
310446
return {result, rerender, unmount}
311447
}
312448

449+
async function renderHookAsync(renderCallback, options = {}) {
450+
const {initialProps, ...renderOptions} = options
451+
452+
if (renderOptions.legacyRoot && typeof ReactDOM.render !== 'function') {
453+
const error = new Error(
454+
'`legacyRoot: true` is not supported in this version of React. ' +
455+
'If your app runs React 19 or later, you should remove this flag. ' +
456+
'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.',
457+
)
458+
Error.captureStackTrace(error, renderHookAsync)
459+
throw error
460+
}
461+
462+
const result = React.createRef()
463+
464+
function TestComponent({renderCallbackProps}) {
465+
const pendingResult = renderCallback(renderCallbackProps)
466+
467+
React.useEffect(() => {
468+
result.current = pendingResult
469+
})
470+
471+
return null
472+
}
473+
474+
const {rerender: baseRerender, unmount} = await renderAsync(
475+
<TestComponent renderCallbackProps={initialProps} />,
476+
renderOptions,
477+
)
478+
479+
function rerender(rerenderCallbackProps) {
480+
return baseRerender(
481+
<TestComponent renderCallbackProps={rerenderCallbackProps} />,
482+
)
483+
}
484+
485+
return {result, rerender, unmount}
486+
}
487+
313488
// just re-export everything from dom-testing-library
314489
export * from '@testing-library/dom'
315-
export {render, renderHook, cleanup, act, fireEvent, getConfig, configure}
490+
export {
491+
render,
492+
renderAsync,
493+
renderHook,
494+
renderHookAsync,
495+
cleanup,
496+
cleanupAsync,
497+
act,
498+
fireEvent,
499+
// TODO: fireEventAsync
500+
getConfig,
501+
configure,
502+
}
316503

317504
/* eslint func-name-matching:0 */

types/index.d.ts

+79
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,27 @@ export type RenderResult<
4646
asFragment: () => DocumentFragment
4747
} & {[P in keyof Q]: BoundFunction<Q[P]>}
4848

49+
export type RenderAsyncResult<
50+
Q extends Queries = typeof queries,
51+
Container extends RendererableContainer | HydrateableContainer = HTMLElement,
52+
BaseElement extends RendererableContainer | HydrateableContainer = Container,
53+
> = {
54+
container: Container
55+
baseElement: BaseElement
56+
debug: (
57+
baseElement?:
58+
| RendererableContainer
59+
| HydrateableContainer
60+
| Array<RendererableContainer | HydrateableContainer>
61+
| undefined,
62+
maxLength?: number | undefined,
63+
options?: prettyFormat.OptionsReceived | undefined,
64+
) => void
65+
rerender: (ui: React.ReactNode) => Promise<void>
66+
unmount: () => Promise<void>
67+
asFragment: () => DocumentFragment
68+
} & {[P in keyof Q]: BoundFunction<Q[P]>}
69+
4970
/** @deprecated */
5071
export type BaseRenderOptions<
5172
Q extends Queries,
@@ -152,6 +173,22 @@ export function render(
152173
options?: Omit<RenderOptions, 'queries'> | undefined,
153174
): RenderResult
154175

176+
/**
177+
* Render into a container which is appended to document.body. It should be used with cleanup.
178+
*/
179+
export function renderAsync<
180+
Q extends Queries = typeof queries,
181+
Container extends RendererableContainer | HydrateableContainer = HTMLElement,
182+
BaseElement extends RendererableContainer | HydrateableContainer = Container,
183+
>(
184+
ui: React.ReactNode,
185+
options: RenderOptions<Q, Container, BaseElement>,
186+
): Promise<RenderAsyncResult<Q, Container, BaseElement>>
187+
export function renderAsync(
188+
ui: React.ReactNode,
189+
options?: Omit<RenderOptions, 'queries'> | undefined,
190+
): Promise<RenderAsyncResult>
191+
155192
export interface RenderHookResult<Result, Props> {
156193
/**
157194
* Triggers a re-render. The props will be passed to your renderHook callback.
@@ -174,6 +211,28 @@ export interface RenderHookResult<Result, Props> {
174211
unmount: () => void
175212
}
176213

214+
export interface RenderHookAsyncResult<Result, Props> {
215+
/**
216+
* Triggers a re-render. The props will be passed to your renderHook callback.
217+
*/
218+
rerender: (props?: Props) => Promise<void>
219+
/**
220+
* This is a stable reference to the latest value returned by your renderHook
221+
* callback
222+
*/
223+
result: {
224+
/**
225+
* The value returned by your renderHook callback
226+
*/
227+
current: Result
228+
}
229+
/**
230+
* Unmounts the test component. This is useful for when you need to test
231+
* any cleanup your useEffects have.
232+
*/
233+
unmount: () => Promise<void>
234+
}
235+
177236
/** @deprecated */
178237
export type BaseRenderHookOptions<
179238
Props,
@@ -242,11 +301,31 @@ export function renderHook<
242301
options?: RenderHookOptions<Props, Q, Container, BaseElement> | undefined,
243302
): RenderHookResult<Result, Props>
244303

304+
/**
305+
* Allows you to render a hook within a test React component without having to
306+
* create that component yourself.
307+
*/
308+
export function renderHookAsync<
309+
Result,
310+
Props,
311+
Q extends Queries = typeof queries,
312+
Container extends RendererableContainer | HydrateableContainer = HTMLElement,
313+
BaseElement extends RendererableContainer | HydrateableContainer = Container,
314+
>(
315+
render: (initialProps: Props) => Result,
316+
options?: RenderHookOptions<Props, Q, Container, BaseElement> | undefined,
317+
): Promise<RenderHookResult<Result, Props>>
318+
245319
/**
246320
* Unmounts React trees that were mounted with render.
247321
*/
248322
export function cleanup(): void
249323

324+
/**
325+
* Unmounts React trees that were mounted with render.
326+
*/
327+
export function cleanupAsync(): Promise<void>
328+
250329
/**
251330
* Simply calls React.act(cb)
252331
* If that's not available (older version of react) then it

0 commit comments

Comments
 (0)