Skip to content

Commit

Permalink
feat: react-dom and SSR compatible rendering
Browse files Browse the repository at this point in the history
- Abstracted rendering out of library core to allow different types of renderers
- Auto-detection of `react-test-renderer` or `react-dom` renderers.  Submodules for:
   - `dom` (`react-dom`)
   - `native` (`react-test-renderer`)
   - `server` (`react-dom/server`)

Co-authored-by: Michael Peyper <mpeyper7@gmail.com>

BREAKING CHANGE: Importing from `renderHook` and `act` from `@testing-library/react-hooks` will now auto-detect which renderer to used based on the project's dependencies
- `peerDependencies` are now optional to support different dependencies being required
   - This means there will be no warning if the dependency is not installed at all, but it will still warn if an incompatible version is installed
  • Loading branch information
joshuaellis authored and mpeyper committed Jan 7, 2021
1 parent b35b152 commit a25993f
Show file tree
Hide file tree
Showing 74 changed files with 2,568 additions and 201 deletions.
10 changes: 8 additions & 2 deletions .all-contributorsrc
Expand Up @@ -19,6 +19,8 @@
"code",
"doc",
"infra",
"maintenance",
"question",
"test"
]
},
Expand Down Expand Up @@ -199,7 +201,11 @@
"profile": "https://github.com/joshuaellis",
"contributions": [
"doc",
"question"
"question",
"code",
"ideas",
"maintenance",
"test"
]
},
{
Expand Down Expand Up @@ -450,4 +456,4 @@
]
}
]
}
}
4 changes: 4 additions & 0 deletions .eslintignore
@@ -1,5 +1,9 @@
node_modules
coverage
lib
dom
native
server
pure
.docz
site
3 changes: 1 addition & 2 deletions .eslintrc
Expand Up @@ -6,12 +6,11 @@
"no-await-in-loop": "off",
"no-console": "off",
"import/no-unresolved": "off",
"react-hooks/rules-of-hooks": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-unnecessary-condition": "off",
"@typescript-eslint/no-invalid-void-type": "off"
},
"parserOptions": {
"project": ["./tsconfig.json", "./test/tsconfig.json"]
"project": ["./tsconfig.json", "./test/tsconfig.json", "./scripts/tsconfig.json"]
}
}
7 changes: 6 additions & 1 deletion .gitignore
@@ -1,5 +1,10 @@
node_modules
coverage
lib
dom
native
server
pure
.docz
site
site
.vscode
4 changes: 2 additions & 2 deletions README.md
Expand Up @@ -166,7 +166,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://github.com/mpeyper"><img src="https://avatars0.githubusercontent.com/u/23029903?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Peyper</b></sub></a><br /><a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=mpeyper" title="Code">💻</a> <a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=mpeyper" title="Documentation">📖</a> <a href="#infra-mpeyper" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=mpeyper" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/mpeyper"><img src="https://avatars0.githubusercontent.com/u/23029903?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Peyper</b></sub></a><br /><a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=mpeyper" title="Code">💻</a> <a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=mpeyper" title="Documentation">📖</a> <a href="#ideas-mpeyper" title="Ideas, Planning, & Feedback">🤔</a> <a href="#infra-mpeyper" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#maintenance-mpeyper" title="Maintenance">🚧</a> <a href="#question-mpeyper" title="Answering Questions">💬</a> <a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=mpeyper" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/otofu-square"><img src="https://avatars0.githubusercontent.com/u/10118235?v=4?s=100" width="100px;" alt=""/><br /><sub><b>otofu-square</b></sub></a><br /><a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=otofu-square" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/ab18556"><img src="https://avatars2.githubusercontent.com/u/988696?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Patrick P. Henley</b></sub></a><br /><a href="#ideas-ab18556" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/testing-library/react-hooks-testing-library/pulls?q=is%3Apr+reviewed-by%3Aab18556" title="Reviewed Pull Requests">👀</a></td>
<td align="center"><a href="https://twitter.com/matiosfm"><img src="https://avatars3.githubusercontent.com/u/7120471?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Matheus Marques</b></sub></a><br /><a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=marquesm91" title="Code">💻</a></td>
Expand All @@ -189,7 +189,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/hemlok"><img src="https://avatars2.githubusercontent.com/u/9043345?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Adam Seckel</b></sub></a><br /><a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=hemlok" title="Code">💻</a></td>
<td align="center"><a href="https://keiya01.github.io/portfolio"><img src="https://avatars1.githubusercontent.com/u/34934510?v=4?s=100" width="100px;" alt=""/><br /><sub><b>keiya sasaki</b></sub></a><br /><a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=keiya01" title="Tests">⚠️</a></td>
<td align="center"><a href="https://huchen.dev/"><img src="https://avatars3.githubusercontent.com/u/2078389?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Hu Chen</b></sub></a><br /><a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=huchenme" title="Code">💻</a> <a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=huchenme" title="Documentation">📖</a> <a href="#example-huchenme" title="Examples">💡</a></td>
<td align="center"><a href="https://github.com/joshuaellis"><img src="https://avatars0.githubusercontent.com/u/37798644?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Josh</b></sub></a><br /><a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=joshuaellis" title="Documentation">📖</a> <a href="#question-joshuaellis" title="Answering Questions">💬</a></td>
<td align="center"><a href="https://github.com/joshuaellis"><img src="https://avatars0.githubusercontent.com/u/37798644?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Josh</b></sub></a><br /><a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=joshuaellis" title="Documentation">📖</a> <a href="#question-joshuaellis" title="Answering Questions">💬</a> <a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=joshuaellis" title="Code">💻</a> <a href="#ideas-joshuaellis" title="Ideas, Planning, & Feedback">🤔</a> <a href="#maintenance-joshuaellis" title="Maintenance">🚧</a> <a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=joshuaellis" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/Goldziher"><img src="https://avatars1.githubusercontent.com/u/30733348?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Na'aman Hirschfeld</b></sub></a><br /><a href="https://github.com/testing-library/react-hooks-testing-library/commits?author=Goldziher" title="Code">💻</a></td>
</tr>
<tr>
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Expand Up @@ -3,5 +3,5 @@ const { jest: jestConfig } = require('kcd-scripts/config')

module.exports = Object.assign(jestConfig, {
roots: ['<rootDir>/src', '<rootDir>/test'],
testMatch: ['<rootDir>/test/*.(ts|tsx|js)']
testMatch: ['<rootDir>/test/**/*.(ts|tsx|js)']
})
23 changes: 21 additions & 2 deletions package.json
Expand Up @@ -14,7 +14,10 @@
"files": [
"lib",
"src",
"pure.js",
"dom",
"native",
"server",
"pure",
"dont-cleanup-after-each.js"
],
"author": "Michael Peyper <mpeyper7@gmail.com>",
Expand All @@ -27,7 +30,8 @@
"setup": "npm install && npm run validate -s",
"validate": "kcd-scripts validate",
"prepare": "npm run build",
"build": "kcd-scripts build --out-dir lib",
"build": "kcd-scripts build --out-dir lib && npm run generate:submodules",
"generate:submodules": "ts-node scripts/generate-submodules.ts",
"test": "kcd-scripts test",
"typecheck": "kcd-scripts typecheck",
"lint": "kcd-scripts lint",
Expand All @@ -40,6 +44,7 @@
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/react": ">=16.9.0",
"@types/react-dom": ">=16.9.0",
"@types/react-test-renderer": ">=16.9.0"
},
"devDependencies": {
Expand All @@ -54,11 +59,25 @@
"kcd-scripts": "7.5.3",
"prettier": "^2.2.1",
"react": "17.0.1",
"react-dom": "^17.0.1",
"react-test-renderer": "17.0.1",
"ts-node": "^9.1.1",
"typescript": "4.1.3"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0",
"react-test-renderer": ">=16.9.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-test-renderer": {
"optional": true
}
}
}
2 changes: 0 additions & 2 deletions pure.js

This file was deleted.

63 changes: 63 additions & 0 deletions scripts/generate-submodules.ts
@@ -0,0 +1,63 @@
import fs from 'fs'
import path from 'path'

type Template = (submodule: string) => string

const templates = {
index: {
'.js': (submodule: string) => `module.exports = require('../lib/${submodule}')`,
'.d.ts': (submodule: string) => `export * from '../lib/${submodule}'`
},
pure: {
'.js': (submodule: string) => `module.exports = require('../lib/${submodule}/pure')`,
'.d.ts': (submodule: string) => `export * from '../lib/${submodule}/pure'`
}
}

const submodules = ['dom', 'native', 'server', 'pure']

function cleanDirectory(directory: string) {
const files = fs.readdirSync(directory)
files.forEach((file) => fs.unlinkSync(path.join(directory, file)))
}

function makeDirectory(submodule: string) {
const submoduleDir = path.join(process.cwd(), submodule)

if (fs.existsSync(submoduleDir)) {
cleanDirectory(submoduleDir)
} else {
fs.mkdirSync(submoduleDir)
}

return submoduleDir
}

function requiredFile(submodule: string) {
return ([name]: [string, unknown]) => {
return name !== submodule
}
}

function makeFile(directory: string, submodule: string) {
return ([name, extensions]: [string, Record<string, Template>]) => {
Object.entries(extensions).forEach(([extension, template]) => {
const fileName = `${name}${extension}`
console.log(` - ${fileName}`)
const filePath = path.join(directory, fileName)
fs.writeFileSync(filePath, template(submodule))
})
}
}

function makeFiles(directory: string, submodule: string) {
Object.entries(templates).filter(requiredFile(submodule)).forEach(makeFile(directory, submodule))
}

function createSubmodule(submodule: string) {
console.log(`Generating submodule: ${submodule}`)
const submoduleDir = makeDirectory(submodule)
makeFiles(submoduleDir, submodule)
}

submodules.forEach(createSubmodule)
8 changes: 8 additions & 0 deletions scripts/tsconfig.json
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig",
"compilerOptions": {
"declaration": false
},
"exclude": [],
"include": ["./**/*.ts"]
}
34 changes: 10 additions & 24 deletions src/asyncUtils.ts → src/core/asyncUtils.ts
@@ -1,28 +1,15 @@
import { act } from 'react-test-renderer'
import { Act, WaitOptions, AsyncUtils } from '../types'

export interface WaitOptions {
interval?: number
timeout?: number
suppressErrors?: boolean
}

class TimeoutError extends Error {
constructor(util: Function, timeout: number) {
super(`Timed out in ${util.name} after ${timeout}ms.`)
}
}

function resolveAfter(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
import { resolveAfter } from '../helpers/promises'
import { TimeoutError } from '../helpers/error'

function asyncUtils(addResolver: (callback: () => void) => void) {
function asyncUtils(act: Act, addResolver: (callback: () => void) => void): AsyncUtils {
let nextUpdatePromise: Promise<void> | null = null

const waitForNextUpdate = async ({ timeout }: Pick<WaitOptions, 'timeout'> = {}) => {
if (!nextUpdatePromise) {
if (nextUpdatePromise) {
await nextUpdatePromise
} else {
nextUpdatePromise = new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout>
if (timeout && timeout > 0) {
Expand All @@ -39,7 +26,6 @@ function asyncUtils(addResolver: (callback: () => void) => void) {
})
await act(() => nextUpdatePromise as Promise<void>)
}
await nextUpdatePromise
}

const waitFor = async (
Expand All @@ -52,7 +38,7 @@ function asyncUtils(addResolver: (callback: () => void) => void) {
return callbackResult ?? callbackResult === undefined
} catch (error: unknown) {
if (!suppressErrors) {
throw error as Error
throw error
}
return undefined
}
Expand All @@ -76,7 +62,7 @@ function asyncUtils(addResolver: (callback: () => void) => void) {
if (error instanceof TimeoutError && initialTimeout) {
throw new TimeoutError(waitFor, initialTimeout)
}
throw error as Error
throw error
}
if (timeout) timeout -= Date.now() - startTime
}
Expand All @@ -98,7 +84,7 @@ function asyncUtils(addResolver: (callback: () => void) => void) {
if (error instanceof TimeoutError && options.timeout) {
throw new TimeoutError(waitForValueToChange, options.timeout)
}
throw error as Error
throw error
}
}

Expand Down
11 changes: 10 additions & 1 deletion src/cleanup.ts → src/core/cleanup.ts
Expand Up @@ -16,4 +16,13 @@ function removeCleanup(callback: () => Promise<void> | void) {
cleanupCallbacks = cleanupCallbacks.filter((cb) => cb !== callback)
}

export { cleanup, addCleanup, removeCleanup }
function autoRegisterCleanup() {
// Automatically registers cleanup in supported testing frameworks
if (typeof afterEach === 'function' && !process.env.RHTL_SKIP_AUTO_CLEANUP) {
afterEach(async () => {
await cleanup()
})
}
}

export { cleanup, addCleanup, removeCleanup, autoRegisterCleanup }
78 changes: 78 additions & 0 deletions src/core/index.ts
@@ -0,0 +1,78 @@
import { CreateRenderer, Renderer, RenderResult, RenderHook } from '../types'
import { ResultContainer, RenderHookOptions } from '../types/internal'

import asyncUtils from './asyncUtils'
import { cleanup, addCleanup, removeCleanup } from './cleanup'

function resultContainer<TValue>(): ResultContainer<TValue> {
const results: Array<{ value?: TValue; error?: Error }> = []
const resolvers: Array<() => void> = []

const result: RenderResult<TValue> = {
get all() {
return results.map(({ value, error }) => error ?? value)
},
get current() {
const { value, error } = results[results.length - 1]
if (error) {
throw error
}
return value as TValue
},
get error() {
const { error } = results[results.length - 1]
return error
}
}

const updateResult = (value?: TValue, error?: Error) => {
results.push({ value, error })
resolvers.splice(0, resolvers.length).forEach((resolve) => resolve())
}

return {
result,
addResolver: (resolver: () => void) => {
resolvers.push(resolver)
},
setValue: (value: TValue) => updateResult(value),
setError: (error: Error) => updateResult(undefined, error)
}
}

const createRenderHook = <TProps, TResult, TOptions extends {}, TRenderer extends Renderer<TProps>>(
createRenderer: CreateRenderer<TProps, TResult, TOptions, TRenderer>
) => (
callback: (props: TProps) => TResult,
options: RenderHookOptions<TProps, TOptions> = {} as RenderHookOptions<TProps, TOptions>
): RenderHook<TProps, TResult, TRenderer> => {
const { result, setValue, setError, addResolver } = resultContainer<TResult>()
const renderProps = { callback, setValue, setError }
let hookProps = options.initialProps

const { render, rerender, unmount, act, ...renderUtils } = createRenderer(renderProps, options)

render(hookProps)

function rerenderHook(newProps = hookProps) {
hookProps = newProps
rerender(hookProps)
}

function unmountHook() {
removeCleanup(unmountHook)
unmount()
}

addCleanup(unmountHook)

return {
result,
rerender: rerenderHook,
unmount: unmountHook,
...asyncUtils(act, addResolver),
...renderUtils
}
}

export { createRenderHook, cleanup, addCleanup, removeCleanup }
5 changes: 5 additions & 0 deletions src/dom/index.ts
@@ -0,0 +1,5 @@
import { autoRegisterCleanup } from '../core/cleanup'

autoRegisterCleanup()

export * from './pure'

0 comments on commit a25993f

Please sign in to comment.