├── .eslintignore ├── .eslintrc.cjs ├── .git-blame-ignore-revs ├── .github └── workflows │ ├── ci.yml │ ├── lint-pr.yml │ └── pkg-pr-new-publish.yml ├── .gitignore ├── .prettierrc.cjs ├── .yarn └── releases │ └── yarn-4.5.0.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── jest.config.cjs ├── other └── apollo-wordmark.svg ├── package.json ├── src ├── __testHelpers__ │ ├── getCleanedErrorMessage.ts │ └── useShim.js ├── __tests__ │ └── renderHookToSnapshotStream.test.tsx ├── assertable.ts ├── disableActEnvironment.ts ├── expect │ ├── __tests__ │ │ └── renderStreamMatchers.test.tsx │ ├── index.ts │ └── renderStreamMatchers.ts ├── index.ts ├── pure.ts ├── renderHookToSnapshotStream.tsx ├── renderStream │ ├── Render.tsx │ ├── __tests__ │ │ ├── createRenderStream.test.tsx │ │ └── useTrackRenders.test.tsx │ ├── context.tsx │ ├── createRenderStream.tsx │ ├── syncQueries.ts │ └── useTrackRenders.ts └── renderWithoutAct.tsx ├── tests ├── polyfill.js └── setup-env.js ├── tsconfig.json ├── tsup.config.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | tsup.config.ts 2 | dist/ -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'kentcdodds', 3 | rules: { 4 | '@typescript-eslint/no-explicit-any': 'off', 5 | '@typescript-eslint/no-empty-interface': 'off', 6 | '@typescript-eslint/no-non-null-assertion': 'off', 7 | '@typescript-eslint/unified-signatures': 'off', 8 | '@typescript-eslint/no-unused-vars': [ 9 | 'error', 10 | { 11 | args: 'after-used', 12 | argsIgnorePattern: '^_', 13 | ignoreRestSiblings: true, 14 | varsIgnorePattern: '^_', 15 | }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # format with kcd-scripts 2 | 325d59e3cd0bf4c7ab738381e1bb49aef3bc7363 -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - '**' 8 | tags: 9 | - '!**' 10 | 11 | jobs: 12 | ci: 13 | name: CI 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repo 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 22.x 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 22.x 23 | cache: yarn 24 | 25 | - name: Install dependencies 26 | run: yarn install 27 | 28 | - name: Run tests, lint and verify package integrity 29 | run: yarn run validate 30 | 31 | release: 32 | permissions: 33 | id-token: write # required for provenance 34 | actions: write # to cancel/stop running workflows (styfle/cancel-workflow-action) 35 | contents: write # to create release tags (cycjimmy/semantic-release-action) 36 | issues: write # to post release that resolves an issue (cycjimmy/semantic-release-action) 37 | 38 | needs: ci 39 | runs-on: ubuntu-latest 40 | if: 41 | ${{ github.repository == 42 | 'testing-library/react-render-stream-testing-library' && github.event_name 43 | == 'push' }} 44 | steps: 45 | - name: Checkout repo 46 | uses: actions/checkout@v4 47 | 48 | - name: Setup Node.js 22.x 49 | uses: actions/setup-node@v4 50 | with: 51 | node-version: 22.x 52 | cache: yarn 53 | 54 | - name: Install dependencies 55 | run: yarn install 56 | 57 | - name: 🏗 Run build script 58 | run: yarn build 59 | 60 | - name: 🚀 Release 61 | uses: cycjimmy/semantic-release-action@v4 62 | with: 63 | branches: | 64 | [ 65 | '+([0-9])?(.{+([0-9]),x}).x', 66 | 'main', 67 | 'next', 68 | 'next-major', 69 | {name: 'beta', prerelease: true}, 70 | {name: 'alpha', prerelease: true} 71 | ] 72 | env: 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 75 | -------------------------------------------------------------------------------- /.github/workflows/lint-pr.yml: -------------------------------------------------------------------------------- 1 | name: 'Lint PR' 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | - reopened 10 | 11 | permissions: 12 | pull-requests: read 13 | 14 | jobs: 15 | main: 16 | name: Validate PR title 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: amannn/action-semantic-pull-request@v5 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | with: 23 | requireScope: false 24 | # If the PR only contains a single commit, the action will validate that 25 | # it matches the configured pattern. 26 | validateSingleCommit: true 27 | # Related to `validateSingleCommit` you can opt-in to validate that the PR 28 | # title matches a single commit to avoid confusion. 29 | validateSingleCommitMatchesPrTitle: true 30 | -------------------------------------------------------------------------------- /.github/workflows/pkg-pr-new-publish.yml: -------------------------------------------------------------------------------- 1 | name: pkg-pr-new Publish 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - '**' 8 | tags: 9 | - '!**' 10 | 11 | jobs: 12 | prerelease: 13 | name: pkg-pr-new Publish 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repo 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 20.x 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 20.x 23 | cache: yarn 24 | 25 | - name: Install dependencies 26 | run: yarn install 27 | 28 | - name: Build and publish to pkg.pr.new 29 | run: yarn run pkg-pr-new-publish 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .yarn/* 4 | !.yarn/patches 5 | !.yarn/plugins 6 | !.yarn/releases 7 | !.yarn/sdks 8 | !.yarn/versions 9 | *.tsbuildinfo -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("kcd-scripts/prettier.js"); 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-4.5.0.cjs 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Apollo Graph, Inc. (Formerly Meteor Development Group, Inc.) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @testing-library/react-render-stream 2 | 3 | ## What is this library? 4 | 5 | This library allows you to make committed-render-to-committed-render assertions 6 | on your React components and hooks. This is usually not necessary, but can be 7 | highly beneficial when testing hot code paths. 8 | 9 | ## Who is this library for? 10 | 11 | This library is intended to test libraries or library-like code. It requires you 12 | to write additional components so you can test how your components interact with 13 | other components in specific scenarios. 14 | 15 | As such, it is not intended to be used for end-to-end testing of your 16 | application. 17 | 18 | ## Brought to you by Apollo 19 | 20 | 21 | 22 | 23 | This library originally was part of the Apollo Client test suite and is 24 | maintained by the Apollo Client team. 25 | 26 | ### Usage examples: 27 | 28 | #### `createRenderStream` with DOM snapshots 29 | 30 | If used with `snapshotDOM`, RSTL will create a snapshot of your DOM after every 31 | render, and you can iterate through all the intermediate states of your DOM at 32 | your own pace, independenly of how fast these renders actually happened. 33 | 34 | ```jsx 35 | test('iterate through renders with DOM snapshots', async () => { 36 | const {takeRender, render} = createRenderStream({ 37 | snapshotDOM: true, 38 | }) 39 | const utils = await render() 40 | const incrementButton = utils.getByText('Increment') 41 | await userEvent.click(incrementButton) 42 | await userEvent.click(incrementButton) 43 | { 44 | const {withinDOM} = await takeRender() 45 | const input = withinDOM().getByLabelText('Value') 46 | expect(input.value).toBe('0') 47 | } 48 | { 49 | const {withinDOM} = await takeRender() 50 | const input = withinDOM().getByLabelText('Value') 51 | expect(input.value).toBe('1') 52 | } 53 | { 54 | const {withinDOM} = await takeRender() 55 | const input = withinDOM().getByLabelText('Value') 56 | expect(input.value).toBe('2') 57 | } 58 | }) 59 | ``` 60 | 61 | ### `renderHookToSnapshotStream` 62 | 63 | Usage is very similar to RTL's `renderHook`, but you get a `snapshotStream` 64 | object back that you can iterate with `takeSnapshot` calls. 65 | 66 | ```jsx 67 | test('`useQuery` with `skip`', async () => { 68 | const {takeSnapshot, rerender} = await renderHookToSnapshotStream( 69 | ({skip}) => useQuery(query, {skip}), 70 | { 71 | wrapper: ({children}) => {children}, 72 | }, 73 | ) 74 | 75 | { 76 | const result = await takeSnapshot() 77 | expect(result.loading).toBe(true) 78 | expect(result.data).toBe(undefined) 79 | } 80 | { 81 | const result = await takeSnapshot() 82 | expect(result.loading).toBe(false) 83 | expect(result.data).toEqual({hello: 'world 1'}) 84 | } 85 | 86 | await rerender({skip: true}) 87 | { 88 | const snapshot = await takeSnapshot() 89 | expect(snapshot.loading).toBe(false) 90 | expect(snapshot.data).toEqual(undefined) 91 | } 92 | }) 93 | ``` 94 | 95 | ### Tracking which components rerender with `useTrackRenders` 96 | 97 | You can track if a component was rerendered during a specific render by calling 98 | `useTrackRenders` within it. 99 | 100 | ```jsx 101 | test('`useTrackRenders` with suspense', async () => { 102 | function ErrorComponent() { 103 | useTrackRenders() 104 | // return ... 105 | } 106 | function DataComponent() { 107 | useTrackRenders() 108 | const data = useSuspenseQuery(someQuery) 109 | // return ... 110 | } 111 | function LoadingComponent() { 112 | useTrackRenders() 113 | // return ... 114 | } 115 | function App() { 116 | useTrackRenders() 117 | return ( 118 | 119 | }> 120 | 121 | 122 | 123 | ) 124 | } 125 | 126 | const {takeRender, render} = createRenderStream() 127 | await render() 128 | { 129 | const {renderedComponents} = await takeRender() 130 | expect(renderedComponents).toEqual([App, LoadingComponent]) 131 | } 132 | { 133 | const {renderedComponents} = await takeRender() 134 | expect(renderedComponents).toEqual([DataComponent]) 135 | } 136 | }) 137 | ``` 138 | 139 | > [!NOTE] 140 | > 141 | > The order of components in `renderedComponents` is the order of execution of 142 | > `useLayoutEffect`. Keep in mind that this might not be the order you would 143 | > expect. 144 | 145 | ### taking custom snapshots inside of helper Components with `replaceSnapshot` 146 | 147 | If you need to, you can also take custom snapshots of data in each render. 148 | 149 | ```tsx 150 | test('custom snapshots with `replaceSnapshot`', async () => { 151 | function Counter() { 152 | const [value, setValue] = React.useState(0) 153 | replaceSnapshot({value}) 154 | // return ... 155 | } 156 | 157 | const {takeRender, replaceSnapshot, render} = createRenderStream<{ 158 | value: number 159 | }>() 160 | const utils = await render() 161 | const incrementButton = utils.getByText('Increment') 162 | await userEvent.click(incrementButton) 163 | { 164 | const {snapshot} = await takeRender() 165 | expect(snapshot).toEqual({value: 0}) 166 | } 167 | { 168 | const {snapshot} = await takeRender() 169 | expect(snapshot).toEqual({value: 1}) 170 | } 171 | }) 172 | ``` 173 | 174 | > [!TIP] 175 | > 176 | > `replaceSnapshot` can also be called with a callback that gives you access to 177 | > the last snapshot value. 178 | 179 | > [!TIP] 180 | > 181 | > You can also use `mergeSnapshot`, which shallowly merges the last snapshot 182 | > with the new one instead of replacing it. 183 | 184 | ### Making assertions directly after a render with `onRender` 185 | 186 | ```tsx 187 | test('assertions in `onRender`', async () => { 188 | function Counter() { 189 | const [value, setValue] = React.useState(0) 190 | replaceSnapshot({value}) 191 | return ( 192 | setValue(v => v + 1)} /> 193 | ) 194 | } 195 | 196 | const {takeRender, replaceSnapshot, utils} = await renderToRenderStream<{ 197 | value: number 198 | }>({ 199 | onRender(info) { 200 | // you can use `expect` here 201 | expect(info.count).toBe(info.snapshot.value + 1) 202 | }, 203 | }) 204 | const incrementButton = utils.getByText('Increment') 205 | await userEvent.click(incrementButton) 206 | await userEvent.click(incrementButton) 207 | await takeRender() 208 | await takeRender() 209 | await takeRender() 210 | }) 211 | ``` 212 | 213 | > [!NOTE] 214 | > 215 | > `info` contains the 216 | > [base profiling information](https://react.dev/reference/react/Profiler#onrender-parameters) 217 | > passed into `onRender` of React's `Profiler` component, as well as `snapshot`, 218 | > `replaceSnapshot` and `mergeSnapshot` 219 | 220 | ### `expect(...)[.not].toRerender()` and `expect(...)[.not].toRenderExactlyTimes(n)` 221 | 222 | This library adds to matchers to `expect` that can be used like 223 | 224 | ```tsx 225 | test('basic functionality', async () => { 226 | const {takeRender} = await renderToRenderStream() 227 | 228 | await expect(takeRender).toRerender() 229 | await takeRender() 230 | 231 | // trigger a rerender somehow 232 | await expect(takeRender).toRerender() 233 | await takeRender() 234 | 235 | // ensure at the end of a test that no more renders will happen 236 | await expect(takeRender).not.toRerender() 237 | await expect(takeRender).toRenderExactlyTimes(2) 238 | }) 239 | ``` 240 | 241 | These matchers can be used on multiple different objects: 242 | 243 | ```ts 244 | await expect(takeRender).toRerender() 245 | await expect(renderStream).toRerender() 246 | await expect(takeSnapshot).toRerender() 247 | await expect(snapshotStream).toRerender() 248 | ``` 249 | 250 | > [!NOTE] 251 | > 252 | > By default, `.toRerender` and `toRenderExactlyTimes` will wait 100ms for 253 | > renders or to ensure no more renders happens. 254 | > 255 | > You can modify that with the `timeout` option: 256 | > 257 | > ```js 258 | > await expect(takeRender).not.toRerender({timeout: 300}) 259 | > ``` 260 | 261 | > [!TIP] 262 | > 263 | > If you don't want these matchers not to be automatically installed, you can 264 | > import from `@testing-library/react-render-stream/pure` instead. 265 | > Keep in mind that if you use the `/pure` import, you have to call the 266 | > `cleanup` export manually after each test. 267 | 268 | ## Usage side-by side with `@testing-library/react` or other tools that use `act` or set `IS_REACT_ACT_ENVIRONMENT` 269 | 270 | This library should not be used with `act`, and it will throw an error if 271 | `IS_REACT_ACT_ENVIRONMENT` is `true`. 272 | 273 | React Testing Library sets `IS_REACT_ACT_ENVIRONMENT` to `true` globally, and 274 | wraps some helpers like `userEvent.click` in `act` calls. 275 | To use this library side-by-side with React Testing Library, we ship the 276 | `disableActEnvironment` helper to undo these changes temporarily. 277 | 278 | It returns a `Disposable` and can be used together with the 279 | [`using` keyword](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management) 280 | to automatically clean up once the scope is left: 281 | 282 | ```ts 283 | test('my test', () => { 284 | using _disabledAct = disableActEnvironment() 285 | 286 | // your test code here 287 | 288 | // as soon as this scope is left, the environment will be cleaned up 289 | }) 290 | ``` 291 | 292 | If you cannot use `using`, you can also manually call the returned `cleanup` 293 | function. We recommend using `finally` to ensure the act environment is cleaned 294 | up if your test fails, otherwise it could leak between tests: 295 | 296 | ```ts 297 | test('my test', () => { 298 | const {cleanup} = disableActEnvironment() 299 | 300 | try { 301 | // your test code here 302 | } finally { 303 | cleanup() 304 | } 305 | }) 306 | ``` 307 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | const {jest: jestConfig} = require('kcd-scripts/config') 2 | 3 | module.exports = Object.assign(jestConfig, { 4 | resolver: 'ts-jest-resolver', 5 | prettierPath: null, 6 | }) 7 | -------------------------------------------------------------------------------- /other/apollo-wordmark.svg: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@testing-library/react-render-stream", 3 | "version": "0.0.0-semantically-released", 4 | "repository": { 5 | "url": "git+https://github.com/testing-library/react-render-stream-testing-library.git" 6 | }, 7 | "author": { 8 | "name": "Lenz Weber-Tronic", 9 | "email": "lenz@apollographql.com" 10 | }, 11 | "type": "module", 12 | "license": "MIT", 13 | "exports": { 14 | ".": { 15 | "types": { 16 | "module-sync": "./dist/index.d.ts", 17 | "module": "./dist/index.d.ts", 18 | "import": "./dist/index.d.ts", 19 | "require": "./dist/index.d.cts" 20 | }, 21 | "module-sync": "./dist/index.js", 22 | "module": "./dist/index.js", 23 | "import": "./dist/index.js", 24 | "require": "./dist/index.cjs" 25 | }, 26 | "./pure": { 27 | "types": { 28 | "module-sync": "./dist/pure.d.ts", 29 | "module": "./dist/pure.d.ts", 30 | "import": "./dist/pure.d.ts", 31 | "require": "./dist/pure.d.cts" 32 | }, 33 | "module-sync": "./dist/pure.js", 34 | "module": "./dist/pure.js", 35 | "import": "./dist/pure.js", 36 | "require": "./dist/pure.cjs" 37 | }, 38 | "./expect": { 39 | "types": { 40 | "module-sync": "./dist/expect.d.ts", 41 | "module": "./dist/expect.d.ts", 42 | "import": "./dist/expect.d.ts", 43 | "require": "./dist/expect.d.cts" 44 | }, 45 | "module-sync": "./dist/expect.js", 46 | "module": "./dist/expect.js", 47 | "import": "./dist/expect.js", 48 | "require": "./dist/expect.cjs" 49 | } 50 | }, 51 | "types": "./dist/index.d.ts", 52 | "typesVersions": { 53 | "*": { 54 | "expect": [ 55 | "./dist/expect.d.ts" 56 | ], 57 | "pure": [ 58 | "./dist/pure.d.ts" 59 | ] 60 | } 61 | }, 62 | "files": [ 63 | "dist", 64 | "other" 65 | ], 66 | "dependencies": { 67 | "@testing-library/dom": "^10.4.0", 68 | "@testing-library/react": "^16.0.1", 69 | "jsdom": "^25.0.1", 70 | "rehackt": "^0.1.0" 71 | }, 72 | "devDependencies": { 73 | "@arethetypeswrong/cli": "^0.16.4", 74 | "@jest/globals": "^29.7.0", 75 | "@testing-library/user-event": "^14.5.2", 76 | "@tsconfig/recommended": "^1.0.7", 77 | "@types/jsdom": "^21.1.7", 78 | "@types/react": "^18", 79 | "@types/react-dom": "^18", 80 | "concurrently": "^9.0.1", 81 | "expect": "^29.7.0", 82 | "kcd-scripts": "^16.0.0", 83 | "pkg-pr-new": "^0.0.29", 84 | "prettier": "^3.3.3", 85 | "publint": "^0.2.11", 86 | "react": "19.0.0", 87 | "react-dom": "19.0.0", 88 | "react-error-boundary": "^4.0.13", 89 | "ts-jest-resolver": "^2.0.1", 90 | "tsup": "^8.3.0", 91 | "typescript": "^5.6.2" 92 | }, 93 | "peerDependencies": { 94 | "@jest/globals": "*", 95 | "expect": "*", 96 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc", 97 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc" 98 | }, 99 | "scripts": { 100 | "build": "tsup", 101 | "pkg-pr-new-publish": "yarn build && pkg-pr-new publish --no-template", 102 | "prepack": "yarn build", 103 | "format": "kcd-scripts format", 104 | "lint": "kcd-scripts lint --config .eslintrc.cjs", 105 | "test": "kcd-scripts test --config jest.config.cjs", 106 | "pack-and-verify": "attw --pack . && publint", 107 | "typecheck": "kcd-scripts typecheck --build --noEmit", 108 | "validate": "yarn pack-and-verify; CI=true yarn concurrently --group --prefix '[{name}]' --names lint,test,typecheck 'yarn lint' 'yarn test --verbose' 'yarn typecheck'" 109 | }, 110 | "packageManager": "yarn@4.5.0", 111 | "resolutions": { 112 | "eslint-config-kentcdodds": "^21.0.0" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/__testHelpers__/getCleanedErrorMessage.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-control-regex 2 | const consoleColors = /\x1b\[\d+m/g 3 | 4 | export function getExpectErrorMessage( 5 | expectPromise: Promise, 6 | ): Promise { 7 | return expectPromise.then( 8 | () => { 9 | throw new Error('Expected promise to fail, but did not.') 10 | }, 11 | e => { 12 | const error = e as Error 13 | return error.message.replace(consoleColors, '') 14 | }, 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/__testHelpers__/useShim.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | /* eslint-disable default-case */ 4 | /* eslint-disable consistent-return */ 5 | function isStatefulPromise(promise) { 6 | return 'status' in promise 7 | } 8 | function wrapPromiseWithState(promise) { 9 | if (isStatefulPromise(promise)) { 10 | return promise 11 | } 12 | const pendingPromise = promise 13 | pendingPromise.status = 'pending' 14 | pendingPromise.then( 15 | value => { 16 | if (pendingPromise.status === 'pending') { 17 | const fulfilledPromise = pendingPromise 18 | fulfilledPromise.status = 'fulfilled' 19 | fulfilledPromise.value = value 20 | } 21 | }, 22 | reason => { 23 | if (pendingPromise.status === 'pending') { 24 | const rejectedPromise = pendingPromise 25 | rejectedPromise.status = 'rejected' 26 | rejectedPromise.reason = reason 27 | } 28 | }, 29 | ) 30 | return promise 31 | } 32 | 33 | /** 34 | * @template T 35 | * @param {Promise} promise 36 | * @returns {T} 37 | */ 38 | function _use(promise) { 39 | const statefulPromise = wrapPromiseWithState(promise) 40 | switch (statefulPromise.status) { 41 | case 'pending': 42 | throw statefulPromise 43 | case 'rejected': 44 | throw statefulPromise.reason 45 | case 'fulfilled': 46 | return statefulPromise.value 47 | } 48 | } 49 | 50 | export const __use = /** @type {{use?: typeof _use}} */ (React).use || _use 51 | -------------------------------------------------------------------------------- /src/__tests__/renderHookToSnapshotStream.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | /* eslint-disable @typescript-eslint/no-unnecessary-condition */ 3 | import {EventEmitter} from 'node:events' 4 | import {scheduler} from 'node:timers/promises' 5 | import {test, expect} from '@jest/globals' 6 | import { 7 | renderHookToSnapshotStream, 8 | SnapshotStream, 9 | } from '@testing-library/react-render-stream' 10 | import * as React from 'react' 11 | 12 | const testEvents = new EventEmitter<{ 13 | rerenderWithValue: [unknown] 14 | }>() 15 | 16 | function useRerenderEvents(initialValue: unknown) { 17 | const lastValueRef = React.useRef(initialValue) 18 | return React.useSyncExternalStore( 19 | onChange => { 20 | const cb = (value: unknown) => { 21 | lastValueRef.current = value 22 | onChange() 23 | } 24 | testEvents.addListener('rerenderWithValue', cb) 25 | return () => { 26 | testEvents.removeListener('rerenderWithValue', cb) 27 | } 28 | }, 29 | () => { 30 | return lastValueRef.current 31 | }, 32 | ) 33 | } 34 | 35 | test('basic functionality', async () => { 36 | const {takeSnapshot} = await renderHookToSnapshotStream(useRerenderEvents, { 37 | initialProps: 'initial', 38 | }) 39 | testEvents.emit('rerenderWithValue', 'value') 40 | await scheduler.wait(10) 41 | testEvents.emit('rerenderWithValue', 'value2') 42 | { 43 | const snapshot = await takeSnapshot() 44 | expect(snapshot).toBe('initial') 45 | } 46 | { 47 | const snapshot = await takeSnapshot() 48 | expect(snapshot).toBe('value') 49 | } 50 | { 51 | const snapshot = await takeSnapshot() 52 | expect(snapshot).toBe('value2') 53 | } 54 | }) 55 | 56 | test.each<[type: string, initialValue: unknown, ...nextValues: unknown[]]>([ 57 | ['string', 'initial', 'value', 'value2'], 58 | ['number', 0, 1, 2], 59 | ['functions', () => {}, () => {}, function named() {}], 60 | ['objects', {a: 1}, {a: 2}, {foo: 'bar'}], 61 | ['arrays', [1], [1, 2], [2]], 62 | ['null/undefined', null, undefined, null], 63 | ['undefined/null', undefined, null, undefined], 64 | ])('works with %s', async (_, initialValue, ...nextValues) => { 65 | const {takeSnapshot} = await renderHookToSnapshotStream(useRerenderEvents, { 66 | initialProps: initialValue, 67 | }) 68 | for (const nextValue of nextValues) { 69 | testEvents.emit('rerenderWithValue', nextValue) 70 | // allow for a render to happen 71 | await Promise.resolve() 72 | } 73 | expect(await takeSnapshot()).toBe(initialValue) 74 | for (const nextValue of nextValues) { 75 | expect(await takeSnapshot()).toBe(nextValue) 76 | } 77 | }) 78 | 79 | test.skip('type test: render function without an argument -> no argument required for `rerender`', async () => { 80 | { 81 | // prop type has nothing to infer on - defaults to `void` 82 | const stream = await renderHookToSnapshotStream(() => {}) 83 | const _test1: SnapshotStream = stream 84 | // @ts-expect-error should not be assignable 85 | const _test2: SnapshotStream = stream 86 | await stream.rerender() 87 | // @ts-expect-error invalid argument 88 | await stream.rerender('foo') 89 | } 90 | { 91 | // prop type is implicitly set via the render function argument 92 | const stream = await renderHookToSnapshotStream((_arg1: string) => {}) 93 | // @ts-expect-error should not be assignable 94 | const _test1: SnapshotStream = stream 95 | const _test2: SnapshotStream = stream 96 | // @ts-expect-error missing argument 97 | await stream.rerender() 98 | await stream.rerender('foo') 99 | } 100 | { 101 | // prop type is implicitly set via the initialProps argument 102 | const stream = await renderHookToSnapshotStream(() => {}, { 103 | initialProps: 'initial', 104 | }) 105 | // @ts-expect-error should not be assignable 106 | const _test1: SnapshotStream = stream 107 | const _test2: SnapshotStream = stream 108 | // @ts-expect-error missing argument 109 | await stream.rerender() 110 | await stream.rerender('foo') 111 | } 112 | { 113 | // argument is optional 114 | const stream = await renderHookToSnapshotStream((_arg1?: string) => {}) 115 | 116 | const _test1: SnapshotStream = stream 117 | const _test2: SnapshotStream = stream 118 | const _test3: SnapshotStream = stream 119 | await stream.rerender() 120 | await stream.rerender('foo') 121 | } 122 | }) 123 | -------------------------------------------------------------------------------- /src/assertable.ts: -------------------------------------------------------------------------------- 1 | import {type RenderStream} from './renderStream/createRenderStream.js' 2 | 3 | export const assertableSymbol = Symbol.for( 4 | '@testing-library/react-render-stream:assertable', 5 | ) 6 | 7 | /** 8 | * A function or object that can be used in assertions, like e.g. 9 | ```ts 10 | expect(assertable).toRerender() 11 | expect(assertable).not.toRerender() 12 | expect(assertable).toRenderExactlyTimes(3) 13 | ``` 14 | */ 15 | export type Assertable = { 16 | [assertableSymbol]: RenderStream 17 | } 18 | 19 | export function markAssertable( 20 | assertable: T, 21 | stream: RenderStream, 22 | ): T & Assertable { 23 | return Object.assign(assertable, { 24 | [assertableSymbol]: stream, 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /src/disableActEnvironment.ts: -------------------------------------------------------------------------------- 1 | import {getConfig} from '@testing-library/dom' 2 | 3 | const dispose: typeof Symbol.dispose = 4 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 5 | Symbol.dispose ?? Symbol.for('nodejs.dispose') 6 | 7 | export interface DisableActEnvironmentOptions { 8 | /** 9 | * If `true`, all modifications of values set by `disableActEnvironment` 10 | * will be prevented until `cleanup` is called. 11 | * 12 | * @default true 13 | */ 14 | preventModification?: boolean 15 | 16 | /** 17 | * If `true`, will change the configuration of the testing library to 18 | * prevent auto-wrapping e.g. `userEvent` calls in `act`. 19 | * 20 | * @default true 21 | */ 22 | adjustTestingLibConfig?: boolean 23 | } 24 | 25 | /** 26 | * Helper to temporarily disable a React 18+ act environment. 27 | * 28 | * By default, this also adjusts the configuration of @testing-library/dom 29 | * to prevent auto-wrapping of user events in `act`, as well as preventing 30 | * all modifications of values set by this method until `cleanup` is called 31 | * or the returned `Disposable` is disposed of. 32 | * 33 | * Both of these behaviors can be disabled with the option, of the defaults 34 | * can be changed for all calls to this method by modifying 35 | * `disableActEnvironment.defaultOptions`. 36 | * 37 | * This returns a disposable and can be used in combination with `using` to 38 | * automatically restore the state from before this method call after your test. 39 | * 40 | * @example 41 | * ```ts 42 | * test("my test", () => { 43 | * using _disabledAct = disableActEnvironment(); 44 | * 45 | * // your test code here 46 | * 47 | * // as soon as this scope is left, the environment will be cleaned up 48 | * }) 49 | * ``` 50 | * 51 | * If you can not use the explicit resouce management keyword `using`, 52 | * you can also manually call `cleanup`: 53 | * 54 | * @example 55 | * ```ts 56 | * test("my test", () => { 57 | * const { cleanup } = disableActEnvironment(); 58 | * 59 | * try { 60 | * // your test code here 61 | * } finally { 62 | * cleanup(); 63 | * } 64 | * }) 65 | * ``` 66 | * 67 | * For more context on what `act` is and why you shouldn't use it in renderStream tests, 68 | * https://github.com/reactwg/react-18/discussions/102 is probably the best resource we have. 69 | */ 70 | export function disableActEnvironment({ 71 | preventModification = disableActEnvironment.defaultOptions 72 | .preventModification, 73 | adjustTestingLibConfig = disableActEnvironment.defaultOptions 74 | .adjustTestingLibConfig, 75 | }: DisableActEnvironmentOptions = {}): {cleanup: () => void} & Disposable { 76 | const typedGlobal = globalThis as any as {IS_REACT_ACT_ENVIRONMENT?: boolean} 77 | const cleanupFns: Array<() => void> = [] 78 | 79 | // core functionality 80 | { 81 | const previous = typedGlobal.IS_REACT_ACT_ENVIRONMENT 82 | cleanupFns.push(() => { 83 | Object.defineProperty(typedGlobal, 'IS_REACT_ACT_ENVIRONMENT', { 84 | value: previous, 85 | writable: true, 86 | configurable: true, 87 | }) 88 | }) 89 | Object.defineProperty( 90 | typedGlobal, 91 | 'IS_REACT_ACT_ENVIRONMENT', 92 | getNewPropertyDescriptor(false, preventModification), 93 | ) 94 | } 95 | 96 | if (adjustTestingLibConfig) { 97 | const config = getConfig() 98 | // eslint-disable-next-line @typescript-eslint/unbound-method 99 | const {asyncWrapper, eventWrapper} = config 100 | cleanupFns.push(() => { 101 | Object.defineProperty(config, 'asyncWrapper', { 102 | value: asyncWrapper, 103 | writable: true, 104 | configurable: true, 105 | }) 106 | Object.defineProperty(config, 'eventWrapper', { 107 | value: eventWrapper, 108 | writable: true, 109 | configurable: true, 110 | }) 111 | }) 112 | 113 | Object.defineProperty( 114 | config, 115 | 'asyncWrapper', 116 | getNewPropertyDescriptor( 117 | fn => fn(), 118 | preventModification, 119 | ), 120 | ) 121 | Object.defineProperty( 122 | config, 123 | 'eventWrapper', 124 | getNewPropertyDescriptor( 125 | fn => fn(), 126 | preventModification, 127 | ), 128 | ) 129 | } 130 | 131 | function cleanup() { 132 | while (cleanupFns.length > 0) { 133 | cleanupFns.pop()!() 134 | } 135 | } 136 | return { 137 | cleanup, 138 | [dispose]: cleanup, 139 | } 140 | } 141 | 142 | /** 143 | * Default options for `disableActEnvironment`. 144 | * 145 | * This can be modified to change the default options for all calls to `disableActEnvironment`. 146 | */ 147 | disableActEnvironment.defaultOptions = { 148 | preventModification: true, 149 | adjustTestingLibConfig: true, 150 | } satisfies Required as Required 151 | 152 | function getNewPropertyDescriptor( 153 | value: T, 154 | preventModification: boolean, 155 | ): PropertyDescriptor { 156 | return preventModification 157 | ? { 158 | configurable: true, 159 | enumerable: true, 160 | get() { 161 | return value 162 | }, 163 | set() {}, 164 | } 165 | : { 166 | configurable: true, 167 | enumerable: true, 168 | writable: true, 169 | value, 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/expect/__tests__/renderStreamMatchers.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unnecessary-condition */ 2 | import {EventEmitter} from 'node:events' 3 | import {describe, test, expect} from '@jest/globals' 4 | import { 5 | createRenderStream, 6 | renderHookToSnapshotStream, 7 | } from '@testing-library/react-render-stream' 8 | import * as React from 'react' 9 | import {getExpectErrorMessage} from '../../__testHelpers__/getCleanedErrorMessage.js' 10 | 11 | const testEvents = new EventEmitter<{ 12 | rerender: [] 13 | }>() 14 | 15 | function useRerender() { 16 | const [, rerender] = React.useReducer(c => c + 1, 0) 17 | React.useEffect(() => { 18 | const cb = () => void rerender() 19 | 20 | testEvents.addListener('rerender', cb) 21 | return () => { 22 | testEvents.removeListener('rerender', cb) 23 | } 24 | }, []) 25 | } 26 | 27 | function RerenderingComponent() { 28 | useRerender() 29 | return null 30 | } 31 | 32 | describe('toRerender', () => { 33 | test('basic functionality', async () => { 34 | const {takeRender, render} = createRenderStream({}) 35 | 36 | await render() 37 | await expect(takeRender).toRerender() 38 | await takeRender() 39 | 40 | testEvents.emit('rerender') 41 | await expect(takeRender).toRerender() 42 | await takeRender() 43 | 44 | await expect(takeRender).not.toRerender() 45 | }) 46 | 47 | test('works with renderStream object', async () => { 48 | const renderStream = createRenderStream({}) 49 | 50 | await renderStream.render() 51 | await expect(renderStream).toRerender() 52 | await renderStream.takeRender() 53 | 54 | testEvents.emit('rerender') 55 | await expect(renderStream).toRerender() 56 | await renderStream.takeRender() 57 | 58 | await expect(renderStream).not.toRerender() 59 | }) 60 | 61 | test('works with takeSnapshot function', async () => { 62 | const {takeSnapshot} = await renderHookToSnapshotStream(() => useRerender()) 63 | 64 | await expect(takeSnapshot).toRerender() 65 | await takeSnapshot() 66 | 67 | testEvents.emit('rerender') 68 | await expect(takeSnapshot).toRerender() 69 | await takeSnapshot() 70 | 71 | await expect(takeSnapshot).not.toRerender() 72 | }) 73 | 74 | test('works with snapshotStream', async () => { 75 | const snapshotStream = await renderHookToSnapshotStream(() => useRerender()) 76 | 77 | await expect(snapshotStream).toRerender() 78 | await snapshotStream.takeSnapshot() 79 | 80 | testEvents.emit('rerender') 81 | await expect(snapshotStream).toRerender() 82 | await snapshotStream.takeSnapshot() 83 | 84 | await expect(snapshotStream).not.toRerender() 85 | }) 86 | 87 | test("errors when it rerenders, but shouldn't", async () => { 88 | const {takeRender, render} = createRenderStream({}) 89 | 90 | await render() 91 | await expect(takeRender).toRerender() 92 | await takeRender() 93 | 94 | testEvents.emit('rerender') 95 | const error = await getExpectErrorMessage( 96 | expect(takeRender).not.toRerender(), 97 | ) 98 | expect(error).toMatchInlineSnapshot(` 99 | expect(received).not.toRerender(expected) 100 | 101 | Expected component to not rerender, but it did. 102 | `) 103 | }) 104 | 105 | test("errors when it should rerender, but doesn't", async () => { 106 | const {takeRender, render} = createRenderStream({}) 107 | 108 | await render() 109 | await expect(takeRender).toRerender() 110 | await takeRender() 111 | 112 | const error = await getExpectErrorMessage(expect(takeRender).toRerender()) 113 | expect(error).toMatchInlineSnapshot(` 114 | expect(received).toRerender(expected) 115 | 116 | Expected component to rerender, but it did not. 117 | `) 118 | }) 119 | }) 120 | 121 | describe('toRenderExactlyTimes', () => { 122 | test('basic functionality', async () => { 123 | const {takeRender, render} = createRenderStream({}) 124 | 125 | await render() 126 | testEvents.emit('rerender') 127 | 128 | await expect(takeRender).toRenderExactlyTimes(2) 129 | }) 130 | 131 | test('works with renderStream object', async () => { 132 | const renderStream = createRenderStream({}) 133 | 134 | await renderStream.render() 135 | testEvents.emit('rerender') 136 | 137 | await expect(renderStream).toRenderExactlyTimes(2) 138 | }) 139 | 140 | test('works with takeSnapshot function', async () => { 141 | const {takeSnapshot} = await renderHookToSnapshotStream(() => useRerender()) 142 | testEvents.emit('rerender') 143 | 144 | await expect(takeSnapshot).toRenderExactlyTimes(2) 145 | }) 146 | 147 | test('works with snapshotStream', async () => { 148 | const snapshotStream = await renderHookToSnapshotStream(() => useRerender()) 149 | testEvents.emit('rerender') 150 | 151 | await expect(snapshotStream).toRenderExactlyTimes(2) 152 | }) 153 | 154 | test('errors when the count of rerenders is wrong', async () => { 155 | const {takeRender, render} = createRenderStream({}) 156 | 157 | await render() 158 | testEvents.emit('rerender') 159 | 160 | const error = await getExpectErrorMessage( 161 | expect(takeRender).toRenderExactlyTimes(3), 162 | ) 163 | expect(error).toMatchInlineSnapshot(` 164 | expect(received).toRenderExactlyTimes(expected) 165 | 166 | Expected component to render exactly 3 times. 167 | It rendered 2 times. 168 | `) 169 | }) 170 | 171 | test('errors when the count of rerenders is right (inverted)', async () => { 172 | const {takeRender, render} = createRenderStream({}) 173 | 174 | await render() 175 | testEvents.emit('rerender') 176 | 177 | const error = await getExpectErrorMessage( 178 | expect(takeRender).not.toRenderExactlyTimes(2), 179 | ) 180 | expect(error).toMatchInlineSnapshot(` 181 | expect(received).not.toRenderExactlyTimes(expected) 182 | 183 | Expected component to not render exactly 2 times. 184 | It rendered 2 times. 185 | `) 186 | }) 187 | }) 188 | -------------------------------------------------------------------------------- /src/expect/index.ts: -------------------------------------------------------------------------------- 1 | import {expect} from 'expect' 2 | import { 3 | toRerender, 4 | toRenderExactlyTimes, 5 | type RenderStreamMatchers, 6 | } from './renderStreamMatchers.js' 7 | 8 | expect.extend({ 9 | toRerender, 10 | toRenderExactlyTimes, 11 | }) 12 | 13 | declare module 'expect' { 14 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 15 | interface Matchers, T = unknown> 16 | extends RenderStreamMatchers {} 17 | } 18 | -------------------------------------------------------------------------------- /src/expect/renderStreamMatchers.ts: -------------------------------------------------------------------------------- 1 | import {MatcherContext, type MatcherFunction} from 'expect' 2 | import { 3 | WaitForRenderTimeoutError, 4 | type Assertable, 5 | type NextRenderOptions, 6 | type RenderStream, 7 | } from '@testing-library/react-render-stream' 8 | // explicitly imported the symbol from the internal file 9 | // this will bundle the `Symbol.for` call twice, but we keep it private 10 | import {assertableSymbol} from '../assertable.js' 11 | 12 | export interface RenderStreamMatchers { 13 | toRerender: T extends RenderStream | Assertable 14 | ? (options?: NextRenderOptions) => Promise 15 | : { 16 | error: 'matcher needs to be called on a `takeRender` function, `takeSnapshot` function or `RenderStream` instance' 17 | } 18 | 19 | toRenderExactlyTimes: T extends RenderStream | Assertable 20 | ? (count: number, options?: NextRenderOptions) => Promise 21 | : { 22 | error: 'matcher needs to be called on a `takeRender` function, `takeSnapshot` function or `RenderStream` instance' 23 | } 24 | } 25 | 26 | export const toRerender: MatcherFunction<[options?: NextRenderOptions]> = 27 | async function toRerender(this: MatcherContext, actual, options) { 28 | const _stream = actual as RenderStream | Assertable 29 | const stream = 30 | assertableSymbol in _stream ? _stream[assertableSymbol] : _stream 31 | const hint = this.utils.matcherHint('toRerender', undefined, undefined, { 32 | isNot: this.isNot, 33 | }) 34 | let pass = true 35 | try { 36 | await stream.peekRender({timeout: 100, ...options}) 37 | } catch (e) { 38 | if (e instanceof WaitForRenderTimeoutError) { 39 | pass = false 40 | } else { 41 | throw e 42 | } 43 | } 44 | 45 | return { 46 | pass, 47 | message() { 48 | return ( 49 | `${hint}\n\nExpected component to${pass ? ' not' : ''} rerender, ` + 50 | `but it did${pass ? '' : ' not'}.` 51 | ) 52 | }, 53 | } 54 | } 55 | 56 | /** to be thrown to "break" test execution and fail it */ 57 | const failed = new Error() 58 | 59 | export const toRenderExactlyTimes: MatcherFunction< 60 | [times: number, options?: NextRenderOptions] 61 | > = async function toRenderExactlyTimes( 62 | this: MatcherContext, 63 | actual, 64 | times, 65 | optionsPerRender, 66 | ) { 67 | const _stream = actual as RenderStream | Assertable 68 | const stream = 69 | assertableSymbol in _stream ? _stream[assertableSymbol] : _stream 70 | const options = {timeout: 100, ...optionsPerRender} 71 | const hint = this.utils.matcherHint( 72 | 'toRenderExactlyTimes', 73 | undefined, 74 | undefined, 75 | {isNot: this.isNot}, 76 | ) 77 | let pass = true 78 | try { 79 | if (stream.totalRenderCount() > times) { 80 | throw failed 81 | } 82 | try { 83 | while (stream.totalRenderCount() < times) { 84 | // eslint-disable-next-line no-await-in-loop 85 | await stream.waitForNextRender(options) 86 | } 87 | } catch (e) { 88 | // timeouts here should just fail the test, rethrow other errors 89 | throw e instanceof WaitForRenderTimeoutError ? failed : e 90 | } 91 | try { 92 | await stream.waitForNextRender(options) 93 | } catch (e) { 94 | // we are expecting a timeout here, so swallow that error, rethrow others 95 | if (!(e instanceof WaitForRenderTimeoutError)) { 96 | throw e 97 | } 98 | } 99 | } catch (e) { 100 | if (e === failed) { 101 | pass = false 102 | } else { 103 | throw e 104 | } 105 | } 106 | return { 107 | pass, 108 | message() { 109 | return ( 110 | `${ 111 | hint 112 | }\n\nExpected component to${pass ? ' not' : ''} render exactly ${times} times.` + 113 | `\nIt rendered ${stream.totalRenderCount()} times.` 114 | ) 115 | }, 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/react-render-stream/expect' 2 | import {cleanup} from '@testing-library/react-render-stream/pure' 3 | export * from '@testing-library/react-render-stream/pure' 4 | 5 | const global = globalThis as {afterEach?: (fn: () => void) => void} 6 | if (global.afterEach) { 7 | global.afterEach(cleanup) 8 | } 9 | -------------------------------------------------------------------------------- /src/pure.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | NextRenderOptions, 3 | RenderStream, 4 | RenderStreamWithRenderFn, 5 | RenderStreamOptions, 6 | } from './renderStream/createRenderStream.js' 7 | export { 8 | createRenderStream, 9 | WaitForRenderTimeoutError, 10 | } from './renderStream/createRenderStream.js' 11 | export {useTrackRenders} from './renderStream/useTrackRenders.js' 12 | 13 | export type {SyncScreen} from './renderStream/Render.js' 14 | 15 | export {renderHookToSnapshotStream} from './renderHookToSnapshotStream.js' 16 | export type {SnapshotStream} from './renderHookToSnapshotStream.js' 17 | 18 | export type {Assertable} from './assertable.js' 19 | 20 | export { 21 | cleanup, 22 | type RenderWithoutActAsync as AsyncRenderFn, 23 | } from './renderWithoutAct.js' 24 | export { 25 | disableActEnvironment, 26 | type DisableActEnvironmentOptions, 27 | } from './disableActEnvironment.js' 28 | -------------------------------------------------------------------------------- /src/renderHookToSnapshotStream.tsx: -------------------------------------------------------------------------------- 1 | import {type RenderHookOptions} from '@testing-library/react/pure.js' 2 | import React from 'rehackt' 3 | import {createRenderStream} from './renderStream/createRenderStream.js' 4 | import {type NextRenderOptions} from './renderStream/createRenderStream.js' 5 | import {Render} from './renderStream/Render.js' 6 | import {Assertable, assertableSymbol, markAssertable} from './assertable.js' 7 | 8 | export interface SnapshotStream extends Assertable { 9 | /** 10 | * An array of all renders that have happened so far. 11 | * Errors thrown during component render will be captured here, too. 12 | */ 13 | renders: Array< 14 | | Render<{value: Snapshot}, never> 15 | | {phase: 'snapshotError'; count: number; error: unknown} 16 | > 17 | /** 18 | * Peeks the next render from the current iterator position, without advancing the iterator. 19 | * If no render has happened yet, it will wait for the next render to happen. 20 | * @throws {WaitForRenderTimeoutError} if no render happens within the timeout 21 | */ 22 | peekSnapshot(options?: NextRenderOptions): Promise 23 | /** 24 | * Iterates to the next render and returns it. 25 | * If no render has happened yet, it will wait for the next render to happen. 26 | * @throws {WaitForRenderTimeoutError} if no render happens within the timeout 27 | */ 28 | takeSnapshot: Assertable & 29 | ((options?: NextRenderOptions) => Promise) 30 | /** 31 | * Returns the total number of renders. 32 | */ 33 | totalSnapshotCount(): number 34 | /** 35 | * Returns the current render. 36 | * @throws {Error} if no render has happened yet 37 | */ 38 | getCurrentSnapshot(): Snapshot 39 | /** 40 | * Waits for the next render to happen. 41 | * Does not advance the render iterator. 42 | */ 43 | waitForNextSnapshot(options?: NextRenderOptions): Promise 44 | rerender: (rerenderCallbackProps: VoidOptionalArg) => Promise 45 | unmount: () => void 46 | } 47 | 48 | /** 49 | * if `Arg` can be `undefined`, replace it with `void` to make type represent an optional argument in a function argument position 50 | */ 51 | type VoidOptionalArg = Arg extends any // distribute members of a potential `Props` union 52 | ? undefined extends Arg 53 | ? // eslint-disable-next-line @typescript-eslint/no-invalid-void-type 54 | void 55 | : Arg 56 | : Arg 57 | 58 | export async function renderHookToSnapshotStream( 59 | renderCallback: (props: Props) => ReturnValue, 60 | {initialProps, ...renderOptions}: RenderHookOptions = {}, 61 | ): Promise> { 62 | const {render, ...stream} = createRenderStream<{value: ReturnValue}, never>() 63 | 64 | const HookComponent: React.FC<{arg: Props}> = props => { 65 | stream.replaceSnapshot({value: renderCallback(props.arg)}) 66 | return null 67 | } 68 | 69 | const {rerender: baseRerender, unmount} = await render( 70 | , 71 | renderOptions, 72 | ) 73 | 74 | function rerender(rerenderCallbackProps: VoidOptionalArg) { 75 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 76 | return baseRerender() 77 | } 78 | 79 | return { 80 | [assertableSymbol]: stream, 81 | renders: stream.renders, 82 | totalSnapshotCount: stream.totalRenderCount, 83 | async peekSnapshot(options) { 84 | return (await stream.peekRender(options)).snapshot.value 85 | }, 86 | takeSnapshot: markAssertable(async function takeSnapshot(options) { 87 | return (await stream.takeRender(options)).snapshot.value 88 | }, stream), 89 | getCurrentSnapshot() { 90 | return stream.getCurrentRender().snapshot.value 91 | }, 92 | async waitForNextSnapshot(options) { 93 | return (await stream.waitForNextRender(options)).snapshot.value 94 | }, 95 | rerender, 96 | unmount, 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/renderStream/Render.tsx: -------------------------------------------------------------------------------- 1 | import {screen, getQueriesForElement, Screen} from '@testing-library/dom' 2 | import {JSDOM, VirtualConsole} from 'jsdom' 3 | import { 4 | BoundSyncFunctions, 5 | type Queries, 6 | type SyncQueries, 7 | } from './syncQueries.js' 8 | 9 | export interface BaseRender { 10 | id: string 11 | phase: 'mount' | 'update' | 'nested-update' 12 | actualDuration: number 13 | baseDuration: number 14 | startTime: number 15 | commitTime: number 16 | /** 17 | * The number of renders that have happened so far (including this render). 18 | */ 19 | count: number 20 | } 21 | 22 | export type SyncScreen = 23 | BoundSyncFunctions & Pick 24 | 25 | export interface Render 26 | extends BaseRender { 27 | /** 28 | * The snapshot, as returned by the `takeSnapshot` option of `createRenderStream`. 29 | */ 30 | snapshot: Snapshot 31 | /** 32 | * A DOM snapshot of the rendered component, if the `snapshotDOM` 33 | * option of `createRenderStream` was enabled. 34 | */ 35 | readonly domSnapshot: HTMLElement 36 | /** 37 | * Returns a callback to receive a `screen` instance that is scoped to the 38 | * DOM snapshot of this `Render` instance. 39 | * Note: this is used as a callback to prevent linter errors. 40 | * @example 41 | * ```diff 42 | * const { withinDOM } = RenderedComponent.takeRender(); 43 | * -expect(screen.getByText("foo")).toBeInTheDocument(); 44 | * +expect(withinDOM().getByText("foo")).toBeInTheDocument(); 45 | * ``` 46 | */ 47 | withinDOM: () => SyncScreen 48 | 49 | renderedComponents: Array 50 | } 51 | 52 | export class RenderInstance 53 | implements Render 54 | { 55 | id: string 56 | phase: 'mount' | 'update' | 'nested-update' 57 | actualDuration: number 58 | baseDuration: number 59 | startTime: number 60 | commitTime: number 61 | count: number 62 | public snapshot: Snapshot 63 | private stringifiedDOM: string | undefined 64 | public renderedComponents: Array 65 | private queries: Q 66 | 67 | constructor( 68 | baseRender: BaseRender, 69 | snapshot: Snapshot, 70 | stringifiedDOM: string | undefined, 71 | renderedComponents: Array, 72 | queries: Q, 73 | ) { 74 | this.snapshot = snapshot 75 | this.stringifiedDOM = stringifiedDOM 76 | this.renderedComponents = renderedComponents 77 | this.id = baseRender.id 78 | this.phase = baseRender.phase 79 | this.actualDuration = baseRender.actualDuration 80 | this.baseDuration = baseRender.baseDuration 81 | this.startTime = baseRender.startTime 82 | this.commitTime = baseRender.commitTime 83 | this.count = baseRender.count 84 | this.queries = queries 85 | } 86 | 87 | private _domSnapshot: HTMLElement | undefined 88 | get domSnapshot() { 89 | if (this._domSnapshot) return this._domSnapshot 90 | if (!this.stringifiedDOM) { 91 | throw new Error( 92 | 'DOM snapshot is not available - please set the `snapshotDOM` option', 93 | ) 94 | } 95 | 96 | const virtualConsole = new VirtualConsole() 97 | virtualConsole.on('jsdomError', (error: any) => { 98 | throw error 99 | }) 100 | 101 | const snapDOM = new JSDOM(this.stringifiedDOM, { 102 | runScripts: 'dangerously', 103 | virtualConsole, 104 | }) 105 | const document = snapDOM.window.document 106 | const body = document.body 107 | const script = document.createElement('script') 108 | script.type = 'text/javascript' 109 | script.text = ` 110 | ${errorOnDomInteraction.toString()}; 111 | ${errorOnDomInteraction.name}(); 112 | ` 113 | body.appendChild(script) 114 | body.removeChild(script) 115 | 116 | return (this._domSnapshot = body) 117 | } 118 | 119 | get withinDOM(): () => SyncScreen { 120 | const snapScreen = Object.assign( 121 | getQueriesForElement( 122 | this.domSnapshot, 123 | this.queries, 124 | ) as any as BoundSyncFunctions, 125 | { 126 | debug: ( 127 | ...[dom = this.domSnapshot, ...rest]: Parameters 128 | ) => screen.debug(dom, ...rest), 129 | logTestingPlaygroundURL: ( 130 | ...[dom = this.domSnapshot, ...rest]: Parameters< 131 | typeof screen.logTestingPlaygroundURL 132 | > 133 | ) => screen.logTestingPlaygroundURL(dom, ...rest), 134 | }, 135 | ) 136 | return () => snapScreen 137 | } 138 | } 139 | 140 | export function errorOnDomInteraction() { 141 | const events: Array = [ 142 | 'auxclick', 143 | 'blur', 144 | 'change', 145 | 'click', 146 | 'copy', 147 | 'cut', 148 | 'dblclick', 149 | 'drag', 150 | 'dragend', 151 | 'dragenter', 152 | 'dragleave', 153 | 'dragover', 154 | 'dragstart', 155 | 'drop', 156 | 'focus', 157 | 'focusin', 158 | 'focusout', 159 | 'input', 160 | 'keydown', 161 | 'keypress', 162 | 'keyup', 163 | 'mousedown', 164 | 'mouseenter', 165 | 'mouseleave', 166 | 'mousemove', 167 | 'mouseout', 168 | 'mouseover', 169 | 'mouseup', 170 | 'paste', 171 | 'pointercancel', 172 | 'pointerdown', 173 | 'pointerenter', 174 | 'pointerleave', 175 | 'pointermove', 176 | 'pointerout', 177 | 'pointerover', 178 | 'pointerup', 179 | 'scroll', 180 | 'select', 181 | 'selectionchange', 182 | 'selectstart', 183 | 'submit', 184 | 'toggle', 185 | 'touchcancel', 186 | 'touchend', 187 | 'touchmove', 188 | 'touchstart', 189 | 'wheel', 190 | ] 191 | function warnOnDomInteraction() { 192 | throw new Error(` 193 | DOM interaction with a snapshot detected in test. 194 | Please don't interact with the DOM you get from \`withinDOM\`, 195 | but still use \`screen\` to get elements for simulating user interaction. 196 | `) 197 | } 198 | events.forEach(event => { 199 | document.addEventListener(event, warnOnDomInteraction) 200 | }) 201 | } 202 | -------------------------------------------------------------------------------- /src/renderStream/__tests__/createRenderStream.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-use-before-define */ 2 | import {jest, describe, test, expect} from '@jest/globals' 3 | import {createRenderStream} from '@testing-library/react-render-stream' 4 | import * as React from 'react' 5 | import {ErrorBoundary} from 'react-error-boundary' 6 | import {userEvent} from '@testing-library/user-event' 7 | import {getExpectErrorMessage} from '../../__testHelpers__/getCleanedErrorMessage.js' 8 | 9 | function CounterForm({ 10 | value, 11 | onIncrement, 12 | }: { 13 | value: number 14 | onIncrement: () => void 15 | }) { 16 | return ( 17 |
18 | 21 | 25 |
26 | ) 27 | } 28 | 29 | describe('snapshotDOM', () => { 30 | test('basic functionality', async () => { 31 | function Counter() { 32 | const [value, setValue] = React.useState(0) 33 | return ( 34 | setValue(v => v + 1)} /> 35 | ) 36 | } 37 | 38 | const {takeRender, render} = createRenderStream({ 39 | snapshotDOM: true, 40 | }) 41 | const utils = await render() 42 | const incrementButton = utils.getByText('Increment') 43 | await userEvent.click(incrementButton) 44 | await userEvent.click(incrementButton) 45 | { 46 | const {withinDOM} = await takeRender() 47 | const input = withinDOM().getByLabelText('Value') 48 | expect(input.value).toBe('0') 49 | } 50 | { 51 | const {withinDOM} = await takeRender() 52 | // a one-off to test that `queryBy` works and accepts a type argument 53 | const input = withinDOM().queryByLabelText('Value')! 54 | expect(input.value).toBe('1') 55 | } 56 | { 57 | const {withinDOM} = await takeRender() 58 | const input = withinDOM().getByLabelText('Value') 59 | expect(input.value).toBe('2') 60 | } 61 | }) 62 | 63 | test('errors when triggering events on rendered elemenst', async () => { 64 | function Counter() { 65 | const [value, setValue] = React.useState(0) 66 | return ( 67 | setValue(v => v + 1)} /> 68 | ) 69 | } 70 | 71 | const {takeRender, render} = createRenderStream({ 72 | snapshotDOM: true, 73 | }) 74 | await render() 75 | { 76 | const {withinDOM} = await takeRender() 77 | const snapshotIncrementButton = withinDOM().getByText('Increment') 78 | try { 79 | await userEvent.click(snapshotIncrementButton) 80 | } catch (error) { 81 | expect(error).toMatchInlineSnapshot(` 82 | [Error: Uncaught [Error: 83 | DOM interaction with a snapshot detected in test. 84 | Please don't interact with the DOM you get from \`withinDOM\`, 85 | but still use \`screen\` to get elements for simulating user interaction. 86 | ]] 87 | `) 88 | } 89 | } 90 | }) 91 | 92 | test('queries option', async () => { 93 | function Component() { 94 | return null 95 | } 96 | const queries = { 97 | foo: (_: any) => { 98 | return null 99 | }, 100 | } 101 | 102 | const {takeRender, render} = createRenderStream({ 103 | snapshotDOM: true, 104 | queries, 105 | }) 106 | await render() 107 | 108 | const {withinDOM} = await takeRender() 109 | expect(withinDOM().foo()).toBe(null) 110 | function _typeTest() { 111 | // @ts-expect-error should not be present 112 | withinDOM().getByText 113 | withinDOM().debug() 114 | const _str: string = withinDOM().logTestingPlaygroundURL() 115 | } 116 | }) 117 | }) 118 | 119 | describe('replaceSnapshot', () => { 120 | test('basic functionality', async () => { 121 | function Counter() { 122 | const [value, setValue] = React.useState(0) 123 | replaceSnapshot({value}) 124 | return ( 125 | setValue(v => v + 1)} /> 126 | ) 127 | } 128 | 129 | const {takeRender, replaceSnapshot, render} = createRenderStream<{ 130 | value: number 131 | }>() 132 | const utils = await render() 133 | const incrementButton = utils.getByText('Increment') 134 | await userEvent.click(incrementButton) 135 | await userEvent.click(incrementButton) 136 | { 137 | const {snapshot} = await takeRender() 138 | expect(snapshot).toEqual({value: 0}) 139 | } 140 | { 141 | const {snapshot} = await takeRender() 142 | expect(snapshot).toEqual({value: 1}) 143 | } 144 | { 145 | const {snapshot} = await takeRender() 146 | expect(snapshot).toEqual({value: 2}) 147 | } 148 | }) 149 | describe('callback notation', () => { 150 | test('basic functionality', async () => { 151 | function Counter() { 152 | const [value, setValue] = React.useState(0) 153 | replaceSnapshot(oldSnapshot => ({...oldSnapshot, value})) 154 | return ( 155 | setValue(v => v + 1)} /> 156 | ) 157 | } 158 | 159 | const {takeRender, replaceSnapshot, render} = createRenderStream({ 160 | initialSnapshot: {unrelatedValue: 'unrelated', value: -1}, 161 | }) 162 | const utils = await render() 163 | const incrementButton = utils.getByText('Increment') 164 | await userEvent.click(incrementButton) 165 | await userEvent.click(incrementButton) 166 | { 167 | const {snapshot} = await takeRender() 168 | expect(snapshot).toEqual({unrelatedValue: 'unrelated', value: 0}) 169 | } 170 | { 171 | const {snapshot} = await takeRender() 172 | expect(snapshot).toEqual({unrelatedValue: 'unrelated', value: 1}) 173 | } 174 | { 175 | const {snapshot} = await takeRender() 176 | expect(snapshot).toEqual({unrelatedValue: 'unrelated', value: 2}) 177 | } 178 | }) 179 | test('requires initialSnapshot', async () => { 180 | function Counter() { 181 | const [value, setValue] = React.useState(0) 182 | replaceSnapshot(() => ({value})) 183 | return ( 184 | setValue(v => v + 1)} /> 185 | ) 186 | } 187 | 188 | const {replaceSnapshot, render} = createRenderStream<{ 189 | value: number 190 | }>() 191 | let caughtError: Error 192 | 193 | const spy = jest.spyOn(console, 'error') 194 | spy.mockImplementation(() => {}) 195 | await render( 196 | { 198 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 199 | caughtError = error 200 | return null 201 | }} 202 | > 203 | 204 | , 205 | ) 206 | spy.mockRestore() 207 | 208 | expect(caughtError!).toMatchInlineSnapshot( 209 | `[Error: Cannot use a function to update the snapshot if no initial snapshot was provided.]`, 210 | ) 211 | }) 212 | }) 213 | }) 214 | 215 | describe('onRender', () => { 216 | test('basic functionality', async () => { 217 | function Counter() { 218 | const [value, setValue] = React.useState(0) 219 | replaceSnapshot({value}) 220 | return ( 221 | setValue(v => v + 1)} /> 222 | ) 223 | } 224 | 225 | const {takeRender, replaceSnapshot, render} = createRenderStream<{ 226 | value: number 227 | }>({ 228 | onRender(info) { 229 | // can use expect here 230 | expect(info.count).toBe(info.snapshot.value + 1) 231 | }, 232 | }) 233 | const utils = await render() 234 | const incrementButton = utils.getByText('Increment') 235 | await userEvent.click(incrementButton) 236 | await userEvent.click(incrementButton) 237 | await takeRender() 238 | await takeRender() 239 | await takeRender() 240 | }) 241 | 242 | test('errors in `onRender` propagate to the associated `takeRender` call', async () => { 243 | function Counter() { 244 | const [value, setValue] = React.useState(0) 245 | return ( 246 | setValue(v => v + 1)} /> 247 | ) 248 | } 249 | 250 | const {takeRender, render} = createRenderStream({ 251 | onRender(info) { 252 | expect(info.count).toBe(1) 253 | }, 254 | }) 255 | 256 | const utils = await render() 257 | const incrementButton = utils.getByText('Increment') 258 | await userEvent.click(incrementButton) 259 | await userEvent.click(incrementButton) 260 | await takeRender() 261 | const error = await getExpectErrorMessage(takeRender()) 262 | 263 | expect(error).toMatchInlineSnapshot(` 264 | expect(received).toBe(expected) // Object.is equality 265 | 266 | Expected: 1 267 | Received: 2 268 | `) 269 | }) 270 | 271 | test('returned `rerender` returns a promise that resolves', async () => { 272 | function Component() { 273 | return null 274 | } 275 | 276 | const {takeRender, render} = createRenderStream() 277 | const {rerender} = await render() 278 | await takeRender() 279 | const promise: Promise = rerender() 280 | expect(promise).toBeInstanceOf(Promise) 281 | await promise 282 | await takeRender() 283 | }) 284 | }) 285 | -------------------------------------------------------------------------------- /src/renderStream/__tests__/useTrackRenders.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unnecessary-condition */ 2 | import {jest, describe, test, expect, beforeEach} from '@jest/globals' 3 | import { 4 | createRenderStream, 5 | useTrackRenders, 6 | } from '@testing-library/react-render-stream' 7 | import * as React from 'react' 8 | import {ErrorBoundary} from 'react-error-boundary' 9 | import {__use} from '../../__testHelpers__/useShim.js' 10 | 11 | type AsyncState = 12 | | {status: 'loading'} 13 | | {status: 'success'; data: string} 14 | | {status: 'error'; error: Error} 15 | 16 | describe('non-suspense use cases', () => { 17 | let asyncAction = Promise.withResolvers() 18 | beforeEach(() => { 19 | asyncAction = Promise.withResolvers() 20 | void asyncAction.promise.catch(() => { 21 | /* avoid uncaught promise rejection */ 22 | }) 23 | }) 24 | function ErrorComponent() { 25 | useTrackRenders() 26 | return null 27 | } 28 | function DataComponent() { 29 | useTrackRenders() 30 | return null 31 | } 32 | function LoadingComponent() { 33 | useTrackRenders() 34 | return null 35 | } 36 | function App() { 37 | useTrackRenders() 38 | const [state, setState] = React.useState({status: 'loading'}) 39 | React.useEffect(() => { 40 | let canceled = false 41 | void (async function iife() { 42 | try { 43 | const data = await asyncAction.promise 44 | if (canceled) return 45 | setState({status: 'success', data}) 46 | } catch (error: any) { 47 | if (canceled) return 48 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 49 | setState({status: 'error', error}) 50 | } 51 | })() 52 | return () => { 53 | canceled = true 54 | } 55 | }, [asyncAction.promise]) 56 | return state.status === 'loading' ? ( 57 | 58 | ) : state.status === 'error' ? ( 59 | 60 | ) : ( 61 | 62 | ) 63 | } 64 | 65 | test('basic functionality', async () => { 66 | const {takeRender, render} = createRenderStream() 67 | await render() 68 | asyncAction.resolve('data') 69 | { 70 | const {renderedComponents} = await takeRender() 71 | expect(renderedComponents).toEqual([App, LoadingComponent]) 72 | } 73 | { 74 | const {renderedComponents} = await takeRender() 75 | expect(renderedComponents).toEqual([App, DataComponent]) 76 | } 77 | }) 78 | 79 | test('error path', async () => { 80 | const {takeRender, render} = createRenderStream() 81 | await render() 82 | asyncAction.reject(new Error('error')) 83 | { 84 | const {renderedComponents} = await takeRender() 85 | expect(renderedComponents).toEqual([App, LoadingComponent]) 86 | } 87 | { 88 | const {renderedComponents} = await takeRender() 89 | expect(renderedComponents).toEqual([App, ErrorComponent]) 90 | } 91 | }) 92 | }) 93 | 94 | describe('suspense use cases', () => { 95 | let asyncAction = Promise.withResolvers() 96 | beforeEach(() => { 97 | asyncAction = Promise.withResolvers() 98 | }) 99 | function ErrorComponent() { 100 | useTrackRenders() 101 | return null 102 | } 103 | function DataComponent() { 104 | useTrackRenders() 105 | __use(asyncAction.promise) 106 | return null 107 | } 108 | function LoadingComponent() { 109 | useTrackRenders() 110 | return null 111 | } 112 | function App() { 113 | useTrackRenders() 114 | return ( 115 | 116 | }> 117 | 118 | 119 | 120 | ) 121 | } 122 | 123 | test('basic functionality', async () => { 124 | const {takeRender, render} = createRenderStream() 125 | await render() 126 | asyncAction.resolve('data') 127 | { 128 | const {renderedComponents} = await takeRender() 129 | expect(renderedComponents).toEqual([App, LoadingComponent]) 130 | } 131 | { 132 | const {renderedComponents} = await takeRender() 133 | expect(renderedComponents).toEqual([DataComponent]) 134 | } 135 | }) 136 | 137 | test('ErrorBoundary', async () => { 138 | const {takeRender, render} = createRenderStream() 139 | await render() 140 | 141 | const spy = jest.spyOn(console, 'error') 142 | spy.mockImplementation(() => {}) 143 | asyncAction.reject(new Error('error')) 144 | { 145 | const {renderedComponents} = await takeRender() 146 | expect(renderedComponents).toEqual([App, LoadingComponent]) 147 | } 148 | { 149 | const {renderedComponents} = await takeRender() 150 | expect(renderedComponents).toEqual([ErrorComponent]) 151 | } 152 | spy.mockRestore() 153 | }) 154 | }) 155 | 156 | test('specifying the `name` option', async () => { 157 | function NamedComponent({name, children}: {name: string; children?: any}) { 158 | useTrackRenders({name: `NamedComponent:${name}`}) 159 | return <>{children} 160 | } 161 | const {takeRender, render} = createRenderStream() 162 | await render( 163 | <> 164 | 165 | 166 | 167 | 168 | 169 | 170 | , 171 | ) 172 | { 173 | const {renderedComponents} = await takeRender() 174 | expect(renderedComponents).toEqual([ 175 | 'NamedComponent:Darth Vader', 176 | // this relies on the order of `useLayoutEffect` being executed, we have no way to influence that siblings seem "backwards" here 177 | 'NamedComponent:Leia', 178 | 'NamedComponent:Luke', 179 | 'NamedComponent:R2D2', 180 | ]) 181 | } 182 | }) 183 | -------------------------------------------------------------------------------- /src/renderStream/context.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'rehackt' 2 | 3 | export interface RenderStreamContextValue { 4 | renderedComponents: Array 5 | } 6 | 7 | const RenderStreamContext = React.createContext< 8 | RenderStreamContextValue | undefined 9 | >(undefined) 10 | 11 | export function RenderStreamContextProvider({ 12 | children, 13 | value, 14 | }: { 15 | children: React.ReactNode 16 | value: RenderStreamContextValue 17 | }) { 18 | const parentContext = useRenderStreamContext() 19 | 20 | if (parentContext) { 21 | throw new Error('Render streams should not be nested in the same tree') 22 | } 23 | 24 | return ( 25 | 26 | {children} 27 | 28 | ) 29 | } 30 | 31 | export function useRenderStreamContext() { 32 | return React.useContext(RenderStreamContext) 33 | } 34 | -------------------------------------------------------------------------------- /src/renderStream/createRenderStream.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'rehackt' 2 | 3 | import {type RenderOptions} from '@testing-library/react/pure.js' 4 | import {Assertable, markAssertable} from '../assertable.js' 5 | import { 6 | renderWithoutAct, 7 | type RenderWithoutActAsync, 8 | } from '../renderWithoutAct.js' 9 | import {RenderInstance, type Render, type BaseRender} from './Render.js' 10 | import {type RenderStreamContextValue} from './context.js' 11 | import {RenderStreamContextProvider} from './context.js' 12 | import {syncQueries, type Queries, type SyncQueries} from './syncQueries.js' 13 | 14 | export type ValidSnapshot = 15 | // eslint-disable-next-line @typescript-eslint/no-invalid-void-type 16 | void | (object & {/* not a function */ call?: never}) 17 | 18 | export interface NextRenderOptions { 19 | timeout?: number 20 | } 21 | 22 | interface ReplaceSnapshot { 23 | (newSnapshot: Snapshot): void 24 | (updateSnapshot: (lastSnapshot: Readonly) => Snapshot): void 25 | } 26 | 27 | interface MergeSnapshot { 28 | (partialSnapshot: Partial): void 29 | ( 30 | updatePartialSnapshot: ( 31 | lastSnapshot: Readonly, 32 | ) => Partial, 33 | ): void 34 | } 35 | 36 | export interface RenderStream< 37 | Snapshot extends ValidSnapshot, 38 | Q extends Queries = SyncQueries, 39 | > { 40 | // Allows for partial updating of the snapshot by shallow merging the results 41 | mergeSnapshot: MergeSnapshot 42 | // Performs a full replacement of the snapshot 43 | replaceSnapshot: ReplaceSnapshot 44 | /** 45 | * An array of all renders that have happened so far. 46 | * Errors thrown during component render will be captured here, too. 47 | */ 48 | renders: Array< 49 | | Render 50 | | {phase: 'snapshotError'; count: number; error: unknown} 51 | > 52 | /** 53 | * Peeks the next render from the current iterator position, without advancing the iterator. 54 | * If no render has happened yet, it will wait for the next render to happen. 55 | * @throws {WaitForRenderTimeoutError} if no render happens within the timeout 56 | */ 57 | peekRender: (options?: NextRenderOptions) => Promise> 58 | /** 59 | * Iterates to the next render and returns it. 60 | * If no render has happened yet, it will wait for the next render to happen. 61 | * @throws {WaitForRenderTimeoutError} if no render happens within the timeout 62 | */ 63 | takeRender: Assertable & 64 | ((options?: NextRenderOptions) => Promise>) 65 | /** 66 | * Returns the total number of renders. 67 | */ 68 | totalRenderCount: () => number 69 | /** 70 | * Returns the current render. 71 | * @throws {Error} if no render has happened yet 72 | */ 73 | getCurrentRender: () => Render 74 | /** 75 | * Waits for the next render to happen. 76 | * Does not advance the render iterator. 77 | */ 78 | waitForNextRender: ( 79 | options?: NextRenderOptions, 80 | ) => Promise> 81 | } 82 | 83 | export interface RenderStreamWithRenderFn< 84 | Snapshot extends ValidSnapshot, 85 | Q extends Queries = SyncQueries, 86 | > extends RenderStream { 87 | render: RenderWithoutActAsync 88 | } 89 | 90 | export type RenderStreamOptions< 91 | Snapshot extends ValidSnapshot, 92 | Q extends Queries = SyncQueries, 93 | > = { 94 | onRender?: ( 95 | info: BaseRender & { 96 | snapshot: Snapshot 97 | replaceSnapshot: ReplaceSnapshot 98 | mergeSnapshot: MergeSnapshot 99 | }, 100 | ) => void 101 | snapshotDOM?: boolean 102 | initialSnapshot?: Snapshot 103 | /** 104 | * This will skip renders during which no renders tracked by 105 | * `useTrackRenders` occured. 106 | */ 107 | skipNonTrackingRenders?: boolean 108 | queries?: Q 109 | } 110 | 111 | export class WaitForRenderTimeoutError extends Error { 112 | constructor() { 113 | super('Exceeded timeout waiting for next render.') 114 | this.name = 'WaitForRenderTimeoutError' 115 | Object.setPrototypeOf(this, new.target.prototype) 116 | } 117 | } 118 | 119 | export function createRenderStream< 120 | Snapshot extends ValidSnapshot = void, 121 | Q extends Queries = SyncQueries, 122 | >({ 123 | onRender, 124 | snapshotDOM = false, 125 | initialSnapshot, 126 | skipNonTrackingRenders, 127 | queries = syncQueries as any as Q, 128 | }: RenderStreamOptions = {}): RenderStreamWithRenderFn< 129 | Snapshot, 130 | Q 131 | > { 132 | // creating the object first and then assigning in all the properties 133 | // allows keeping the object instance for reference while the members are 134 | // created, which is important for the `markAssertable` function 135 | const stream = {} as any as RenderStreamWithRenderFn 136 | 137 | let nextRender: Promise> | undefined, 138 | resolveNextRender: ((render: Render) => void) | undefined, 139 | rejectNextRender: ((error: unknown) => void) | undefined 140 | function resetNextRender() { 141 | nextRender = undefined 142 | resolveNextRender = undefined 143 | rejectNextRender = undefined 144 | } 145 | const snapshotRef = {current: initialSnapshot} 146 | const replaceSnapshot: ReplaceSnapshot = snap => { 147 | if (typeof snap === 'function') { 148 | if (!initialSnapshot) { 149 | throw new Error( 150 | 'Cannot use a function to update the snapshot if no initial snapshot was provided.', 151 | ) 152 | } 153 | snapshotRef.current = snap( 154 | typeof snapshotRef.current === 'object' 155 | ? // "cheap best effort" to prevent accidental mutation of the last snapshot 156 | {...snapshotRef.current} 157 | : snapshotRef.current!, 158 | ) 159 | } else { 160 | snapshotRef.current = snap 161 | } 162 | } 163 | 164 | const mergeSnapshot: MergeSnapshot = partialSnapshot => { 165 | replaceSnapshot(snapshot => ({ 166 | ...snapshot, 167 | ...(typeof partialSnapshot === 'function' 168 | ? partialSnapshot(snapshot) 169 | : partialSnapshot), 170 | })) 171 | } 172 | 173 | const renderStreamContext: RenderStreamContextValue = { 174 | renderedComponents: [], 175 | } 176 | 177 | const profilerOnRender: React.ProfilerOnRenderCallback = ( 178 | id, 179 | phase, 180 | actualDuration, 181 | baseDuration, 182 | startTime, 183 | commitTime, 184 | ) => { 185 | if ( 186 | skipNonTrackingRenders && 187 | renderStreamContext.renderedComponents.length === 0 188 | ) { 189 | return 190 | } 191 | 192 | const renderBase = { 193 | id, 194 | phase, 195 | actualDuration, 196 | baseDuration, 197 | startTime, 198 | commitTime, 199 | count: stream.renders.length + 1, 200 | } 201 | try { 202 | /* 203 | * The `onRender` function could contain `expect` calls that throw 204 | * `JestAssertionError`s - but we are still inside of React, where errors 205 | * might be swallowed. 206 | * So we record them and re-throw them in `takeRender` 207 | * Additionally, we reject the `waitForNextRender` promise. 208 | */ 209 | onRender?.({ 210 | ...renderBase, 211 | replaceSnapshot, 212 | mergeSnapshot, 213 | snapshot: snapshotRef.current!, 214 | }) 215 | 216 | const snapshot = snapshotRef.current as Snapshot 217 | const domSnapshot = snapshotDOM 218 | ? window.document.body.innerHTML 219 | : undefined 220 | const render = new RenderInstance( 221 | renderBase, 222 | snapshot, 223 | domSnapshot, 224 | renderStreamContext.renderedComponents, 225 | queries, 226 | ) 227 | renderStreamContext.renderedComponents = [] 228 | stream.renders.push(render) 229 | resolveNextRender?.(render) 230 | } catch (error) { 231 | stream.renders.push({ 232 | phase: 'snapshotError', 233 | count: stream.renders.length, 234 | error, 235 | }) 236 | rejectNextRender?.(error) 237 | } finally { 238 | resetNextRender() 239 | } 240 | } 241 | 242 | let iteratorPosition = 0 243 | function Wrapper({children}: {children: React.ReactNode}) { 244 | return ( 245 | 246 | 247 | {children} 248 | 249 | 250 | ) 251 | } 252 | 253 | const render: RenderWithoutActAsync = (async ( 254 | ui: React.ReactNode, 255 | options?: RenderOptions, 256 | ) => { 257 | const ret = await renderWithoutAct(ui, { 258 | ...options, 259 | wrapper: props => { 260 | const ParentWrapper = options?.wrapper ?? React.Fragment 261 | return ( 262 | 263 | {props.children} 264 | 265 | ) 266 | }, 267 | }) 268 | if (stream.renders.length === 0) { 269 | await stream.waitForNextRender() 270 | } 271 | const origRerender = ret.rerender 272 | ret.rerender = async function rerender(rerenderUi: React.ReactNode) { 273 | const previousRenderCount = stream.renders.length 274 | try { 275 | return await origRerender(rerenderUi) 276 | } finally { 277 | // only wait for the next render if the rerender was not 278 | // synchronous (React 17) 279 | if (previousRenderCount === stream.renders.length) { 280 | await stream.waitForNextRender() 281 | } 282 | } 283 | } 284 | return ret 285 | }) as unknown as RenderWithoutActAsync // TODO 286 | 287 | Object.assign(stream, { 288 | replaceSnapshot, 289 | mergeSnapshot, 290 | renders: new Array< 291 | | Render 292 | | {phase: 'snapshotError'; count: number; error: unknown} 293 | >(), 294 | totalRenderCount() { 295 | return stream.renders.length 296 | }, 297 | async peekRender(options: NextRenderOptions = {}) { 298 | try { 299 | if (iteratorPosition < stream.renders.length) { 300 | const peekedRender = stream.renders[iteratorPosition] 301 | 302 | if (peekedRender.phase === 'snapshotError') { 303 | throw peekedRender.error 304 | } 305 | 306 | return peekedRender 307 | } 308 | return await stream 309 | .waitForNextRender(options) 310 | .catch(rethrowWithCapturedStackTrace(stream.peekRender)) 311 | } finally { 312 | /** drain microtask queue */ 313 | await new Promise(resolve => { 314 | setTimeout(() => { 315 | resolve() 316 | }, 0) 317 | }) 318 | } 319 | }, 320 | takeRender: markAssertable(async function takeRender( 321 | options: NextRenderOptions = {}, 322 | ) { 323 | let error: unknown 324 | 325 | try { 326 | return await stream.peekRender({ 327 | ...options, 328 | }) 329 | } catch (e) { 330 | if (e instanceof Object) { 331 | Error.captureStackTrace(e, stream.takeRender) 332 | } 333 | error = e 334 | throw e 335 | } finally { 336 | if (!(error && error instanceof WaitForRenderTimeoutError)) { 337 | iteratorPosition++ 338 | } 339 | } 340 | }, stream), 341 | getCurrentRender() { 342 | // The "current" render should point at the same render that the most 343 | // recent `takeRender` call returned, so we need to get the "previous" 344 | // iterator position, otherwise `takeRender` advances the iterator 345 | // to the next render. This means we need to call `takeRender` at least 346 | // once before we can get a current render. 347 | const currentPosition = iteratorPosition - 1 348 | 349 | if (currentPosition < 0) { 350 | throw new Error( 351 | 'No current render available. You need to call `takeRender` before you can get the current render.', 352 | ) 353 | } 354 | 355 | const currentRender = stream.renders[currentPosition] 356 | 357 | if (currentRender.phase === 'snapshotError') { 358 | throw currentRender.error 359 | } 360 | return currentRender 361 | }, 362 | waitForNextRender({timeout = 1000}: NextRenderOptions = {}) { 363 | if (!nextRender) { 364 | nextRender = Promise.race>([ 365 | new Promise>((resolve, reject) => { 366 | resolveNextRender = resolve 367 | rejectNextRender = reject 368 | }), 369 | new Promise>((_, reject) => 370 | setTimeout(() => { 371 | const error = new WaitForRenderTimeoutError() 372 | Error.captureStackTrace(error, stream.waitForNextRender) 373 | reject(error) 374 | resetNextRender() 375 | }, timeout), 376 | ), 377 | ]) 378 | } 379 | return nextRender 380 | }, 381 | render, 382 | }) 383 | return stream 384 | } 385 | 386 | function rethrowWithCapturedStackTrace(constructorOpt: Function | undefined) { 387 | return function catchFn(error: unknown) { 388 | if (error instanceof Object) { 389 | Error.captureStackTrace(error, constructorOpt) 390 | } 391 | throw error 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /src/renderStream/syncQueries.ts: -------------------------------------------------------------------------------- 1 | import {queries} from '@testing-library/dom' 2 | 3 | export type {Queries} from '@testing-library/dom' 4 | 5 | type OriginalQueries = typeof queries 6 | 7 | export type SyncQueries = { 8 | [K in keyof OriginalQueries as K extends `${'find'}${string}` 9 | ? never 10 | : K]: OriginalQueries[K] 11 | } 12 | 13 | export const syncQueries = Object.fromEntries( 14 | Object.entries(queries).filter( 15 | ([key]) => key.startsWith('get') || key.startsWith('query'), 16 | ), 17 | ) as any as SyncQueries 18 | 19 | export type BoundFunction = T extends ( 20 | container: HTMLElement, 21 | ...args: infer P 22 | ) => infer R 23 | ? (...args: P) => R 24 | : never 25 | 26 | export type BoundSyncFunctions = Q extends typeof syncQueries 27 | ? { 28 | getByLabelText( 29 | ...args: Parameters>> 30 | ): ReturnType> 31 | getAllByLabelText( 32 | ...args: Parameters>> 33 | ): ReturnType> 34 | queryByLabelText( 35 | ...args: Parameters>> 36 | ): ReturnType> 37 | queryAllByLabelText( 38 | ...args: Parameters>> 39 | ): ReturnType> 40 | getByPlaceholderText( 41 | ...args: Parameters>> 42 | ): ReturnType> 43 | getAllByPlaceholderText( 44 | ...args: Parameters>> 45 | ): ReturnType> 46 | queryByPlaceholderText( 47 | ...args: Parameters>> 48 | ): ReturnType> 49 | queryAllByPlaceholderText( 50 | ...args: Parameters>> 51 | ): ReturnType> 52 | getByText( 53 | ...args: Parameters>> 54 | ): ReturnType> 55 | getAllByText( 56 | ...args: Parameters>> 57 | ): ReturnType> 58 | queryByText( 59 | ...args: Parameters>> 60 | ): ReturnType> 61 | queryAllByText( 62 | ...args: Parameters>> 63 | ): ReturnType> 64 | getByAltText( 65 | ...args: Parameters>> 66 | ): ReturnType> 67 | getAllByAltText( 68 | ...args: Parameters>> 69 | ): ReturnType> 70 | queryByAltText( 71 | ...args: Parameters>> 72 | ): ReturnType> 73 | queryAllByAltText( 74 | ...args: Parameters>> 75 | ): ReturnType> 76 | getByTitle( 77 | ...args: Parameters>> 78 | ): ReturnType> 79 | getAllByTitle( 80 | ...args: Parameters>> 81 | ): ReturnType> 82 | queryByTitle( 83 | ...args: Parameters>> 84 | ): ReturnType> 85 | queryAllByTitle( 86 | ...args: Parameters>> 87 | ): ReturnType> 88 | getByDisplayValue( 89 | ...args: Parameters>> 90 | ): ReturnType> 91 | getAllByDisplayValue( 92 | ...args: Parameters>> 93 | ): ReturnType> 94 | queryByDisplayValue( 95 | ...args: Parameters>> 96 | ): ReturnType> 97 | queryAllByDisplayValue( 98 | ...args: Parameters>> 99 | ): ReturnType> 100 | getByRole( 101 | ...args: Parameters>> 102 | ): ReturnType> 103 | getAllByRole( 104 | ...args: Parameters>> 105 | ): ReturnType> 106 | queryByRole( 107 | ...args: Parameters>> 108 | ): ReturnType> 109 | queryAllByRole( 110 | ...args: Parameters>> 111 | ): ReturnType> 112 | getByTestId( 113 | ...args: Parameters>> 114 | ): ReturnType> 115 | getAllByTestId( 116 | ...args: Parameters>> 117 | ): ReturnType> 118 | queryByTestId( 119 | ...args: Parameters>> 120 | ): ReturnType> 121 | queryAllByTestId( 122 | ...args: Parameters>> 123 | ): ReturnType> 124 | } & { 125 | [P in keyof Q]: BoundFunction 126 | } 127 | : { 128 | [P in keyof Q]: BoundFunction 129 | } 130 | -------------------------------------------------------------------------------- /src/renderStream/useTrackRenders.ts: -------------------------------------------------------------------------------- 1 | import React from 'rehackt' 2 | import {useRenderStreamContext} from './context.js' 3 | 4 | function resolveR18HookOwner(): React.ComponentType | undefined { 5 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 6 | return (React as any).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED 7 | ?.ReactCurrentOwner?.current?.elementType 8 | } 9 | 10 | function resolveR19HookOwner(): React.ComponentType | undefined { 11 | /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ 12 | return ( 13 | React as any 14 | ).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE?.A?.getOwner() 15 | .elementType 16 | } 17 | 18 | export function useTrackRenders({name}: {name?: string} = {}) { 19 | const component = name ?? resolveR18HookOwner() ?? resolveR19HookOwner() 20 | 21 | if (!component) { 22 | throw new Error( 23 | 'useTrackRenders: Unable to determine component. Please ensure the hook is called inside a rendered component or provide a `name` option.', 24 | ) 25 | } 26 | 27 | const ctx = useRenderStreamContext() 28 | 29 | if (!ctx) { 30 | throw new Error( 31 | 'useTrackRenders: A Render Stream must be created and rendered to track component renders', 32 | ) 33 | } 34 | 35 | React.useLayoutEffect(() => { 36 | ctx.renderedComponents.unshift(component) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/renderWithoutAct.tsx: -------------------------------------------------------------------------------- 1 | import * as ReactDOMClient from 'react-dom/client' 2 | import * as ReactDOM from 'react-dom' 3 | import {type RenderOptions} from '@testing-library/react/pure.js' 4 | import { 5 | BoundFunction, 6 | getQueriesForElement, 7 | prettyDOM, 8 | prettyFormat, 9 | type Queries, 10 | } from '@testing-library/dom' 11 | import React from 'react' 12 | import {SyncQueries} from './renderStream/syncQueries.js' 13 | import { 14 | disableActEnvironment, 15 | DisableActEnvironmentOptions, 16 | } from './disableActEnvironment.js' 17 | 18 | // Ideally we'd just use a WeakMap where containers are keys and roots are values. 19 | // We use two variables so that we can bail out in constant time when we render with a new container (most common use case) 20 | 21 | const mountedContainers: Set = new Set() 22 | const mountedRootEntries: Array<{ 23 | container: import('react-dom').Container 24 | root: ReturnType 25 | }> = [] 26 | 27 | export type AsyncRenderResult< 28 | Q extends Queries = SyncQueries, 29 | Container extends ReactDOMClient.Container = HTMLElement, 30 | BaseElement extends ReactDOMClient.Container = Container, 31 | > = { 32 | container: Container 33 | baseElement: BaseElement 34 | debug: ( 35 | baseElement?: 36 | | ReactDOMClient.Container 37 | | Array 38 | | undefined, 39 | maxLength?: number | undefined, 40 | options?: prettyFormat.OptionsReceived | undefined, 41 | ) => void 42 | rerender: (rerenderUi: React.ReactNode) => Promise 43 | unmount: () => void 44 | asFragment: () => DocumentFragment 45 | } & {[P in keyof Q]: BoundFunction} 46 | 47 | function renderRoot( 48 | ui: React.ReactNode, 49 | { 50 | baseElement, 51 | container, 52 | queries, 53 | wrapper: WrapperComponent, 54 | root, 55 | }: Pick, 'queries' | 'wrapper'> & { 56 | baseElement: ReactDOMClient.Container 57 | container: ReactDOMClient.Container 58 | root: ReturnType 59 | }, 60 | ): AsyncRenderResult<{}, any, any> { 61 | root.render( 62 | WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui, 63 | ) 64 | 65 | return { 66 | container, 67 | baseElement, 68 | debug: (el = baseElement, maxLength, options) => 69 | Array.isArray(el) 70 | ? // eslint-disable-next-line no-console 71 | el.forEach(e => 72 | console.log(prettyDOM(e as Element, maxLength, options)), 73 | ) 74 | : // eslint-disable-next-line no-console, 75 | console.log(prettyDOM(el as Element, maxLength, options)), 76 | unmount: () => { 77 | root.unmount() 78 | }, 79 | rerender: async rerenderUi => { 80 | renderRoot(rerenderUi, { 81 | container, 82 | baseElement, 83 | root, 84 | wrapper: WrapperComponent, 85 | }) 86 | // Intentionally do not return anything to avoid unnecessarily complicating the API. 87 | // folks can use all the same utilities we return in the first place that are bound to the container 88 | }, 89 | asFragment: () => { 90 | /* istanbul ignore else (old jsdom limitation) */ 91 | if (typeof document.createRange === 'function') { 92 | return document 93 | .createRange() 94 | .createContextualFragment((container as HTMLElement).innerHTML) 95 | } else { 96 | const template = document.createElement('template') 97 | template.innerHTML = (container as HTMLElement).innerHTML 98 | return template.content 99 | } 100 | }, 101 | ...getQueriesForElement(baseElement as HTMLElement, queries), 102 | } 103 | } 104 | 105 | export type RenderWithoutActAsync = { 106 | < 107 | Q extends Queries = SyncQueries, 108 | Container extends ReactDOMClient.Container = HTMLElement, 109 | BaseElement extends ReactDOMClient.Container = Container, 110 | >( 111 | this: any, 112 | ui: React.ReactNode, 113 | options: Pick< 114 | RenderOptions, 115 | 'container' | 'baseElement' | 'queries' | 'wrapper' 116 | >, 117 | ): Promise> 118 | ( 119 | this: any, 120 | ui: React.ReactNode, 121 | options?: 122 | | Pick 123 | | undefined, 124 | ): Promise< 125 | AsyncRenderResult< 126 | SyncQueries, 127 | ReactDOMClient.Container, 128 | ReactDOMClient.Container 129 | > 130 | > 131 | } 132 | 133 | export const renderWithoutAct = 134 | _renderWithoutAct as unknown as RenderWithoutActAsync 135 | 136 | async function _renderWithoutAct( 137 | ui: React.ReactNode, 138 | { 139 | container, 140 | baseElement = container, 141 | queries, 142 | wrapper, 143 | }: Pick< 144 | RenderOptions, 145 | 'container' | 'baseElement' | 'wrapper' | 'queries' 146 | > = {}, 147 | ): Promise> { 148 | if (!baseElement) { 149 | // default to document.body instead of documentElement to avoid output of potentially-large 150 | // head elements (such as JSS style blocks) in debug output 151 | baseElement = document.body 152 | } 153 | if (!container) { 154 | container = baseElement.appendChild(document.createElement('div')) 155 | } 156 | 157 | let root: ReturnType 158 | // 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. 159 | if (!mountedContainers.has(container)) { 160 | root = ( 161 | ReactDOM.version.startsWith('16') || ReactDOM.version.startsWith('17') 162 | ? createLegacyRoot 163 | : createConcurrentRoot 164 | )(container) 165 | mountedRootEntries.push({container, root}) 166 | // we'll add it to the mounted containers regardless of whether it's actually 167 | // added to document.body so the cleanup method works regardless of whether 168 | // they're passing us a custom container or not. 169 | mountedContainers.add(container) 170 | } else { 171 | mountedRootEntries.forEach(rootEntry => { 172 | // Else is unreachable since `mountedContainers` has the `container`. 173 | // Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries` 174 | /* istanbul ignore else */ 175 | if (rootEntry.container === container) { 176 | root = rootEntry.root 177 | } 178 | }) 179 | } 180 | 181 | return renderRoot(ui, { 182 | baseElement, 183 | container, 184 | queries, 185 | wrapper, 186 | root: root!, 187 | }) 188 | } 189 | 190 | function createLegacyRoot(container: ReactDOMClient.Container) { 191 | return { 192 | render(element: React.ReactNode) { 193 | ReactDOM.render(element as unknown as React.ReactElement, container) 194 | }, 195 | unmount() { 196 | ReactDOM.unmountComponentAtNode(container) 197 | }, 198 | } 199 | } 200 | 201 | function createConcurrentRoot(container: ReactDOMClient.Container) { 202 | const anyThis = globalThis as any as {IS_REACT_ACT_ENVIRONMENT?: boolean} 203 | if (anyThis.IS_REACT_ACT_ENVIRONMENT) { 204 | throw new Error(`Tried to create a React root for a render stream inside a React act environment. 205 | This is not supported. Please use \`disableActEnvironment\` to disable the act environment for this test.`) 206 | } 207 | const root = ReactDOMClient.createRoot(container) 208 | 209 | return { 210 | render(element: React.ReactNode) { 211 | if (anyThis.IS_REACT_ACT_ENVIRONMENT) { 212 | throw new Error(`Tried to render a render stream inside a React act environment. 213 | This is not supported. Please use \`disableActEnvironment\` to disable the act environment for this test.`) 214 | } 215 | root.render(element) 216 | }, 217 | unmount() { 218 | root.unmount() 219 | }, 220 | } 221 | } 222 | 223 | export function cleanup() { 224 | if (!mountedRootEntries.length) { 225 | // nothing to clean up 226 | return 227 | } 228 | 229 | // there is a good chance this happens outside of a test, where the user 230 | // has no control over enabling or disabling the React Act environment, 231 | // so we do it for them here. 232 | 233 | const disabledAct = disableActEnvironment({ 234 | preventModification: false, 235 | adjustTestingLibConfig: false, 236 | } satisfies /* ensure that all possible options are passed here in case we add more in the future */ Required) 237 | try { 238 | for (const {root, container} of mountedRootEntries) { 239 | root.unmount() 240 | 241 | if (container.parentNode === document.body) { 242 | document.body.removeChild(container) 243 | } 244 | } 245 | mountedRootEntries.length = 0 246 | mountedContainers.clear() 247 | } finally { 248 | disabledAct.cleanup() 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /tests/polyfill.js: -------------------------------------------------------------------------------- 1 | import {TextEncoder, TextDecoder} from 'util' 2 | 3 | global.TextEncoder = TextEncoder 4 | global.TextDecoder = TextDecoder 5 | 6 | Symbol.dispose = Symbol('Symbol.dispose') 7 | -------------------------------------------------------------------------------- /tests/setup-env.js: -------------------------------------------------------------------------------- 1 | import './polyfill.js' 2 | 3 | Object.defineProperty(global, 'IS_REACT_ACT_ENVIRONMENT', { 4 | get() { 5 | return false 6 | }, 7 | set(value) { 8 | if (!!value) { 9 | throw new Error( 10 | 'Cannot set IS_REACT_ACT_ENVIRONMENT to true, this probably pulled in some RTL dependency?', 11 | ) 12 | } 13 | }, 14 | configurable: true, 15 | }) 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "target": "esnext", 7 | "rootDir": ".", 8 | "outDir": "dist", 9 | "declaration": true, 10 | "sourceMap": true, 11 | "jsx": "react", 12 | "allowJs": true, 13 | "declarationMap": true, 14 | "types": [ 15 | "react", 16 | "node" 17 | ], 18 | "esModuleInterop": true, 19 | "allowSyntheticDefaultImports": true, 20 | "paths": { 21 | "@testing-library/react-render-stream": [ 22 | "./src/index.ts" 23 | ], 24 | "@testing-library/react-render-stream/pure": [ 25 | "./src/pure.ts" 26 | ] 27 | } 28 | }, 29 | "include": [ 30 | "src", 31 | "tests" 32 | ] 33 | } -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: { 5 | index: 'src/index.ts', 6 | pure: 'src/pure.ts', 7 | expect: 'src/expect/index.ts', 8 | }, 9 | splitting: false, 10 | sourcemap: true, 11 | clean: true, 12 | dts: true, 13 | format: ['cjs', 'esm'], 14 | target: ['node20'], 15 | external: [/^@testing-library\/react-render-stream/], 16 | }) 17 | --------------------------------------------------------------------------------