Skip to content

Commit e37e634

Browse files
committed
feat: Use concurrent React when available (testing-library#937)
BREAKING CHANGE: If you have React 18 installed, we'll use the new [`createRoot` API](reactwg/react-18#5) by default which comes with a set of [changes while also enabling support for concurrent features](reactwg/react-18#4). To can opt-out of this change by using `render(ui, { legacyRoot: true } )`. But be aware that the legacy root API is deprecated in React 18 and its usage will trigger console warnings.
1 parent 8f17a2b commit e37e634

14 files changed

+210
-83
lines changed

.github/workflows/validate.yml

+3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jobs:
1616
# ignore all-contributors PRs
1717
if: ${{ !contains(github.head_ref, 'all-contributors') }}
1818
strategy:
19+
fail-fast: false
1920
matrix:
2021
# 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
2122
node: [12, 14, '16.9.1']
@@ -52,6 +53,8 @@ jobs:
5253

5354
- name: ⬆️ Upload coverage report
5455
uses: codecov/codecov-action@v1
56+
with:
57+
flags: ${{ matrix.react }}
5558

5659
release:
5760
needs: main

jest.config.js

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
const {jest: jestConfig} = require('kcd-scripts/config')
2+
3+
module.exports = Object.assign(jestConfig, {
4+
coverageThreshold: {
5+
...jestConfig.coverageThreshold,
6+
// full coverage across the build matrix (React 17, 18) but not in a single job
7+
'./src/pure': {
8+
// minimum coverage of jobs using React 17 and 18
9+
branches: 80,
10+
functions: 78,
11+
lines: 84,
12+
statements: 84,
13+
},
14+
},
15+
})

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"license": "MIT",
4646
"dependencies": {
4747
"@babel/runtime": "^7.12.5",
48-
"@testing-library/dom": "^8.0.0"
48+
"@testing-library/dom": "^8.5.0"
4949
},
5050
"devDependencies": {
5151
"@testing-library/jest-dom": "^5.11.6",

src/__tests__/cleanup.js

+5-14
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,7 @@ describe('fake timers and missing act warnings', () => {
8383
expect(microTaskSpy).toHaveBeenCalledTimes(0)
8484
// console.error is mocked
8585
// eslint-disable-next-line no-console
86-
expect(console.error).toHaveBeenCalledTimes(
87-
// ReactDOM.render is deprecated in React 18
88-
React.version.startsWith('18') ? 1 : 0,
89-
)
86+
expect(console.error).toHaveBeenCalledTimes(0)
9087
})
9188

9289
test('cleanup does not swallow missing act warnings', () => {
@@ -118,16 +115,10 @@ describe('fake timers and missing act warnings', () => {
118115
expect(deferredStateUpdateSpy).toHaveBeenCalledTimes(1)
119116
// console.error is mocked
120117
// eslint-disable-next-line no-console
121-
expect(console.error).toHaveBeenCalledTimes(
122-
// ReactDOM.render is deprecated in React 18
123-
React.version.startsWith('18') ? 2 : 1,
124-
)
118+
expect(console.error).toHaveBeenCalledTimes(1)
125119
// eslint-disable-next-line no-console
126-
expect(
127-
console.error.mock.calls[
128-
// ReactDOM.render is deprecated in React 18
129-
React.version.startsWith('18') ? 1 : 0
130-
][0],
131-
).toMatch('a test was not wrapped in act(...)')
120+
expect(console.error.mock.calls[0][0]).toMatch(
121+
'a test was not wrapped in act(...)',
122+
)
132123
})
133124
})

src/__tests__/end-to-end.js

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ function ComponentWithLoader() {
1717
let cancelled = false
1818
fetchAMessage().then(data => {
1919
if (!cancelled) {
20+
// Will trigger "missing act" warnings in React 18 with real timers
21+
// Need to wait for an action on https://github.com/reactwg/react-18/discussions/23#discussioncomment-1087897
2022
setState({data, loading: false})
2123
}
2224
})

src/__tests__/new-act.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
let asyncAct, consoleErrorMock
1+
let asyncAct
22

33
jest.mock('react-dom/test-utils', () => ({
44
act: cb => {
@@ -9,11 +9,11 @@ jest.mock('react-dom/test-utils', () => ({
99
beforeEach(() => {
1010
jest.resetModules()
1111
asyncAct = require('../act-compat').asyncAct
12-
consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {})
12+
jest.spyOn(console, 'error').mockImplementation(() => {})
1313
})
1414

1515
afterEach(() => {
16-
consoleErrorMock.mockRestore()
16+
console.error.mockRestore()
1717
})
1818

1919
test('async act works when it does not exist (older versions of react)', async () => {

src/__tests__/no-act.js

+8
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,15 @@ afterEach(() => {
1212
consoleErrorMock.mockRestore()
1313
})
1414

15+
// no react-dom/test-utils also means no isomorphic act since isomorphic act got released after test-utils act
1516
jest.mock('react-dom/test-utils', () => ({}))
17+
jest.mock('react', () => {
18+
const ReactActual = jest.requireActual('react')
19+
20+
delete ReactActual.unstable_act
21+
22+
return ReactActual
23+
})
1624

1725
test('act works even when there is no act from test utils', () => {
1826
const callback = jest.fn()

src/__tests__/old-act.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
let asyncAct, consoleErrorMock
1+
let asyncAct
22

33
beforeEach(() => {
44
jest.resetModules()
55
asyncAct = require('../act-compat').asyncAct
6-
consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {})
6+
jest.spyOn(console, 'error').mockImplementation(() => {})
77
})
88

99
afterEach(() => {
10-
consoleErrorMock.mockRestore()
10+
console.error.mockRestore()
1111
})
1212

1313
jest.mock('react-dom/test-utils', () => ({

src/__tests__/render.js

+33
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,36 @@ test('flushes useEffect cleanup functions sync on unmount()', () => {
101101

102102
expect(spy).toHaveBeenCalledTimes(1)
103103
})
104+
105+
test('throws if `legacyRoot: false` is used with an incomaptible version', () => {
106+
const isConcurrentReact = typeof ReactDOM.createRoot === 'function'
107+
108+
const performConcurrentRender = () => render(<div />, {legacyRoot: false})
109+
110+
// eslint-disable-next-line jest/no-if -- jest doesn't support conditional tests
111+
if (isConcurrentReact) {
112+
// eslint-disable-next-line jest/no-conditional-expect -- yes, jest still doesn't support conditional tests
113+
expect(performConcurrentRender).not.toThrow()
114+
} else {
115+
// eslint-disable-next-line jest/no-conditional-expect -- yes, jest still doesn't support conditional tests
116+
expect(performConcurrentRender).toThrowError(
117+
`Attempted to use concurrent React with \`react-dom@${ReactDOM.version}\`. Be sure to use the \`next\` or \`experimental\` release channel (https://reactjs.org/docs/release-channels.html).`,
118+
)
119+
}
120+
})
121+
122+
test('can be called multiple times on the same container', () => {
123+
const container = document.createElement('div')
124+
125+
const {unmount} = render(<strong />, {container})
126+
127+
expect(container).toContainHTML('<strong></strong>')
128+
129+
render(<em />, {container})
130+
131+
expect(container).toContainHTML('<em></em>')
132+
133+
unmount()
134+
135+
expect(container).toBeEmptyDOMElement()
136+
})

src/__tests__/stopwatch.js

+1-4
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,5 @@ test('unmounts a component', async () => {
5353
// and get an error.
5454
await sleep(5)
5555
// eslint-disable-next-line no-console
56-
expect(console.error).toHaveBeenCalledTimes(
57-
// ReactDOM.render is deprecated in React 18
58-
React.version.startsWith('18') ? 1 : 0,
59-
)
56+
expect(console.error).not.toHaveBeenCalled()
6057
})

src/act-compat.js

+5-4
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import * as React from 'react'
22
import ReactDOM from 'react-dom'
33
import * as testUtils from 'react-dom/test-utils'
44

5-
const reactAct = testUtils.act
6-
const actSupported = reactAct !== undefined
5+
const isomorphicAct = React.unstable_act
6+
const domAct = testUtils.act
7+
const actSupported = domAct !== undefined
78

89
// act is supported [email protected]
910
// so for versions that don't have act from test utils
@@ -14,7 +15,7 @@ function actPolyfill(cb) {
1415
ReactDOM.render(<div />, document.createElement('div'))
1516
}
1617

17-
const act = reactAct || actPolyfill
18+
const act = isomorphicAct || domAct || actPolyfill
1819

1920
let youHaveBeenWarned = false
2021
let isAsyncActSupported = null
@@ -50,7 +51,7 @@ function asyncAct(cb) {
5051
}
5152
let cbReturn, result
5253
try {
53-
result = reactAct(() => {
54+
result = domAct(() => {
5455
cbReturn = cb()
5556
return cbReturn
5657
})

0 commit comments

Comments
 (0)