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/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/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(