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