Skip to content

Commit

Permalink
feat: Use concurrent React when available (#937)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
eps1lon committed Sep 13, 2021
1 parent 84851dc commit c888cb6
Show file tree
Hide file tree
Showing 14 changed files with 210 additions and 83 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/validate.yml
Expand Up @@ -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']
Expand Down Expand Up @@ -52,6 +53,8 @@ jobs:

- name: 猬嗭笍 Upload coverage report
uses: codecov/codecov-action@v1
with:
flags: ${{ matrix.react }}

release:
needs: main
Expand Down
15 changes: 15 additions & 0 deletions jest.config.js
@@ -0,0 +1,15 @@
const {jest: jestConfig} = require('kcd-scripts/config')

module.exports = Object.assign(jestConfig, {
coverageThreshold: {
...jestConfig.coverageThreshold,
// full coverage across the build matrix (React 17, 18) but not in a single job
'./src/pure': {
// minimum coverage of jobs using React 17 and 18
branches: 80,
functions: 78,
lines: 84,
statements: 84,
},
},
})
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -45,7 +45,7 @@
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@testing-library/dom": "^8.0.0"
"@testing-library/dom": "^8.5.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.11.6",
Expand Down
19 changes: 5 additions & 14 deletions src/__tests__/cleanup.js
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(...)',
)
})
})
2 changes: 2 additions & 0 deletions src/__tests__/end-to-end.js
Expand Up @@ -17,6 +17,8 @@ function ComponentWithLoader() {
let cancelled = false
fetchAMessage().then(data => {
if (!cancelled) {
// Will trigger "missing act" warnings in React 18 with real timers
// Need to wait for an action on https://github.com/reactwg/react-18/discussions/23#discussioncomment-1087897
setState({data, loading: false})
}
})
Expand Down
6 changes: 3 additions & 3 deletions src/__tests__/new-act.js
@@ -1,4 +1,4 @@
let asyncAct, consoleErrorMock
let asyncAct

jest.mock('react-dom/test-utils', () => ({
act: cb => {
Expand All @@ -9,11 +9,11 @@ jest.mock('react-dom/test-utils', () => ({
beforeEach(() => {
jest.resetModules()
asyncAct = require('../act-compat').asyncAct
consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {})
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 () => {
Expand Down
8 changes: 8 additions & 0 deletions src/__tests__/no-act.js
Expand Up @@ -12,7 +12,15 @@ afterEach(() => {
consoleErrorMock.mockRestore()
})

// no react-dom/test-utils also means no isomorphic act since isomorphic act got released after test-utils act
jest.mock('react-dom/test-utils', () => ({}))
jest.mock('react', () => {
const ReactActual = jest.requireActual('react')

delete ReactActual.unstable_act

return ReactActual
})

test('act works even when there is no act from test utils', () => {
const callback = jest.fn()
Expand Down
6 changes: 3 additions & 3 deletions src/__tests__/old-act.js
@@ -1,13 +1,13 @@
let asyncAct, consoleErrorMock
let asyncAct

beforeEach(() => {
jest.resetModules()
asyncAct = require('../act-compat').asyncAct
consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {})
jest.spyOn(console, 'error').mockImplementation(() => {})
})

afterEach(() => {
consoleErrorMock.mockRestore()
console.error.mockRestore()
})

jest.mock('react-dom/test-utils', () => ({
Expand Down
33 changes: 33 additions & 0 deletions src/__tests__/render.js
Expand Up @@ -101,3 +101,36 @@ test('flushes useEffect cleanup functions sync on unmount()', () => {

expect(spy).toHaveBeenCalledTimes(1)
})

test('throws if `legacyRoot: false` is used with an incomaptible version', () => {
const isConcurrentReact = typeof ReactDOM.createRoot === 'function'

const performConcurrentRender = () => render(<div />, {legacyRoot: false})

// eslint-disable-next-line jest/no-if -- jest doesn't support conditional tests
if (isConcurrentReact) {
// eslint-disable-next-line jest/no-conditional-expect -- yes, jest still doesn't support conditional tests
expect(performConcurrentRender).not.toThrow()
} else {
// eslint-disable-next-line jest/no-conditional-expect -- yes, jest still doesn't support conditional tests
expect(performConcurrentRender).toThrowError(
`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).`,
)
}
})

test('can be called multiple times on the same container', () => {
const container = document.createElement('div')

const {unmount} = render(<strong />, {container})

expect(container).toContainHTML('<strong></strong>')

render(<em />, {container})

expect(container).toContainHTML('<em></em>')

unmount()

expect(container).toBeEmptyDOMElement()
})
5 changes: 1 addition & 4 deletions src/__tests__/stopwatch.js
Expand Up @@ -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()
})
9 changes: 5 additions & 4 deletions src/act-compat.js
Expand Up @@ -2,8 +2,9 @@ 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 isomorphicAct = React.unstable_act
const domAct = testUtils.act
const actSupported = domAct !== undefined

// act is supported react-dom@16.8.0
// so for versions that don't have act from test utils
Expand All @@ -14,7 +15,7 @@ function actPolyfill(cb) {
ReactDOM.render(<div />, document.createElement('div'))
}

const act = reactAct || actPolyfill
const act = isomorphicAct || domAct || actPolyfill

let youHaveBeenWarned = false
let isAsyncActSupported = null
Expand Down Expand Up @@ -50,7 +51,7 @@ function asyncAct(cb) {
}
let cbReturn, result
try {
result = reactAct(() => {
result = domAct(() => {
cbReturn = cb()
return cbReturn
})
Expand Down

0 comments on commit c888cb6

Please sign in to comment.