+ ...,
+ ]
+ `)
+})
+
+/*
+eslint
+ no-console: "off",
+*/
diff --git a/src/__tests__/end-to-end.js b/src/__tests__/end-to-end.js
index e8ad2f8b..f93c23be 100644
--- a/src/__tests__/end-to-end.js
+++ b/src/__tests__/end-to-end.js
@@ -1,42 +1,234 @@
-import React from 'react'
-import {render, wait, cleanup} from '../'
-
-afterEach(cleanup)
-
-const fetchAMessage = () =>
- new Promise(resolve => {
- // we are using random timeout here to simulate a real-time example
- // of an async operation calling a callback at a non-deterministic time
- const randomTimeout = Math.floor(Math.random() * 100)
- setTimeout(() => {
- resolve({returnedMessage: 'Hello World'})
- }, randomTimeout)
- })
-
-class ComponentWithLoader extends React.Component {
- state = {loading: true}
- async componentDidMount() {
- const data = await fetchAMessage()
- this.setState({data, loading: false}) // eslint-disable-line
- }
- render() {
- if (this.state.loading) {
- return
Loading...
- } else {
+import * as React from 'react'
+import {render, waitForElementToBeRemoved, screen, waitFor} from '../'
+
+describe.each([
+ ['real timers', () => jest.useRealTimers()],
+ ['fake legacy timers', () => jest.useFakeTimers('legacy')],
+ ['fake modern timers', () => jest.useFakeTimers('modern')],
+])(
+ 'it waits for the data to be loaded in a macrotask using %s',
+ (label, useTimers) => {
+ beforeEach(() => {
+ useTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ const fetchAMessageInAMacrotask = () =>
+ new Promise(resolve => {
+ // we are using random timeout here to simulate a real-time example
+ // of an async operation calling a callback at a non-deterministic time
+ const randomTimeout = Math.floor(Math.random() * 100)
+ setTimeout(() => {
+ resolve({returnedMessage: 'Hello World'})
+ }, randomTimeout)
+ })
+
+ function ComponentWithMacrotaskLoader() {
+ const [state, setState] = React.useState({data: undefined, loading: true})
+ React.useEffect(() => {
+ let cancelled = false
+ fetchAMessageInAMacrotask().then(data => {
+ if (!cancelled) {
+ setState({data, loading: false})
+ }
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ if (state.loading) {
+ return
Loading...
+ }
+
return (
- Loaded this message: {this.state.data.returnedMessage}!
+ Loaded this message: {state.data.returnedMessage}!
)
}
- }
-}
-test('it waits for the data to be loaded', async () => {
- const {queryByText, queryByTestId} = render(
)
+ test('waitForElementToBeRemoved', async () => {
+ render(
)
+ const loading = () => screen.getByText('Loading...')
+ await waitForElementToBeRemoved(loading)
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('waitFor', async () => {
+ render(
)
+ await waitFor(() => screen.getByText(/Loading../))
+ await waitFor(() => screen.getByText(/Loaded this message:/))
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('findBy', async () => {
+ render(
)
+ await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
+ /Hello World/,
+ )
+ })
+ },
+)
+
+describe.each([
+ ['real timers', () => jest.useRealTimers()],
+ ['fake legacy timers', () => jest.useFakeTimers('legacy')],
+ ['fake modern timers', () => jest.useFakeTimers('modern')],
+])(
+ 'it waits for the data to be loaded in many microtask using %s',
+ (label, useTimers) => {
+ beforeEach(() => {
+ useTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ const fetchAMessageInAMicrotask = () =>
+ Promise.resolve({
+ status: 200,
+ json: () => Promise.resolve({title: 'Hello World'}),
+ })
+
+ function ComponentWithMicrotaskLoader() {
+ const [fetchState, setFetchState] = React.useState({fetching: true})
+
+ React.useEffect(() => {
+ if (fetchState.fetching) {
+ fetchAMessageInAMicrotask().then(res => {
+ return (
+ res
+ .json()
+ // By spec, the runtime can only yield back to the event loop once
+ // the microtask queue is empty.
+ // So we ensure that we actually wait for that as well before yielding back from `waitFor`.
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => {
+ setFetchState({todo: data.title, fetching: false})
+ })
+ )
+ })
+ }
+ }, [fetchState])
+
+ if (fetchState.fetching) {
+ return
Loading..
+ }
+
+ return (
+
Loaded this message: {fetchState.todo}
+ )
+ }
+
+ test('waitForElementToBeRemoved', async () => {
+ render(
)
+ const loading = () => screen.getByText('Loading..')
+ await waitForElementToBeRemoved(loading)
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('waitFor', async () => {
+ render(
)
+ await waitFor(() => {
+ screen.getByText('Loading..')
+ })
+ await waitFor(() => {
+ screen.getByText(/Loaded this message:/)
+ })
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('findBy', async () => {
+ render(
)
+ await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
+ /Hello World/,
+ )
+ })
+ },
+)
+
+describe.each([
+ ['real timers', () => jest.useRealTimers()],
+ ['fake legacy timers', () => jest.useFakeTimers('legacy')],
+ ['fake modern timers', () => jest.useFakeTimers('modern')],
+])(
+ 'it waits for the data to be loaded in a microtask using %s',
+ (label, useTimers) => {
+ beforeEach(() => {
+ useTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ const fetchAMessageInAMicrotask = () =>
+ Promise.resolve({
+ status: 200,
+ json: () => Promise.resolve({title: 'Hello World'}),
+ })
+
+ function ComponentWithMicrotaskLoader() {
+ const [fetchState, setFetchState] = React.useState({fetching: true})
+
+ React.useEffect(() => {
+ if (fetchState.fetching) {
+ fetchAMessageInAMicrotask().then(res => {
+ return res.json().then(data => {
+ setFetchState({todo: data.title, fetching: false})
+ })
+ })
+ }
+ }, [fetchState])
+
+ if (fetchState.fetching) {
+ return
Loading..
+ }
+
+ return (
+
Loaded this message: {fetchState.todo}
+ )
+ }
+
+ test('waitForElementToBeRemoved', async () => {
+ render(
)
+ const loading = () => screen.getByText('Loading..')
+ await waitForElementToBeRemoved(loading)
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
- expect(queryByText('Loading...')).toBeTruthy()
+ test('waitFor', async () => {
+ render(
)
+ await waitFor(() => {
+ screen.getByText('Loading..')
+ })
+ await waitFor(() => {
+ screen.getByText(/Loaded this message:/)
+ })
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
- await wait(() => expect(queryByText('Loading...')).toBeNull())
- expect(queryByTestId('message').textContent).toMatch(/Hello World/)
-})
+ test('findBy', async () => {
+ render(
)
+ await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
+ /Hello World/,
+ )
+ })
+ },
+)
diff --git a/src/__tests__/error-handlers.js b/src/__tests__/error-handlers.js
new file mode 100644
index 00000000..60db1410
--- /dev/null
+++ b/src/__tests__/error-handlers.js
@@ -0,0 +1,183 @@
+/* eslint-disable jest/no-if */
+/* eslint-disable jest/no-conditional-in-test */
+/* eslint-disable jest/no-conditional-expect */
+import * as React from 'react'
+import {render, renderHook} from '../'
+
+const isReact19 = React.version.startsWith('19.')
+
+const testGateReact19 = isReact19 ? test : test.skip
+
+test('render errors', () => {
+ function Thrower() {
+ throw new Error('Boom!')
+ }
+
+ if (isReact19) {
+ expect(() => {
+ render(
)
+ }).toThrow('Boom!')
+ } else {
+ expect(() => {
+ expect(() => {
+ render(
)
+ }).toThrow('Boom!')
+ }).toErrorDev([
+ 'Error: Uncaught [Error: Boom!]',
+ // React retries on error
+ 'Error: Uncaught [Error: Boom!]',
+ ])
+ }
+})
+
+test('onUncaughtError is not supported in render', () => {
+ function Thrower() {
+ throw new Error('Boom!')
+ }
+ const onUncaughtError = jest.fn(() => {})
+
+ expect(() => {
+ render(
, {
+ onUncaughtError(error, errorInfo) {
+ console.log({error, errorInfo})
+ },
+ })
+ }).toThrow(
+ 'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
+ )
+
+ expect(onUncaughtError).toHaveBeenCalledTimes(0)
+})
+
+testGateReact19('onCaughtError is supported in render', () => {
+ const thrownError = new Error('Boom!')
+ const handleComponentDidCatch = jest.fn()
+ const onCaughtError = jest.fn()
+ class ErrorBoundary extends React.Component {
+ state = {error: null}
+ static getDerivedStateFromError(error) {
+ return {error}
+ }
+ componentDidCatch(error, errorInfo) {
+ handleComponentDidCatch(error, errorInfo)
+ }
+ render() {
+ if (this.state.error) {
+ return null
+ }
+ return this.props.children
+ }
+ }
+ function Thrower() {
+ throw thrownError
+ }
+
+ render(
+
+
+ ,
+ {
+ onCaughtError,
+ },
+ )
+
+ expect(onCaughtError).toHaveBeenCalledWith(thrownError, {
+ componentStack: expect.any(String),
+ errorBoundary: expect.any(Object),
+ })
+})
+
+test('onRecoverableError is supported in render', () => {
+ const onRecoverableError = jest.fn()
+
+ const container = document.createElement('div')
+ container.innerHTML = '
server
'
+ // We just hope we forwarded the callback correctly (which is guaranteed since we just pass it along)
+ // Frankly, I'm too lazy to assert on React 18 hydration errors since they're a mess.
+ // eslint-disable-next-line jest/no-conditional-in-test
+ if (isReact19) {
+ render(
client
, {
+ container,
+ hydrate: true,
+ onRecoverableError,
+ })
+ expect(onRecoverableError).toHaveBeenCalledTimes(1)
+ } else {
+ expect(() => {
+ render(
client
, {
+ container,
+ hydrate: true,
+ onRecoverableError,
+ })
+ }).toErrorDev(['', ''], {withoutStack: 1})
+ expect(onRecoverableError).toHaveBeenCalledTimes(2)
+ }
+})
+
+test('onUncaughtError is not supported in renderHook', () => {
+ function useThrower() {
+ throw new Error('Boom!')
+ }
+ const onUncaughtError = jest.fn(() => {})
+
+ expect(() => {
+ renderHook(useThrower, {
+ onUncaughtError(error, errorInfo) {
+ console.log({error, errorInfo})
+ },
+ })
+ }).toThrow(
+ 'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
+ )
+
+ expect(onUncaughtError).toHaveBeenCalledTimes(0)
+})
+
+testGateReact19('onCaughtError is supported in renderHook', () => {
+ const thrownError = new Error('Boom!')
+ const handleComponentDidCatch = jest.fn()
+ const onCaughtError = jest.fn()
+ class ErrorBoundary extends React.Component {
+ state = {error: null}
+ static getDerivedStateFromError(error) {
+ return {error}
+ }
+ componentDidCatch(error, errorInfo) {
+ handleComponentDidCatch(error, errorInfo)
+ }
+ render() {
+ if (this.state.error) {
+ return null
+ }
+ return this.props.children
+ }
+ }
+ function useThrower() {
+ throw thrownError
+ }
+
+ renderHook(useThrower, {
+ onCaughtError,
+ wrapper: ErrorBoundary,
+ })
+
+ expect(onCaughtError).toHaveBeenCalledWith(thrownError, {
+ componentStack: expect.any(String),
+ errorBoundary: expect.any(Object),
+ })
+})
+
+// Currently, there's no recoverable error without hydration.
+// The option is still supported though.
+test('onRecoverableError is supported in renderHook', () => {
+ const onRecoverableError = jest.fn()
+
+ renderHook(
+ () => {
+ // TODO: trigger recoverable error
+ },
+ {
+ onRecoverableError,
+ },
+ )
+})
diff --git a/src/__tests__/events.js b/src/__tests__/events.js
index b8dd487b..587bfdae 100644
--- a/src/__tests__/events.js
+++ b/src/__tests__/events.js
@@ -1,5 +1,5 @@
-import React from 'react'
-import {render, cleanup, fireEvent} from '../'
+import * as React from 'react'
+import {render, fireEvent} from '../'
const eventTypes = [
{
@@ -62,6 +62,22 @@ const eventTypes = [
],
elementType: 'button',
},
+ {
+ type: 'Pointer',
+ events: [
+ 'pointerOver',
+ 'pointerEnter',
+ 'pointerDown',
+ 'pointerMove',
+ 'pointerUp',
+ 'pointerCancel',
+ 'pointerOut',
+ 'pointerLeave',
+ 'gotPointerCapture',
+ 'lostPointerCapture',
+ ],
+ elementType: 'button',
+ },
{
type: 'Selection',
events: ['select'],
@@ -128,8 +144,6 @@ const eventTypes = [
},
]
-afterEach(cleanup)
-
eventTypes.forEach(({type, events, elementType, init}) => {
describe(`${type} Events`, () => {
events.forEach(eventName => {
@@ -155,6 +169,41 @@ eventTypes.forEach(({type, events, elementType, init}) => {
})
})
+eventTypes.forEach(({type, events, elementType, init}) => {
+ describe(`Native ${type} Events`, () => {
+ events.forEach(eventName => {
+ let nativeEventName = eventName.toLowerCase()
+
+ // The doubleClick synthetic event maps to the dblclick native event
+ if (nativeEventName === 'doubleclick') {
+ nativeEventName = 'dblclick'
+ }
+
+ it(`triggers native ${nativeEventName}`, () => {
+ const ref = React.createRef()
+ const spy = jest.fn()
+ const Element = elementType
+
+ const NativeEventElement = () => {
+ React.useEffect(() => {
+ const element = ref.current
+ element.addEventListener(nativeEventName, spy)
+ return () => {
+ element.removeEventListener(nativeEventName, spy)
+ }
+ })
+ return
+ }
+
+ render(
)
+
+ fireEvent[eventName](ref.current, init)
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
+ })
+ })
+})
+
test('onChange works', () => {
const handleChange = jest.fn()
const {
@@ -163,3 +212,45 @@ test('onChange works', () => {
fireEvent.change(input, {target: {value: 'a'}})
expect(handleChange).toHaveBeenCalledTimes(1)
})
+
+test('calling `fireEvent` directly works too', () => {
+ const handleEvent = jest.fn()
+ const {
+ container: {firstChild: button},
+ } = render(
)
+ fireEvent(
+ button,
+ new Event('MouseEvent', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ }),
+ )
+})
+
+test('blur/focus bubbles in react', () => {
+ const handleBlur = jest.fn()
+ const handleBubbledBlur = jest.fn()
+ const handleFocus = jest.fn()
+ const handleBubbledFocus = jest.fn()
+ const {container} = render(
+
+
+
,
+ )
+ const button = container.firstChild.firstChild
+
+ fireEvent.focus(button)
+
+ expect(handleBlur).toHaveBeenCalledTimes(0)
+ expect(handleBubbledBlur).toHaveBeenCalledTimes(0)
+ expect(handleFocus).toHaveBeenCalledTimes(1)
+ expect(handleBubbledFocus).toHaveBeenCalledTimes(1)
+
+ fireEvent.blur(button)
+
+ expect(handleBlur).toHaveBeenCalledTimes(1)
+ expect(handleBubbledBlur).toHaveBeenCalledTimes(1)
+ expect(handleFocus).toHaveBeenCalledTimes(1)
+ expect(handleBubbledFocus).toHaveBeenCalledTimes(1)
+})
diff --git a/src/__tests__/fetch.js b/src/__tests__/fetch.js
deleted file mode 100644
index c607508c..00000000
--- a/src/__tests__/fetch.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import React from 'react'
-import axiosMock from 'axios'
-import {render, fireEvent, cleanup, wait} from '../'
-
-afterEach(cleanup)
-
-// instead of importing it, we'll define it inline here
-// import Fetch from '../fetch'
-
-class Fetch extends React.Component {
- state = {}
- componentDidUpdate(prevProps) {
- if (this.props.url !== prevProps.url) {
- this.fetch()
- }
- }
- fetch = async () => {
- const response = await axiosMock.get(this.props.url)
- this.setState({data: response.data})
- }
- render() {
- const {data} = this.state
- return (
-
- Fetch
- {data ? {data.greeting} : null}
-
- )
- }
-}
-
-test('Fetch makes an API call and displays the greeting when load-greeting is clicked', async () => {
- // Arrange
- axiosMock.get.mockResolvedValueOnce({data: {greeting: 'hello there'}})
- const url = '/greeting'
- const {container, getByText} = render(
)
-
- // Act
- fireEvent.click(getByText('Fetch'))
-
- await wait()
-
- // Assert
- expect(axiosMock.get).toHaveBeenCalledTimes(1)
- expect(axiosMock.get).toHaveBeenCalledWith(url)
- // this assertion is funny because if the textContent were not "hello there"
- // then the `getByText` would throw anyway... 🤔
- expect(getByText('hello there').textContent).toBe('hello there')
- expect(container.firstChild).toMatchSnapshot()
-})
diff --git a/src/__tests__/forms.js b/src/__tests__/forms.js
deleted file mode 100644
index f3b7ebe9..00000000
--- a/src/__tests__/forms.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import React from 'react'
-import {render, fireEvent, cleanup} from '../'
-
-afterEach(cleanup)
-
-function Login({onSubmit}) {
- return (
-
-
-
- )
-}
-
-test('login form submits', () => {
- const fakeUser = {username: 'jackiechan', password: 'hiya! 🥋'}
- const handleSubmit = jest.fn()
- const {getByLabelText, getByText} = render(
)
-
- const usernameNode = getByLabelText(/username/i)
- const passwordNode = getByLabelText(/password/i)
- const submitButtonNode = getByText(/submit/i)
-
- // Act
- usernameNode.value = fakeUser.username
- passwordNode.value = fakeUser.password
- fireEvent.click(submitButtonNode)
-
- // Assert
- expect(handleSubmit).toHaveBeenCalledTimes(1)
- expect(handleSubmit).toHaveBeenCalledWith(fakeUser)
-})
-
-/* eslint jsx-a11y/label-has-for:0, jsx-a11y/aria-proptypes:0 */
diff --git a/src/__tests__/multi-base.js b/src/__tests__/multi-base.js
new file mode 100644
index 00000000..ef5a7e11
--- /dev/null
+++ b/src/__tests__/multi-base.js
@@ -0,0 +1,45 @@
+import * as React from 'react'
+import {render} from '../'
+
+// these are created once per test suite and reused for each case
+let treeA, treeB
+beforeAll(() => {
+ treeA = document.createElement('div')
+ treeB = document.createElement('div')
+ document.body.appendChild(treeA)
+ document.body.appendChild(treeB)
+})
+
+afterAll(() => {
+ treeA.parentNode.removeChild(treeA)
+ treeB.parentNode.removeChild(treeB)
+})
+
+test('baseElement isolates trees from one another', () => {
+ const {getByText: getByTextInA} = render(
Jekyll
, {
+ baseElement: treeA,
+ })
+ const {getByText: getByTextInB} = render(
Hyde
, {
+ baseElement: treeB,
+ })
+
+ expect(() => getByTextInA('Jekyll')).not.toThrow(
+ 'Unable to find an element with the text: Jekyll.',
+ )
+ expect(() => getByTextInB('Jekyll')).toThrow(
+ 'Unable to find an element with the text: Jekyll.',
+ )
+
+ expect(() => getByTextInA('Hyde')).toThrow(
+ 'Unable to find an element with the text: Hyde.',
+ )
+ expect(() => getByTextInB('Hyde')).not.toThrow(
+ 'Unable to find an element with the text: Hyde.',
+ )
+})
+
+// https://github.com/testing-library/eslint-plugin-testing-library/issues/188
+/*
+eslint
+ testing-library/prefer-screen-queries: "off",
+*/
diff --git a/src/__tests__/new-act.js b/src/__tests__/new-act.js
new file mode 100644
index 00000000..0464ad24
--- /dev/null
+++ b/src/__tests__/new-act.js
@@ -0,0 +1,79 @@
+let asyncAct
+
+jest.mock('react', () => {
+ return {
+ ...jest.requireActual('react'),
+ act: cb => {
+ return cb()
+ },
+ }
+})
+
+beforeEach(() => {
+ jest.resetModules()
+ asyncAct = require('../act-compat').default
+ jest.spyOn(console, 'error').mockImplementation(() => {})
+})
+
+afterEach(() => {
+ jest.restoreAllMocks()
+})
+
+test('async act works when it does not exist (older versions of react)', async () => {
+ const callback = jest.fn()
+ await asyncAct(async () => {
+ await Promise.resolve()
+ await callback()
+ })
+ expect(console.error).toHaveBeenCalledTimes(0)
+ expect(callback).toHaveBeenCalledTimes(1)
+
+ callback.mockClear()
+ console.error.mockClear()
+
+ await asyncAct(async () => {
+ await Promise.resolve()
+ await callback()
+ })
+ expect(console.error).toHaveBeenCalledTimes(0)
+ expect(callback).toHaveBeenCalledTimes(1)
+})
+
+test('async act recovers from errors', async () => {
+ try {
+ await asyncAct(async () => {
+ await null
+ throw new Error('test error')
+ })
+ } catch (err) {
+ console.error('call console.error')
+ }
+ expect(console.error).toHaveBeenCalledTimes(1)
+ expect(console.error.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ call console.error,
+ ],
+ ]
+ `)
+})
+
+test('async act recovers from sync errors', async () => {
+ try {
+ await asyncAct(() => {
+ throw new Error('test error')
+ })
+ } catch (err) {
+ console.error('call console.error')
+ }
+ expect(console.error).toHaveBeenCalledTimes(1)
+ expect(console.error.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ call console.error,
+ ],
+ ]
+ `)
+})
+
+/* eslint no-console:0 */
diff --git a/src/__tests__/render.js b/src/__tests__/render.js
index f2ec2351..6f5b5b39 100644
--- a/src/__tests__/render.js
+++ b/src/__tests__/render.js
@@ -1,99 +1,299 @@
-import 'jest-dom/extend-expect'
-import React from 'react'
+import * as React from 'react'
import ReactDOM from 'react-dom'
-import {render, cleanup, flushEffects} from '../'
+import ReactDOMServer from 'react-dom/server'
+import {fireEvent, render, screen, configure} from '../'
-afterEach(cleanup)
+const isReact18 = React.version.startsWith('18.')
+const isReact19 = React.version.startsWith('19.')
-test('renders div into document', () => {
- const ref = React.createRef()
- const {container} = render(
)
- expect(container.firstChild).toBe(ref.current)
-})
+const testGateReact18 = isReact18 ? test : test.skip
+const testGateReact19 = isReact19 ? test : test.skip
-test('works great with react portals', () => {
- class MyPortal extends React.Component {
- constructor(...args) {
- super(...args)
- this.portalNode = document.createElement('div')
- this.portalNode.dataset.testid = 'my-portal'
- }
- componentDidMount() {
- document.body.appendChild(this.portalNode)
- }
- componentWillUnmount() {
- this.portalNode.parentNode.removeChild(this.portalNode)
+describe('render API', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ test('renders div into document', () => {
+ const ref = React.createRef()
+ const {container} = render(
)
+ expect(container.firstChild).toBe(ref.current)
+ })
+
+ test('works great with react portals', () => {
+ class MyPortal extends React.Component {
+ constructor(...args) {
+ super(...args)
+ this.portalNode = document.createElement('div')
+ this.portalNode.dataset.testid = 'my-portal'
+ }
+ componentDidMount() {
+ document.body.appendChild(this.portalNode)
+ }
+ componentWillUnmount() {
+ this.portalNode.parentNode.removeChild(this.portalNode)
+ }
+ render() {
+ return ReactDOM.createPortal(
+
,
+ this.portalNode,
+ )
+ }
}
- render() {
- return ReactDOM.createPortal(
-
,
- this.portalNode,
+
+ function Greet({greeting, subject}) {
+ return (
+
+
+ {greeting} {subject}
+
+
)
}
- }
-
- function Greet({greeting, subject}) {
- return (
-
-
- {greeting} {subject}
-
-
+
+ const {unmount} = render(
)
+ expect(screen.getByText('Hello World')).toBeInTheDocument()
+ const portalNode = screen.getByTestId('my-portal')
+ expect(portalNode).toBeInTheDocument()
+ unmount()
+ expect(portalNode).not.toBeInTheDocument()
+ })
+
+ test('returns baseElement which defaults to document.body', () => {
+ const {baseElement} = render(
)
+ expect(baseElement).toBe(document.body)
+ })
+
+ test('supports fragments', () => {
+ class Test extends React.Component {
+ render() {
+ return (
+
+ DocumentFragment
is pretty cool!
+
+ )
+ }
+ }
+
+ const {asFragment} = render(
)
+ expect(asFragment()).toMatchSnapshot()
+ })
+
+ test('renders options.wrapper around node', () => {
+ const WrapperComponent = ({children}) => (
+
{children}
)
- }
-
- const {unmount, getByTestId, getByText} = render(
)
- expect(getByText('Hello World')).toBeInTheDocument()
- const portalNode = getByTestId('my-portal')
- expect(portalNode).toBeInTheDocument()
- unmount()
- expect(portalNode).not.toBeInTheDocument()
-})
-test('returns baseElement which defaults to document.body', () => {
- const {baseElement} = render(
)
- expect(baseElement).toBe(document.body)
-})
+ const {container} = render(
, {
+ wrapper: WrapperComponent,
+ })
+
+ expect(screen.getByTestId('wrapper')).toBeInTheDocument()
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+ `)
+ })
+
+ test('renders options.wrapper around node when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
-it('cleansup document', () => {
- const spy = jest.fn()
+ const WrapperComponent = ({children}) => (
+
{children}
+ )
+ const {container} = render(
, {
+ wrapper: WrapperComponent,
+ })
+
+ expect(screen.getByTestId('wrapper')).toBeInTheDocument()
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+ `)
+ })
- class Test extends React.Component {
- componentWillUnmount() {
+ test('renders twice when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
+
+ const spy = jest.fn()
+ function Component() {
spy()
+ return null
}
- render() {
- return
+ render(
)
+ expect(spy).toHaveBeenCalledTimes(2)
+ })
+
+ test('flushes useEffect cleanup functions sync on unmount()', () => {
+ const spy = jest.fn()
+ function Component() {
+ React.useEffect(() => spy, [])
+ return null
}
- }
+ const {unmount} = render(
)
+ expect(spy).toHaveBeenCalledTimes(0)
- render(
)
- cleanup()
- expect(document.body.innerHTML).toBe('')
- expect(spy).toHaveBeenCalledTimes(1)
-})
+ unmount()
+
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
+
+ test('can be called multiple times on the same container', () => {
+ const container = document.createElement('div')
+
+ const {unmount} = render(
, {container})
+
+ expect(container).toContainHTML('
')
+
+ render(
, {container})
+
+ expect(container).toContainHTML('
')
+
+ unmount()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ test('hydrate will make the UI interactive', () => {
+ function App() {
+ const [clicked, handleClick] = React.useReducer(n => n + 1, 0)
-it('supports fragments', () => {
- class Test extends React.Component {
- render() {
return (
-
- DocumentFragment
is pretty cool!
-
+
+ clicked:{clicked}
+
)
}
- }
+ const ui =
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ container.innerHTML = ReactDOMServer.renderToString(ui)
- const {asFragment} = render(
)
- expect(asFragment()).toMatchSnapshot()
- cleanup()
- expect(document.body.innerHTML).toBe('')
-})
+ expect(container).toHaveTextContent('clicked:0')
+
+ render(ui, {container, hydrate: true})
+
+ fireEvent.click(container.querySelector('button'))
+
+ expect(container).toHaveTextContent('clicked:1')
+ })
+
+ test('hydrate can have a wrapper', () => {
+ const wrapperComponentMountEffect = jest.fn()
+ function WrapperComponent({children}) {
+ React.useEffect(() => {
+ wrapperComponentMountEffect()
+ })
+
+ return children
+ }
+ const ui =
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+
+ render(ui, {container, hydrate: true, wrapper: WrapperComponent})
+
+ expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(1)
+ })
+
+ testGateReact18('legacyRoot uses legacy ReactDOM.render', () => {
+ expect(() => {
+ render(
, {legacyRoot: true})
+ }).toErrorDev(
+ [
+ "Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
+ ],
+ {withoutStack: true},
+ )
+ })
+
+ testGateReact19('legacyRoot throws', () => {
+ expect(() => {
+ render(
, {legacyRoot: true})
+ }).toThrowErrorMatchingInlineSnapshot(
+ `\`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.`,
+ )
+ })
+
+ testGateReact18('legacyRoot uses legacy ReactDOM.hydrate', () => {
+ const ui =
+ const container = document.createElement('div')
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+ expect(() => {
+ render(ui, {container, hydrate: true, legacyRoot: true})
+ }).toErrorDev(
+ [
+ "Warning: ReactDOM.hydrate is no longer supported in React 18. Use hydrateRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
+ ],
+ {withoutStack: true},
+ )
+ })
+
+ testGateReact19('legacyRoot throws even with hydrate', () => {
+ const ui =
+ const container = document.createElement('div')
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+ expect(() => {
+ render(ui, {container, hydrate: true, legacyRoot: true})
+ }).toThrowErrorMatchingInlineSnapshot(
+ `\`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.`,
+ )
+ })
+
+ test('reactStrictMode in renderOptions has precedence over config when rendering', () => {
+ const wrapperComponentMountEffect = jest.fn()
+ function WrapperComponent({children}) {
+ React.useEffect(() => {
+ wrapperComponentMountEffect()
+ })
+
+ return children
+ }
+ const ui =
+ configure({reactStrictMode: false})
+
+ render(ui, {wrapper: WrapperComponent, reactStrictMode: true})
+
+ expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(2)
+ })
+
+ test('reactStrictMode in config is used when renderOptions does not specify reactStrictMode', () => {
+ const wrapperComponentMountEffect = jest.fn()
+ function WrapperComponent({children}) {
+ React.useEffect(() => {
+ wrapperComponentMountEffect()
+ })
+
+ return children
+ }
+ const ui =
+ configure({reactStrictMode: true})
+
+ render(ui, {wrapper: WrapperComponent})
-test('flushEffects can be called without causing issues', () => {
- render(
)
- const preHtml = document.documentElement.innerHTML
- flushEffects()
- expect(document.documentElement.innerHTML).toBe(preHtml)
+ expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(2)
+ })
})
diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js
new file mode 100644
index 00000000..f331e90e
--- /dev/null
+++ b/src/__tests__/renderHook.js
@@ -0,0 +1,141 @@
+import React, {useEffect} from 'react'
+import {configure, renderHook} from '../pure'
+
+const isReact18 = React.version.startsWith('18.')
+const isReact19 = React.version.startsWith('19.')
+
+const testGateReact18 = isReact18 ? test : test.skip
+const testGateReact19 = isReact19 ? test : test.skip
+
+test('gives committed result', () => {
+ const {result} = renderHook(() => {
+ const [state, setState] = React.useState(1)
+
+ React.useEffect(() => {
+ setState(2)
+ }, [])
+
+ return [state, setState]
+ })
+
+ expect(result.current).toEqual([2, expect.any(Function)])
+})
+
+test('allows rerendering', () => {
+ const {result, rerender} = renderHook(
+ ({branch}) => {
+ const [left, setLeft] = React.useState('left')
+ const [right, setRight] = React.useState('right')
+
+ // eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false-positive
+ switch (branch) {
+ case 'left':
+ return [left, setLeft]
+ case 'right':
+ return [right, setRight]
+
+ default:
+ throw new Error(
+ 'No Props passed. This is a bug in the implementation',
+ )
+ }
+ },
+ {initialProps: {branch: 'left'}},
+ )
+
+ expect(result.current).toEqual(['left', expect.any(Function)])
+
+ rerender({branch: 'right'})
+
+ expect(result.current).toEqual(['right', expect.any(Function)])
+})
+
+test('allows wrapper components', async () => {
+ const Context = React.createContext('default')
+ function Wrapper({children}) {
+ return
{children}
+ }
+ const {result} = renderHook(
+ () => {
+ return React.useContext(Context)
+ },
+ {
+ wrapper: Wrapper,
+ },
+ )
+
+ expect(result.current).toEqual('provided')
+})
+
+testGateReact18('legacyRoot uses legacy ReactDOM.render', () => {
+ const Context = React.createContext('default')
+ function Wrapper({children}) {
+ return
{children}
+ }
+ let result
+ expect(() => {
+ result = renderHook(
+ () => {
+ return React.useContext(Context)
+ },
+ {
+ wrapper: Wrapper,
+ legacyRoot: true,
+ },
+ ).result
+ }).toErrorDev(
+ [
+ "Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
+ ],
+ {withoutStack: true},
+ )
+ expect(result.current).toEqual('provided')
+})
+
+testGateReact19('legacyRoot throws', () => {
+ const Context = React.createContext('default')
+ function Wrapper({children}) {
+ return
{children}
+ }
+ expect(() => {
+ renderHook(
+ () => {
+ return React.useContext(Context)
+ },
+ {
+ wrapper: Wrapper,
+ legacyRoot: true,
+ },
+ ).result
+ }).toThrowErrorMatchingInlineSnapshot(
+ `\`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.`,
+ )
+})
+
+describe('reactStrictMode', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ test('reactStrictMode in renderOptions has precedence over config when rendering', () => {
+ const hookMountEffect = jest.fn()
+ configure({reactStrictMode: false})
+
+ renderHook(() => useEffect(() => hookMountEffect()), {
+ reactStrictMode: true,
+ })
+
+ expect(hookMountEffect).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/__tests__/rerender.js b/src/__tests__/rerender.js
index a35144b5..6c48c4dd 100644
--- a/src/__tests__/rerender.js
+++ b/src/__tests__/rerender.js
@@ -1,35 +1,98 @@
-import React from 'react'
-import {render, cleanup} from '../'
-import 'jest-dom/extend-expect'
-
-afterEach(cleanup)
-
-test('rerender will re-render the element', () => {
- const Greeting = props =>
{props.message}
- const {container, rerender} = render(
)
- expect(container.firstChild).toHaveTextContent('hi')
- rerender(
)
- expect(container.firstChild).toHaveTextContent('hey')
-})
+import * as React from 'react'
+import {render, configure} from '../'
+
+describe('rerender API', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ test('rerender will re-render the element', () => {
+ const Greeting = props =>
{props.message}
+ const {container, rerender} = render(
)
+ expect(container.firstChild).toHaveTextContent('hi')
+ rerender(
)
+ expect(container.firstChild).toHaveTextContent('hey')
+ })
+
+ test('hydrate will not update props until next render', () => {
+ const initialInputElement = document.createElement('input')
+ const container = document.createElement('div')
+ container.appendChild(initialInputElement)
+ document.body.appendChild(container)
+
+ const firstValue = 'hello'
+ initialInputElement.value = firstValue
-test('hydrate will not update props until next render', () => {
- const initial = '
'
+ const {rerender} = render(
null} />, {
+ container,
+ hydrate: true,
+ })
- const container = document.body.appendChild(document.createElement('div'))
- container.innerHTML = initial
- const input = container.querySelector('input')
- const firstValue = 'hello'
+ expect(initialInputElement).toHaveValue(firstValue)
- if (!input) throw new Error('No element')
- input.value = firstValue
- const {rerender} = render(
null} />, {
- container,
- hydrate: true,
+ const secondValue = 'goodbye'
+ rerender(
null} />)
+ expect(initialInputElement).toHaveValue(secondValue)
})
- const secondValue = 'goodbye'
+ test('re-renders options.wrapper around node when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
- expect(input.value).toBe(firstValue)
- rerender(
null} />)
- expect(input.value).toBe(secondValue)
+ const WrapperComponent = ({children}) => (
+
{children}
+ )
+ const Greeting = props =>
{props.message}
+ const {container, rerender} = render(
, {
+ wrapper: WrapperComponent,
+ })
+
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+ `)
+
+ rerender(
)
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+ `)
+ })
+
+ test('re-renders twice when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
+
+ const spy = jest.fn()
+ function Component() {
+ spy()
+ return null
+ }
+
+ const {rerender} = render(
)
+ expect(spy).toHaveBeenCalledTimes(2)
+
+ spy.mockClear()
+ rerender(
)
+ expect(spy).toHaveBeenCalledTimes(2)
+ })
})
diff --git a/src/__tests__/stopwatch.js b/src/__tests__/stopwatch.js
index 34f58561..e3eaebbe 100644
--- a/src/__tests__/stopwatch.js
+++ b/src/__tests__/stopwatch.js
@@ -1,7 +1,5 @@
-import React from 'react'
-import {render, cleanup, fireEvent} from '../'
-
-afterEach(cleanup)
+import * as React from 'react'
+import {render, fireEvent, screen} from '../'
class StopWatch extends React.Component {
state = {lapse: 0, running: false}
@@ -39,20 +37,18 @@ class StopWatch extends React.Component {
}
}
-const wait = time => new Promise(resolve => setTimeout(resolve, time))
+const sleep = t => new Promise(resolve => setTimeout(resolve, t))
test('unmounts a component', async () => {
- jest.spyOn(console, 'error').mockImplementation(() => {})
- const {unmount, getByText, container} = render(
)
- fireEvent.click(getByText('Start'))
+ const {unmount, container} = render(
)
+ fireEvent.click(screen.getByText('Start'))
unmount()
// hey there reader! You don't need to have an assertion like this one
// this is just me making sure that the unmount function works.
// You don't need to do this in your apps. Just rely on the fact that this works.
- expect(container.innerHTML).toBe('')
+ expect(container).toBeEmptyDOMElement()
// just wait to see if the interval is cleared or not
// if it's not, then we'll call setState on an unmounted component
// and get an error.
- // eslint-disable-next-line no-console
- await wait(() => expect(console.error).not.toHaveBeenCalled())
+ await sleep(5)
})
diff --git a/src/act-compat.js b/src/act-compat.js
new file mode 100644
index 00000000..6eaec0fb
--- /dev/null
+++ b/src/act-compat.js
@@ -0,0 +1,91 @@
+import * as React from 'react'
+import * as DeprecatedReactTestUtils from 'react-dom/test-utils'
+
+const reactAct =
+ typeof React.act === 'function' ? React.act : DeprecatedReactTestUtils.act
+
+function getGlobalThis() {
+ /* istanbul ignore else */
+ if (typeof globalThis !== 'undefined') {
+ return globalThis
+ }
+ /* istanbul ignore next */
+ if (typeof self !== 'undefined') {
+ return self
+ }
+ /* istanbul ignore next */
+ if (typeof window !== 'undefined') {
+ return window
+ }
+ /* istanbul ignore next */
+ if (typeof global !== 'undefined') {
+ return global
+ }
+ /* istanbul ignore next */
+ throw new Error('unable to locate global object')
+}
+
+function setIsReactActEnvironment(isReactActEnvironment) {
+ getGlobalThis().IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment
+}
+
+function getIsReactActEnvironment() {
+ return getGlobalThis().IS_REACT_ACT_ENVIRONMENT
+}
+
+function withGlobalActEnvironment(actImplementation) {
+ return callback => {
+ const previousActEnvironment = getIsReactActEnvironment()
+ setIsReactActEnvironment(true)
+ try {
+ // The return value of `act` is always a thenable.
+ let callbackNeedsToBeAwaited = false
+ const actResult = actImplementation(() => {
+ const result = callback()
+ if (
+ result !== null &&
+ typeof result === 'object' &&
+ typeof result.then === 'function'
+ ) {
+ callbackNeedsToBeAwaited = true
+ }
+ return result
+ })
+ if (callbackNeedsToBeAwaited) {
+ const thenable = actResult
+ return {
+ then: (resolve, reject) => {
+ thenable.then(
+ returnValue => {
+ setIsReactActEnvironment(previousActEnvironment)
+ resolve(returnValue)
+ },
+ error => {
+ setIsReactActEnvironment(previousActEnvironment)
+ reject(error)
+ },
+ )
+ },
+ }
+ } else {
+ setIsReactActEnvironment(previousActEnvironment)
+ return actResult
+ }
+ } catch (error) {
+ // Can't be a `finally {}` block since we don't know if we have to immediately restore IS_REACT_ACT_ENVIRONMENT
+ // or if we have to await the callback first.
+ setIsReactActEnvironment(previousActEnvironment)
+ throw error
+ }
+ }
+}
+
+const act = withGlobalActEnvironment(reactAct)
+
+export default act
+export {
+ setIsReactActEnvironment as setReactActEnvironment,
+ getIsReactActEnvironment,
+}
+
+/* eslint no-console:0 */
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 00000000..dc8a5035
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,34 @@
+import {
+ getConfig as getConfigDTL,
+ configure as configureDTL,
+} from '@testing-library/dom'
+
+let configForRTL = {
+ reactStrictMode: false,
+}
+
+function getConfig() {
+ return {
+ ...getConfigDTL(),
+ ...configForRTL,
+ }
+}
+
+function configure(newConfig) {
+ if (typeof newConfig === 'function') {
+ // Pass the existing config out to the provided function
+ // and accept a delta in return
+ newConfig = newConfig(getConfig())
+ }
+
+ const {reactStrictMode, ...configForDTL} = newConfig
+
+ configureDTL(configForDTL)
+
+ configForRTL = {
+ ...configForRTL,
+ reactStrictMode,
+ }
+}
+
+export {getConfig, configure}
diff --git a/src/fire-event.js b/src/fire-event.js
new file mode 100644
index 00000000..cb790c7f
--- /dev/null
+++ b/src/fire-event.js
@@ -0,0 +1,69 @@
+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 = (...args) => {
+ mouseEnter(...args)
+ return fireEvent.mouseOver(...args)
+}
+fireEvent.mouseLeave = (...args) => {
+ mouseLeave(...args)
+ return fireEvent.mouseOut(...args)
+}
+
+const pointerEnter = fireEvent.pointerEnter
+const pointerLeave = fireEvent.pointerLeave
+fireEvent.pointerEnter = (...args) => {
+ pointerEnter(...args)
+ return fireEvent.pointerOver(...args)
+}
+fireEvent.pointerLeave = (...args) => {
+ pointerLeave(...args)
+ return fireEvent.pointerOut(...args)
+}
+
+const select = fireEvent.select
+fireEvent.select = (node, init) => {
+ 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
+ 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 = (...args) => {
+ fireEvent.focusOut(...args)
+ return blur(...args)
+}
+fireEvent.focus = (...args) => {
+ fireEvent.focusIn(...args)
+ return focus(...args)
+}
+
+export {fireEvent}
diff --git a/src/index.js b/src/index.js
index 2f48a4e0..bb0d0270 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,97 +1,41 @@
-import ReactDOM from 'react-dom'
-import {getQueriesForElement, prettyDOM, fireEvent} from 'dom-testing-library'
-
-const mountedContainers = new Set()
-
-function render(
- ui,
- {container, baseElement = container, queries, hydrate = false} = {},
-) {
- if (!container) {
- // 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
- container = document.body.appendChild(document.createElement('div'))
- }
-
- // 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)
-
- if (hydrate) {
- ReactDOM.hydrate(ui, container)
- } else {
- ReactDOM.render(ui, container)
+import {getIsReactActEnvironment, setReactActEnvironment} from './act-compat'
+import {cleanup} from './pure'
+
+// 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(() => {
+ 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(() => {
+ cleanup()
+ })
}
- return {
- container,
- baseElement,
- // eslint-disable-next-line no-console
- debug: (el = baseElement) => console.log(prettyDOM(el)),
- unmount: () => ReactDOM.unmountComponentAtNode(container),
- rerender: rerenderUi => {
- render(rerenderUi, {container, baseElement})
- // 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 if (jsdom limitation) */
- if (typeof document.createRange === 'function') {
- return document
- .createRange()
- .createContextualFragment(container.innerHTML)
- }
- const template = document.createElement('template')
- template.innerHTML = container.innerHTML
- return template.content
- },
- ...getQueriesForElement(baseElement, queries),
+ // 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)
+ })
}
}
-function cleanup() {
- mountedContainers.forEach(cleanupAtContainer)
-}
-
-function flushEffects() {
- ReactDOM.render(null, document.createElement('div'))
-}
-
-// maybe one day we'll expose this (perhaps even as a utility returned by render).
-// but let's wait until someone asks for it.
-function cleanupAtContainer(container) {
- if (container.parentNode === document.body) {
- document.body.removeChild(container)
- }
- ReactDOM.unmountComponentAtNode(container)
- mountedContainers.delete(container)
-}
-
-// 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
-fireEvent.mouseEnter = fireEvent.mouseOver
-fireEvent.mouseLeave = fireEvent.mouseOut
-
-fireEvent.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
- fireEvent.keyUp(node, init)
-}
-
-// just re-export everything from dom-testing-library
-export * from 'dom-testing-library'
-export {render, cleanup, flushEffects}
-
-/* eslint func-name-matching:0 */
+export * from './pure'
diff --git a/src/pure.js b/src/pure.js
new file mode 100644
index 00000000..0f9c487d
--- /dev/null
+++ b/src/pure.js
@@ -0,0 +1,363 @@
+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 act, {
+ getIsReactActEnvironment,
+ setReactActEnvironment,
+} from './act-compat'
+import {fireEvent} from './fire-event'
+import {getConfig, configure} from './config'
+
+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: cb => {
+ let result
+ 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, reactStrictMode) {
+ return reactStrictMode ?? getConfig().reactStrictMode
+ ? React.createElement(React.StrictMode, null, innerElement)
+ : innerElement
+}
+
+function wrapUiIfNeeded(innerElement, wrapperComponent) {
+ return wrapperComponent
+ ? React.createElement(wrapperComponent, null, innerElement)
+ : innerElement
+}
+
+function createConcurrentRoot(
+ container,
+ {
+ hydrate,
+ onCaughtError,
+ onRecoverableError,
+ ui,
+ wrapper: WrapperComponent,
+ reactStrictMode,
+ },
+) {
+ let root
+ if (hydrate) {
+ act(() => {
+ root = ReactDOMClient.hydrateRoot(
+ container,
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
+ {onCaughtError, onRecoverableError},
+ )
+ })
+ } else {
+ root = ReactDOMClient.createRoot(container, {
+ onCaughtError,
+ onRecoverableError,
+ })
+ }
+
+ 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)
+ },
+ }
+}
+
+function renderRoot(
+ ui,
+ {
+ baseElement,
+ container,
+ hydrate,
+ queries,
+ root,
+ wrapper: WrapperComponent,
+ reactStrictMode,
+ },
+) {
+ act(() => {
+ if (hydrate) {
+ root.hydrate(
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
+ container,
+ )
+ } else {
+ root.render(
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
+ 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: () => {
+ act(() => {
+ root.unmount()
+ })
+ },
+ rerender: rerenderUi => {
+ renderRoot(rerenderUi, {
+ container,
+ baseElement,
+ root,
+ wrapper: WrapperComponent,
+ reactStrictMode,
+ })
+ // 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,
+ {
+ container,
+ baseElement = container,
+ legacyRoot = false,
+ onCaughtError,
+ onUncaughtError,
+ onRecoverableError,
+ queries,
+ hydrate = false,
+ wrapper,
+ reactStrictMode,
+ } = {},
+) {
+ if (onUncaughtError !== undefined) {
+ throw new Error(
+ 'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
+ )
+ }
+ 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,
+ onCaughtError,
+ onRecoverableError,
+ ui,
+ wrapper,
+ reactStrictMode,
+ })
+
+ 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 renderRoot(ui, {
+ container,
+ baseElement,
+ queries,
+ hydrate,
+ wrapper,
+ root,
+ reactStrictMode,
+ })
+}
+
+function cleanup() {
+ mountedRootEntries.forEach(({root, container}) => {
+ act(() => {
+ root.unmount()
+ })
+ if (container.parentNode === document.body) {
+ document.body.removeChild(container)
+ }
+ })
+ mountedRootEntries.length = 0
+ mountedContainers.clear()
+}
+
+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} = 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/tests/failOnUnexpectedConsoleCalls.js b/tests/failOnUnexpectedConsoleCalls.js
new file mode 100644
index 00000000..83e0c641
--- /dev/null
+++ b/tests/failOnUnexpectedConsoleCalls.js
@@ -0,0 +1,129 @@
+// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/setupTests.js#L71-L161
+/**
+MIT License
+
+Copyright (c) Facebook, Inc. and its affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+/* eslint-disable prefer-template */
+/* eslint-disable func-names */
+const util = require('util')
+const chalk = require('chalk')
+const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError')
+
+const patchConsoleMethod = (methodName, unexpectedConsoleCallStacks) => {
+ const newMethod = function (format, ...args) {
+ // Ignore uncaught errors reported by jsdom
+ // and React addendums because they're too noisy.
+ if (methodName === 'error' && shouldIgnoreConsoleError(format, args)) {
+ return
+ }
+
+ // Capture the call stack now so we can warn about it later.
+ // The call stack has helpful information for the test author.
+ // Don't throw yet though b'c it might be accidentally caught and suppressed.
+ const stack = new Error().stack
+ unexpectedConsoleCallStacks.push([
+ stack.substr(stack.indexOf('\n') + 1),
+ util.format(format, ...args),
+ ])
+ }
+
+ console[methodName] = newMethod
+
+ return newMethod
+}
+
+const isSpy = spy =>
+ (spy.calls && typeof spy.calls.count === 'function') ||
+ spy._isMockFunction === true
+
+const flushUnexpectedConsoleCalls = (
+ mockMethod,
+ methodName,
+ expectedMatcher,
+ unexpectedConsoleCallStacks,
+) => {
+ if (console[methodName] !== mockMethod && !isSpy(console[methodName])) {
+ throw new Error(
+ `Test did not tear down console.${methodName} mock properly.`,
+ )
+ }
+ if (unexpectedConsoleCallStacks.length > 0) {
+ const messages = unexpectedConsoleCallStacks.map(
+ ([stack, message]) =>
+ `${chalk.red(message)}\n` +
+ `${stack
+ .split('\n')
+ .map(line => chalk.gray(line))
+ .join('\n')}`,
+ )
+
+ const message =
+ `Expected test not to call ${chalk.bold(
+ `console.${methodName}()`,
+ )}.\n\n` +
+ 'If the warning is expected, test for it explicitly by:\n' +
+ `1. Using the ${chalk.bold('.' + expectedMatcher + '()')} ` +
+ `matcher, or...\n` +
+ `2. Mock it out using ${chalk.bold(
+ 'spyOnDev',
+ )}(console, '${methodName}') or ${chalk.bold(
+ 'spyOnProd',
+ )}(console, '${methodName}'), and test that the warning occurs.`
+
+ throw new Error(`${message}\n\n${messages.join('\n\n')}`)
+ }
+}
+
+const unexpectedErrorCallStacks = []
+const unexpectedWarnCallStacks = []
+
+const errorMethod = patchConsoleMethod('error', unexpectedErrorCallStacks)
+const warnMethod = patchConsoleMethod('warn', unexpectedWarnCallStacks)
+
+const flushAllUnexpectedConsoleCalls = () => {
+ flushUnexpectedConsoleCalls(
+ errorMethod,
+ 'error',
+ 'toErrorDev',
+ unexpectedErrorCallStacks,
+ )
+ flushUnexpectedConsoleCalls(
+ warnMethod,
+ 'warn',
+ 'toWarnDev',
+ unexpectedWarnCallStacks,
+ )
+ unexpectedErrorCallStacks.length = 0
+ unexpectedWarnCallStacks.length = 0
+}
+
+const resetAllUnexpectedConsoleCalls = () => {
+ unexpectedErrorCallStacks.length = 0
+ unexpectedWarnCallStacks.length = 0
+}
+
+expect.extend({
+ ...require('./toWarnDev'),
+})
+
+beforeEach(resetAllUnexpectedConsoleCalls)
+afterEach(flushAllUnexpectedConsoleCalls)
diff --git a/tests/setup-env.js b/tests/setup-env.js
new file mode 100644
index 00000000..1a4401de
--- /dev/null
+++ b/tests/setup-env.js
@@ -0,0 +1,9 @@
+import '@testing-library/jest-dom/extend-expect'
+import './failOnUnexpectedConsoleCalls'
+import {TextEncoder} from 'util'
+import {MessageChannel} from 'worker_threads'
+
+global.TextEncoder = TextEncoder
+// TODO: Revisit once https://github.com/jsdom/jsdom/issues/2448 is resolved
+// This isn't perfect but good enough.
+global.MessageChannel = MessageChannel
diff --git a/tests/shouldIgnoreConsoleError.js b/tests/shouldIgnoreConsoleError.js
new file mode 100644
index 00000000..1c722ba1
--- /dev/null
+++ b/tests/shouldIgnoreConsoleError.js
@@ -0,0 +1,52 @@
+// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/shouldIgnoreConsoleError.js
+/**
+MIT License
+
+Copyright (c) Facebook, Inc. and its affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+
+module.exports = function shouldIgnoreConsoleError(format) {
+ if (process.env.NODE_ENV !== 'production') {
+ if (typeof format === 'string') {
+ if (format.indexOf('Error: Uncaught [') === 0) {
+ // This looks like an uncaught error from invokeGuardedCallback() wrapper
+ // in development that is reported by jsdom. Ignore because it's noisy.
+ return true
+ }
+ if (format.indexOf('The above error occurred') === 0) {
+ // This looks like an error addendum from ReactFiberErrorLogger.
+ // Ignore it too.
+ return true
+ }
+ if (
+ format.startsWith(
+ 'Warning: `ReactDOMTestUtils.act` is deprecated in favor of `React.act`.',
+ )
+ ) {
+ // This is a React bug in 18.3.0.
+ // Versions with `ReactDOMTestUtils.ac` being deprecated, should have `React.act`
+ return true
+ }
+ }
+ }
+ // Looks legit
+ return false
+}
diff --git a/tests/toWarnDev.js b/tests/toWarnDev.js
new file mode 100644
index 00000000..3005125e
--- /dev/null
+++ b/tests/toWarnDev.js
@@ -0,0 +1,303 @@
+// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/matchers/toWarnDev.js
+/**
+MIT License
+
+Copyright (c) Facebook, Inc. and its affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+/* eslint-disable no-unsafe-finally */
+/* eslint-disable no-negated-condition */
+/* eslint-disable no-invalid-this */
+/* eslint-disable prefer-template */
+/* eslint-disable func-names */
+/* eslint-disable complexity */
+const util = require('util')
+const jestDiff = require('jest-diff').diff
+const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError')
+
+function normalizeCodeLocInfo(str) {
+ if (typeof str !== 'string') {
+ return str
+ }
+ // This special case exists only for the special source location in
+ // ReactElementValidator. That will go away if we remove source locations.
+ str = str.replace(/Check your code at .+?:\d+/g, 'Check your code at **')
+ // V8 format:
+ // at Component (/path/filename.js:123:45)
+ // React format:
+ // in Component (at filename.js:123)
+ // eslint-disable-next-line prefer-arrow-callback
+ return str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) {
+ return '\n in ' + name + ' (at **)'
+ })
+}
+
+const createMatcherFor = (consoleMethod, matcherName) =>
+ function matcher(callback, expectedMessages, options = {}) {
+ if (process.env.NODE_ENV !== 'production') {
+ // Warn about incorrect usage of matcher.
+ if (typeof expectedMessages === 'string') {
+ expectedMessages = [expectedMessages]
+ } else if (!Array.isArray(expectedMessages)) {
+ throw Error(
+ `${matcherName}() requires a parameter of type string or an array of strings ` +
+ `but was given ${typeof expectedMessages}.`,
+ )
+ }
+ if (
+ options != null &&
+ (typeof options !== 'object' || Array.isArray(options))
+ ) {
+ throw new Error(
+ `${matcherName}() second argument, when present, should be an object. ` +
+ 'Did you forget to wrap the messages into an array?',
+ )
+ }
+ if (arguments.length > 3) {
+ // `matcher` comes from Jest, so it's more than 2 in practice
+ throw new Error(
+ `${matcherName}() received more than two arguments. ` +
+ 'Did you forget to wrap the messages into an array?',
+ )
+ }
+
+ const withoutStack = options.withoutStack
+ const logAllErrors = options.logAllErrors
+ const warningsWithoutComponentStack = []
+ const warningsWithComponentStack = []
+ const unexpectedWarnings = []
+
+ let lastWarningWithMismatchingFormat = null
+ let lastWarningWithExtraComponentStack = null
+
+ // Catch errors thrown by the callback,
+ // But only rethrow them if all test expectations have been satisfied.
+ // Otherwise an Error in the callback can mask a failed expectation,
+ // and result in a test that passes when it shouldn't.
+ let caughtError
+
+ const isLikelyAComponentStack = message =>
+ typeof message === 'string' &&
+ (message.includes('\n in ') || message.includes('\n at '))
+
+ const consoleSpy = (format, ...args) => {
+ // Ignore uncaught errors reported by jsdom
+ // and React addendums because they're too noisy.
+ if (
+ !logAllErrors &&
+ consoleMethod === 'error' &&
+ shouldIgnoreConsoleError(format, args)
+ ) {
+ return
+ }
+
+ const message = util.format(format, ...args)
+ const normalizedMessage = normalizeCodeLocInfo(message)
+
+ // Remember if the number of %s interpolations
+ // doesn't match the number of arguments.
+ // We'll fail the test if it happens.
+ let argIndex = 0
+ String(format).replace(/%s/g, () => argIndex++)
+ if (argIndex !== args.length) {
+ lastWarningWithMismatchingFormat = {
+ format,
+ args,
+ expectedArgCount: argIndex,
+ }
+ }
+
+ // Protect against accidentally passing a component stack
+ // to warning() which already injects the component stack.
+ if (
+ args.length >= 2 &&
+ isLikelyAComponentStack(args[args.length - 1]) &&
+ isLikelyAComponentStack(args[args.length - 2])
+ ) {
+ lastWarningWithExtraComponentStack = {
+ format,
+ }
+ }
+
+ for (let index = 0; index < expectedMessages.length; index++) {
+ const expectedMessage = expectedMessages[index]
+ if (
+ normalizedMessage === expectedMessage ||
+ normalizedMessage.includes(expectedMessage)
+ ) {
+ if (isLikelyAComponentStack(normalizedMessage)) {
+ warningsWithComponentStack.push(normalizedMessage)
+ } else {
+ warningsWithoutComponentStack.push(normalizedMessage)
+ }
+ expectedMessages.splice(index, 1)
+ return
+ }
+ }
+
+ let errorMessage
+ if (expectedMessages.length === 0) {
+ errorMessage =
+ 'Unexpected warning recorded: ' +
+ this.utils.printReceived(normalizedMessage)
+ } else if (expectedMessages.length === 1) {
+ errorMessage =
+ 'Unexpected warning recorded: ' +
+ jestDiff(expectedMessages[0], normalizedMessage)
+ } else {
+ errorMessage =
+ 'Unexpected warning recorded: ' +
+ jestDiff(expectedMessages, [normalizedMessage])
+ }
+
+ // Record the call stack for unexpected warnings.
+ // We don't throw an Error here though,
+ // Because it might be suppressed by ReactFiberScheduler.
+ unexpectedWarnings.push(new Error(errorMessage))
+ }
+
+ // TODO Decide whether we need to support nested toWarn* expectations.
+ // If we don't need it, add a check here to see if this is already our spy,
+ // And throw an error.
+ const originalMethod = console[consoleMethod]
+
+ // Avoid using Jest's built-in spy since it can't be removed.
+ console[consoleMethod] = consoleSpy
+
+ try {
+ callback()
+ } catch (error) {
+ caughtError = error
+ } finally {
+ // Restore the unspied method so that unexpected errors fail tests.
+ console[consoleMethod] = originalMethod
+
+ // Any unexpected Errors thrown by the callback should fail the test.
+ // This should take precedence since unexpected errors could block warnings.
+ if (caughtError) {
+ throw caughtError
+ }
+
+ // Any unexpected warnings should be treated as a failure.
+ if (unexpectedWarnings.length > 0) {
+ return {
+ message: () => unexpectedWarnings[0].stack,
+ pass: false,
+ }
+ }
+
+ // Any remaining messages indicate a failed expectations.
+ if (expectedMessages.length > 0) {
+ return {
+ message: () =>
+ `Expected warning was not recorded:\n ${this.utils.printReceived(
+ expectedMessages[0],
+ )}`,
+ pass: false,
+ }
+ }
+
+ if (typeof withoutStack === 'number') {
+ // We're expecting a particular number of warnings without stacks.
+ if (withoutStack !== warningsWithoutComponentStack.length) {
+ return {
+ message: () =>
+ `Expected ${withoutStack} warnings without a component stack but received ${warningsWithoutComponentStack.length}:\n` +
+ warningsWithoutComponentStack.map(warning =>
+ this.utils.printReceived(warning),
+ ),
+ pass: false,
+ }
+ }
+ } else if (withoutStack === true) {
+ // We're expecting that all warnings won't have the stack.
+ // If some warnings have it, it's an error.
+ if (warningsWithComponentStack.length > 0) {
+ return {
+ message: () =>
+ `Received warning unexpectedly includes a component stack:\n ${this.utils.printReceived(
+ warningsWithComponentStack[0],
+ )}\nIf this warning intentionally includes the component stack, remove ` +
+ `{withoutStack: true} from the ${matcherName}() call. If you have a mix of ` +
+ `warnings with and without stack in one ${matcherName}() call, pass ` +
+ `{withoutStack: N} where N is the number of warnings without stacks.`,
+ pass: false,
+ }
+ }
+ } else if (withoutStack === false || withoutStack === undefined) {
+ // We're expecting that all warnings *do* have the stack (default).
+ // If some warnings don't have it, it's an error.
+ if (warningsWithoutComponentStack.length > 0) {
+ return {
+ message: () =>
+ `Received warning unexpectedly does not include a component stack:\n ${this.utils.printReceived(
+ warningsWithoutComponentStack[0],
+ )}\nIf this warning intentionally omits the component stack, add ` +
+ `{withoutStack: true} to the ${matcherName} call.`,
+ pass: false,
+ }
+ }
+ } else {
+ throw Error(
+ `The second argument for ${matcherName}(), when specified, must be an object. It may have a ` +
+ `property called "withoutStack" whose value may be undefined, boolean, or a number. ` +
+ `Instead received ${typeof withoutStack}.`,
+ )
+ }
+
+ if (lastWarningWithMismatchingFormat !== null) {
+ return {
+ message: () =>
+ `Received ${
+ lastWarningWithMismatchingFormat.args.length
+ } arguments for a message with ${
+ lastWarningWithMismatchingFormat.expectedArgCount
+ } placeholders:\n ${this.utils.printReceived(
+ lastWarningWithMismatchingFormat.format,
+ )}`,
+ pass: false,
+ }
+ }
+
+ if (lastWarningWithExtraComponentStack !== null) {
+ return {
+ message: () =>
+ `Received more than one component stack for a warning:\n ${this.utils.printReceived(
+ lastWarningWithExtraComponentStack.format,
+ )}\nDid you accidentally pass a stack to warning() as the last argument? ` +
+ `Don't forget warning() already injects the component stack automatically.`,
+ pass: false,
+ }
+ }
+
+ return {pass: true}
+ }
+ } else {
+ // Any uncaught errors or warnings should fail tests in production mode.
+ callback()
+
+ return {pass: true}
+ }
+ }
+
+module.exports = {
+ toWarnDev: createMatcherFor('warn', 'toWarnDev'),
+ toErrorDev: createMatcherFor('error', 'toErrorDev'),
+}
diff --git a/types/index.d.ts b/types/index.d.ts
new file mode 100644
index 00000000..bdd60567
--- /dev/null
+++ b/types/index.d.ts
@@ -0,0 +1,287 @@
+// TypeScript Version: 3.8
+import * as ReactDOMClient from 'react-dom/client'
+import {
+ queries,
+ Queries,
+ BoundFunction,
+ prettyFormat,
+ Config as ConfigDTL,
+} from '@testing-library/dom'
+import {act as reactDeprecatedAct} from 'react-dom/test-utils'
+//@ts-ignore
+import {act as reactAct} from 'react'
+
+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) => void
+ unmount: () => void
+ 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
+ /**
+ * Only supported in React 19.
+ * Callback called when React catches an error in an Error Boundary.
+ * Called with the error caught by the Error Boundary, and an `errorInfo` object containing the `componentStack`.
+ *
+ * @see {@link https://react.dev/reference/react-dom/client/createRoot#parameters createRoot#options}
+ */
+ onCaughtError?: ReactDOMClient.RootOptions extends {
+ onCaughtError: infer OnCaughtError
+ }
+ ? OnCaughtError
+ : never
+ /**
+ * Callback called when React automatically recovers from errors.
+ * Called with an error React throws, and an `errorInfo` object containing the `componentStack`.
+ * Some recoverable errors may include the original error cause as `error.cause`.
+ *
+ * @see {@link https://react.dev/reference/react-dom/client/createRoot#parameters createRoot#options}
+ */
+ onRecoverableError?: ReactDOMClient.RootOptions['onRecoverableError']
+ /**
+ * Not supported at the moment
+ */
+ onUncaughtError?: never
+ /**
+ * 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
+ /**
+ * When enabled, is rendered around the inner element.
+ * If defined, overrides the value of `reactStrictMode` set in `configure`.
+ */
+ reactStrictMode?: boolean
+}
+
+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,
+): RenderResult
+export function render(
+ ui: React.ReactNode,
+ options?: Omit | undefined,
+): RenderResult
+
+export interface RenderHookResult {
+ /**
+ * Triggers a re-render. The props will be passed to your renderHook callback.
+ */
+ rerender: (props?: Props) => void
+ /**
+ * 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: () => void
+}
+
+/** @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,
+): RenderHookResult
+
+/**
+ * Unmounts React trees that were mounted with render.
+ */
+export function cleanup(): void
+
+/**
+ * Simply calls React.act(cb)
+ * If that's not available (older version of react) then it
+ * simply calls the deprecated version which is ReactTestUtils.act(cb)
+ */
+// IfAny from https://stackoverflow.com/a/61626123/3406963
+export const act: 0 extends 1 & typeof reactAct
+ ? typeof reactDeprecatedAct
+ : typeof reactAct
diff --git a/types/pure.d.ts b/types/pure.d.ts
new file mode 100644
index 00000000..7b527195
--- /dev/null
+++ b/types/pure.d.ts
@@ -0,0 +1 @@
+export * from './'
diff --git a/types/test.tsx b/types/test.tsx
new file mode 100644
index 00000000..825d5699
--- /dev/null
+++ b/types/test.tsx
@@ -0,0 +1,317 @@
+import * as React from 'react'
+import {render, fireEvent, screen, waitFor, renderHook} from '.'
+import * as pure from './pure'
+
+export async function testRender() {
+ const view = render( )
+
+ // single queries
+ view.getByText('foo')
+ view.queryByText('foo')
+ await view.findByText('foo')
+
+ // multiple queries
+ view.getAllByText('bar')
+ view.queryAllByText('bar')
+ await view.findAllByText('bar')
+
+ // helpers
+ const {container, rerender, debug} = view
+ expectType(container)
+ return {container, rerender, debug}
+}
+
+export async function testPureRender() {
+ const view = pure.render( )
+
+ // single queries
+ view.getByText('foo')
+ view.queryByText('foo')
+ await view.findByText('foo')
+
+ // multiple queries
+ view.getAllByText('bar')
+ view.queryAllByText('bar')
+ await view.findAllByText('bar')
+
+ // helpers
+ const {container, rerender, debug} = view
+ expectType(container)
+ return {container, rerender, debug}
+}
+
+export function testRenderOptions() {
+ const container = document.createElement('div')
+ const options = {container}
+ const {container: returnedContainer} = render( , options)
+ expectType(returnedContainer)
+
+ render(
, {wrapper: () => null})
+}
+
+export function testSVGRenderOptions() {
+ const container = document.createElementNS(
+ 'http://www.w3.org/2000/svg',
+ 'svg',
+ )
+ const options = {container}
+ const {container: returnedContainer} = render( , options)
+ expectType(returnedContainer)
+}
+
+export function testFireEvent() {
+ const {container} = render( )
+ fireEvent.click(container)
+}
+
+export function testConfigure() {
+ // test for DTL's config
+ pure.configure({testIdAttribute: 'foobar'})
+ pure.configure(existingConfig => ({
+ testIdAttribute: `modified-${existingConfig.testIdAttribute}`,
+ }))
+
+ // test for RTL's config
+ pure.configure({reactStrictMode: true})
+ pure.configure(existingConfig => ({
+ reactStrictMode: !existingConfig.reactStrictMode,
+ }))
+}
+
+export function testGetConfig() {
+ // test for DTL's config
+ pure.getConfig().testIdAttribute
+
+ // test for RTL's config
+ pure.getConfig().reactStrictMode
+}
+
+export function testDebug() {
+ const {debug, getAllByTestId} = render(
+ <>
+ Hello World
+ Hello World
+ >,
+ )
+ debug(getAllByTestId('testid'))
+}
+
+export async function testScreen() {
+ render( )
+
+ await screen.findByRole('button')
+}
+
+export async function testWaitFor() {
+ const {container} = render( )
+ fireEvent.click(container)
+ await waitFor(() => {})
+}
+
+export function testQueries() {
+ const {getByLabelText} = render(
+ Username ,
+ )
+ expectType>(
+ getByLabelText('Username'),
+ )
+
+ const container = document.createElement('div')
+ const options = {container}
+ const {getByText} = render(Hello World
, options)
+ expectType>(
+ getByText('Hello World'),
+ )
+}
+
+export function wrappedRender(
+ ui: React.ReactNode,
+ options?: pure.RenderOptions,
+) {
+ const Wrapper = ({
+ children,
+ }: {
+ children: React.ReactNode
+ }): React.JSX.Element => {
+ return {children}
+ }
+
+ return pure.render(ui, {
+ wrapper: Wrapper,
+ // testing exactOptionalPropertyTypes comaptibility
+ hydrate: options?.hydrate,
+ ...options,
+ })
+}
+
+export function wrappedRenderB(
+ ui: React.ReactNode,
+ options?: pure.RenderOptions,
+) {
+ const Wrapper: React.FunctionComponent<{children?: React.ReactNode}> = ({
+ children,
+ }) => {
+ return {children}
+ }
+
+ return pure.render(ui, {wrapper: Wrapper, ...options})
+}
+
+export function wrappedRenderC(
+ ui: React.ReactNode,
+ options?: pure.RenderOptions,
+) {
+ interface AppWrapperProps {
+ children?: React.ReactNode
+ userProviderProps?: {user: string}
+ }
+ const AppWrapperProps: React.FunctionComponent = ({
+ children,
+ userProviderProps = {user: 'TypeScript'},
+ }) => {
+ return {children}
+ }
+
+ return pure.render(ui, {wrapper: AppWrapperProps, ...options})
+}
+
+export function wrappedRenderHook(
+ hook: () => unknown,
+ options?: pure.RenderHookOptions,
+) {
+ interface AppWrapperProps {
+ children?: React.ReactNode
+ userProviderProps?: {user: string}
+ }
+ const AppWrapperProps: React.FunctionComponent = ({
+ children,
+ userProviderProps = {user: 'TypeScript'},
+ }) => {
+ return {children}
+ }
+
+ return pure.renderHook(hook, {...options})
+}
+
+export function testBaseElement() {
+ const {baseElement: baseDefaultElement} = render(
)
+ expectType(baseDefaultElement)
+
+ const container = document.createElement('input')
+ const {baseElement: baseElementFromContainer} = render(
, {container})
+ expectType(
+ baseElementFromContainer,
+ )
+
+ const baseElementOption = document.createElement('input')
+ const {baseElement: baseElementFromOption} = render(
, {
+ baseElement: baseElementOption,
+ })
+ expectType(
+ baseElementFromOption,
+ )
+}
+
+export function testRenderHook() {
+ const {result, rerender, unmount} = renderHook(() => React.useState(2)[0])
+
+ expectType(result.current)
+
+ rerender()
+
+ unmount()
+
+ renderHook(() => null, {wrapper: () => null})
+}
+
+export function testRenderHookProps() {
+ const {result, rerender, unmount} = renderHook(
+ ({defaultValue}) => React.useState(defaultValue)[0],
+ {initialProps: {defaultValue: 2}},
+ )
+
+ expectType(result.current)
+
+ rerender()
+
+ unmount()
+}
+
+export function testContainer() {
+ render('a', {container: document.createElement('div')})
+ render('a', {container: document.createDocumentFragment()})
+ // Only allowed in React 19
+ render('a', {container: document})
+ render('a', {container: document.createElement('div'), hydrate: true})
+ // Only allowed for createRoot but typing `render` appropriately makes it harder to compose.
+ render('a', {container: document.createDocumentFragment(), hydrate: true})
+ render('a', {container: document, hydrate: true})
+
+ renderHook(() => null, {container: document.createElement('div')})
+ renderHook(() => null, {container: document.createDocumentFragment()})
+ // Only allowed in React 19
+ renderHook(() => null, {container: document})
+ renderHook(() => null, {
+ container: document.createElement('div'),
+ hydrate: true,
+ })
+ // Only allowed for createRoot but typing `render` appropriately makes it harder to compose.
+ renderHook(() => null, {
+ container: document.createDocumentFragment(),
+ hydrate: true,
+ })
+ renderHook(() => null, {container: document, hydrate: true})
+}
+
+export function testErrorHandlers() {
+ // React 19 types are not used in tests. Verify manually if this works with `"@types/react": "npm:types-react@rc"`
+ render(null, {
+ // Should work with React 19 types
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ onCaughtError: () => {},
+ })
+ render(null, {
+ // Should never work as it's not supported yet.
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ onUncaughtError: () => {},
+ })
+ render(null, {
+ onRecoverableError: (error, errorInfo) => {
+ console.error(error)
+ console.log(errorInfo.componentStack)
+ },
+ })
+}
+
+/*
+eslint
+ testing-library/prefer-explicit-assert: "off",
+ testing-library/no-wait-for-empty-callback: "off",
+ testing-library/prefer-screen-queries: "off"
+*/
+
+// https://stackoverflow.com/questions/53807517/how-to-test-if-two-types-are-exactly-the-same
+type IfEquals = (() => G extends T
+ ? 1
+ : 2) extends () => G extends U ? 1 : 2
+ ? Yes
+ : No
+
+/**
+ * Issues a type error if `Expected` is not identical to `Actual`.
+ *
+ * `Expected` should be declared when invoking `expectType`.
+ * `Actual` should almost always we be a `typeof value` statement.
+ *
+ * Source: https://github.com/mui-org/material-ui/blob/6221876a4b468a3330ffaafa8472de7613933b87/packages/material-ui-types/index.d.ts#L73-L84
+ *
+ * @example `expectType(value)`
+ * TypeScript issues a type error since `value is not assignable to never`.
+ * This means `typeof value` is not identical to `number | string`
+ * @param actual
+ */
+declare function expectType(
+ actual: IfEquals,
+): void
diff --git a/types/tsconfig.json b/types/tsconfig.json
new file mode 100644
index 00000000..bad26af7
--- /dev/null
+++ b/types/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../node_modules/kcd-scripts/shared-tsconfig.json",
+ "compilerOptions": {
+ "exactOptionalPropertyTypes": true,
+ "skipLibCheck": false
+ },
+ "include": ["."]
+}
diff --git a/typings/index.d.ts b/typings/index.d.ts
deleted file mode 100644
index 4aac7035..00000000
--- a/typings/index.d.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import {getQueriesForElement} from 'dom-testing-library'
-
-export * from 'dom-testing-library'
-
-type GetsAndQueries = ReturnType
-
-export interface RenderResult extends GetsAndQueries {
- container: HTMLElement
- baseElement: HTMLElement
- debug: (baseElement?: HTMLElement | DocumentFragment) => void
- rerender: (ui: React.ReactElement) => void
- unmount: () => boolean
- asFragment: () => DocumentFragment
-}
-
-export interface RenderOptions {
- container?: HTMLElement
- baseElement?: HTMLElement
- hydrate?: boolean
-}
-
-/**
- * Render into a container which is appended to document.body. It should be used with cleanup.
- */
-export function render(
- ui: React.ReactElement,
- options?: RenderOptions,
-): RenderResult
-
-/**
- * Unmounts React trees that were mounted with render.
- */
-export function cleanup(): void
-
-/**
- * Forces React's `useEffect` hook to run synchronously.
- */
-export function flushEffects(): void