diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 67b71c24..45cc7d13 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -16,6 +16,7 @@ jobs:
# ignore all-contributors PRs
if: ${{ !contains(github.head_ref, 'all-contributors') }}
strategy:
+ fail-fast: false
matrix:
# TODO: relax `'16.9.1'` to `16` once GitHub has 16.9.1 cached. 16.9.0 is broken due to https://github.com/nodejs/node/issues/40030
node: [12, 14, '16.9.1']
@@ -52,6 +53,8 @@ jobs:
- name: ⬆️ Upload coverage report
uses: codecov/codecov-action@v1
+ with:
+ flags: ${{ matrix.react }}
release:
needs: main
diff --git a/package.json b/package.json
index f184a8bc..4781e962 100644
--- a/package.json
+++ b/package.json
@@ -46,7 +46,7 @@
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
- "@testing-library/dom": "^8.0.0",
+ "@testing-library/dom": "^8.5.0",
"@types/react-dom": "*"
},
"devDependencies": {
@@ -54,14 +54,14 @@
"dotenv-cli": "^4.0.0",
"kcd-scripts": "^11.1.0",
"npm-run-all": "^4.1.5",
- "react": "^17.0.1",
- "react-dom": "^17.0.1",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0",
"rimraf": "^3.0.2",
"typescript": "^4.1.2"
},
"peerDependencies": {
- "react": "*",
- "react-dom": "*"
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
},
"eslintConfig": {
"extends": "./node_modules/kcd-scripts/eslint.js",
diff --git a/src/__tests__/act.js b/src/__tests__/act.js
index b60aac37..5430f28b 100644
--- a/src/__tests__/act.js
+++ b/src/__tests__/act.js
@@ -1,5 +1,5 @@
import * as React from 'react'
-import {render, fireEvent, screen} from '../'
+import {act, render, fireEvent, screen} from '../'
test('render calls useEffect immediately', () => {
const effectCb = jest.fn()
@@ -43,3 +43,27 @@ test('calls to hydrate will run useEffects', () => {
render(, {hydrate: true})
expect(effectCb).toHaveBeenCalledTimes(1)
})
+
+test('cleans up IS_REACT_ACT_ENVIRONMENT if its callback throws', () => {
+ global.IS_REACT_ACT_ENVIRONMENT = false
+
+ expect(() =>
+ act(() => {
+ throw new Error('threw')
+ }),
+ ).toThrow('threw')
+
+ expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
+})
+
+test('cleans up IS_REACT_ACT_ENVIRONMENT if its async callback throws', async () => {
+ global.IS_REACT_ACT_ENVIRONMENT = false
+
+ await expect(() =>
+ act(async () => {
+ throw new Error('thenable threw')
+ }),
+ ).rejects.toThrow('thenable threw')
+
+ expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
+})
diff --git a/src/__tests__/cleanup.js b/src/__tests__/cleanup.js
index 9d3f52d4..0dcbac12 100644
--- a/src/__tests__/cleanup.js
+++ b/src/__tests__/cleanup.js
@@ -83,10 +83,7 @@ describe('fake timers and missing act warnings', () => {
expect(microTaskSpy).toHaveBeenCalledTimes(0)
// console.error is mocked
// eslint-disable-next-line no-console
- expect(console.error).toHaveBeenCalledTimes(
- // ReactDOM.render is deprecated in React 18
- React.version.startsWith('18') ? 1 : 0,
- )
+ expect(console.error).toHaveBeenCalledTimes(0)
})
test('cleanup does not swallow missing act warnings', () => {
@@ -118,16 +115,10 @@ describe('fake timers and missing act warnings', () => {
expect(deferredStateUpdateSpy).toHaveBeenCalledTimes(1)
// console.error is mocked
// eslint-disable-next-line no-console
- expect(console.error).toHaveBeenCalledTimes(
- // ReactDOM.render is deprecated in React 18
- React.version.startsWith('18') ? 2 : 1,
- )
+ expect(console.error).toHaveBeenCalledTimes(1)
// eslint-disable-next-line no-console
- expect(
- console.error.mock.calls[
- // ReactDOM.render is deprecated in React 18
- React.version.startsWith('18') ? 1 : 0
- ][0],
- ).toMatch('a test was not wrapped in act(...)')
+ expect(console.error.mock.calls[0][0]).toMatch(
+ 'a test was not wrapped in act(...)',
+ )
})
})
diff --git a/src/__tests__/new-act.js b/src/__tests__/new-act.js
index af81e29c..05f9d45a 100644
--- a/src/__tests__/new-act.js
+++ b/src/__tests__/new-act.js
@@ -1,4 +1,4 @@
-let asyncAct, consoleErrorMock
+let asyncAct
jest.mock('react-dom/test-utils', () => ({
act: cb => {
@@ -8,12 +8,12 @@ jest.mock('react-dom/test-utils', () => ({
beforeEach(() => {
jest.resetModules()
- asyncAct = require('../act-compat').asyncAct
- consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {})
+ asyncAct = require('../act-compat').default
+ jest.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
- consoleErrorMock.mockRestore()
+ console.error.mockRestore()
})
test('async act works when it does not exist (older versions of react)', async () => {
diff --git a/src/__tests__/no-act.js b/src/__tests__/no-act.js
deleted file mode 100644
index d739e763..00000000
--- a/src/__tests__/no-act.js
+++ /dev/null
@@ -1,92 +0,0 @@
-let act, asyncAct, React, consoleErrorMock
-
-beforeEach(() => {
- jest.resetModules()
- act = require('../pure').act
- asyncAct = require('../act-compat').asyncAct
- React = require('react')
- consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {})
-})
-
-afterEach(() => {
- consoleErrorMock.mockRestore()
-})
-
-jest.mock('react-dom/test-utils', () => ({}))
-
-test('act works even when there is no act from test utils', () => {
- const callback = jest.fn()
- act(callback)
- expect(callback).toHaveBeenCalledTimes(1)
- expect(console.error).toHaveBeenCalledTimes(
- // ReactDOM.render is deprecated in React 18
- React.version.startsWith('18') ? 1 : 0,
- )
-})
-
-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(
- // ReactDOM.render is deprecated in React 18
- React.version.startsWith('18') ? 2 : 0,
- )
- expect(callback).toHaveBeenCalledTimes(1)
-
- callback.mockClear()
- console.error.mockClear()
-
- await asyncAct(async () => {
- await Promise.resolve()
- await callback()
- })
- expect(console.error).toHaveBeenCalledTimes(
- // ReactDOM.render is deprecated in React 18
- React.version.startsWith('18') ? 2 : 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(
- // ReactDOM.render is deprecated in React 18
- React.version.startsWith('18') ? 2 : 1,
- )
- expect(
- console.error.mock.calls[
- // ReactDOM.render is deprecated in React 18
- React.version.startsWith('18') ? 1 : 0
- ][0],
- ).toMatch('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(`
- Array [
- Array [
- call console.error,
- ],
- ]
- `)
-})
-
-/* eslint no-console:0 */
diff --git a/src/__tests__/old-act.js b/src/__tests__/old-act.js
deleted file mode 100644
index 6081fef8..00000000
--- a/src/__tests__/old-act.js
+++ /dev/null
@@ -1,142 +0,0 @@
-let asyncAct, consoleErrorMock
-
-beforeEach(() => {
- jest.resetModules()
- asyncAct = require('../act-compat').asyncAct
- consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {})
-})
-
-afterEach(() => {
- consoleErrorMock.mockRestore()
-})
-
-jest.mock('react-dom/test-utils', () => ({
- act: cb => {
- cb()
- return {
- then() {
- console.error(
- 'Warning: Do not await the result of calling ReactTestUtils.act(...), it is not a Promise.',
- )
- },
- }
- },
-}))
-
-test('async act works even when the act is an old one', async () => {
- const callback = jest.fn()
- await asyncAct(async () => {
- console.error('sigil')
- await Promise.resolve()
- await callback()
- console.error('sigil')
- })
- expect(console.error.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- sigil,
- ],
- Array [
- It looks like you're using a version of react-dom that supports the "act" function, but not an awaitable version of "act" which you will need. Please upgrade to at least react-dom@16.9.0 to remove this warning.,
- ],
- Array [
- sigil,
- ],
- ]
- `)
- expect(callback).toHaveBeenCalledTimes(1)
-
- // and it doesn't warn you twice
- 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 async errors', async () => {
- try {
- await asyncAct(async () => {
- await null
- throw new Error('test error')
- })
- } catch (err) {
- console.error('call console.error')
- }
- expect(console.error).toHaveBeenCalledTimes(2)
- expect(console.error.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- It looks like you're using a version of react-dom that supports the "act" function, but not an awaitable version of "act" which you will need. Please upgrade to at least react-dom@16.9.0 to remove this warning.,
- ],
- Array [
- 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(`
- Array [
- Array [
- call console.error,
- ],
- ]
- `)
-})
-
-test('async act can handle any sort of console.error', async () => {
- await asyncAct(async () => {
- console.error({error: 'some error'})
- await null
- })
-
- expect(console.error).toHaveBeenCalledTimes(2)
- expect(console.error.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- error: some error,
- },
- ],
- Array [
- It looks like you're using a version of react-dom that supports the "act" function, but not an awaitable version of "act" which you will need. Please upgrade to at least react-dom@16.9.0 to remove this warning.,
- ],
- ]
- `)
-})
-
-test('async act should not show an error when ReactTestUtils.act returns something', async () => {
- jest.resetModules()
- jest.mock('react-dom/test-utils', () => ({
- act: () => {
- return new Promise(resolve => {
- console.error(
- 'Warning: The callback passed to ReactTestUtils.act(...) function must not return anything',
- )
- resolve()
- })
- },
- }))
- asyncAct = require('../act-compat').asyncAct
- await asyncAct(async () => {
- await null
- })
-
- expect(console.error).toHaveBeenCalledTimes(0)
-})
-
-/* eslint no-console:0 */
diff --git a/src/__tests__/render.js b/src/__tests__/render.js
index fea1a649..88e2b98d 100644
--- a/src/__tests__/render.js
+++ b/src/__tests__/render.js
@@ -1,6 +1,13 @@
import * as React from 'react'
import ReactDOM from 'react-dom'
-import {render, screen} from '../'
+import ReactDOMServer from 'react-dom/server'
+import {fireEvent, render, screen} from '../'
+
+afterEach(() => {
+ if (console.error.mockRestore !== undefined) {
+ console.error.mockRestore()
+ }
+})
test('renders div into document', () => {
const ref = React.createRef()
@@ -101,3 +108,90 @@ test('flushes useEffect cleanup functions sync on 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', () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {})
+ function App() {
+ const [clicked, handleClick] = React.useReducer(n => n + 1, 0)
+
+ return (
+
+ )
+ }
+ const ui =
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+
+ expect(container).toHaveTextContent('clicked:0')
+
+ render(ui, {container, hydrate: true})
+
+ expect(console.error).not.toHaveBeenCalled()
+
+ 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)
+})
+
+test('legacyRoot uses legacy ReactDOM.render', () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {})
+ render(, {legacyRoot: true})
+
+ expect(console.error).toHaveBeenCalledTimes(1)
+ expect(console.error).toHaveBeenNthCalledWith(
+ 1,
+ "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",
+ )
+})
+
+test('legacyRoot uses legacy ReactDOM.hydrate', () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {})
+ const ui =
+ const container = document.createElement('div')
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+ render(ui, {container, hydrate: true, legacyRoot: true})
+
+ expect(console.error).toHaveBeenCalledTimes(1)
+ expect(console.error).toHaveBeenNthCalledWith(
+ 1,
+ "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",
+ )
+})
diff --git a/src/__tests__/stopwatch.js b/src/__tests__/stopwatch.js
index 400fce10..eeaf395c 100644
--- a/src/__tests__/stopwatch.js
+++ b/src/__tests__/stopwatch.js
@@ -53,8 +53,5 @@ test('unmounts a component', async () => {
// and get an error.
await sleep(5)
// eslint-disable-next-line no-console
- expect(console.error).toHaveBeenCalledTimes(
- // ReactDOM.render is deprecated in React 18
- React.version.startsWith('18') ? 1 : 0,
- )
+ expect(console.error).not.toHaveBeenCalled()
})
diff --git a/src/act-compat.js b/src/act-compat.js
index 40ecdab9..d7a09d68 100644
--- a/src/act-compat.js
+++ b/src/act-compat.js
@@ -1,135 +1,85 @@
-import * as React from 'react'
-import ReactDOM from 'react-dom'
import * as testUtils from 'react-dom/test-utils'
-const reactAct = testUtils.act
-const actSupported = reactAct !== undefined
+const domAct = testUtils.act
-// act is supported react-dom@16.8.0
-// so for versions that don't have act from test utils
-// we do this little polyfill. No warnings, but it's
-// better than nothing.
-function actPolyfill(cb) {
- ReactDOM.unstable_batchedUpdates(cb)
- ReactDOM.render(, document.createElement('div'))
+function getGlobalThis() {
+ /* istanbul ignore else */
+ 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')
}
-const act = reactAct || actPolyfill
+function setIsReactActEnvironment(isReactActEnvironment) {
+ getGlobalThis().IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment
+}
-let youHaveBeenWarned = false
-let isAsyncActSupported = null
+function getIsReactActEnvironment() {
+ return getGlobalThis().IS_REACT_ACT_ENVIRONMENT
+}
-function asyncAct(cb) {
- if (actSupported === true) {
- if (isAsyncActSupported === null) {
- return new Promise((resolve, reject) => {
- // patch console.error here
- const originalConsoleError = console.error
- console.error = function error(...args) {
- /* if console.error fired *with that specific message* */
- /* istanbul ignore next */
- const firstArgIsString = typeof args[0] === 'string'
- if (
- firstArgIsString &&
- args[0].indexOf(
- 'Warning: Do not await the result of calling ReactTestUtils.act',
- ) === 0
- ) {
- // v16.8.6
- isAsyncActSupported = false
- } else if (
- firstArgIsString &&
- args[0].indexOf(
- 'Warning: The callback passed to ReactTestUtils.act(...) function must not return anything',
- ) === 0
- ) {
- // no-op
- } else {
- originalConsoleError.apply(console, args)
- }
+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
}
- let cbReturn, result
- try {
- result = reactAct(() => {
- cbReturn = cb()
- return cbReturn
- })
- } catch (err) {
- console.error = originalConsoleError
- reject(err)
- return
- }
-
- result.then(
- () => {
- console.error = originalConsoleError
- // if it got here, it means async act is supported
- isAsyncActSupported = true
- resolve()
- },
- err => {
- console.error = originalConsoleError
- isAsyncActSupported = true
- reject(err)
- },
- )
-
- // 16.8.6's act().then() doesn't call a resolve handler, so we need to manually flush here, sigh
-
- if (isAsyncActSupported === false) {
- console.error = originalConsoleError
- /* istanbul ignore next */
- if (!youHaveBeenWarned) {
- // if act is supported and async act isn't and they're trying to use async
- // act, then they need to upgrade from 16.8 to 16.9.
- // This is a seamless upgrade, so we'll add a warning
- console.error(
- `It looks like you're using a version of react-dom that supports the "act" function, but not an awaitable version of "act" which you will need. Please upgrade to at least react-dom@16.9.0 to remove this warning.`,
+ return result
+ })
+ if (callbackNeedsToBeAwaited) {
+ const thenable = actResult
+ return {
+ then: (resolve, reject) => {
+ thenable.then(
+ returnValue => {
+ setIsReactActEnvironment(previousActEnvironment)
+ resolve(returnValue)
+ },
+ error => {
+ setIsReactActEnvironment(previousActEnvironment)
+ reject(error)
+ },
)
- youHaveBeenWarned = true
- }
-
- cbReturn.then(() => {
- // a faux-version.
- // todo - copy https://github.com/facebook/react/blob/master/packages/shared/enqueueTask.js
- Promise.resolve().then(() => {
- // use sync act to flush effects
- act(() => {})
- resolve()
- })
- }, reject)
+ },
}
- })
- } else if (isAsyncActSupported === false) {
- // use the polyfill directly
- let result
- act(() => {
- result = cb()
- })
- return result.then(() => {
- return Promise.resolve().then(() => {
- // use sync act to flush effects
- act(() => {})
- })
- })
+ } 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
}
- // all good! regular act
- return act(cb)
}
- // use the polyfill
- let result
- act(() => {
- result = cb()
- })
- return result.then(() => {
- return Promise.resolve().then(() => {
- // use sync act to flush effects
- act(() => {})
- })
- })
}
+const act = withGlobalActEnvironment(domAct)
+
export default act
-export {asyncAct}
+export {
+ setIsReactActEnvironment as setReactActEnvironment,
+ getIsReactActEnvironment,
+}
/* eslint no-console:0 */
diff --git a/src/index.js b/src/index.js
index 96fbe155..bb0d0270 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,3 +1,4 @@
+import {getIsReactActEnvironment, setReactActEnvironment} from './act-compat'
import {cleanup} from './pure'
// if we're running in a test runner that supports afterEach
@@ -20,6 +21,21 @@ if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) {
cleanup()
})
}
+
+ // No test setup with other test runners available
+ /* istanbul ignore else */
+ if (typeof beforeAll === 'function' && typeof afterAll === 'function') {
+ // This matches the behavior of React < 18.
+ let previousIsReactActEnvironment = getIsReactActEnvironment()
+ beforeAll(() => {
+ previousIsReactActEnvironment = getIsReactActEnvironment()
+ setReactActEnvironment(true)
+ })
+
+ afterAll(() => {
+ setReactActEnvironment(previousIsReactActEnvironment)
+ })
+ }
}
export * from './pure'
diff --git a/src/pure.js b/src/pure.js
index 75098f78..64b761b0 100644
--- a/src/pure.js
+++ b/src/pure.js
@@ -1,20 +1,32 @@
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, {asyncAct} from './act-compat'
+import act, {
+ getIsReactActEnvironment,
+ setReactActEnvironment,
+} from './act-compat'
import {fireEvent} from './fire-event'
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 => {
- let result
- await asyncAct(async () => {
- result = await cb()
- })
- return result
+ const previousActEnvironment = getIsReactActEnvironment()
+ setReactActEnvironment(false)
+ try {
+ return await cb()
+ } finally {
+ setReactActEnvironment(previousActEnvironment)
+ }
},
eventWrapper: cb => {
let result
@@ -25,32 +37,70 @@ configureDTL({
},
})
+// 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 render(
- ui,
- {
- container,
- baseElement = container,
- queries,
- hydrate = false,
- wrapper: WrapperComponent,
- } = {},
+function createConcurrentRoot(
+ container,
+ {hydrate, ui, wrapper: WrapperComponent},
) {
- 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
+ let root
+ if (hydrate) {
+ act(() => {
+ root = ReactDOMClient.hydrateRoot(
+ container,
+ WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui,
+ )
+ })
+ } else {
+ root = ReactDOMClient.createRoot(container)
}
- if (!container) {
- container = baseElement.appendChild(document.createElement('div'))
+
+ 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()
+ },
}
+}
- // 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)
+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},
+) {
const wrapUiIfNeeded = innerElement =>
WrapperComponent
? React.createElement(WrapperComponent, null, innerElement)
@@ -58,9 +108,9 @@ function render(
act(() => {
if (hydrate) {
- ReactDOM.hydrate(wrapUiIfNeeded(ui), container)
+ root.hydrate(wrapUiIfNeeded(ui), container)
} else {
- ReactDOM.render(wrapUiIfNeeded(ui), container)
+ root.render(wrapUiIfNeeded(ui), container)
}
})
@@ -75,11 +125,15 @@ function render(
console.log(prettyDOM(el, maxLength, options)),
unmount: () => {
act(() => {
- ReactDOM.unmountComponentAtNode(container)
+ root.unmount()
})
},
rerender: rerenderUi => {
- render(wrapUiIfNeeded(rerenderUi), {container, baseElement})
+ renderRoot(wrapUiIfNeeded(rerenderUi), {
+ container,
+ baseElement,
+ root,
+ })
// 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
},
@@ -99,28 +153,73 @@ function render(
}
}
-function cleanup() {
- mountedContainers.forEach(cleanupAtContainer)
+function render(
+ ui,
+ {
+ container,
+ baseElement = container,
+ legacyRoot = false,
+ queries,
+ hydrate = false,
+ wrapper,
+ } = {},
+) {
+ if (!baseElement) {
+ // default to document.body instead of documentElement to avoid output of potentially-large
+ // head elements (such as JSS style blocks) in debug output
+ baseElement = document.body
+ }
+ if (!container) {
+ container = baseElement.appendChild(document.createElement('div'))
+ }
+
+ let root
+ // eslint-disable-next-line no-negated-condition -- we want to map the evolution of this over time. The root is created first. Only later is it re-used so we don't want to read the case that happens later first.
+ if (!mountedContainers.has(container)) {
+ const createRootImpl = legacyRoot ? createLegacyRoot : createConcurrentRoot
+ root = createRootImpl(container, {hydrate, ui, wrapper})
+
+ mountedRootEntries.push({container, root})
+ // we'll add it to the mounted containers regardless of whether it's actually
+ // added to document.body so the cleanup method works regardless of whether
+ // they're passing us a custom container or not.
+ mountedContainers.add(container)
+ } else {
+ mountedRootEntries.forEach(rootEntry => {
+ // Else is unreachable since `mountedContainers` has the `container`.
+ // Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries`
+ /* istanbul ignore else */
+ if (rootEntry.container === container) {
+ root = rootEntry.root
+ }
+ })
+ }
+
+ return renderRoot(ui, {
+ container,
+ baseElement,
+ queries,
+ hydrate,
+ wrapper,
+ root,
+ })
}
-// 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) {
- act(() => {
- ReactDOM.unmountComponentAtNode(container)
+function cleanup() {
+ mountedRootEntries.forEach(({root, container}) => {
+ act(() => {
+ root.unmount()
+ })
+ if (container.parentNode === document.body) {
+ document.body.removeChild(container)
+ }
})
- if (container.parentNode === document.body) {
- document.body.removeChild(container)
- }
- mountedContainers.delete(container)
+ mountedRootEntries.length = 0
+ mountedContainers.clear()
}
// just re-export everything from dom-testing-library
export * from '@testing-library/dom'
export {render, cleanup, act, fireEvent}
-// NOTE: we're not going to export asyncAct because that's our own compatibility
-// thing for people using react-dom@16.8.0. Anyone else doesn't need it and
-// people should just upgrade anyway.
-
/* eslint func-name-matching:0 */
diff --git a/tests/setup-env.js b/tests/setup-env.js
index 6c0b953b..264828a9 100644
--- a/tests/setup-env.js
+++ b/tests/setup-env.js
@@ -1,20 +1 @@
import '@testing-library/jest-dom/extend-expect'
-
-let consoleErrorMock
-
-beforeEach(() => {
- const originalConsoleError = console.error
- consoleErrorMock = jest
- .spyOn(console, 'error')
- .mockImplementation((message, ...optionalParams) => {
- // Ignore ReactDOM.render/ReactDOM.hydrate deprecation warning
- if (message.indexOf('Use createRoot instead.') !== -1) {
- return
- }
- originalConsoleError(message, ...optionalParams)
- })
-})
-
-afterEach(() => {
- consoleErrorMock.mockRestore()
-})
diff --git a/types/index.d.ts b/types/index.d.ts
index 604b3966..a9bfa279 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -60,6 +60,11 @@ export interface RenderOptions<
* @see https://testing-library.com/docs/react-testing-library/api/#hydrate)
*/
hydrate?: boolean
+ /**
+ * Set to `true` if you want to force synchronous `ReactDOM.render`.
+ * Otherwise `render` will default to concurrent React if available.
+ */
+ legacyRoot?: boolean
/**
* Queries to bind. Overrides the default set from DOM Testing Library unless merged.
*