Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 571fbc8

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

File tree

4 files changed

+309
-1
lines changed

4 files changed

+309
-1
lines changed

‎src/__tests__/renderAsync.js‎

Lines changed: 25 additions & 0 deletions
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‎

Lines changed: 14 additions & 0 deletions
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‎

Lines changed: 189 additions & 1 deletion
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,60 @@ 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+
actAsync,
499+
fireEvent,
500+
// TODO: fireEventAsync
501+
getConfig,
502+
configure,
503+
}
316504

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

‎types/index.d.ts‎

Lines changed: 81 additions & 0 deletions
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
@@ -256,3 +335,5 @@ export function cleanup(): void
256335
export const act: 0 extends 1 & typeof reactAct
257336
? typeof reactDeprecatedAct
258337
: typeof reactAct
338+
339+
export function actAsync(scope: () => void | Promise<void>): Promise<void>

0 commit comments

Comments
(0)

AltStyle によって変換されたページ (->オリジナル) /