From 149d9a9af3addeb6c49696867b05b87afe0d0b3c Mon Sep 17 00:00:00 2001 From: Fabian Meumertzheim Date: 2022年2月15日 23:50:32 +0100 Subject: [PATCH 01/71] fix: Add `@types/react-dom` as a direct dependency (#1001) Co-authored-by: Sebastian Silbermann --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1cd283fb..73d39de8 100644 --- a/package.json +++ b/package.json @@ -45,11 +45,11 @@ "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^8.0.0" + "@testing-library/dom": "^8.0.0", + "@types/react-dom": "*" }, "devDependencies": { "@testing-library/jest-dom": "^5.11.6", - "@types/react-dom": "^17.0.0", "dotenv-cli": "^4.0.0", "kcd-scripts": "^11.1.0", "npm-run-all": "^4.1.5", From b0f9d9741205c54836bf82b76b86ec001a8c0e1f Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: 2022年2月15日 23:51:13 +0100 Subject: [PATCH 02/71] docs: add fmeum as a contributor for code, bug (#1016) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 10 ++++++++++ README.md | 1 + 2 files changed, 11 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 6e91ce5d..df9690ed 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1297,6 +1297,16 @@ "contributions": [ "bug" ] + }, + { + "login": "fmeum", + "name": "Fabian Meumertzheim", + "avatar_url": "https://avatars.githubusercontent.com/u/4312191?v=4", + "profile": "https://hen.ne.ke", + "contributions": [ + "code", + "bug" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 3adcede1..0bc06ceb 100644 --- a/README.md +++ b/README.md @@ -614,6 +614,7 @@ Thanks goes to these people ([emoji key][emojis]): From 96ed8dafa5d02add2168a3da65d1cc0ffe6d6d1f Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Wed, 9 Mar 2022 15:32:49 +0100 Subject: [PATCH 03/71] fix: Match runtime type of baseElement in TypeScript types (#1023) --- types/index.d.ts | 11 +++++++---- types/test.tsx | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index 92eb2d7b..604b3966 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -14,9 +14,10 @@ export * from '@testing-library/dom' export type RenderResult< Q extends Queries = typeof queries, Container extends Element | DocumentFragment = HTMLElement, + BaseElement extends Element | DocumentFragment = Container,> = { container: Container - baseElement: Element + baseElement: BaseElement debug: ( baseElement?: | Element @@ -33,6 +34,7 @@ export type RenderResult< export interface RenderOptions< Q extends Queries = typeof queries, Container extends Element | DocumentFragment = HTMLElement, + BaseElement extends Element | DocumentFragment = 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, @@ -50,7 +52,7 @@ export interface RenderOptions< * * @see https://testing-library.com/docs/react-testing-library/api/#baseelement */ - baseElement?: Element + baseElement?: BaseElement /** * 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. @@ -81,10 +83,11 @@ type Omit = Pick> export function render< Q extends Queries = typeof queries, Container extends Element | DocumentFragment = HTMLElement, + BaseElement extends Element | DocumentFragment = Container,>( ui: React.ReactElement, - options: RenderOptions, -): RenderResult + options: RenderOptions, +): RenderResult export function render( ui: React.ReactElement, options?: Omit, diff --git a/types/test.tsx b/types/test.tsx index 71ea30a9..eae6e81f 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -141,6 +141,25 @@ export function wrappedRenderC( return pure.render(ui, {wrapper: AppWrapperProps, ...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, + ) +} + /* eslint testing-library/prefer-explicit-assert: "off", From 0c4aabe0da1587754229f7614a2ddfdaddd0b1aa Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2022年3月19日 08:43:44 +0100 Subject: [PATCH 04/71] chore: Fix failing codesandbox/ci (#1026) --- .codesandbox/ci.json | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index f866879a..bf3237bb 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -1,4 +1,5 @@ { + "installCommand": "install:csb", "sandboxes": ["new", "github/kentcdodds/react-testing-library-examples"], "node": "12" } diff --git a/package.json b/package.json index 73d39de8..f184a8bc 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build:bundle:pure": "dotenv -e .bundle.main.env -e .bundle.pure.env kcd-scripts build -- --bundle --no-clean", "build:main": "kcd-scripts build --no-clean", "format": "kcd-scripts format", + "install:csb": "npm install", "lint": "kcd-scripts lint", "setup": "npm install && npm run validate -s", "test": "kcd-scripts test", From ccd8a0d97dd9da0a420f2cf012a24d414d1646ed Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2022年3月31日 06:55:38 +0200 Subject: [PATCH 05/71] feat: Add support for React 18 (#1031) BREAKING CHANGE: Drop support for React 17 and earlier. 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. Co-authored-by: Philipp Fritsche --- .github/workflows/validate.yml | 3 + package.json | 10 +- src/__tests__/act.js | 26 ++++- src/__tests__/cleanup.js | 19 +--- src/__tests__/new-act.js | 8 +- src/__tests__/no-act.js | 92 ---------------- src/__tests__/old-act.js | 142 ------------------------- src/__tests__/render.js | 96 ++++++++++++++++- src/__tests__/stopwatch.js | 5 +- src/act-compat.js | 186 ++++++++++++-------------------- src/index.js | 16 +++ src/pure.js | 187 +++++++++++++++++++++++++-------- tests/setup-env.js | 19 ---- types/index.d.ts | 5 + 14 files changed, 370 insertions(+), 444 deletions(-) delete mode 100644 src/__tests__/no-act.js delete mode 100644 src/__tests__/old-act.js 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. * From 93bc2c8afc8a7988ef9b4f5cb7f4101a2400735d Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2022年4月11日 19:59:54 +0200 Subject: [PATCH 06/71] test(types): Don't assume implicit children (#1042) * test(types): Don't assume implicit children * format --- types/test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/types/test.tsx b/types/test.tsx index eae6e81f..a8a7c7ae 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -129,6 +129,7 @@ export function wrappedRenderC( options?: pure.RenderOptions, ) { interface AppWrapperProps { + children?: React.ReactNode userProviderProps?: {user: string} } const AppWrapperProps: React.FunctionComponent = ({ From 2a889e80658ce93882c5ba253ea65f5542ece2d0 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: 2022年4月11日 11:03:31 -0700 Subject: [PATCH 07/71] fix: Specify a non-* version for @types/react-dom (#1040) fixes https://github.com/testing-library/react-testing-library/issues/1039 Co-authored-by: Sebastian Silbermann --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4781e962..8d7c629b 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.5.0", - "@types/react-dom": "*" + "@types/react-dom": "^18.0.0" }, "devDependencies": { "@testing-library/jest-dom": "^5.11.6", From c8c93f83228a68a270583c139972e79b1812b7d3 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: 2022年4月11日 20:06:24 +0200 Subject: [PATCH 08/71] docs: add Nokel81 as a contributor for bug, code (#1043) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 10 ++++++++++ README.md | 1 + 2 files changed, 11 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index df9690ed..0eb9e2a5 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1307,6 +1307,16 @@ "code", "bug" ] + }, + { + "login": "Nokel81", + "name": "Sebastian Malton", + "avatar_url": "https://avatars.githubusercontent.com/u/8225332?v=4", + "profile": "https://github.com/Nokel81", + "contributions": [ + "bug", + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 0bc06ceb..ea31e3f8 100644 --- a/README.md +++ b/README.md @@ -615,6 +615,7 @@ Thanks goes to these people ([emoji key][emojis]):
Marcos Gómez

📖
Akash Shyam

🐛
Fabian Meumertzheim

💻 🐛 +
Sebastian Malton

🐛 💻
From 2c451b346815b30dace8a5f7b2ed6a78d17f47cc Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2022年4月13日 16:38:43 +0200 Subject: [PATCH 09/71] chore: Run release from 12.x branch (#1044) (#1045) --- .github/workflows/validate.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 45cc7d13..7e95b942 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -2,7 +2,9 @@ name: validate on: push: branches: - - '+([0-9])?(.{+([0-9]),x}).x' + # Match SemVer major release branches + # e.g. "12.x" or "8.x" + - '[0-9]+.x' - 'main' - 'next' - 'next-major' @@ -61,8 +63,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.repository == 'testing-library/react-testing-library' && - contains('refs/heads/main,refs/heads/beta,refs/heads/next,refs/heads/alpha', - github.ref) && github.event_name == 'push' }} + github.event_name == 'push' }} steps: - name: 🛑 Cancel Previous Runs uses: styfle/cancel-workflow-action@0.9.0 From 9535eff82ada685c410b3b25ef3e2313ea3a86aa Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2022年4月15日 20:55:24 +0200 Subject: [PATCH 10/71] feat: Add `renderHook` (#991) Co-authored-by: Michael Peyper Co-authored-by: Kent C. Dodds --- src/__tests__/renderHook.js | 62 +++++++++++++++++++++++++++++++++++++ src/pure.js | 30 +++++++++++++++++- types/index.d.ts | 46 +++++++++++++++++++++++++++ types/test.tsx | 25 ++++++++++++++- 4 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/renderHook.js diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js new file mode 100644 index 00000000..fd6b95a4 --- /dev/null +++ b/src/__tests__/renderHook.js @@ -0,0 +1,62 @@ +import React from 'react' +import {renderHook} from '../pure' + +test('gives comitted 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 + 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') +}) diff --git a/src/pure.js b/src/pure.js index 64b761b0..4c416d44 100644 --- a/src/pure.js +++ b/src/pure.js @@ -218,8 +218,36 @@ function cleanup() { mountedContainers.clear() } +function renderHook(renderCallback, options = {}) { + const {initialProps, wrapper} = options + const result = React.createRef() + + function TestComponent({renderCallbackProps}) { + const pendingResult = renderCallback(renderCallbackProps) + + React.useEffect(() => { + result.current = pendingResult + }) + + return null + } + + const {rerender: baseRerender, unmount} = render( + , + {wrapper}, + ) + + function rerender(rerenderCallbackProps) { + return baseRerender( + , + ) + } + + return {result, rerender, unmount} +} + // just re-export everything from dom-testing-library export * from '@testing-library/dom' -export {render, cleanup, act, fireEvent} +export {render, renderHook, cleanup, act, fireEvent} /* eslint func-name-matching:0 */ diff --git a/types/index.d.ts b/types/index.d.ts index a9bfa279..fda03e5b 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -98,6 +98,52 @@ export function render( options?: Omit, ): RenderResult +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 +} + +interface RenderHookOptions { + /** + * 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 + /** + * 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.ReactElement}> +} + +/** + * Allows you to render a hook within a test React component without having to + * create that component yourself. + */ +export function renderHook( + render: (initialProps: Props) => Result, + options?: RenderHookOptions, +): RenderHookResult + /** * Unmounts React trees that were mounted with render. */ diff --git a/types/test.tsx b/types/test.tsx index a8a7c7ae..17ba7012 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import {render, fireEvent, screen, waitFor} from '.' +import {render, fireEvent, screen, waitFor, renderHook} from '.' import * as pure from './pure' export async function testRender() { @@ -161,6 +161,29 @@ export function testBaseElement() { ) } +export function testRenderHook() { + const {result, rerender, unmount} = renderHook(() => React.useState(2)[0]) + + expectType(result.current) + + rerender() + + unmount() +} + +export function testRenderHookProps() { + const {result, rerender, unmount} = renderHook( + ({defaultValue}) => React.useState(defaultValue)[0], + {initialProps: {defaultValue: 2}}, + ) + + expectType(result.current) + + rerender() + + unmount() +} + /* eslint testing-library/prefer-explicit-assert: "off", From 9171163fccf0a7ea43763475ca2980898b4079a5 Mon Sep 17 00:00:00 2001 From: Andrew Hummel Date: 2022年4月15日 17:07:31 -0500 Subject: [PATCH 11/71] fix(TS): export interface RenderHookResult (#1049) --- types/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/index.d.ts b/types/index.d.ts index fda03e5b..1948114f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -98,7 +98,7 @@ export function render( options?: Omit, ): RenderResult -interface RenderHookResult { +export interface RenderHookResult { /** * Triggers a re-render. The props will be passed to your renderHook callback. */ From 46b28ade730f97a49a253d630f5b97c17ff24f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20B=C3=B6ttcher?= Date: Tue, 3 May 2022 20:34:37 +0200 Subject: [PATCH 12/71] feat: Export RenderHookOptions type (#1062) --- types/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/index.d.ts b/types/index.d.ts index 1948114f..e3f5bc60 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -120,7 +120,7 @@ export interface RenderHookResult { unmount: () => void } -interface RenderHookOptions { +export interface RenderHookOptions { /** * 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. From 00c89dce86585d6a163c383a05abaf5a7f646bf6 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Tue, 3 May 2022 20:36:41 +0200 Subject: [PATCH 13/71] docs: add mboettcher as a contributor for code (#1063) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 0eb9e2a5..e267d285 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1317,6 +1317,15 @@ "bug", "code" ] + }, + { + "login": "mboettcher", + "name": "Martin Böttcher", + "avatar_url": "https://avatars.githubusercontent.com/u/2325337?v=4", + "profile": "https://github.com/mboettcher", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index ea31e3f8..9992250a 100644 --- a/README.md +++ b/README.md @@ -616,6 +616,7 @@ Thanks goes to these people ([emoji key][emojis]):
Akash Shyam

🐛
Fabian Meumertzheim

💻 🐛
Sebastian Malton

🐛 💻 +
Martin Böttcher

💻
From f176285e4e92754751b708e1b1adf1f38edea6a8 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2022年5月17日 20:51:03 +0200 Subject: [PATCH 14/71] chore: Run with latest Node 16 again (#1071) --- .github/workflows/validate.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7e95b942..9379216c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -20,8 +20,7 @@ jobs: 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'] + node: [12, 14, 16] react: [latest, next, experimental] runs-on: ubuntu-latest steps: From c80809a956b0b9f3289c4a6fa8b5e8cc72d6ef6d Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2022年5月28日 10:18:06 +0200 Subject: [PATCH 15/71] feat: Use `globalThis` if available (#1070) --- package.json | 3 +++ src/act-compat.js | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/package.json b/package.json index 8d7c629b..4cba00fd 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,9 @@ }, "eslintConfig": { "extends": "./node_modules/kcd-scripts/eslint.js", + "globals": { + "globalThis": "readonly" + }, "rules": { "react/prop-types": "off", "react/no-adjacent-inline-elements": "off", diff --git a/src/act-compat.js b/src/act-compat.js index d7a09d68..86518196 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -4,6 +4,10 @@ const domAct = testUtils.act function getGlobalThis() { /* istanbul ignore else */ + if (typeof globalThis !== 'undefined') { + return globalThis + } + /* istanbul ignore next */ if (typeof self !== 'undefined') { return self } From 73ee9ba13cb4b337f06e2ed61099d6af9a4968da Mon Sep 17 00:00:00 2001 From: Felix Yan Date: 2022年8月22日 12:40:43 +0300 Subject: [PATCH 16/71] test: Correct a typo in test name (#1112) --- src/__tests__/renderHook.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js index fd6b95a4..b65d67a2 100644 --- a/src/__tests__/renderHook.js +++ b/src/__tests__/renderHook.js @@ -1,7 +1,7 @@ import React from 'react' import {renderHook} from '../pure' -test('gives comitted result', () => { +test('gives committed result', () => { const {result} = renderHook(() => { const [state, setState] = React.useState(1) From 27a9584629e28339b9961edefbb2134d7c570678 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sun, 4 Sep 2022 18:47:54 +0200 Subject: [PATCH 17/71] feat(renderHook): allow passing of all render options to renderHook (#1118) --- src/__tests__/renderHook.js | 26 ++++++++++++++++++++++++++ src/pure.js | 4 ++-- types/index.d.ts | 24 ++++++++++++++---------- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js index b65d67a2..92bc47ed 100644 --- a/src/__tests__/renderHook.js +++ b/src/__tests__/renderHook.js @@ -60,3 +60,29 @@ test('allows wrapper components', async () => { expect(result.current).toEqual('provided') }) + +test('legacyRoot uses legacy ReactDOM.render', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + + const Context = React.createContext('default') + function Wrapper({children}) { + return {children} + } + const {result} = renderHook( + () => { + return React.useContext(Context) + }, + { + wrapper: Wrapper, + legacyRoot: true, + }, + ) + + expect(result.current).toEqual('provided') + + 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", + ) +}) diff --git a/src/pure.js b/src/pure.js index 4c416d44..94b3b2bd 100644 --- a/src/pure.js +++ b/src/pure.js @@ -219,7 +219,7 @@ function cleanup() { } function renderHook(renderCallback, options = {}) { - const {initialProps, wrapper} = options + const {initialProps, ...renderOptions} = options const result = React.createRef() function TestComponent({renderCallbackProps}) { @@ -234,7 +234,7 @@ function renderHook(renderCallback, options = {}) { const {rerender: baseRerender, unmount} = render( , - {wrapper}, + renderOptions, ) function rerender(rerenderCallbackProps) { diff --git a/types/index.d.ts b/types/index.d.ts index e3f5bc60..558edfad 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -120,28 +120,32 @@ export interface RenderHookResult { unmount: () => void } -export interface RenderHookOptions { +export interface RenderHookOptions< + Props, + Q extends Queries = typeof queries, + Container extends Element | DocumentFragment = HTMLElement, + BaseElement extends Element | DocumentFragment = Container, +> extends RenderOptions { /** * 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 - /** - * 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.ReactElement}> } /** * Allows you to render a hook within a test React component without having to * create that component yourself. */ -export function renderHook( +export function renderHook< + Result, + Props, + Q extends Queries = typeof queries, + Container extends Element | DocumentFragment = HTMLElement, + BaseElement extends Element | DocumentFragment = Container, +>( render: (initialProps: Props) => Result, - options?: RenderHookOptions, + options?: RenderHookOptions, ): RenderHookResult /** From bef9e07c1743affa6fca459fda5ab5b488ccd9bf Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sun, 4 Sep 2022 18:48:26 +0200 Subject: [PATCH 18/71] docs: add TkDodo as a contributor for code (#1119) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index e267d285..2d451b71 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1326,6 +1326,15 @@ "contributions": [ "code" ] + }, + { + "login": "TkDodo", + "name": "Dominik Dorfmeister", + "avatar_url": "https://avatars.githubusercontent.com/u/1021430?v=4", + "profile": "http://tkdodo.eu", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 9992250a..bbe8d94e 100644 --- a/README.md +++ b/README.md @@ -617,6 +617,7 @@ Thanks goes to these people ([emoji key][emojis]):
Fabian Meumertzheim

💻 🐛
Sebastian Malton

🐛 💻
Martin Böttcher

💻 +
Dominik Dorfmeister

💻
From 7c7dc785501f2e75cbcb5d49df78340914dfba8c Mon Sep 17 00:00:00 2001 From: Stephen Sauceda Date: Sat, 1 Oct 2022 15:20:38 -0400 Subject: [PATCH 19/71] docs: acknowledge peer dependency requirements (#1131) --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index bbe8d94e..8704fa2b 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,16 @@ yarn add --dev @testing-library/react This library has `peerDependencies` listings for `react` and `react-dom`. +_React Testing Library versions 13+ require React v18. If your project uses an +older version of React, be sure to install version 12:_ + +``` +npm install --save-dev @testing-library/react@12 + + +yarn add --dev @testing-library/react@12 +``` + You may also be interested in installing `@testing-library/jest-dom` so you can use [the custom jest matchers](https://github.com/testing-library/jest-dom). From bca9bf8bca1dfb9655980801838fb851d0ef8763 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 1 Oct 2022 21:23:18 +0200 Subject: [PATCH 20/71] add stephensauceda as a contributor for doc (#1132) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 ++ README.md | 364 ++++++++++++++++++++++---------------------- 2 files changed, 194 insertions(+), 179 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 2d451b71..270dd6a0 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1335,6 +1335,15 @@ "contributions": [ "code" ] + }, + { + "login": "stephensauceda", + "name": "Stephen Sauceda", + "avatar_url": "https://avatars.githubusercontent.com/u/1017723?v=4", + "profile": "https://stephensauceda.com", + "contributions": [ + "doc" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 8704fa2b..45324901 100644 --- a/README.md +++ b/README.md @@ -450,185 +450,191 @@ Thanks goes to these people ([emoji key][emojis]): - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + From 4d76a4a75541ceccbc23a452ac6b291e6bfde927 Mon Sep 17 00:00:00 2001 From: Sergey Bunas Date: Wed, 5 Oct 2022 21:32:52 +0300 Subject: [PATCH 21/71] Update outdated LICENSE year (#1133) --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 4c43675b..ca399d57 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ The MIT License (MIT) -Copyright (c) 2017 Kent C. Dodds +Copyright (c) 2017-Present Kent C. Dodds Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 185e3142a320908fc2a707c7aba815444abf675c Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Sat, 8 Oct 2022 13:22:18 +0200 Subject: [PATCH 22/71] test: Add Node.js 18.x to test matrix (#1138) --- .github/workflows/validate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 9379216c..ad4adccf 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - node: [12, 14, 16] + node: [12, 14, 16, 18] react: [latest, next, experimental] runs-on: ubuntu-latest steps: From 801ad37ac79caced867aa05931b914035c6b527a Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Tue, 6 Dec 2022 21:25:35 +0100 Subject: [PATCH 23/71] test: Fail on unexpected console.warn and console.error (#1139) --- package.json | 2 + src/__tests__/cleanup.js | 1 + src/__tests__/new-act.js | 2 +- src/__tests__/render.js | 37 ++-- src/__tests__/renderHook.js | 33 ++- tests/failOnUnexpectedConsoleCalls.js | 129 +++++++++++ tests/setup-env.js | 1 + tests/shouldIgnoreConsoleError.js | 43 ++++ tests/toWarnDev.js | 303 ++++++++++++++++++++++++++ 9 files changed, 510 insertions(+), 41 deletions(-) create mode 100644 tests/failOnUnexpectedConsoleCalls.js create mode 100644 tests/shouldIgnoreConsoleError.js create mode 100644 tests/toWarnDev.js diff --git a/package.json b/package.json index 4cba00fd..d2dd6a97 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ }, "devDependencies": { "@testing-library/jest-dom": "^5.11.6", + "chalk": "^4.1.2", "dotenv-cli": "^4.0.0", + "jest-diff": "^27.5.1", "kcd-scripts": "^11.1.0", "npm-run-all": "^4.1.5", "react": "^18.0.0", diff --git a/src/__tests__/cleanup.js b/src/__tests__/cleanup.js index 0dcbac12..4517c098 100644 --- a/src/__tests__/cleanup.js +++ b/src/__tests__/cleanup.js @@ -51,6 +51,7 @@ describe('fake timers and missing act warnings', () => { }) afterEach(() => { + jest.restoreAllMocks() jest.useRealTimers() }) diff --git a/src/__tests__/new-act.js b/src/__tests__/new-act.js index 05f9d45a..4909d4a6 100644 --- a/src/__tests__/new-act.js +++ b/src/__tests__/new-act.js @@ -13,7 +13,7 @@ beforeEach(() => { }) afterEach(() => { - console.error.mockRestore() + jest.restoreAllMocks() }) test('async act works when it does not exist (older versions of react)', async () => { diff --git a/src/__tests__/render.js b/src/__tests__/render.js index 88e2b98d..46925f49 100644 --- a/src/__tests__/render.js +++ b/src/__tests__/render.js @@ -3,12 +3,6 @@ import ReactDOM from 'react-dom' 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() const {container} = render(
) @@ -126,7 +120,6 @@ test('can be called multiple times on the same container', () => { }) test('hydrate will make the UI interactive', () => { - jest.spyOn(console, 'error').mockImplementation(() => {}) function App() { const [clicked, handleClick] = React.useReducer(n => n + 1, 0) @@ -145,8 +138,6 @@ test('hydrate will make the UI interactive', () => { render(ui, {container, hydrate: true}) - expect(console.error).not.toHaveBeenCalled() - fireEvent.click(container.querySelector('button')) expect(container).toHaveTextContent('clicked:1') @@ -172,26 +163,26 @@ test('hydrate can have a wrapper', () => { }) 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", + 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}, ) }) 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", + 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}, ) }) diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js index 92bc47ed..f6b7a343 100644 --- a/src/__tests__/renderHook.js +++ b/src/__tests__/renderHook.js @@ -62,27 +62,26 @@ test('allows wrapper components', async () => { }) test('legacyRoot uses legacy ReactDOM.render', () => { - jest.spyOn(console, 'error').mockImplementation(() => {}) - const Context = React.createContext('default') function Wrapper({children}) { return {children} } - const {result} = renderHook( - () => { - return React.useContext(Context) - }, - { - wrapper: Wrapper, - legacyRoot: true, - }, + 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') - - 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", - ) }) 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 index 264828a9..a4ddfa17 100644 --- a/tests/setup-env.js +++ b/tests/setup-env.js @@ -1 +1,2 @@ import '@testing-library/jest-dom/extend-expect' +import './failOnUnexpectedConsoleCalls' diff --git a/tests/shouldIgnoreConsoleError.js b/tests/shouldIgnoreConsoleError.js new file mode 100644 index 00000000..75528267 --- /dev/null +++ b/tests/shouldIgnoreConsoleError.js @@ -0,0 +1,43 @@ +// 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 + } + } + } + // Looks legit + return false +} diff --git a/tests/toWarnDev.js b/tests/toWarnDev.js new file mode 100644 index 00000000..ac5f1b19 --- /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 @babel/no-invalid-this */ +/* eslint-disable prefer-template */ +/* eslint-disable func-names */ +/* eslint-disable complexity */ +const util = require('util') +const jestDiff = require('jest-diff').default +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 + 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'), +} From c43512a9271f5738496a3ed49aed7e3e9dad071c Mon Sep 17 00:00:00 2001 From: Alex Date: 2022年12月12日 19:33:05 +0200 Subject: [PATCH 24/71] GitHub Workflows security hardening (#1162) --- .github/workflows/validate.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ad4adccf..0f99d084 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -12,6 +12,10 @@ on: - 'alpha' - '!all-contributors/**' pull_request: {} +permissions: + actions: write # to cancel/stop running workflows (styfle/cancel-workflow-action) + contents: read # to fetch code (actions/checkout) + jobs: main: continue-on-error: ${{ matrix.react != 'latest' }} @@ -58,6 +62,10 @@ jobs: flags: ${{ matrix.react }} release: + permissions: + actions: write # to cancel/stop running workflows (styfle/cancel-workflow-action) + contents: write # to create release tags (cycjimmy/semantic-release-action) + needs: main runs-on: ubuntu-latest if: From 9b7a1e2bea5bf20ba9728f98eb7c68cdb80b7fdd Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2023年1月31日 05:53:01 +0100 Subject: [PATCH 25/71] feat: Drop support for Node.js 12.x (#1169) BREAKING CHANGE: Minimum supported Node.js version is now 14.x --- .codesandbox/ci.json | 2 +- .github/workflows/validate.yml | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index bf3237bb..d5850328 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -1,5 +1,5 @@ { "installCommand": "install:csb", "sandboxes": ["new", "github/kentcdodds/react-testing-library-examples"], - "node": "12" + "node": "14" } diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 0f99d084..53093e67 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - node: [12, 14, 16, 18] + node: [14, 16, 18] react: [latest, next, experimental] runs-on: ubuntu-latest steps: diff --git a/package.json b/package.json index d2dd6a97..9ee97f1d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "types": "types/index.d.ts", "module": "dist/@testing-library/react.esm.js", "engines": { - "node": ">=12" + "node": ">=14" }, "scripts": { "prebuild": "rimraf dist", From 1934bf224f9d45f3fc91cb722e31d3885aa9c7a0 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2023年2月15日 17:52:48 +0100 Subject: [PATCH 26/71] Bump kcd-scripts to 13.0.0 (#1170) * Bump kcd-scripts to 13.0.0 * Resolve lint issues --- package.json | 8 ++++++-- src/__tests__/cleanup.js | 3 ++- src/__tests__/debug.js | 3 +-- src/__tests__/new-act.js | 8 ++++---- src/__tests__/renderHook.js | 2 +- tests/setup-env.js | 3 +++ tests/toWarnDev.js | 2 +- types/test.tsx | 1 - 8 files changed, 18 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 9ee97f1d..b475390a 100644 --- a/package.json +++ b/package.json @@ -53,8 +53,8 @@ "@testing-library/jest-dom": "^5.11.6", "chalk": "^4.1.2", "dotenv-cli": "^4.0.0", - "jest-diff": "^27.5.1", - "kcd-scripts": "^11.1.0", + "jest-diff": "^29.4.1", + "kcd-scripts": "^13.0.0", "npm-run-all": "^4.1.5", "react": "^18.0.0", "react-dom": "^18.0.0", @@ -67,6 +67,9 @@ }, "eslintConfig": { "extends": "./node_modules/kcd-scripts/eslint.js", + "parserOptions": { + "ecmaVersion": 2022 + }, "globals": { "globalThis": "readonly" }, @@ -76,6 +79,7 @@ "import/no-unassigned-import": "off", "import/named": "off", "testing-library/no-container": "off", + "testing-library/no-debugging-utils": "off", "testing-library/no-dom-import": "off", "testing-library/no-unnecessary-act": "off", "testing-library/prefer-user-event": "off" diff --git a/src/__tests__/cleanup.js b/src/__tests__/cleanup.js index 4517c098..9f17c722 100644 --- a/src/__tests__/cleanup.js +++ b/src/__tests__/cleanup.js @@ -64,7 +64,7 @@ describe('fake timers and missing act warnings', () => { let cancelled = false Promise.resolve().then(() => { microTaskSpy() - // eslint-disable-next-line jest/no-if -- false positive + // eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false positive if (!cancelled) { setDeferredCounter(counter) } @@ -96,6 +96,7 @@ describe('fake timers and missing act warnings', () => { let cancelled = false setTimeout(() => { deferredStateUpdateSpy() + // eslint-disable-next-line jest/no-conditional-in-test -- false-positive if (!cancelled) { setDeferredCounter(counter) } diff --git a/src/__tests__/debug.js b/src/__tests__/debug.js index f3aad595..c6a1d1fe 100644 --- a/src/__tests__/debug.js +++ b/src/__tests__/debug.js @@ -42,7 +42,7 @@ test('allows same arguments as prettyDOM', () => { debug(container, 6, {highlight: false}) expect(console.log).toHaveBeenCalledTimes(1) expect(console.log.mock.calls[0]).toMatchInlineSnapshot(` - Array [ + [
..., ] @@ -52,5 +52,4 @@ test('allows same arguments as prettyDOM', () => { /* eslint no-console: "off", - testing-library/no-debug: "off", */ diff --git a/src/__tests__/new-act.js b/src/__tests__/new-act.js index 4909d4a6..0412a8a3 100644 --- a/src/__tests__/new-act.js +++ b/src/__tests__/new-act.js @@ -47,8 +47,8 @@ test('async act recovers from errors', async () => { } expect(console.error).toHaveBeenCalledTimes(1) expect(console.error.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ call console.error, ], ] @@ -65,8 +65,8 @@ test('async act recovers from sync errors', async () => { } expect(console.error).toHaveBeenCalledTimes(1) expect(console.error.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ call console.error, ], ] diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js index f6b7a343..11b7009a 100644 --- a/src/__tests__/renderHook.js +++ b/src/__tests__/renderHook.js @@ -21,7 +21,7 @@ test('allows rerendering', () => { const [left, setLeft] = React.useState('left') const [right, setRight] = React.useState('right') - // eslint-disable-next-line jest/no-if + // eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false-positive switch (branch) { case 'left': return [left, setLeft] diff --git a/tests/setup-env.js b/tests/setup-env.js index a4ddfa17..c9b976f5 100644 --- a/tests/setup-env.js +++ b/tests/setup-env.js @@ -1,2 +1,5 @@ import '@testing-library/jest-dom/extend-expect' import './failOnUnexpectedConsoleCalls' +import {TextEncoder} from 'util' + +global.TextEncoder = TextEncoder diff --git a/tests/toWarnDev.js b/tests/toWarnDev.js index ac5f1b19..ca58346f 100644 --- a/tests/toWarnDev.js +++ b/tests/toWarnDev.js @@ -24,7 +24,7 @@ SOFTWARE. */ /* eslint-disable no-unsafe-finally */ /* eslint-disable no-negated-condition */ -/* eslint-disable @babel/no-invalid-this */ +/* eslint-disable no-invalid-this */ /* eslint-disable prefer-template */ /* eslint-disable func-names */ /* eslint-disable complexity */ diff --git a/types/test.tsx b/types/test.tsx index 17ba7012..c33f07b6 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -188,7 +188,6 @@ export function testRenderHookProps() { eslint testing-library/prefer-explicit-assert: "off", testing-library/no-wait-for-empty-callback: "off", - testing-library/no-debug: "off", testing-library/prefer-screen-queries: "off" */ From 153a095369cdbe3149a720df9435dc698024c678 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2023年2月16日 23:34:19 +0100 Subject: [PATCH 27/71] chore: Allow semantic-release to post updates in issues (#1176) --- .github/workflows/validate.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 53093e67..5db8153c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -65,6 +65,7 @@ jobs: permissions: actions: write # to cancel/stop running workflows (styfle/cancel-workflow-action) contents: write # to create release tags (cycjimmy/semantic-release-action) + issues: write # to post release that resolves an issue (cycjimmy/semantic-release-action) needs: main runs-on: ubuntu-latest From 6653c239c0acbafd204326c8951cde8206d39898 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2023年2月16日 23:37:50 +0100 Subject: [PATCH 28/71] feat: Bump `@testing-library/dom` to 9.0.0 (#1177) BREAKING CHANGE: See https://github.com/testing-library/dom-testing-library/releases/tag/v9.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b475390a..f9061345 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^8.5.0", + "@testing-library/dom": "^9.0.0", "@types/react-dom": "^18.0.0" }, "devDependencies": { From f78839bf4147a777a823e33a429bcf5de9562f9e Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2023年2月16日 23:46:50 +0100 Subject: [PATCH 29/71] fix: Prevent "missing act" warning for queued microtasks (#1137) * Add intended behavior * fix: Prevent "missing act" warning for in-flight promises * Disable TL lint rules in tests * Implementation without macrotask * Now I member --- package.json | 2 + src/__tests__/end-to-end.js | 211 ++++++++++++++++++++++++++---------- src/pure.js | 30 ++++- 3 files changed, 182 insertions(+), 61 deletions(-) diff --git a/package.json b/package.json index f9061345..70aebdad 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,8 @@ "testing-library/no-debugging-utils": "off", "testing-library/no-dom-import": "off", "testing-library/no-unnecessary-act": "off", + "testing-library/prefer-explicit-assert": "off", + "testing-library/prefer-find-by": "off", "testing-library/prefer-user-event": "off" } }, diff --git a/src/__tests__/end-to-end.js b/src/__tests__/end-to-end.js index cf222aec..005591d3 100644 --- a/src/__tests__/end-to-end.js +++ b/src/__tests__/end-to-end.js @@ -1,73 +1,164 @@ import * as React from 'react' import {render, waitForElementToBeRemoved, screen, waitFor} from '../' -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) - }) - -function ComponentWithLoader() { - const [state, setState] = React.useState({data: undefined, loading: true}) - React.useEffect(() => { - let cancelled = false - fetchAMessage().then(data => { - if (!cancelled) { - setState({data, loading: false}) - } +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() }) - return () => { - cancelled = true + 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: {state.data.returnedMessage}! +
+ ) } - }, []) - if (state.loading) { - return
Loading...
- } + 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/) + }) - return ( -
- Loaded this message: {state.data.returnedMessage}! -
- ) -} + 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 using %s', (label, useTimers) => { - beforeEach(() => { - useTimers() - }) - - afterEach(() => { - jest.useRealTimers() - }) - - test('waitForElementToBeRemoved', async () => { - render() - const loading = () => screen.getByText('Loading...') - await waitForElementToBeRemoved(loading) - expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) - }) - - test('waitFor', async () => { - render() - const message = () => screen.getByText(/Loaded this message:/) - await waitFor(message) - expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) - }) - - test('findBy', async () => { - render() - await expect(screen.findByTestId('message')).resolves.toHaveTextContent( - /Hello World/, - ) - }) -}) +])( + '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() + // 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/, + ) + }) + }, +) diff --git a/src/pure.js b/src/pure.js index 94b3b2bd..845aede1 100644 --- a/src/pure.js +++ b/src/pure.js @@ -12,6 +12,20 @@ import act, { } from './act-compat' import {fireEvent} from './fire-event' +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) @@ -23,7 +37,21 @@ configureDTL({ const previousActEnvironment = getIsReactActEnvironment() setReactActEnvironment(false) try { - return await cb() + 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) } From f6c6d9610da4fe90ec64445391e0ea8bfe39e65d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20De=20Boey?= Date: 2023年4月26日 12:56:53 +0200 Subject: [PATCH 30/71] chore: remove `styfle/cancel-workflow-action` usage (#1204) --- .github/workflows/validate.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5db8153c..60e8fd44 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -11,7 +11,12 @@ on: - 'beta' - 'alpha' - '!all-contributors/**' - pull_request: {} + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: actions: write # to cancel/stop running workflows (styfle/cancel-workflow-action) contents: read # to fetch code (actions/checkout) @@ -28,9 +33,6 @@ jobs: react: [latest, next, experimental] runs-on: ubuntu-latest steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.0 - - name: ⬇️ Checkout repo uses: actions/checkout@v2 @@ -73,9 +75,6 @@ jobs: ${{ github.repository == 'testing-library/react-testing-library' && github.event_name == 'push' }} steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.0 - - name: ⬇️ Checkout repo uses: actions/checkout@v2 From 5dc81dc790b1831707e89cf52b3fecb3c3d294d2 Mon Sep 17 00:00:00 2001 From: Matan Borenkraout Date: Thu, 4 May 2023 15:17:31 +0300 Subject: [PATCH 31/71] chore: rename `next` channel to `canary` (#1207) * chore: add canary to our test matrix * chore: rename next to canary --- .github/workflows/validate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 60e8fd44..f1359d76 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -30,7 +30,7 @@ jobs: fail-fast: false matrix: node: [14, 16, 18] - react: [latest, next, experimental] + react: [latest, canary, experimental] runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo From 6b4180e71286cef86a359435697965e59d408d91 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2023年5月28日 11:03:05 +0200 Subject: [PATCH 32/71] test: Add test for flushing before exiting `waitFor` (#1215) --- src/__tests__/end-to-end.js | 72 ++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/__tests__/end-to-end.js b/src/__tests__/end-to-end.js index 005591d3..f93c23be 100644 --- a/src/__tests__/end-to-end.js +++ b/src/__tests__/end-to-end.js @@ -80,7 +80,7 @@ describe.each([ ['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', + 'it waits for the data to be loaded in many microtask using %s', (label, useTimers) => { beforeEach(() => { useTimers() @@ -162,3 +162,73 @@ describe.each([ }) }, ) + +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/) + }) + + 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/, + ) + }) + }, +) From 6de5f4c29f73e740152de31bbe3ccc6e711aa210 Mon Sep 17 00:00:00 2001 From: Matan Borenkraout Date: 2023年7月26日 21:57:48 +0300 Subject: [PATCH 33/71] docs(readme): remove deprecated link (#1229) Resolves #1228 --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 45324901..6752c3d2 100644 --- a/README.md +++ b/README.md @@ -376,9 +376,6 @@ Some included are: - [`react-router`](https://codesandbox.io/s/github/kentcdodds/react-testing-library-examples/tree/main/?fontsize=14&module=%2Fsrc%2F__tests__%2Freact-router.js&previewwindow=tests) - [`react-context`](https://codesandbox.io/s/github/kentcdodds/react-testing-library-examples/tree/main/?fontsize=14&module=%2Fsrc%2F__tests__%2Freact-context.js&previewwindow=tests) -You can also find React Testing Library examples at -[react-testing-examples.com](https://react-testing-examples.com/jest-rtl/). - ## Hooks If you are interested in testing a custom hook, check out [React Hooks Testing From 5b489166e50d5d53608d98b283e8e936e1cce91d Mon Sep 17 00:00:00 2001 From: Colin Diesh Date: 2023年9月10日 15:58:41 -0400 Subject: [PATCH 34/71] docs: fix readme CI badge (#1237) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6752c3d2..b129c789 100644 --- a/README.md +++ b/README.md @@ -651,7 +651,7 @@ Contributions of any kind welcome! [npm]: https://www.npmjs.com/ [yarn]: https://classic.yarnpkg.com [node]: https://nodejs.org -[build-badge]: https://img.shields.io/github/workflow/status/testing-library/react-testing-library/validate?logo=github&style=flat-square +[build-badge]: https://img.shields.io/github/actions/workflow/status/testing-library/react-testing-library/validate.yml?branch=main&logo=github [build]: https://github.com/testing-library/react-testing-library/actions?query=workflow%3Avalidate [coverage-badge]: https://img.shields.io/codecov/c/github/testing-library/react-testing-library.svg?style=flat-square [coverage]: https://codecov.io/github/testing-library/react-testing-library From c04b8f006c5a683d05c460c8ee1e2248d6f74350 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: 2023年9月10日 23:00:37 +0300 Subject: [PATCH 35/71] docs: add cmdcolin as a contributor for doc (#1238) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 13 +- README.md | 286 ++++++++++++++++++++++---------------------- 2 files changed, 155 insertions(+), 144 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 270dd6a0..16957ca9 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1344,8 +1344,19 @@ "contributions": [ "doc" ] + }, + { + "login": "cmdcolin", + "name": "Colin Diesh", + "avatar_url": "https://avatars.githubusercontent.com/u/6511937?v=4", + "profile": "http://cmdcolin.github.io", + "contributions": [ + "doc" + ] } ], "contributorsPerLine": 7, - "repoHost": "https://github.com" + "repoHost": "https://github.com", + "commitType": "docs", + "commitConvention": "angular" } diff --git a/README.md b/README.md index b129c789..a3731749 100644 --- a/README.md +++ b/README.md @@ -449,189 +449,189 @@ Thanks goes to these people ([emoji key][emojis]): + - - - From d80319f5695d0ddbd93f7d63ca1cb71450663ba6 Mon Sep 17 00:00:00 2001 From: Matan Borenkraout Date: Wed, 8 Nov 2023 08:51:17 +0200 Subject: [PATCH 36/71] feat: add warnings when globals are missing (#1244) * feat: add warnings when globals are missing * revert the istanbul ignore removal * improve error message * Apply suggestions from code review Co-authored-by: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> --------- Co-authored-by: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> --- src/index.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/index.js b/src/index.js index bb0d0270..26028a9a 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,10 @@ if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) { teardown(() => { cleanup() }) + } else { + console.warn( + `The current test runner does not support afterEach/teardown hooks. This means we won't be able to run automatic cleanup and you should be calling cleanup() manually.`, + ) } // No test setup with other test runners available @@ -35,6 +39,10 @@ if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) { afterAll(() => { setReactActEnvironment(previousIsReactActEnvironment) }) + } else { + console.warn( + 'The current test runner does not support beforeAll/afterAll hooks. This means you should be setting IS_REACT_ACT_ENVIRONMENT manually.', + ) } } From fd52a593a7987a14d3cf5c94f112795a1630725d Mon Sep 17 00:00:00 2001 From: Matan Borenkraout Date: 2023年11月17日 16:46:53 +0200 Subject: [PATCH 37/71] fix: log globals warning only once (#1252) Resolves #1249 --- src/index.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index 26028a9a..42cfe59e 100644 --- a/src/index.js +++ b/src/index.js @@ -20,7 +20,8 @@ if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) { teardown(() => { cleanup() }) - } else { + } else if (!process.env.RTL_AFTEREACH_WARNING_LOGGED) { + process.env.RTL_AFTEREACH_WARNING_LOGGED = true console.warn( `The current test runner does not support afterEach/teardown hooks. This means we won't be able to run automatic cleanup and you should be calling cleanup() manually.`, ) @@ -39,7 +40,8 @@ if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) { afterAll(() => { setReactActEnvironment(previousIsReactActEnvironment) }) - } else { + } else if (!process.env.RTL_AFTERALL_WARNING_LOGGED) { + process.env.RTL_AFTERALL_WARNING_LOGGED = true console.warn( 'The current test runner does not support beforeAll/afterAll hooks. This means you should be setting IS_REACT_ACT_ENVIRONMENT manually.', ) From 1c67477443244e52c3ae57db49e1a6e8226e0c0d Mon Sep 17 00:00:00 2001 From: Matan Borenkraout Date: 2023年11月17日 17:46:40 +0200 Subject: [PATCH 38/71] fix: revert missing hooks warnings (#1255) --- src/index.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/index.js b/src/index.js index 42cfe59e..bb0d0270 100644 --- a/src/index.js +++ b/src/index.js @@ -20,11 +20,6 @@ if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) { teardown(() => { cleanup() }) - } else if (!process.env.RTL_AFTEREACH_WARNING_LOGGED) { - process.env.RTL_AFTEREACH_WARNING_LOGGED = true - console.warn( - `The current test runner does not support afterEach/teardown hooks. This means we won't be able to run automatic cleanup and you should be calling cleanup() manually.`, - ) } // No test setup with other test runners available @@ -40,11 +35,6 @@ if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) { afterAll(() => { setReactActEnvironment(previousIsReactActEnvironment) }) - } else if (!process.env.RTL_AFTERALL_WARNING_LOGGED) { - process.env.RTL_AFTERALL_WARNING_LOGGED = true - console.warn( - 'The current test runner does not support beforeAll/afterAll hooks. This means you should be setting IS_REACT_ACT_ENVIRONMENT manually.', - ) } } From 03a301f2488b32c94d6f6f139191f6ff71221944 Mon Sep 17 00:00:00 2001 From: Matan Borenkraout Date: Fri, 8 Dec 2023 18:32:16 +0200 Subject: [PATCH 39/71] chore: update stackblitz url in issue template (#1258) --- .github/ISSUE_TEMPLATE/Bug_Report.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/Bug_Report.md b/.github/ISSUE_TEMPLATE/Bug_Report.md index daefe8c6..c04bef38 100644 --- a/.github/ISSUE_TEMPLATE/Bug_Report.md +++ b/.github/ISSUE_TEMPLATE/Bug_Report.md @@ -60,13 +60,8 @@ https://github.com/testing-library/testing-library-docs ### Reproduction: ### Problem description: From 0880eba4a01c030f942ad93600081bbb86eac959 Mon Sep 17 00:00:00 2001 From: Yusuke Iinuma Date: 2024年1月31日 06:20:01 +0900 Subject: [PATCH 40/71] feat: add `reactStrictMode` option to enable strict mode render (#1241) * feat: add `reactStrictMode` option and override `getConfig` and `configure` functions from DTL * feat: update types for overridden `getConfig` and `configure` functions * test: add tests for checking configure APIs support RTL option and do not degrade * refactor: use a wrapper option for simplicity * refactor: use same function for wrapping UI if needed * feat: enable strict mode render if `reactStrictMode` option is true * test: add tests for checking strict mode works and can be combine with wrapper --------- Co-authored-by: Sebastian Silbermann --- src/__tests__/__snapshots__/render.js.snap | 2 +- src/__tests__/config.js | 66 ++++ src/__tests__/render.js | 332 ++++++++++++--------- src/__tests__/rerender.js | 113 +++++-- src/config.js | 34 +++ src/pure.js | 35 ++- types/index.d.ts | 13 + types/test.tsx | 22 ++ 8 files changed, 443 insertions(+), 174 deletions(-) create mode 100644 src/__tests__/config.js create mode 100644 src/config.js diff --git a/src/__tests__/__snapshots__/render.js.snap b/src/__tests__/__snapshots__/render.js.snap index eaf41443..345cd937 100644 --- a/src/__tests__/__snapshots__/render.js.snap +++ b/src/__tests__/__snapshots__/render.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`supports fragments 1`] = ` +exports[`render API supports fragments 1`] = `
diff --git a/src/__tests__/config.js b/src/__tests__/config.js new file mode 100644 index 00000000..7fdb1e00 --- /dev/null +++ b/src/__tests__/config.js @@ -0,0 +1,66 @@ +import {configure, getConfig} from '../' + +describe('configuration 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) + }) + + describe('DTL options', () => { + test('configure can set by a plain JS object', () => { + const testIdAttribute = 'not-data-testid' + configure({testIdAttribute}) + + expect(getConfig().testIdAttribute).toBe(testIdAttribute) + }) + + test('configure can set by a function', () => { + // setup base option + const baseTestIdAttribute = 'data-testid' + configure({testIdAttribute: baseTestIdAttribute}) + + const modifiedPrefix = 'modified-' + configure(existingConfig => ({ + testIdAttribute: `${modifiedPrefix}${existingConfig.testIdAttribute}`, + })) + + expect(getConfig().testIdAttribute).toBe( + `${modifiedPrefix}${baseTestIdAttribute}`, + ) + }) + }) + + describe('RTL options', () => { + test('configure can set by a plain JS object', () => { + configure({reactStrictMode: true}) + + expect(getConfig().reactStrictMode).toBe(true) + }) + + test('configure can set by a function', () => { + configure(existingConfig => ({ + reactStrictMode: !existingConfig.reactStrictMode, + })) + + expect(getConfig().reactStrictMode).toBe(true) + }) + }) + + test('configure can set DTL and RTL options at once', () => { + const testIdAttribute = 'not-data-testid' + configure({testIdAttribute, reactStrictMode: true}) + + expect(getConfig().testIdAttribute).toBe(testIdAttribute) + expect(getConfig().reactStrictMode).toBe(true) + }) +}) diff --git a/src/__tests__/render.js b/src/__tests__/render.js index 46925f49..39f4bc92 100644 --- a/src/__tests__/render.js +++ b/src/__tests__/render.js @@ -1,84 +1,100 @@ import * as React from 'react' import ReactDOM from 'react-dom' import ReactDOMServer from 'react-dom/server' -import {fireEvent, render, screen} from '../' +import {fireEvent, render, screen, configure} from '../' + +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 {} + }) + }) -test('renders div into document', () => { - const ref = React.createRef() - const {container} = render(
) - expect(container.firstChild).toBe(ref.current) -}) + afterEach(() => { + configure(originalConfig) + }) -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, - ) - } - } - - 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('renders div into document', () => { + const ref = React.createRef() + const {container} = render(
) + expect(container.firstChild).toBe(ref.current) + }) -test('returns baseElement which defaults to document.body', () => { - const {baseElement} = render(
) - expect(baseElement).toBe(document.body) -}) + 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, + ) + } + } -test('supports fragments', () => { - class Test extends React.Component { - render() { + function Greet({greeting, subject}) { return (
- DocumentFragment is pretty cool! + + {greeting} {subject} +
) } - } - const {asFragment} = render() - expect(asFragment()).toMatchSnapshot() -}) + const {unmount} = render() + expect(screen.getByText('Hello World')).toBeInTheDocument() + const portalNode = screen.getByTestId('my-portal') + expect(portalNode).toBeInTheDocument() + unmount() + expect(portalNode).not.toBeInTheDocument() + }) -test('renders options.wrapper around node', () => { - const WrapperComponent = ({children}) => ( -
{children}
- ) + 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 {container} = render(
, { - wrapper: WrapperComponent, + const {asFragment} = render() + expect(asFragment()).toMatchSnapshot() }) - expect(screen.getByTestId('wrapper')).toBeInTheDocument() - expect(container.firstChild).toMatchInlineSnapshot(` + test('renders options.wrapper around node', () => { + const WrapperComponent = ({children}) => ( +
{children}
+ ) + + const {container} = render(
, { + wrapper: WrapperComponent, + }) + + expect(screen.getByTestId('wrapper')).toBeInTheDocument() + expect(container.firstChild).toMatchInlineSnapshot(`
@@ -87,102 +103,138 @@ test('renders options.wrapper around node', () => { />
`) -}) + }) -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) + test('renders options.wrapper around node when reactStrictMode is true', () => { + configure({reactStrictMode: true}) - unmount() + const WrapperComponent = ({children}) => ( +
{children}
+ ) + const {container} = render(
, { + wrapper: WrapperComponent, + }) - expect(spy).toHaveBeenCalledTimes(1) -}) + expect(screen.getByTestId('wrapper')).toBeInTheDocument() + expect(container.firstChild).toMatchInlineSnapshot(` +
+
+
+ `) + }) + + test('renders twice when reactStrictMode is true', () => { + configure({reactStrictMode: true}) -test('can be called multiple times on the same container', () => { - const container = document.createElement('div') + const spy = jest.fn() + function Component() { + spy() + return null + } - const {unmount} = render(, {container}) + render() + expect(spy).toHaveBeenCalledTimes(2) + }) - expect(container).toContainHTML('') + 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(, {container}) + unmount() - expect(container).toContainHTML('') + expect(spy).toHaveBeenCalledTimes(1) + }) - unmount() + test('can be called multiple times on the same container', () => { + const container = document.createElement('div') - expect(container).toBeEmptyDOMElement() -}) + const {unmount} = render(, {container}) -test('hydrate will make the UI interactive', () => { - function App() { - const [clicked, handleClick] = React.useReducer(n => n + 1, 0) + expect(container).toContainHTML('') - return ( - - ) - } - const ui = - const container = document.createElement('div') - document.body.appendChild(container) - container.innerHTML = ReactDOMServer.renderToString(ui) + render(, {container}) - expect(container).toHaveTextContent('clicked:0') + expect(container).toContainHTML('') - render(ui, {container, hydrate: true}) + unmount() - fireEvent.click(container.querySelector('button')) + expect(container).toBeEmptyDOMElement() + }) - expect(container).toHaveTextContent('clicked:1') -}) + test('hydrate will make the UI interactive', () => { + function App() { + const [clicked, handleClick] = React.useReducer(n => n + 1, 0) -test('hydrate can have a wrapper', () => { - const wrapperComponentMountEffect = jest.fn() - function WrapperComponent({children}) { - React.useEffect(() => { - wrapperComponentMountEffect() - }) + return ( + + ) + } + const ui = + const container = document.createElement('div') + document.body.appendChild(container) + container.innerHTML = ReactDOMServer.renderToString(ui) - return children - } - 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, wrapper: WrapperComponent}) + render(ui, {container, hydrate: true}) - expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(1) -}) + fireEvent.click(container.querySelector('button')) -test('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}, - ) -}) + 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) -test('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}, - ) + render(ui, {container, hydrate: true, wrapper: WrapperComponent}) + + expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(1) + }) + + test('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}, + ) + }) + + test('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}, + ) + }) }) diff --git a/src/__tests__/rerender.js b/src/__tests__/rerender.js index be3c259c..6c48c4dd 100644 --- a/src/__tests__/rerender.js +++ b/src/__tests__/rerender.js @@ -1,31 +1,98 @@ import * as React from 'react' -import {render} from '../' - -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 {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 initialInputElement = document.createElement('input') - const container = document.createElement('div') - container.appendChild(initialInputElement) - document.body.appendChild(container) + const {rerender} = render( null} />, { + container, + hydrate: true, + }) - const firstValue = 'hello' - initialInputElement.value = firstValue + expect(initialInputElement).toHaveValue(firstValue) - const {rerender} = render( null} />, { - container, - hydrate: true, + const secondValue = 'goodbye' + rerender( null} />) + expect(initialInputElement).toHaveValue(secondValue) }) - expect(initialInputElement).toHaveValue(firstValue) + test('re-renders options.wrapper around node when reactStrictMode is true', () => { + configure({reactStrictMode: true}) - const secondValue = 'goodbye' - rerender( null} />) - expect(initialInputElement).toHaveValue(secondValue) + const WrapperComponent = ({children}) => ( +
{children}
+ ) + const Greeting = props =>
{props.message}
+ const {container, rerender} = render(, { + wrapper: WrapperComponent, + }) + + expect(container.firstChild).toMatchInlineSnapshot(` +
+
+ hi +
+
+ `) + + rerender() + expect(container.firstChild).toMatchInlineSnapshot(` +
+
+ hey +
+
+ `) + }) + + 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/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/pure.js b/src/pure.js index 845aede1..3939a11a 100644 --- a/src/pure.js +++ b/src/pure.js @@ -11,6 +11,7 @@ import act, { setReactActEnvironment, } from './act-compat' import {fireEvent} from './fire-event' +import {getConfig, configure} from './config' function jestFakeTimersAreEnabled() { /* istanbul ignore else */ @@ -76,6 +77,18 @@ const mountedContainers = new Set() */ const mountedRootEntries = [] +function strictModeIfNeeded(innerElement) { + return 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, ui, wrapper: WrapperComponent}, @@ -85,7 +98,7 @@ function createConcurrentRoot( act(() => { root = ReactDOMClient.hydrateRoot( container, - WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui, + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), ) }) } else { @@ -129,16 +142,17 @@ function renderRoot( ui, {baseElement, container, hydrate, queries, root, wrapper: WrapperComponent}, ) { - const wrapUiIfNeeded = innerElement => - WrapperComponent - ? React.createElement(WrapperComponent, null, innerElement) - : innerElement - act(() => { if (hydrate) { - root.hydrate(wrapUiIfNeeded(ui), container) + root.hydrate( + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + container, + ) } else { - root.render(wrapUiIfNeeded(ui), container) + root.render( + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + container, + ) } }) @@ -157,10 +171,11 @@ function renderRoot( }) }, rerender: rerenderUi => { - renderRoot(wrapUiIfNeeded(rerenderUi), { + renderRoot(rerenderUi, { container, baseElement, root, + wrapper: WrapperComponent, }) // 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 @@ -276,6 +291,6 @@ function renderHook(renderCallback, options = {}) { // just re-export everything from dom-testing-library export * from '@testing-library/dom' -export {render, renderHook, cleanup, act, fireEvent} +export {render, renderHook, cleanup, act, fireEvent, getConfig, configure} /* eslint func-name-matching:0 */ diff --git a/types/index.d.ts b/types/index.d.ts index 558edfad..1f1135c5 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -5,12 +5,25 @@ import { Queries, BoundFunction, prettyFormat, + Config as ConfigDTL, } from '@testing-library/dom' import {Renderer} from 'react-dom' import {act as reactAct} from 'react-dom/test-utils' 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 Element | DocumentFragment = HTMLElement, diff --git a/types/test.tsx b/types/test.tsx index c33f07b6..3486a9a8 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -62,6 +62,28 @@ export function testFireEvent() { 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( From 4509fb68aaf42f3b750e57a3e2d073a498fc59db Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: 2024年1月30日 22:21:05 +0100 Subject: [PATCH 41/71] docs: add yinm as a contributor for code (#1269) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 16957ca9..de2ba851 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1353,6 +1353,15 @@ "contributions": [ "doc" ] + }, + { + "login": "yinm", + "name": "Yusuke Iinuma", + "avatar_url": "https://avatars.githubusercontent.com/u/13295106?v=4", + "profile": "http://yinm.info", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index a3731749..1ffc881d 100644 --- a/README.md +++ b/README.md @@ -630,6 +630,7 @@ Thanks goes to these people ([emoji key][emojis]):
From 55e79c290d3ec8a8eb3d39539e2c05bf35dff3d9 Mon Sep 17 00:00:00 2001 From: Jeff Way Date: Thu, 1 Feb 2024 11:49:10 -0800 Subject: [PATCH 42/71] fix: Update types to support all possible react component return values (#1272) * Update types to support all possible react component return values * Update type test types --- types/index.d.ts | 8 ++++---- types/test.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index 1f1135c5..5db1d201 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -39,7 +39,7 @@ export type RenderResult< maxLength?: number, options?: prettyFormat.OptionsReceived, ) => void - rerender: (ui: React.ReactElement) => void + rerender: (ui: React.ReactNode) => void unmount: () => void asFragment: () => DocumentFragment } & {[P in keyof Q]: BoundFunction} @@ -90,7 +90,7 @@ export interface RenderOptions< * * @see https://testing-library.com/docs/react-testing-library/api/#wrapper */ - wrapper?: React.JSXElementConstructor<{children: React.ReactElement}> + wrapper?: React.JSXElementConstructor<{children: React.ReactNode}> } type Omit = Pick> @@ -103,11 +103,11 @@ export function render< Container extends Element | DocumentFragment = HTMLElement, BaseElement extends Element | DocumentFragment = Container,>( - ui: React.ReactElement, + ui: React.ReactNode, options: RenderOptions, ): RenderResult export function render( - ui: React.ReactElement, + ui: React.ReactNode, options?: Omit, ): RenderResult diff --git a/types/test.tsx b/types/test.tsx index 3486a9a8..6ff899de 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -123,10 +123,10 @@ export function testQueries() { } export function wrappedRender( - ui: React.ReactElement, + ui: React.ReactNode, options?: pure.RenderOptions, ) { - const Wrapper = ({children}: {children: React.ReactElement}): JSX.Element => { + const Wrapper = ({children}: {children: React.ReactNode}): JSX.Element => { return
{children}
} @@ -134,7 +134,7 @@ export function wrappedRender( } export function wrappedRenderB( - ui: React.ReactElement, + ui: React.ReactNode, options?: pure.RenderOptions, ) { const Wrapper: React.FunctionComponent<{children?: React.ReactNode}> = ({ @@ -147,7 +147,7 @@ export function wrappedRenderB( } export function wrappedRenderC( - ui: React.ReactElement, + ui: React.ReactNode, options?: pure.RenderOptions, ) { interface AppWrapperProps { From edb6344d578a8c224daf0cd6e2984f36cc6e8d86 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 1 Feb 2024 20:49:43 +0100 Subject: [PATCH 43/71] docs: add trappar as a contributor for code (#1273) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index de2ba851..c3b86064 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1362,6 +1362,15 @@ "contributions": [ "code" ] + }, + { + "login": "trappar", + "name": "Jeff Way", + "avatar_url": "https://avatars.githubusercontent.com/u/525726?v=4", + "profile": "https://github.com/trappar", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 1ffc881d..85613475 100644 --- a/README.md +++ b/README.md @@ -631,6 +631,7 @@ Thanks goes to these people ([emoji key][emojis]): From 7e42f4e84115510f560be36b5febb3d9f20e8899 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2024年3月19日 23:24:53 +0100 Subject: [PATCH 44/71] chore: Fix tests (#1288) --- jest.config.js | 16 ++++++++++++++++ package.json | 2 +- src/__tests__/render.js | 31 +++++++++++++++++++++++-------- src/__tests__/renderHook.js | 19 +++++++++++++++---- tests/toWarnDev.js | 2 +- 5 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 jest.config.js diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..30654cdb --- /dev/null +++ b/jest.config.js @@ -0,0 +1,16 @@ +const {jest: jestConfig} = require('kcd-scripts/config') + +module.exports = Object.assign(jestConfig, { + coverageThreshold: { + ...jestConfig.coverageThreshold, + // Full coverage across the build matrix (React versions) but not in a single job + // Ful coverage is checked via codecov + './src/pure.js': { + // minimum coverage of jobs using different React versions + branches: 97, + functions: 88, + lines: 94, + statements: 94, + }, + }, +}) diff --git a/package.json b/package.json index 70aebdad..0b7f83d8 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@testing-library/jest-dom": "^5.11.6", "chalk": "^4.1.2", "dotenv-cli": "^4.0.0", - "jest-diff": "^29.4.1", + "jest-diff": "^29.7.0", "kcd-scripts": "^13.0.0", "npm-run-all": "^4.1.5", "react": "^18.0.0", diff --git a/src/__tests__/render.js b/src/__tests__/render.js index 39f4bc92..b5222d81 100644 --- a/src/__tests__/render.js +++ b/src/__tests__/render.js @@ -3,6 +3,13 @@ import ReactDOM from 'react-dom' import ReactDOMServer from 'react-dom/server' import {fireEvent, render, screen, configure} from '../' +// Needs to be changed to 19.0.0 once alpha started. +const isReactExperimental = React.version.startsWith('18.3.0-experimental') +const isReactCanary = React.version.startsWith('18.3.0') + +// Needs to be changed to isReactExperimental || isReactCanary once alpha started. +const testGateReact18 = isReactExperimental ? test.skip : test + describe('render API', () => { let originalConfig beforeEach(() => { @@ -213,27 +220,35 @@ describe('render API', () => { expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(1) }) - test('legacyRoot uses legacy ReactDOM.render', () => { + 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", - ], + isReactCanary + ? [ + "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://react.dev/link/switch-to-createroot", + ] + : [ + "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}, ) }) - test('legacyRoot uses legacy ReactDOM.hydrate', () => { + 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", - ], + isReactCanary + ? [ + "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://react.dev/link/switch-to-createroot", + ] + : [ + "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}, ) }) diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js index 11b7009a..34259b44 100644 --- a/src/__tests__/renderHook.js +++ b/src/__tests__/renderHook.js @@ -1,6 +1,13 @@ import React from 'react' import {renderHook} from '../pure' +// Needs to be changed to 19.0.0 once alpha started. +const isReactExperimental = React.version.startsWith('18.3.0-experimental') +const isReactCanary = React.version.startsWith('18.3.0') + +// Needs to be changed to isReactExperimental || isReactCanary once alpha started. +const testGateReact18 = isReactExperimental ? test.skip : test + test('gives committed result', () => { const {result} = renderHook(() => { const [state, setState] = React.useState(1) @@ -61,7 +68,7 @@ test('allows wrapper components', async () => { expect(result.current).toEqual('provided') }) -test('legacyRoot uses legacy ReactDOM.render', () => { +testGateReact18('legacyRoot uses legacy ReactDOM.render', () => { const Context = React.createContext('default') function Wrapper({children}) { return {children} @@ -78,9 +85,13 @@ test('legacyRoot uses legacy ReactDOM.render', () => { }, ).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", - ], + isReactCanary + ? [ + "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://react.dev/link/switch-to-createroot", + ] + : [ + "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') diff --git a/tests/toWarnDev.js b/tests/toWarnDev.js index ca58346f..2aae39f0 100644 --- a/tests/toWarnDev.js +++ b/tests/toWarnDev.js @@ -29,7 +29,7 @@ SOFTWARE. /* eslint-disable func-names */ /* eslint-disable complexity */ const util = require('util') -const jestDiff = require('jest-diff').default +const jestDiff = require('jest-diff').diff const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError') function normalizeCodeLocInfo(str) { From 3da62fd9741ca74bcd0d2bc668ba76a2d8f3751f Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2024年3月19日 23:54:12 +0100 Subject: [PATCH 45/71] fix: Remove unused types (#1287) --- types/index.d.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/types/index.d.ts b/types/index.d.ts index 5db1d201..49a1b7ff 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -7,7 +7,6 @@ import { prettyFormat, Config as ConfigDTL, } from '@testing-library/dom' -import {Renderer} from 'react-dom' import {act as reactAct} from 'react-dom/test-utils' export * from '@testing-library/dom' From cf045b4743afeb651b14bd7bb0d04b955768c010 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2024年3月20日 00:08:40 +0100 Subject: [PATCH 46/71] chore: Update Codecov configuration to latest (#1289) --- .github/workflows/validate.yml | 12 +++++++----- codecov.yml | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 codecov.yml diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index f1359d76..c2e20a61 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -34,10 +34,10 @@ jobs: runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} @@ -59,9 +59,11 @@ jobs: run: npm run validate - name: ⬆️ Upload coverage report - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 with: + fail_ci_if_error: true flags: ${{ matrix.react }} + token: ${{ secrets.CODECOV_TOKEN }} release: permissions: @@ -76,10 +78,10 @@ jobs: github.event_name == 'push' }} steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: 14 diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..472fcd83 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,20 @@ +coverage: + status: + project: + default: + # basic + target: 100% + threshold: 0% + flags: + - canary + - experimental + - latest + branches: + - main + - 12.x + if_ci_failed: success + if_not_found: failure + informational: false + only_pulls: false +github_checks: + annotations: true From 4e10ba3a788f6f66287dab5bb4a09f658664ec50 Mon Sep 17 00:00:00 2001 From: Matan Borenkraout Date: 2024年3月21日 11:24:52 +0200 Subject: [PATCH 47/71] chore: change canary version to specific prefix (#1290) --- src/__tests__/render.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/render.js b/src/__tests__/render.js index b5222d81..175174ca 100644 --- a/src/__tests__/render.js +++ b/src/__tests__/render.js @@ -5,7 +5,7 @@ import {fireEvent, render, screen, configure} from '../' // Needs to be changed to 19.0.0 once alpha started. const isReactExperimental = React.version.startsWith('18.3.0-experimental') -const isReactCanary = React.version.startsWith('18.3.0') +const isReactCanary = React.version.startsWith('18.3.0-canary') // Needs to be changed to isReactExperimental || isReactCanary once alpha started. const testGateReact18 = isReactExperimental ? test.skip : test From 9c4a46d5b9923c21c936d206614a8febcc939fc2 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Mon, 8 Apr 2024 16:07:24 +0200 Subject: [PATCH 48/71] feat: Add support for React 19 Canary (#1294) --- .github/workflows/validate.yml | 2 +- jest.config.js | 15 ++++++----- src/__tests__/new-act.js | 13 +++++---- src/__tests__/render.js | 48 ++++++++++++++++++++-------------- src/__tests__/renderHook.js | 39 ++++++++++++++++++--------- src/act-compat.js | 7 ++--- src/pure.js | 17 ++++++++++++ types/index.d.ts | 1 + 8 files changed, 96 insertions(+), 46 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c2e20a61..4a20e2ab 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -30,7 +30,7 @@ jobs: fail-fast: false matrix: node: [14, 16, 18] - react: [latest, canary, experimental] + react: ['18.x', latest, canary, experimental] runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo diff --git a/jest.config.js b/jest.config.js index 30654cdb..860358cd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,14 +3,17 @@ const {jest: jestConfig} = require('kcd-scripts/config') module.exports = Object.assign(jestConfig, { coverageThreshold: { ...jestConfig.coverageThreshold, - // Full coverage across the build matrix (React versions) but not in a single job + // Full coverage across the build matrix (React 18, 19) but not in a single job // Ful coverage is checked via codecov - './src/pure.js': { - // minimum coverage of jobs using different React versions - branches: 97, + './src/act-compat': { + branches: 90, + }, + './src/pure': { + // minimum coverage of jobs using React 18 and 19 + branches: 95, functions: 88, - lines: 94, - statements: 94, + lines: 92, + statements: 92, }, }, }) diff --git a/src/__tests__/new-act.js b/src/__tests__/new-act.js index 0412a8a3..0464ad24 100644 --- a/src/__tests__/new-act.js +++ b/src/__tests__/new-act.js @@ -1,10 +1,13 @@ let asyncAct -jest.mock('react-dom/test-utils', () => ({ - act: cb => { - return cb() - }, -})) +jest.mock('react', () => { + return { + ...jest.requireActual('react'), + act: cb => { + return cb() + }, + } +}) beforeEach(() => { jest.resetModules() diff --git a/src/__tests__/render.js b/src/__tests__/render.js index 175174ca..16f7dbe2 100644 --- a/src/__tests__/render.js +++ b/src/__tests__/render.js @@ -3,12 +3,11 @@ import ReactDOM from 'react-dom' import ReactDOMServer from 'react-dom/server' import {fireEvent, render, screen, configure} from '../' -// Needs to be changed to 19.0.0 once alpha started. -const isReactExperimental = React.version.startsWith('18.3.0-experimental') -const isReactCanary = React.version.startsWith('18.3.0-canary') +const isReact18 = React.version.startsWith('18.') +const isReact19 = React.version.startsWith('19.') -// Needs to be changed to isReactExperimental || isReactCanary once alpha started. -const testGateReact18 = isReactExperimental ? test.skip : test +const testGateReact18 = isReact18 ? test : test.skip +const testGateReact19 = isReact19 ? test : test.skip describe('render API', () => { let originalConfig @@ -224,17 +223,21 @@ describe('render API', () => { expect(() => { render(
, {legacyRoot: true}) }).toErrorDev( - isReactCanary - ? [ - "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://react.dev/link/switch-to-createroot", - ] - : [ - "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", - ], + [ + "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. Please use React 18 instead.`, + ) + }) + testGateReact18('legacyRoot uses legacy ReactDOM.hydrate', () => { const ui =
const container = document.createElement('div') @@ -242,14 +245,21 @@ describe('render API', () => { expect(() => { render(ui, {container, hydrate: true, legacyRoot: true}) }).toErrorDev( - isReactCanary - ? [ - "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://react.dev/link/switch-to-createroot", - ] - : [ - "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", - ], + [ + "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. Please use React 18 instead.`, + ) + }) }) diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js index 34259b44..c7c8b066 100644 --- a/src/__tests__/renderHook.js +++ b/src/__tests__/renderHook.js @@ -1,12 +1,11 @@ import React from 'react' import {renderHook} from '../pure' -// Needs to be changed to 19.0.0 once alpha started. -const isReactExperimental = React.version.startsWith('18.3.0-experimental') -const isReactCanary = React.version.startsWith('18.3.0') +const isReact18 = React.version.startsWith('18.') +const isReact19 = React.version.startsWith('19.') -// Needs to be changed to isReactExperimental || isReactCanary once alpha started. -const testGateReact18 = isReactExperimental ? test.skip : test +const testGateReact18 = isReact18 ? test : test.skip +const testGateReact19 = isReact19 ? test : test.skip test('gives committed result', () => { const {result} = renderHook(() => { @@ -85,14 +84,30 @@ testGateReact18('legacyRoot uses legacy ReactDOM.render', () => { }, ).result }).toErrorDev( - isReactCanary - ? [ - "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://react.dev/link/switch-to-createroot", - ] - : [ - "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", - ], + [ + "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. Please use React 18 instead.`, + ) +}) diff --git a/src/act-compat.js b/src/act-compat.js index 86518196..5877755c 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -1,6 +1,7 @@ -import * as testUtils from 'react-dom/test-utils' +import * as React from 'react' +import * as DeprecatedReactTestUtils from 'react-dom/test-utils' -const domAct = testUtils.act +const reactAct = React.act ?? DeprecatedReactTestUtils.act function getGlobalThis() { /* istanbul ignore else */ @@ -78,7 +79,7 @@ function withGlobalActEnvironment(actImplementation) { } } -const act = withGlobalActEnvironment(domAct) +const act = withGlobalActEnvironment(reactAct) export default act export { diff --git a/src/pure.js b/src/pure.js index 3939a11a..38ec519f 100644 --- a/src/pure.js +++ b/src/pure.js @@ -207,6 +207,14 @@ function render( wrapper, } = {}, ) { + if (legacyRoot && typeof ReactDOM.render !== 'function') { + const error = new Error( + '`legacyRoot: true` is not supported in this version of React. Please use React 18 instead.', + ) + 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 @@ -263,6 +271,15 @@ function cleanup() { 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. Please use React 18 instead.', + ) + Error.captureStackTrace(error, renderHook) + throw error + } + const result = React.createRef() function TestComponent({renderCallbackProps}) { diff --git a/types/index.d.ts b/types/index.d.ts index 49a1b7ff..e7cf02bc 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -73,6 +73,7 @@ export interface RenderOptions< */ hydrate?: boolean /** + * 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. */ From 787cb85f8baa3d2e2a9916b7dad12c0a76d787a4 Mon Sep 17 00:00:00 2001 From: Matan Borenkraout Date: 2024年4月10日 18:18:28 +0300 Subject: [PATCH 49/71] Release: 15.0.0 (#1295) BREAKING CHANGE: Minimum supported Node.js version is 18.0 BREAKING CHANGE: New version of `@testing-library/dom` changes various roles. Check out the changed tests in https://github.com/testing-library/dom-testing-library/commit/2c570553d8f31b008451398152a9bd30bce362b3 to get an overview about what changed. --- .codesandbox/ci.json | 2 +- .github/workflows/validate.yml | 2 +- package.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index d5850328..002bafb4 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -1,5 +1,5 @@ { "installCommand": "install:csb", "sandboxes": ["new", "github/kentcdodds/react-testing-library-examples"], - "node": "14" + "node": "18" } diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 4a20e2ab..aa4eeed7 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -29,7 +29,7 @@ jobs: strategy: fail-fast: false matrix: - node: [14, 16, 18] + node: [18, 20] react: ['18.x', latest, canary, experimental] runs-on: ubuntu-latest steps: diff --git a/package.json b/package.json index 0b7f83d8..9483256a 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "types": "types/index.d.ts", "module": "dist/@testing-library/react.esm.js", "engines": { - "node": ">=14" + "node": ">=18" }, "scripts": { "prebuild": "rimraf dist", @@ -46,7 +46,7 @@ "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^9.0.0", + "@testing-library/dom": "^10.0.0", "@types/react-dom": "^18.0.0" }, "devDependencies": { From 1645d21950ab8e3c6740b7e51b8a179a4c975c24 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2024年4月11日 19:03:17 +0200 Subject: [PATCH 50/71] fix: Stop using nullish coalescing (#1299) --- src/act-compat.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/act-compat.js b/src/act-compat.js index 5877755c..6eaec0fb 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -1,7 +1,8 @@ import * as React from 'react' import * as DeprecatedReactTestUtils from 'react-dom/test-utils' -const reactAct = React.act ?? DeprecatedReactTestUtils.act +const reactAct = + typeof React.act === 'function' ? React.act : DeprecatedReactTestUtils.act function getGlobalThis() { /* istanbul ignore else */ From c63b873072d62c858959c2a19e68f8e2cc0b11be Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2024年4月13日 11:21:10 +0200 Subject: [PATCH 51/71] fix: Improve `legacyRoot` error message (#1301) --- src/__tests__/render.js | 4 ++-- src/__tests__/renderHook.js | 2 +- src/pure.js | 8 ++++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/__tests__/render.js b/src/__tests__/render.js index 16f7dbe2..f00410b4 100644 --- a/src/__tests__/render.js +++ b/src/__tests__/render.js @@ -234,7 +234,7 @@ describe('render API', () => { expect(() => { render(
, {legacyRoot: true}) }).toThrowErrorMatchingInlineSnapshot( - `\`legacyRoot: true\` is not supported in this version of React. Please use React 18 instead.`, + `\`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.`, ) }) @@ -259,7 +259,7 @@ describe('render API', () => { expect(() => { render(ui, {container, hydrate: true, legacyRoot: true}) }).toThrowErrorMatchingInlineSnapshot( - `\`legacyRoot: true\` is not supported in this version of React. Please use React 18 instead.`, + `\`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.`, ) }) }) diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js index c7c8b066..fe7551a2 100644 --- a/src/__tests__/renderHook.js +++ b/src/__tests__/renderHook.js @@ -108,6 +108,6 @@ testGateReact19('legacyRoot throws', () => { }, ).result }).toThrowErrorMatchingInlineSnapshot( - `\`legacyRoot: true\` is not supported in this version of React. Please use React 18 instead.`, + `\`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.`, ) }) diff --git a/src/pure.js b/src/pure.js index 38ec519f..f546af98 100644 --- a/src/pure.js +++ b/src/pure.js @@ -209,7 +209,9 @@ function render( ) { if (legacyRoot && typeof ReactDOM.render !== 'function') { const error = new Error( - '`legacyRoot: true` is not supported in this version of React. Please use React 18 instead.', + '`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 @@ -274,7 +276,9 @@ function renderHook(renderCallback, options = {}) { if (renderOptions.legacyRoot && typeof ReactDOM.render !== 'function') { const error = new Error( - '`legacyRoot: true` is not supported in this version of React. Please use React 18 instead.', + '`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 From 067d0c6d2e87092f6ecaa8c9fcf505e4576055cf Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2024年4月23日 12:21:18 +0200 Subject: [PATCH 52/71] fix: Don't raise TypeScript errors when hydating `document` (#1304) --- types/index.d.ts | 60 ++++++++++++++++++++++++++++++++++++++++++------ types/test.tsx | 11 +++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index e7cf02bc..566e3d05 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,5 +1,5 @@ // TypeScript Version: 3.8 - +import * as ReactDOMClient from 'react-dom/client' import { queries, Queries, @@ -43,10 +43,10 @@ export type RenderResult< asFragment: () => DocumentFragment } & {[P in keyof Q]: BoundFunction} -export interface RenderOptions< - Q extends Queries = typeof queries, - Container extends Element | DocumentFragment = HTMLElement, - BaseElement extends Element | DocumentFragment = Container, +export interface BaseRenderOptions< + Q extends Queries, + Container extends RendererableContainer | HydrateableContainer, + BaseElement extends Element | DocumentFragment,> { /** * 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, @@ -93,6 +93,44 @@ export interface RenderOptions< wrapper?: React.JSXElementConstructor<{children: React.ReactNode}> } +type RendererableContainer = ReactDOMClient.Container +type HydrateableContainer = Parameters[0] +export interface ClientRenderOptions< + Q extends Queries, + Container extends Element | DocumentFragment, + BaseElement extends Element | DocumentFragment = 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 +} + +export interface HydrateOptions< + Q extends Queries, + Container extends Element | DocumentFragment, + BaseElement extends Element | DocumentFragment = 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 type RenderOptions< + Q extends Queries = typeof queries, + Container extends RendererableContainer | HydrateableContainer = HTMLElement, + BaseElement extends Element | DocumentFragment = Container, +> = + | ClientRenderOptions + | HydrateOptions + type Omit = Pick> /** @@ -100,11 +138,19 @@ type Omit = Pick> */ export function render< Q extends Queries = typeof queries, - Container extends Element | DocumentFragment = HTMLElement, + Container extends RendererableContainer = HTMLElement, + BaseElement extends Element | DocumentFragment = Container, +>( + ui: React.ReactNode, + options: ClientRenderOptions, +): RenderResult +export function render< + Q extends Queries = typeof queries, + Container extends HydrateableContainer = HTMLElement, BaseElement extends Element | DocumentFragment = Container,>( ui: React.ReactNode, - options: RenderOptions, + options: HydrateOptions, ): RenderResult export function render( ui: React.ReactNode, diff --git a/types/test.tsx b/types/test.tsx index 6ff899de..da0bda06 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -206,6 +206,17 @@ export function testRenderHookProps() { unmount() } +export function testContainer() { + render('a', {container: document.createElement('div')}) + render('a', {container: document.createDocumentFragment()}) + // @ts-expect-error Only allowed in React 19 + render('a', {container: document}) + render('a', {container: document.createElement('div'), hydrate: true}) + // @ts-expect-error Only allowed for createRoot + render('a', {container: document.createDocumentFragment(), hydrate: true}) + render('a', {container: document, hydrate: true}) +} + /* eslint testing-library/prefer-explicit-assert: "off", From 48282c2f35fb7338834b40983c12b889af35f5d1 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: 2024年4月23日 17:32:08 +0200 Subject: [PATCH 53/71] fix: Ensure `renderHook` options extend options for `render` (#1308) --- types/index.d.ts | 63 ++++++++++++++++++++++++++++++++++++++++++------ types/test.tsx | 19 +++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index 566e3d05..78302693 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -179,12 +179,12 @@ export interface RenderHookResult { unmount: () => void } -export interface RenderHookOptions< +export interface BaseRenderHookOptions< Props, - Q extends Queries = typeof queries, - Container extends Element | DocumentFragment = HTMLElement, - BaseElement extends Element | DocumentFragment = Container, -> extends RenderOptions { + Q extends Queries, + Container extends RendererableContainer | HydrateableContainer, + BaseElement extends Element | DocumentFragment, +> 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. @@ -192,6 +192,45 @@ export interface RenderHookOptions< initialProps?: Props } +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 +} + +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 type RenderHookOptions< + Props, + Q extends Queries = typeof queries, + Container extends Element | DocumentFragment = HTMLElement, + BaseElement extends Element | DocumentFragment = Container, +> = + | ClientRenderHookOptions + | HydrateHookOptions + /** * Allows you to render a hook within a test React component without having to * create that component yourself. @@ -200,11 +239,21 @@ export function renderHook< Result, Props, Q extends Queries = typeof queries, - Container extends Element | DocumentFragment = HTMLElement, + Container extends RendererableContainer = HTMLElement, + BaseElement extends Element | DocumentFragment = Container, +>( + render: (initialProps: Props) => Result, + options?: ClientRenderHookOptions, +): RenderHookResult +export function renderHook< + Result, + Props, + Q extends Queries = typeof queries, + Container extends HydrateableContainer = HTMLElement, BaseElement extends Element | DocumentFragment = Container,>( render: (initialProps: Props) => Result, - options?: RenderHookOptions, + options?: HydrateHookOptions, ): RenderHookResult /** diff --git a/types/test.tsx b/types/test.tsx index da0bda06..734d70e7 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -45,6 +45,8 @@ export function testRenderOptions() { const options = {container} const {container: returnedContainer} = render(