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/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..28caf78b --- /dev/null +++ b/src/__tests__/async.js @@ -0,0 +1,71 @@ +// TODO: Upstream that the rule should check import source +/* eslint-disable testing-library/no-await-sync-events */ +/* eslint-disable jest/no-conditional-in-test */ +/* eslint-disable jest/no-if */ +import * as React from 'react' +import {act, render, fireEvent} from '../async' + +test('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!') +}) + +test('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 */