From 571fbc818ebe683a99e7d5ecdba349fe9cceb831 Mon Sep 17 00:00:00 2001 From: eps1lon Date: Tue, 26 Nov 2024 00:04:00 +0100 Subject: [PATCH 1/2] Add async render APIs --- src/__tests__/renderAsync.js | 25 +++++ src/act-compat.js | 14 +++ src/pure.js | 190 ++++++++++++++++++++++++++++++++++- types/index.d.ts | 81 +++++++++++++++ 4 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/renderAsync.js diff --git a/src/__tests__/renderAsync.js b/src/__tests__/renderAsync.js new file mode 100644 index 00000000..4e580b82 --- /dev/null +++ b/src/__tests__/renderAsync.js @@ -0,0 +1,25 @@ +import * as React from 'react' +import {act, renderAsync} from '../' + +test('async data requires async APIs', async () => { + const {promise, resolve} = Promise.withResolvers() + + function Component() { + const value = React.use(promise) + return
{value}
+ } + + const {container} = await renderAsync( + + + , + ) + + expect(container).toHaveTextContent('loading...') + + await act(async () => { + resolve('Hello, Dave!') + }) + + expect(container).toHaveTextContent('Hello, Dave!') +}) diff --git a/src/act-compat.js b/src/act-compat.js index 6eaec0fb..8d5da94b 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -82,8 +82,22 @@ function withGlobalActEnvironment(actImplementation) { const act = withGlobalActEnvironment(reactAct) +async function actAsync(scope) { + const previousActEnvironment = getIsReactActEnvironment() + setIsReactActEnvironment(true) + try { + // React.act isn't async yet so we need to force it. + return await reactAct(async () => { + scope() + }) + } finally { + setIsReactActEnvironment(previousActEnvironment) + } +} + export default act export { + actAsync, setIsReactActEnvironment as setReactActEnvironment, getIsReactActEnvironment, } diff --git a/src/pure.js b/src/pure.js index f546af98..750d10be 100644 --- a/src/pure.js +++ b/src/pure.js @@ -7,6 +7,7 @@ import { configure as configureDTL, } from '@testing-library/dom' import act, { + actAsync, getIsReactActEnvironment, setReactActEnvironment, } from './act-compat' @@ -196,6 +197,64 @@ function renderRoot( } } +async function renderRootAsync( + ui, + {baseElement, container, hydrate, queries, root, wrapper: WrapperComponent}, +) { + await actAsync(() => { + if (hydrate) { + root.hydrate( + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + container, + ) + } else { + root.render( + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + container, + ) + } + }) + + return { + container, + baseElement, + debug: (el = baseElement, maxLength, options) => + Array.isArray(el) + ? // eslint-disable-next-line no-console + el.forEach(e => console.log(prettyDOM(e, maxLength, options))) + : // eslint-disable-next-line no-console, + console.log(prettyDOM(el, maxLength, options)), + unmount: async () => { + await actAsync(() => { + root.unmount() + }) + }, + rerender: async rerenderUi => { + await renderRootAsync(rerenderUi, { + container, + baseElement, + root, + wrapper: WrapperComponent, + }) + // Intentionally do not return anything to avoid unnecessarily complicating the API. + // folks can use all the same utilities we return in the first place that are bound to the container + }, + asFragment: () => { + /* istanbul ignore else (old jsdom limitation) */ + if (typeof document.createRange === 'function') { + return document + .createRange() + .createContextualFragment(container.innerHTML) + } else { + const template = document.createElement('template') + template.innerHTML = container.innerHTML + return template.content + } + }, + ...getQueriesForElement(baseElement, queries), + } +} + function render( ui, { @@ -258,6 +317,68 @@ function render( }) } +function renderAsync( + ui, + { + container, + baseElement = container, + legacyRoot = false, + queries, + hydrate = false, + wrapper, + } = {}, +) { + if (legacyRoot && typeof ReactDOM.render !== 'function') { + const error = new Error( + '`legacyRoot: true` is not supported in this version of React. ' + + 'If your app runs React 19 or later, you should remove this flag. ' + + 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.', + ) + Error.captureStackTrace(error, render) + throw error + } + + if (!baseElement) { + // default to document.body instead of documentElement to avoid output of potentially-large + // head elements (such as JSS style blocks) in debug output + baseElement = document.body + } + if (!container) { + container = baseElement.appendChild(document.createElement('div')) + } + + let root + // 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. + if (!mountedContainers.has(container)) { + const createRootImpl = legacyRoot ? createLegacyRoot : createConcurrentRoot + root = createRootImpl(container, {hydrate, ui, wrapper}) + + mountedRootEntries.push({container, root}) + // we'll add it to the mounted containers regardless of whether it's actually + // added to document.body so the cleanup method works regardless of whether + // they're passing us a custom container or not. + mountedContainers.add(container) + } else { + mountedRootEntries.forEach(rootEntry => { + // Else is unreachable since `mountedContainers` has the `container`. + // Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries` + /* istanbul ignore else */ + if (rootEntry.container === container) { + root = rootEntry.root + } + }) + } + + return renderRootAsync(ui, { + container, + baseElement, + queries, + hydrate, + wrapper, + root, + }) +} + function cleanup() { mountedRootEntries.forEach(({root, container}) => { act(() => { @@ -271,6 +392,21 @@ function cleanup() { mountedContainers.clear() } +async function cleanupAsync() { + for (const {root, container} of mountedRootEntries) { + // eslint-disable-next-line no-await-in-loop -- act calls can't overlap + await actAsync(() => { + root.unmount() + }) + if (container.parentNode === document.body) { + document.body.removeChild(container) + } + } + + mountedRootEntries.length = 0 + mountedContainers.clear() +} + function renderHook(renderCallback, options = {}) { const {initialProps, ...renderOptions} = options @@ -310,8 +446,60 @@ function renderHook(renderCallback, options = {}) { return {result, rerender, unmount} } +async function renderHookAsync(renderCallback, options = {}) { + const {initialProps, ...renderOptions} = options + + if (renderOptions.legacyRoot && typeof ReactDOM.render !== 'function') { + const error = new Error( + '`legacyRoot: true` is not supported in this version of React. ' + + 'If your app runs React 19 or later, you should remove this flag. ' + + 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.', + ) + Error.captureStackTrace(error, renderHookAsync) + throw error + } + + const result = React.createRef() + + function TestComponent({renderCallbackProps}) { + const pendingResult = renderCallback(renderCallbackProps) + + React.useEffect(() => { + result.current = pendingResult + }) + + return null + } + + const {rerender: baseRerender, unmount} = await renderAsync( + , + renderOptions, + ) + + function rerender(rerenderCallbackProps) { + return baseRerender( + , + ) + } + + return {result, rerender, unmount} +} + // 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, + renderHookAsync, + cleanup, + cleanupAsync, + act, + actAsync, + fireEvent, + // TODO: fireEventAsync + getConfig, + configure, +} /* eslint func-name-matching:0 */ diff --git a/types/index.d.ts b/types/index.d.ts index 3ad8cf46..71a8d60b 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -46,6 +46,27 @@ export type RenderResult< asFragment: () => DocumentFragment } & {[P in keyof Q]: BoundFunction} +export type RenderAsyncResult< + Q extends Queries = typeof queries, + Container extends RendererableContainer | HydrateableContainer = HTMLElement, + BaseElement extends RendererableContainer | HydrateableContainer = Container, +> = { + container: Container + baseElement: BaseElement + debug: ( + baseElement?: + | RendererableContainer + | HydrateableContainer + | Array + | undefined, + maxLength?: number | undefined, + options?: prettyFormat.OptionsReceived | undefined, + ) => void + rerender: (ui: React.ReactNode) => Promise + unmount: () => Promise + asFragment: () => DocumentFragment +} & {[P in keyof Q]: BoundFunction} + /** @deprecated */ export type BaseRenderOptions< Q extends Queries, @@ -152,6 +173,22 @@ export function render( options?: Omit | undefined, ): RenderResult +/** + * Render into a container which is appended to document.body. It should be used with cleanup. + */ +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 + export interface RenderHookResult { /** * Triggers a re-render. The props will be passed to your renderHook callback. @@ -174,6 +211,28 @@ export interface RenderHookResult { unmount: () => void } +export interface RenderHookAsyncResult { + /** + * Triggers a re-render. The props will be passed to your renderHook callback. + */ + rerender: (props?: Props) => Promise + /** + * This is a stable reference to the latest value returned by your renderHook + * callback + */ + result: { + /** + * The value returned by your renderHook callback + */ + current: Result + } + /** + * Unmounts the test component. This is useful for when you need to test + * any cleanup your useEffects have. + */ + unmount: () => Promise +} + /** @deprecated */ export type BaseRenderHookOptions< Props, @@ -242,11 +301,31 @@ export function renderHook< options?: RenderHookOptions | undefined, ): RenderHookResult +/** + * Allows you to render a hook within a test React component without having to + * create that component yourself. + */ +export function renderHookAsync< + Result, + Props, + Q extends Queries = typeof queries, + Container extends RendererableContainer | HydrateableContainer = HTMLElement, + BaseElement extends RendererableContainer | HydrateableContainer = Container, +>( + render: (initialProps: Props) => Result, + options?: RenderHookOptions | undefined, +): Promise> + /** * Unmounts React trees that were mounted with render. */ export function cleanup(): void +/** + * Unmounts React trees that were mounted with render. + */ +export function cleanupAsync(): Promise + /** * Simply calls React.act(cb) * If that's not available (older version of react) then it @@ -256,3 +335,5 @@ export function cleanup(): void export const act: 0 extends 1 & typeof reactAct ? typeof reactDeprecatedAct : typeof reactAct + +export function actAsync(scope: () => void | Promise): Promise From 9d9778cdbd03b610c90001d1056949c514ba38fe Mon Sep 17 00:00:00 2001 From: eps1lon Date: Wed, 27 Nov 2024 12:08:17 +0100 Subject: [PATCH 2/2] Use dedicated entrypoint instead Less renaming to-do --- async.d.ts | 1 + async.js | 2 + package.json | 4 + pure-async.d.ts | 1 + pure-async.js | 2 + src/__tests__/async.js | 73 ++++++++ src/__tests__/renderAsync.js | 25 --- src/act-compat.js | 14 -- src/async.js | 42 +++++ src/fire-event-async.js | 70 ++++++++ src/pure-async.js | 330 +++++++++++++++++++++++++++++++++++ src/pure.js | 190 +------------------- types/index.d.ts | 81 --------- types/pure-async.d.ts | 264 ++++++++++++++++++++++++++++ types/test.tsx | 13 ++ 15 files changed, 803 insertions(+), 309 deletions(-) create mode 100644 async.d.ts create mode 100644 async.js create mode 100644 pure-async.d.ts create mode 100644 pure-async.js create mode 100644 src/__tests__/async.js delete mode 100644 src/__tests__/renderAsync.js create mode 100644 src/async.js create mode 100644 src/fire-event-async.js create mode 100644 src/pure-async.js create mode 100644 types/pure-async.d.ts diff --git a/async.d.ts b/async.d.ts new file mode 100644 index 00000000..1c8f6ead --- /dev/null +++ b/async.d.ts @@ -0,0 +1 @@ +export * from './types/pure-async' diff --git a/async.js b/async.js new file mode 100644 index 00000000..e791260d --- /dev/null +++ b/async.js @@ -0,0 +1,2 @@ +// makes it so people can import from '@testing-library/react/async' +module.exports = require('./dist/async') diff --git a/package.json b/package.json index 8bfbeecc..3a30a6f4 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,13 @@ }, "files": [ "dist", + "async.js", + "async.d.ts", "dont-cleanup-after-each.js", "pure.js", "pure.d.ts", + "pure-async.js", + "pure-async.d.ts", "types/*.d.ts" ], "keywords": [ diff --git a/pure-async.d.ts b/pure-async.d.ts new file mode 100644 index 00000000..1c8f6ead --- /dev/null +++ b/pure-async.d.ts @@ -0,0 +1 @@ +export * from './types/pure-async' diff --git a/pure-async.js b/pure-async.js new file mode 100644 index 00000000..856726a1 --- /dev/null +++ b/pure-async.js @@ -0,0 +1,2 @@ +// makes it so people can import from '@testing-library/react/pure-async' +module.exports = require('./dist/pure-async') diff --git a/src/__tests__/async.js b/src/__tests__/async.js new file mode 100644 index 00000000..f6c13426 --- /dev/null +++ b/src/__tests__/async.js @@ -0,0 +1,73 @@ +// TODO: Upstream that the rule should check import source +/* eslint-disable testing-library/no-await-sync-events */ +import * as React from 'react' +import {act, render, fireEvent} from '../async' + +const isReact19 = React.version.startsWith('19.') + +const testGateReact19 = isReact19 ? test : test.skip + +testGateReact19('async data requires async APIs', async () => { + let resolve + const promise = new Promise(_resolve => { + resolve = _resolve + }) + + function Component() { + const value = React.use(promise) + return
{value}
+ } + + const {container} = await render( + + + , + ) + + expect(container).toHaveTextContent('loading...') + + await act(async () => { + resolve('Hello, Dave!') + }) + + expect(container).toHaveTextContent('Hello, Dave!') +}) + +testGateReact19('async fireEvent', async () => { + let resolve + function Component() { + const [promise, setPromise] = React.useState('initial') + const value = typeof promise === 'string' ? promise : React.use(promise) + return ( + + ) + } + + const {container} = await render( + + + , + ) + + expect(container).toHaveTextContent('Value: initial') + + await fireEvent.click(container.querySelector('button')) + + expect(container).toHaveTextContent('loading...') + + await act(() => { + resolve('Hello, Dave!') + }) + + expect(container).toHaveTextContent('Hello, Dave!') +}) diff --git a/src/__tests__/renderAsync.js b/src/__tests__/renderAsync.js deleted file mode 100644 index 4e580b82..00000000 --- a/src/__tests__/renderAsync.js +++ /dev/null @@ -1,25 +0,0 @@ -import * as React from 'react' -import {act, renderAsync} from '../' - -test('async data requires async APIs', async () => { - const {promise, resolve} = Promise.withResolvers() - - function Component() { - const value = React.use(promise) - return
{value}
- } - - const {container} = await renderAsync( - - - , - ) - - expect(container).toHaveTextContent('loading...') - - await act(async () => { - resolve('Hello, Dave!') - }) - - expect(container).toHaveTextContent('Hello, Dave!') -}) diff --git a/src/act-compat.js b/src/act-compat.js index 8d5da94b..6eaec0fb 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -82,22 +82,8 @@ function withGlobalActEnvironment(actImplementation) { const act = withGlobalActEnvironment(reactAct) -async function actAsync(scope) { - const previousActEnvironment = getIsReactActEnvironment() - setIsReactActEnvironment(true) - try { - // React.act isn't async yet so we need to force it. - return await reactAct(async () => { - scope() - }) - } finally { - setIsReactActEnvironment(previousActEnvironment) - } -} - export default act export { - actAsync, setIsReactActEnvironment as setReactActEnvironment, getIsReactActEnvironment, } diff --git a/src/async.js b/src/async.js new file mode 100644 index 00000000..cffcbfea --- /dev/null +++ b/src/async.js @@ -0,0 +1,42 @@ +/* istanbul ignore file */ +import {getIsReactActEnvironment, setReactActEnvironment} from './act-compat' +import {cleanup} from './pure-async' + +// if we're running in a test runner that supports afterEach +// or teardown then we'll automatically run cleanup afterEach test +// this ensures that tests run in isolation from each other +// if you don't like this then either import the `pure` module +// or set the RTL_SKIP_AUTO_CLEANUP env variable to 'true'. +if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) { + // ignore teardown() in code coverage because Jest does not support it + /* istanbul ignore else */ + if (typeof afterEach === 'function') { + afterEach(async () => { + await cleanup() + }) + } else if (typeof teardown === 'function') { + // Block is guarded by `typeof` check. + // eslint does not support `typeof` guards. + // eslint-disable-next-line no-undef + teardown(async () => { + await cleanup() + }) + } + + // No test setup with other test runners available + /* istanbul ignore else */ + if (typeof beforeAll === 'function' && typeof afterAll === 'function') { + // This matches the behavior of React < 18. + let previousIsReactActEnvironment = getIsReactActEnvironment() + beforeAll(() => { + previousIsReactActEnvironment = getIsReactActEnvironment() + setReactActEnvironment(true) + }) + + afterAll(() => { + setReactActEnvironment(previousIsReactActEnvironment) + }) + } +} + +export * from './pure-async' diff --git a/src/fire-event-async.js b/src/fire-event-async.js new file mode 100644 index 00000000..09c7719d --- /dev/null +++ b/src/fire-event-async.js @@ -0,0 +1,70 @@ +/* istanbul ignore file */ +import {fireEvent as dtlFireEvent} from '@testing-library/dom' + +// react-testing-library's version of fireEvent will call +// dom-testing-library's version of fireEvent. The reason +// we make this distinction however is because we have +// a few extra events that work a bit differently +const fireEvent = (...args) => dtlFireEvent(...args) + +Object.keys(dtlFireEvent).forEach(key => { + fireEvent[key] = (...args) => dtlFireEvent[key](...args) +}) + +// React event system tracks native mouseOver/mouseOut events for +// running onMouseEnter/onMouseLeave handlers +// @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/EnterLeaveEventPlugin.js#L24-L31 +const mouseEnter = fireEvent.mouseEnter +const mouseLeave = fireEvent.mouseLeave +fireEvent.mouseEnter = async (...args) => { + await mouseEnter(...args) + return fireEvent.mouseOver(...args) +} +fireEvent.mouseLeave = async (...args) => { + await mouseLeave(...args) + return fireEvent.mouseOut(...args) +} + +const pointerEnter = fireEvent.pointerEnter +const pointerLeave = fireEvent.pointerLeave +fireEvent.pointerEnter = async (...args) => { + await pointerEnter(...args) + return fireEvent.pointerOver(...args) +} +fireEvent.pointerLeave = async (...args) => { + await pointerLeave(...args) + return fireEvent.pointerOut(...args) +} + +const select = fireEvent.select +fireEvent.select = async (node, init) => { + await select(node, init) + // React tracks this event only on focused inputs + node.focus() + + // React creates this event when one of the following native events happens + // - contextMenu + // - mouseUp + // - dragEnd + // - keyUp + // - keyDown + // so we can use any here + // @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/SelectEventPlugin.js#L203-L224 + await fireEvent.keyUp(node, init) +} + +// React event system tracks native focusout/focusin events for +// running blur/focus handlers +// @link https://github.com/facebook/react/pull/19186 +const blur = fireEvent.blur +const focus = fireEvent.focus +fireEvent.blur = async (...args) => { + await fireEvent.focusOut(...args) + return blur(...args) +} +fireEvent.focus = async (...args) => { + await fireEvent.focusIn(...args) + return focus(...args) +} + +export {fireEvent} diff --git a/src/pure-async.js b/src/pure-async.js new file mode 100644 index 00000000..21ffd97f --- /dev/null +++ b/src/pure-async.js @@ -0,0 +1,330 @@ +/* istanbul ignore file */ +import * as React from 'react' +import ReactDOM from 'react-dom' +import * as ReactDOMClient from 'react-dom/client' +import { + getQueriesForElement, + prettyDOM, + configure as configureDTL, +} from '@testing-library/dom' +import {getIsReactActEnvironment, setReactActEnvironment} from './act-compat' +import {fireEvent} from './fire-event' +import {getConfig, configure} from './config' + +async function act(scope) { + const previousActEnvironment = getIsReactActEnvironment() + setReactActEnvironment(true) + try { + // React.act isn't async yet so we need to force it. + return await React.act(async () => { + scope() + }) + } finally { + setReactActEnvironment(previousActEnvironment) + } +} + +function jestFakeTimersAreEnabled() { + /* istanbul ignore else */ + if (typeof jest !== 'undefined' && jest !== null) { + return ( + // legacy timers + setTimeout._isMockFunction === true || // modern timers + // eslint-disable-next-line prefer-object-has-own -- No Object.hasOwn in all target environments we support. + Object.prototype.hasOwnProperty.call(setTimeout, 'clock') + ) + } // istanbul ignore next + + return false +} + +configureDTL({ + unstable_advanceTimersWrapper: cb => { + return act(cb) + }, + // We just want to run `waitFor` without IS_REACT_ACT_ENVIRONMENT + // But that's not necessarily how `asyncWrapper` is used since it's a public method. + // Let's just hope nobody else is using it. + asyncWrapper: async cb => { + const previousActEnvironment = getIsReactActEnvironment() + setReactActEnvironment(false) + try { + const result = await cb() + // Drain microtask queue. + // Otherwise we'll restore the previous act() environment, before we resolve the `waitFor` call. + // The caller would have no chance to wrap the in-flight Promises in `act()` + await new Promise(resolve => { + setTimeout(() => { + resolve() + }, 0) + + if (jestFakeTimersAreEnabled()) { + jest.advanceTimersByTime(0) + } + }) + + return result + } finally { + setReactActEnvironment(previousActEnvironment) + } + }, + eventWrapper: async cb => { + let result + await act(() => { + result = cb() + }) + return result + }, +}) + +// Ideally we'd just use a WeakMap where containers are keys and roots are values. +// We use two variables so that we can bail out in constant time when we render with a new container (most common use case) +/** + * @type {Set} + */ +const mountedContainers = new Set() +/** + * @type Array<{container: import('react-dom').Container, root: ReturnType}> + */ +const mountedRootEntries = [] + +function strictModeIfNeeded(innerElement) { + return getConfig().reactStrictMode + ? React.createElement(React.StrictMode, null, innerElement) + : innerElement +} + +function wrapUiIfNeeded(innerElement, wrapperComponent) { + return wrapperComponent + ? React.createElement(wrapperComponent, null, innerElement) + : innerElement +} + +async function createConcurrentRoot( + container, + {hydrate, ui, wrapper: WrapperComponent}, +) { + let root + if (hydrate) { + await act(() => { + root = ReactDOMClient.hydrateRoot( + container, + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + ) + }) + } else { + root = ReactDOMClient.createRoot(container) + } + + return { + hydrate() { + /* istanbul ignore if */ + if (!hydrate) { + throw new Error( + 'Attempted to hydrate a non-hydrateable root. This is a bug in `@testing-library/react`.', + ) + } + // Nothing to do since hydration happens when creating the root object. + }, + render(element) { + root.render(element) + }, + unmount() { + root.unmount() + }, + } +} + +function createLegacyRoot(container) { + return { + hydrate(element) { + ReactDOM.hydrate(element, container) + }, + render(element) { + ReactDOM.render(element, container) + }, + unmount() { + ReactDOM.unmountComponentAtNode(container) + }, + } +} + +async function renderRootAsync( + ui, + {baseElement, container, hydrate, queries, root, wrapper: WrapperComponent}, +) { + await act(() => { + if (hydrate) { + root.hydrate( + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + container, + ) + } else { + root.render( + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + container, + ) + } + }) + + return { + container, + baseElement, + debug: (el = baseElement, maxLength, options) => + Array.isArray(el) + ? // eslint-disable-next-line no-console + el.forEach(e => console.log(prettyDOM(e, maxLength, options))) + : // eslint-disable-next-line no-console, + console.log(prettyDOM(el, maxLength, options)), + unmount: async () => { + await act(() => { + root.unmount() + }) + }, + rerender: async rerenderUi => { + await renderRootAsync(rerenderUi, { + container, + baseElement, + root, + wrapper: WrapperComponent, + }) + // Intentionally do not return anything to avoid unnecessarily complicating the API. + // folks can use all the same utilities we return in the first place that are bound to the container + }, + asFragment: () => { + /* istanbul ignore else (old jsdom limitation) */ + if (typeof document.createRange === 'function') { + return document + .createRange() + .createContextualFragment(container.innerHTML) + } else { + const template = document.createElement('template') + template.innerHTML = container.innerHTML + return template.content + } + }, + ...getQueriesForElement(baseElement, queries), + } +} + +async function render( + ui, + { + container, + baseElement = container, + legacyRoot = false, + queries, + hydrate = false, + wrapper, + } = {}, +) { + if (legacyRoot && typeof ReactDOM.render !== 'function') { + const error = new Error( + '`legacyRoot: true` is not supported in this version of React. ' + + 'If your app runs React 19 or later, you should remove this flag. ' + + 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.', + ) + Error.captureStackTrace(error, render) + throw error + } + + if (!baseElement) { + // default to document.body instead of documentElement to avoid output of potentially-large + // head elements (such as JSS style blocks) in debug output + baseElement = document.body + } + if (!container) { + container = baseElement.appendChild(document.createElement('div')) + } + + let root + // 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. + if (!mountedContainers.has(container)) { + const createRootImpl = legacyRoot ? createLegacyRoot : createConcurrentRoot + root = await createRootImpl(container, {hydrate, ui, wrapper}) + + mountedRootEntries.push({container, root}) + // we'll add it to the mounted containers regardless of whether it's actually + // added to document.body so the cleanup method works regardless of whether + // they're passing us a custom container or not. + mountedContainers.add(container) + } else { + mountedRootEntries.forEach(rootEntry => { + // Else is unreachable since `mountedContainers` has the `container`. + // Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries` + /* istanbul ignore else */ + if (rootEntry.container === container) { + root = rootEntry.root + } + }) + } + + return renderRootAsync(ui, { + container, + baseElement, + queries, + hydrate, + wrapper, + root, + }) +} + +async function cleanup() { + for (const {root, container} of mountedRootEntries) { + // eslint-disable-next-line no-await-in-loop -- act calls can't overlap + await act(() => { + root.unmount() + }) + if (container.parentNode === document.body) { + document.body.removeChild(container) + } + } + + mountedRootEntries.length = 0 + mountedContainers.clear() +} + +async function renderHook(renderCallback, options = {}) { + const {initialProps, ...renderOptions} = options + + if (renderOptions.legacyRoot && typeof ReactDOM.render !== 'function') { + const error = new Error( + '`legacyRoot: true` is not supported in this version of React. ' + + 'If your app runs React 19 or later, you should remove this flag. ' + + 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.', + ) + Error.captureStackTrace(error, renderHook) + throw error + } + + const result = React.createRef() + + function TestComponent({renderCallbackProps}) { + const pendingResult = renderCallback(renderCallbackProps) + + React.useEffect(() => { + result.current = pendingResult + }) + + return null + } + + const {rerender: baseRerender, unmount} = await render( + , + renderOptions, + ) + + function rerender(rerenderCallbackProps) { + return baseRerender( + , + ) + } + + return {result, rerender, unmount} +} + +// just re-export everything from dom-testing-library +export * from '@testing-library/dom' +export {render, renderHook, cleanup, act, fireEvent, getConfig, configure} + +/* eslint func-name-matching:0 */ diff --git a/src/pure.js b/src/pure.js index 750d10be..f546af98 100644 --- a/src/pure.js +++ b/src/pure.js @@ -7,7 +7,6 @@ import { configure as configureDTL, } from '@testing-library/dom' import act, { - actAsync, getIsReactActEnvironment, setReactActEnvironment, } from './act-compat' @@ -197,64 +196,6 @@ function renderRoot( } } -async function renderRootAsync( - ui, - {baseElement, container, hydrate, queries, root, wrapper: WrapperComponent}, -) { - await actAsync(() => { - if (hydrate) { - root.hydrate( - strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), - container, - ) - } else { - root.render( - strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), - container, - ) - } - }) - - return { - container, - baseElement, - debug: (el = baseElement, maxLength, options) => - Array.isArray(el) - ? // eslint-disable-next-line no-console - el.forEach(e => console.log(prettyDOM(e, maxLength, options))) - : // eslint-disable-next-line no-console, - console.log(prettyDOM(el, maxLength, options)), - unmount: async () => { - await actAsync(() => { - root.unmount() - }) - }, - rerender: async rerenderUi => { - await renderRootAsync(rerenderUi, { - container, - baseElement, - root, - wrapper: WrapperComponent, - }) - // Intentionally do not return anything to avoid unnecessarily complicating the API. - // folks can use all the same utilities we return in the first place that are bound to the container - }, - asFragment: () => { - /* istanbul ignore else (old jsdom limitation) */ - if (typeof document.createRange === 'function') { - return document - .createRange() - .createContextualFragment(container.innerHTML) - } else { - const template = document.createElement('template') - template.innerHTML = container.innerHTML - return template.content - } - }, - ...getQueriesForElement(baseElement, queries), - } -} - function render( ui, { @@ -317,68 +258,6 @@ function render( }) } -function renderAsync( - ui, - { - container, - baseElement = container, - legacyRoot = false, - queries, - hydrate = false, - wrapper, - } = {}, -) { - if (legacyRoot && typeof ReactDOM.render !== 'function') { - const error = new Error( - '`legacyRoot: true` is not supported in this version of React. ' + - 'If your app runs React 19 or later, you should remove this flag. ' + - 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.', - ) - Error.captureStackTrace(error, render) - throw error - } - - if (!baseElement) { - // default to document.body instead of documentElement to avoid output of potentially-large - // head elements (such as JSS style blocks) in debug output - baseElement = document.body - } - if (!container) { - container = baseElement.appendChild(document.createElement('div')) - } - - let root - // 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. - if (!mountedContainers.has(container)) { - const createRootImpl = legacyRoot ? createLegacyRoot : createConcurrentRoot - root = createRootImpl(container, {hydrate, ui, wrapper}) - - mountedRootEntries.push({container, root}) - // we'll add it to the mounted containers regardless of whether it's actually - // added to document.body so the cleanup method works regardless of whether - // they're passing us a custom container or not. - mountedContainers.add(container) - } else { - mountedRootEntries.forEach(rootEntry => { - // Else is unreachable since `mountedContainers` has the `container`. - // Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries` - /* istanbul ignore else */ - if (rootEntry.container === container) { - root = rootEntry.root - } - }) - } - - return renderRootAsync(ui, { - container, - baseElement, - queries, - hydrate, - wrapper, - root, - }) -} - function cleanup() { mountedRootEntries.forEach(({root, container}) => { act(() => { @@ -392,21 +271,6 @@ function cleanup() { mountedContainers.clear() } -async function cleanupAsync() { - for (const {root, container} of mountedRootEntries) { - // eslint-disable-next-line no-await-in-loop -- act calls can't overlap - await actAsync(() => { - root.unmount() - }) - if (container.parentNode === document.body) { - document.body.removeChild(container) - } - } - - mountedRootEntries.length = 0 - mountedContainers.clear() -} - function renderHook(renderCallback, options = {}) { const {initialProps, ...renderOptions} = options @@ -446,60 +310,8 @@ function renderHook(renderCallback, options = {}) { return {result, rerender, unmount} } -async function renderHookAsync(renderCallback, options = {}) { - const {initialProps, ...renderOptions} = options - - if (renderOptions.legacyRoot && typeof ReactDOM.render !== 'function') { - const error = new Error( - '`legacyRoot: true` is not supported in this version of React. ' + - 'If your app runs React 19 or later, you should remove this flag. ' + - 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.', - ) - Error.captureStackTrace(error, renderHookAsync) - throw error - } - - const result = React.createRef() - - function TestComponent({renderCallbackProps}) { - const pendingResult = renderCallback(renderCallbackProps) - - React.useEffect(() => { - result.current = pendingResult - }) - - return null - } - - const {rerender: baseRerender, unmount} = await renderAsync( - , - renderOptions, - ) - - function rerender(rerenderCallbackProps) { - return baseRerender( - , - ) - } - - return {result, rerender, unmount} -} - // just re-export everything from dom-testing-library export * from '@testing-library/dom' -export { - render, - renderAsync, - renderHook, - renderHookAsync, - cleanup, - cleanupAsync, - act, - actAsync, - fireEvent, - // TODO: fireEventAsync - getConfig, - configure, -} +export {render, renderHook, cleanup, act, fireEvent, getConfig, configure} /* eslint func-name-matching:0 */ diff --git a/types/index.d.ts b/types/index.d.ts index 71a8d60b..3ad8cf46 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -46,27 +46,6 @@ export type RenderResult< asFragment: () => DocumentFragment } & {[P in keyof Q]: BoundFunction} -export type RenderAsyncResult< - Q extends Queries = typeof queries, - Container extends RendererableContainer | HydrateableContainer = HTMLElement, - BaseElement extends RendererableContainer | HydrateableContainer = Container, -> = { - container: Container - baseElement: BaseElement - debug: ( - baseElement?: - | RendererableContainer - | HydrateableContainer - | Array - | undefined, - maxLength?: number | undefined, - options?: prettyFormat.OptionsReceived | undefined, - ) => void - rerender: (ui: React.ReactNode) => Promise - unmount: () => Promise - asFragment: () => DocumentFragment -} & {[P in keyof Q]: BoundFunction} - /** @deprecated */ export type BaseRenderOptions< Q extends Queries, @@ -173,22 +152,6 @@ export function render( options?: Omit | undefined, ): RenderResult -/** - * Render into a container which is appended to document.body. It should be used with cleanup. - */ -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 - export interface RenderHookResult { /** * Triggers a re-render. The props will be passed to your renderHook callback. @@ -211,28 +174,6 @@ export interface RenderHookResult { unmount: () => void } -export interface RenderHookAsyncResult { - /** - * Triggers a re-render. The props will be passed to your renderHook callback. - */ - rerender: (props?: Props) => Promise - /** - * This is a stable reference to the latest value returned by your renderHook - * callback - */ - result: { - /** - * The value returned by your renderHook callback - */ - current: Result - } - /** - * Unmounts the test component. This is useful for when you need to test - * any cleanup your useEffects have. - */ - unmount: () => Promise -} - /** @deprecated */ export type BaseRenderHookOptions< Props, @@ -301,31 +242,11 @@ export function renderHook< options?: RenderHookOptions | undefined, ): RenderHookResult -/** - * Allows you to render a hook within a test React component without having to - * create that component yourself. - */ -export function renderHookAsync< - Result, - Props, - Q extends Queries = typeof queries, - Container extends RendererableContainer | HydrateableContainer = HTMLElement, - BaseElement extends RendererableContainer | HydrateableContainer = Container, ->( - render: (initialProps: Props) => Result, - options?: RenderHookOptions | undefined, -): Promise> - /** * Unmounts React trees that were mounted with render. */ export function cleanup(): void -/** - * Unmounts React trees that were mounted with render. - */ -export function cleanupAsync(): Promise - /** * Simply calls React.act(cb) * If that's not available (older version of react) then it @@ -335,5 +256,3 @@ export function cleanupAsync(): Promise export const act: 0 extends 1 & typeof reactAct ? typeof reactDeprecatedAct : typeof reactAct - -export function actAsync(scope: () => void | Promise): Promise diff --git a/types/pure-async.d.ts b/types/pure-async.d.ts new file mode 100644 index 00000000..7257c396 --- /dev/null +++ b/types/pure-async.d.ts @@ -0,0 +1,264 @@ +// TypeScript Version: 3.8 +// copy of ./index.d.ts but async +import * as ReactDOMClient from 'react-dom/client' +import { + queries, + Queries, + BoundFunction, + prettyFormat, + Config as ConfigDTL, + EventType, + FireFunction, + FireObject, +} from '@testing-library/dom' + +export * from '@testing-library/dom' + +export interface Config extends ConfigDTL { + reactStrictMode: boolean +} + +export interface ConfigFn { + (existingConfig: Config): Partial +} + +export function configure(configDelta: ConfigFn | Partial): void + +export function getConfig(): Config + +export type RenderResult< + Q extends Queries = typeof queries, + Container extends RendererableContainer | HydrateableContainer = HTMLElement, + BaseElement extends RendererableContainer | HydrateableContainer = Container, +> = { + container: Container + baseElement: BaseElement + debug: ( + baseElement?: + | RendererableContainer + | HydrateableContainer + | Array + | undefined, + maxLength?: number | undefined, + options?: prettyFormat.OptionsReceived | undefined, + ) => void + rerender: (ui: React.ReactNode) => Promise + unmount: () => Promise + asFragment: () => DocumentFragment +} & {[P in keyof Q]: BoundFunction} + +/** @deprecated */ +export type BaseRenderOptions< + Q extends Queries, + Container extends RendererableContainer | HydrateableContainer, + BaseElement extends RendererableContainer | HydrateableContainer, +> = RenderOptions + +type RendererableContainer = ReactDOMClient.Container +type HydrateableContainer = Parameters[0] +/** @deprecated */ +export interface ClientRenderOptions< + Q extends Queries, + Container extends RendererableContainer, + BaseElement extends RendererableContainer = Container, +> extends BaseRenderOptions { + /** + * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side + * rendering and use ReactDOM.hydrate to mount your components. + * + * @see https://testing-library.com/docs/react-testing-library/api/#hydrate) + */ + hydrate?: false | undefined +} +/** @deprecated */ +export interface HydrateOptions< + Q extends Queries, + Container extends HydrateableContainer, + BaseElement extends HydrateableContainer = Container, +> extends BaseRenderOptions { + /** + * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side + * rendering and use ReactDOM.hydrate to mount your components. + * + * @see https://testing-library.com/docs/react-testing-library/api/#hydrate) + */ + hydrate: true +} + +export interface RenderOptions< + Q extends Queries = typeof queries, + Container extends RendererableContainer | HydrateableContainer = HTMLElement, + BaseElement extends RendererableContainer | HydrateableContainer = Container, +> { + /** + * By default, React Testing Library will create a div and append that div to the document.body. Your React component will be rendered in the created div. If you provide your own HTMLElement container via this option, + * it will not be appended to the document.body automatically. + * + * For example: If you are unit testing a `` element, it cannot be a child of a div. In this case, you can + * specify a table as the render container. + * + * @see https://testing-library.com/docs/react-testing-library/api/#container + */ + container?: Container | undefined + /** + * Defaults to the container if the container is specified. Otherwise `document.body` is used for the default. This is used as + * the base element for the queries as well as what is printed when you use `debug()`. + * + * @see https://testing-library.com/docs/react-testing-library/api/#baseelement + */ + baseElement?: BaseElement | undefined + /** + * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side + * rendering and use ReactDOM.hydrate to mount your components. + * + * @see https://testing-library.com/docs/react-testing-library/api/#hydrate) + */ + hydrate?: boolean | undefined + /** + * Only works if used with React 18. + * Set to `true` if you want to force synchronous `ReactDOM.render`. + * Otherwise `render` will default to concurrent React if available. + */ + legacyRoot?: boolean | undefined + /** + * Queries to bind. Overrides the default set from DOM Testing Library unless merged. + * + * @see https://testing-library.com/docs/react-testing-library/api/#queries + */ + queries?: Q | undefined + /** + * Pass a React Component as the wrapper option to have it rendered around the inner element. This is most useful for creating + * reusable custom render functions for common data providers. See setup for examples. + * + * @see https://testing-library.com/docs/react-testing-library/api/#wrapper + */ + wrapper?: React.JSXElementConstructor<{children: React.ReactNode}> | undefined +} + +type Omit = Pick> + +/** + * Render into a container which is appended to document.body. It should be used with cleanup. + */ +export function render< + Q extends Queries = typeof queries, + Container extends RendererableContainer | HydrateableContainer = HTMLElement, + BaseElement extends RendererableContainer | HydrateableContainer = Container, +>( + ui: React.ReactNode, + options: RenderOptions, +): Promise> +export function render( + ui: React.ReactNode, + options?: Omit | undefined, +): Promise + +export interface RenderHookResult { + /** + * Triggers a re-render. The props will be passed to your renderHook callback. + */ + rerender: (props?: Props) => Promise + /** + * This is a stable reference to the latest value returned by your renderHook + * callback + */ + result: { + /** + * The value returned by your renderHook callback + */ + current: Result + } + /** + * Unmounts the test component. This is useful for when you need to test + * any cleanup your useEffects have. + */ + unmount: () => Promise +} + +/** @deprecated */ +export type BaseRenderHookOptions< + Props, + Q extends Queries, + Container extends RendererableContainer | HydrateableContainer, + BaseElement extends Element | DocumentFragment, +> = RenderHookOptions + +/** @deprecated */ +export interface ClientRenderHookOptions< + Props, + Q extends Queries, + Container extends Element | DocumentFragment, + BaseElement extends Element | DocumentFragment = Container, +> extends BaseRenderHookOptions { + /** + * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side + * rendering and use ReactDOM.hydrate to mount your components. + * + * @see https://testing-library.com/docs/react-testing-library/api/#hydrate) + */ + hydrate?: false | undefined +} + +/** @deprecated */ +export interface HydrateHookOptions< + Props, + Q extends Queries, + Container extends Element | DocumentFragment, + BaseElement extends Element | DocumentFragment = Container, +> extends BaseRenderHookOptions { + /** + * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side + * rendering and use ReactDOM.hydrate to mount your components. + * + * @see https://testing-library.com/docs/react-testing-library/api/#hydrate) + */ + hydrate: true +} + +export interface RenderHookOptions< + Props, + Q extends Queries = typeof queries, + Container extends RendererableContainer | HydrateableContainer = HTMLElement, + BaseElement extends RendererableContainer | HydrateableContainer = Container, +> extends BaseRenderOptions { + /** + * The argument passed to the renderHook callback. Can be useful if you plan + * to use the rerender utility to change the values passed to your hook. + */ + initialProps?: Props | undefined +} + +/** + * Allows you to render a hook within a test React component without having to + * create that component yourself. + */ +export function renderHook< + Result, + Props, + Q extends Queries = typeof queries, + Container extends RendererableContainer | HydrateableContainer = HTMLElement, + BaseElement extends RendererableContainer | HydrateableContainer = Container, +>( + render: (initialProps: Props) => Result, + options?: RenderHookOptions | undefined, +): Promise> + +/** + * Unmounts React trees that were mounted with render. + */ +export function cleanup(): Promise + +export function act(cb: () => void | Promise): Promise + +export type AsyncFireFunction = ( + element: Document | Element | Window | Node, + event: Event, +) => Promise +export type AsyncFireObject = { + [K in EventType]: ( + element: Document | Element | Window | Node, + options?: {}, + ) => Promise +} + +export const fireEvent: AsyncFireFunction & AsyncFireObject diff --git a/types/test.tsx b/types/test.tsx index 2b3dd7ca..d3915889 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import * as async from '../async' import {render, fireEvent, screen, waitFor, renderHook} from '.' import * as pure from './pure' @@ -259,6 +260,18 @@ export function testContainer() { renderHook(() => null, {container: document, hydrate: true}) } +export async function testAsync() { + await async.render(