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/jest.config.js b/jest.config.js new file mode 100644 index 00000000..5c840226 --- /dev/null +++ b/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, + }, + }, +}) diff --git a/package.json b/package.json index 1cd283fb..cbf61367 100644 --- a/package.json +++ b/package.json @@ -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", 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__/end-to-end.js b/src/__tests__/end-to-end.js index cf222aec..787a944d 100644 --- a/src/__tests__/end-to-end.js +++ b/src/__tests__/end-to-end.js @@ -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}) } }) diff --git a/src/__tests__/new-act.js b/src/__tests__/new-act.js index af81e29c..42552594 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 => { @@ -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 () => { diff --git a/src/__tests__/no-act.js b/src/__tests__/no-act.js index d739e763..de4117bb 100644 --- a/src/__tests__/no-act.js +++ b/src/__tests__/no-act.js @@ -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() diff --git a/src/__tests__/old-act.js b/src/__tests__/old-act.js index 6081fef8..0153fea3 100644 --- a/src/__tests__/old-act.js +++ b/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', () => ({ diff --git a/src/__tests__/render.js b/src/__tests__/render.js index fea1a649..ac996444 100644 --- a/src/__tests__/render.js +++ b/src/__tests__/render.js @@ -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(
, {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(, {container}) + + expect(container).toContainHTML('') + + render(, {container}) + + expect(container).toContainHTML('') + + unmount() + + expect(container).toBeEmptyDOMElement() +}) 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..16124afc 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -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 @@ -14,7 +15,7 @@ function actPolyfill(cb) { ReactDOM.render(, document.createElement('div')) } -const act = reactAct || actPolyfill +const act = isomorphicAct || domAct || actPolyfill let youHaveBeenWarned = false let isAsyncActSupported = null @@ -50,7 +51,7 @@ function asyncAct(cb) { } let cbReturn, result try { - result = reactAct(() => { + result = domAct(() => { cbReturn = cb() return cbReturn }) diff --git a/src/pure.js b/src/pure.js index 75098f78..0ac63feb 100644 --- a/src/pure.js +++ b/src/pure.js @@ -25,32 +25,73 @@ configureDTL({ }, }) +if (React.startTransition !== undefined) { + configureDTL({ + unstable_advanceTimersWrapper: cb => { + return act(cb) + }, + asyncWrapper: cb => cb(), + }) +} + +// 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