diff --git a/.all-contributorsrc b/.all-contributorsrc
index d7d137c5..5e5edeaf 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -8,6 +8,7 @@
],
"imageSize": 100,
"commit": false,
+ "commitConvention": "none",
"contributors": [
{
"login": "mpeyper",
@@ -16,13 +17,12 @@
"profile": "https://github.com/mpeyper",
"contributions": [
"code",
- "design",
"doc",
"ideas",
"infra",
- "platform",
- "test",
- "tool"
+ "maintenance",
+ "question",
+ "test"
]
},
{
@@ -201,7 +201,12 @@
"avatar_url": "https://avatars0.githubusercontent.com/u/37798644?v=4",
"profile": "https://github.com/joshuaellis",
"contributions": [
- "doc"
+ "doc",
+ "question",
+ "code",
+ "ideas",
+ "maintenance",
+ "test"
]
},
{
@@ -270,7 +275,186 @@
"code",
"test"
]
+ },
+ {
+ "login": "marcosvega91",
+ "name": "Marco Moretti",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/5365582?v=4",
+ "profile": "https://github.com/marcosvega91",
+ "contributions": [
+ "infra"
+ ]
+ },
+ {
+ "login": "ndresx",
+ "name": "Martin V.",
+ "avatar_url": "https://avatars0.githubusercontent.com/u/27507295?v=4",
+ "profile": "https://www.parkside.at/",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "erozak",
+ "name": "Erozak",
+ "avatar_url": "https://avatars3.githubusercontent.com/u/22066282?v=4",
+ "profile": "https://github.com/erozak",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "nickmccurdy",
+ "name": "Nick McCurdy",
+ "avatar_url": "https://avatars0.githubusercontent.com/u/927220?v=4",
+ "profile": "https://nickmccurdy.com/",
+ "contributions": [
+ "maintenance"
+ ]
+ },
+ {
+ "login": "aryyya",
+ "name": "Arya",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/29365565?v=4",
+ "profile": "https://codepen.io/aryyya/",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "numb86",
+ "name": "numb86",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/16703337?v=4",
+ "profile": "https://numb86.net/",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "foray1010",
+ "name": "Alex Young",
+ "avatar_url": "https://avatars3.githubusercontent.com/u/3212221?v=4",
+ "profile": "https://github.com/foray1010",
+ "contributions": [
+ "maintenance"
+ ]
+ },
+ {
+ "login": "benjdlambert",
+ "name": "Ben Lambert",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/3645856?v=4",
+ "profile": "https://blam.sh/",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "ElRatonDeFuego",
+ "name": "David Cho-Lerat",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/12750934?v=4",
+ "profile": "https://github.com/ElRatonDeFuego",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "evanharmon",
+ "name": "Evan Harmon",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/8229989?v=4",
+ "profile": "https://github.com/evanharmon",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "browniefed",
+ "name": "Jason Brown",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/1714673?v=4",
+ "profile": "http://codedaily.io/",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "kahwee",
+ "name": "KahWee Teng",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/262105?v=4",
+ "profile": "https://github.com/kahwee",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "shagabutdinov",
+ "name": "Leonid Shagabutdinov",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/1635613?v=4",
+ "profile": "http://shagabutdinov.com/",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "LeviButcher",
+ "name": "Levi Butcher",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/31522433?v=4",
+ "profile": "https://levibutcher.dev/",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "7michele7",
+ "name": "Michele Settepani",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/17926167?v=4",
+ "profile": "https://github.com/7michele7",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "samnoh",
+ "name": "Sam",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/14857416?v=4",
+ "profile": "https://github.com/samnoh",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "tanaypratap",
+ "name": "Tanay Pratap",
+ "avatar_url": "https://avatars0.githubusercontent.com/u/10216863?v=4",
+ "profile": "https://github.com/tanaypratap",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "techanvil",
+ "name": "Tom Rees-Herdman",
+ "avatar_url": "https://avatars0.githubusercontent.com/u/18395600?v=4",
+ "profile": "https://github.com/techanvil",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "iqbal125",
+ "name": "iqbal125",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/24860061?v=4",
+ "profile": "https://github.com/iqbal125",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "cliffzhaobupt",
+ "name": "cliffzhaobupt",
+ "avatar_url": "https://avatars3.githubusercontent.com/u/7374506?v=4",
+ "profile": "https://github.com/cliffzhaobupt",
+ "contributions": [
+ "maintenance"
+ ]
}
- ],
- "commitConvention": "none"
-}
+ ]
+}
\ No newline at end of file
diff --git a/.eslintignore b/.eslintignore
index 4594ebf4..a9ac17ec 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,5 +1,9 @@
node_modules
coverage
lib
+dom
+native
+server
+pure
.docz
site
diff --git a/.eslintrc b/.eslintrc
index b16789bf..5e90a5cf 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -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"]
}
}
diff --git a/.gitignore b/.gitignore
index 032db993..2236836e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,10 @@
node_modules
coverage
lib
+dom
+native
+server
+pure
.docz
-site
\ No newline at end of file
+site
+.vscode
diff --git a/README.md b/README.md
index b62c279d..4674bda3 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@
[![downloads](https://img.shields.io/npm/dm/@testing-library/react-hooks.svg?style=flat-square)](http://www.npmtrends.com/@testing-library/react-hooks)
[![MIT License](https://img.shields.io/npm/l/@testing-library/react-hooks.svg?style=flat-square)](https://github.com/testing-library/react-hooks-testing-library/blob/master/LICENSE.md)
-[![All Contributors](https://img.shields.io/badge/all_contributors-13-orange.svg?style=flat-square)](#contributors)
+[![All Contributors](https://img.shields.io/github/all-contributors/testing-library/react-hooks-testing-library?color=orange&style=flat-square)](#contributors)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com)
[![Code of Conduct](https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square)](https://github.com/testing-library/react-hooks-testing-library/blob/master/CODE_OF_CONDUCT.md)
[![Netlify Status](https://api.netlify.com/api/v1/badges/9a8f27a5-df38-4910-a248-4908b1ba29a7/deploy-status)](https://app.netlify.com/sites/react-hooks-testing-library/deploys)
@@ -166,7 +166,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
diff --git a/jest.config.js b/jest.config.js
index bb6a1d85..d9ce8bbb 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -3,5 +3,5 @@ const { jest: jestConfig } = require('kcd-scripts/config')
module.exports = Object.assign(jestConfig, {
roots: ['/src', '/test'],
- testMatch: ['/test/*.(ts|tsx|js)']
+ testMatch: ['/test/**/*.(ts|tsx|js)']
})
diff --git a/package.json b/package.json
index 89315581..bb0f3d55 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,10 @@
"files": [
"lib",
"src",
- "pure.js",
+ "dom",
+ "native",
+ "server",
+ "pure",
"dont-cleanup-after-each.js"
],
"author": "Michael Peyper ",
@@ -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",
@@ -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": {
@@ -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
+ }
}
}
diff --git a/pure.js b/pure.js
deleted file mode 100644
index 53c16328..00000000
--- a/pure.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// makes it so people can import from '@testing-library/react-hooks/pure'
-module.exports = require('./lib/pure')
diff --git a/scripts/generate-submodules.ts b/scripts/generate-submodules.ts
new file mode 100644
index 00000000..7946b30f
--- /dev/null
+++ b/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]) => {
+ 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)
diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json
new file mode 100644
index 00000000..bbb2c4c6
--- /dev/null
+++ b/scripts/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig",
+ "compilerOptions": {
+ "declaration": false
+ },
+ "exclude": [],
+ "include": ["./**/*.ts"]
+}
diff --git a/src/asyncUtils.ts b/src/core/asyncUtils.ts
similarity index 79%
rename from src/asyncUtils.ts
rename to src/core/asyncUtils.ts
index 814f2aa8..4c2ecf77 100644
--- a/src/asyncUtils.ts
+++ b/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 | null = null
const waitForNextUpdate = async ({ timeout }: Pick = {}) => {
- if (!nextUpdatePromise) {
+ if (nextUpdatePromise) {
+ await nextUpdatePromise
+ } else {
nextUpdatePromise = new Promise((resolve, reject) => {
let timeoutId: ReturnType
if (timeout && timeout > 0) {
@@ -39,7 +26,6 @@ function asyncUtils(addResolver: (callback: () => void) => void) {
})
await act(() => nextUpdatePromise as Promise)
}
- await nextUpdatePromise
}
const waitFor = async (
@@ -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
}
@@ -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
}
@@ -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
}
}
diff --git a/src/cleanup.ts b/src/core/cleanup.ts
similarity index 60%
rename from src/cleanup.ts
rename to src/core/cleanup.ts
index 8309bd04..2a56d5b1 100644
--- a/src/cleanup.ts
+++ b/src/core/cleanup.ts
@@ -16,4 +16,13 @@ function removeCleanup(callback: () => Promise | 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 }
diff --git a/src/core/index.ts b/src/core/index.ts
new file mode 100644
index 00000000..068016f5
--- /dev/null
+++ b/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(): ResultContainer {
+ const results: Array<{ value?: TValue; error?: Error }> = []
+ const resolvers: Array<() => void> = []
+
+ const result: RenderResult = {
+ 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 = >(
+ createRenderer: CreateRenderer
+) => (
+ callback: (props: TProps) => TResult,
+ options: RenderHookOptions = {} as RenderHookOptions
+): RenderHook => {
+ const { result, setValue, setError, addResolver } = resultContainer()
+ 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 }
diff --git a/src/dom/index.ts b/src/dom/index.ts
new file mode 100644
index 00000000..7d558c25
--- /dev/null
+++ b/src/dom/index.ts
@@ -0,0 +1,5 @@
+import { autoRegisterCleanup } from '../core/cleanup'
+
+autoRegisterCleanup()
+
+export * from './pure'
diff --git a/src/dom/pure.ts b/src/dom/pure.ts
new file mode 100644
index 00000000..c2f90916
--- /dev/null
+++ b/src/dom/pure.ts
@@ -0,0 +1,45 @@
+import ReactDOM from 'react-dom'
+import { act } from 'react-dom/test-utils'
+
+import { RendererProps } from '../types'
+import { RendererOptions } from '../types/react'
+
+import { createRenderHook, cleanup, addCleanup, removeCleanup } from '../core'
+import { createTestHarness } from '../helpers/createTestHarness'
+
+function createDomRenderer(
+ rendererProps: RendererProps,
+ { wrapper }: RendererOptions
+) {
+ const container = document.createElement('div')
+
+ const testHook = createTestHarness(rendererProps, wrapper)
+
+ return {
+ render(props?: TProps) {
+ document.body.appendChild(container)
+ act(() => {
+ ReactDOM.render(testHook(props), container)
+ })
+ },
+ rerender(props?: TProps) {
+ act(() => {
+ ReactDOM.render(testHook(props), container)
+ })
+ },
+ unmount() {
+ act(() => {
+ ReactDOM.unmountComponentAtNode(container)
+ })
+ document.body.removeChild(container)
+ },
+ act
+ }
+}
+
+const renderHook = createRenderHook(createDomRenderer)
+
+export { renderHook, act, cleanup, addCleanup, removeCleanup }
+
+export * from '../types'
+export * from '../types/react'
diff --git a/src/helpers/createTestHarness.tsx b/src/helpers/createTestHarness.tsx
new file mode 100644
index 00000000..b382a080
--- /dev/null
+++ b/src/helpers/createTestHarness.tsx
@@ -0,0 +1,42 @@
+import React, { Suspense } from 'react'
+
+import { RendererProps } from '../types'
+import { WrapperComponent } from '../types/react'
+
+import { isPromise } from './promises'
+
+function TestComponent({
+ hookProps,
+ callback,
+ setError,
+ setValue
+}: RendererProps & { hookProps?: TProps }) {
+ try {
+ // coerce undefined into TProps, so it maintains the previous behaviour
+ setValue(callback(hookProps as TProps))
+ } catch (err: unknown) {
+ if (isPromise(err)) {
+ throw err
+ } else {
+ setError(err as Error)
+ }
+ }
+ return null
+}
+
+export const createTestHarness = (
+ rendererProps: RendererProps,
+ Wrapper?: WrapperComponent,
+ suspense: boolean = true
+) => {
+ return (props?: TProps) => {
+ let component =
+ if (Wrapper) {
+ component = {component}
+ }
+ if (suspense) {
+ component = {component}
+ }
+ return component
+ }
+}
diff --git a/src/helpers/error.ts b/src/helpers/error.ts
new file mode 100644
index 00000000..5aba68d7
--- /dev/null
+++ b/src/helpers/error.ts
@@ -0,0 +1,7 @@
+class TimeoutError extends Error {
+ constructor(util: Function, timeout: number) {
+ super(`Timed out in ${util.name} after ${timeout}ms.`)
+ }
+}
+
+export { TimeoutError }
diff --git a/src/helpers/promises.ts b/src/helpers/promises.ts
new file mode 100644
index 00000000..d7dec9bd
--- /dev/null
+++ b/src/helpers/promises.ts
@@ -0,0 +1,9 @@
+const resolveAfter = (ms: number) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms)
+ })
+
+const isPromise = (value: unknown): boolean =>
+ typeof (value as PromiseLike).then === 'function'
+
+export { isPromise, resolveAfter }
diff --git a/src/index.ts b/src/index.ts
index c1abc074..10b0b905 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,10 +1,5 @@
-import { cleanup } from './pure'
+import { autoRegisterCleanup } from './core/cleanup'
-// Automatically registers cleanup in supported testing frameworks
-if (typeof afterEach === 'function' && !process.env.RHTL_SKIP_AUTO_CLEANUP) {
- afterEach(async () => {
- await cleanup()
- })
-}
+autoRegisterCleanup()
export * from './pure'
diff --git a/src/native/index.ts b/src/native/index.ts
new file mode 100644
index 00000000..7d558c25
--- /dev/null
+++ b/src/native/index.ts
@@ -0,0 +1,5 @@
+import { autoRegisterCleanup } from '../core/cleanup'
+
+autoRegisterCleanup()
+
+export * from './pure'
diff --git a/src/native/pure.ts b/src/native/pure.ts
new file mode 100644
index 00000000..e9156bc1
--- /dev/null
+++ b/src/native/pure.ts
@@ -0,0 +1,42 @@
+import { act, create, ReactTestRenderer } from 'react-test-renderer'
+
+import { RendererProps } from '../types'
+import { RendererOptions } from '../types/react'
+
+import { createRenderHook, cleanup, addCleanup, removeCleanup } from '../core'
+import { createTestHarness } from '../helpers/createTestHarness'
+
+function createNativeRenderer(
+ testHookProps: RendererProps,
+ { wrapper }: RendererOptions
+) {
+ let container: ReactTestRenderer
+
+ const testHook = createTestHarness(testHookProps, wrapper)
+
+ return {
+ render(props?: TProps) {
+ act(() => {
+ container = create(testHook(props))
+ })
+ },
+ rerender(props?: TProps) {
+ act(() => {
+ container.update(testHook(props))
+ })
+ },
+ unmount() {
+ act(() => {
+ container.unmount()
+ })
+ },
+ act
+ }
+}
+
+const renderHook = createRenderHook(createNativeRenderer)
+
+export { renderHook, act, cleanup, addCleanup, removeCleanup }
+
+export * from '../types'
+export * from '../types/react'
diff --git a/src/pure.ts b/src/pure.ts
new file mode 100644
index 00000000..30c84181
--- /dev/null
+++ b/src/pure.ts
@@ -0,0 +1,40 @@
+import { ReactHooksRenderer } from './types'
+
+const renderers = [
+ { required: 'react-test-renderer', renderer: './native/pure' },
+ { required: 'react-dom', renderer: './dom/pure' }
+]
+
+function hasDependency(name: string) {
+ try {
+ require(name)
+ return true
+ } catch {
+ return false
+ }
+}
+
+function getRenderer() {
+ const validRenderer = renderers.find(({ required }) => hasDependency(required))
+
+ if (validRenderer) {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ return require(validRenderer.renderer) as ReactHooksRenderer
+ } else {
+ const options = renderers
+ .map(({ required }) => ` - ${required}`)
+ .sort((a, b) => a.localeCompare(b))
+ .join('/n')
+
+ throw new Error(
+ `Could not auto-detect a React renderer. Are you sure you've installed one of the following\n${options}`
+ )
+ }
+}
+
+const { renderHook, act, cleanup, addCleanup, removeCleanup } = getRenderer()
+
+export { renderHook, act, cleanup, addCleanup, removeCleanup }
+
+export * from './types'
+export * from './types/react'
diff --git a/src/pure.tsx b/src/pure.tsx
deleted file mode 100644
index a1c14897..00000000
--- a/src/pure.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import React, { ReactElement, ReactNode, Suspense } from 'react'
-import { act, create, ReactTestRenderer } from 'react-test-renderer'
-import asyncUtils from './asyncUtils'
-import { cleanup, addCleanup, removeCleanup } from './cleanup'
-
-function isPromise(value: unknown): boolean {
- return typeof (value as PromiseLike).then === 'function'
-}
-
-type TestHookProps = {
- callback: (props: TProps) => TResult
- hookProps: TProps | undefined
- onError: (error: Error) => void
- children: (value: TResult) => void
-}
-
-function TestHook({
- callback,
- hookProps,
- onError,
- children
-}: TestHookProps) {
- try {
- // coerce undefined into TProps, so it maintains the previous behaviour
- children(callback(hookProps as TProps))
- } catch (err: unknown) {
- if (isPromise(err)) {
- throw err
- } else {
- onError(err as Error)
- }
- }
- return null
-}
-
-function Fallback() {
- return null
-}
-
-function resultContainer() {
- const results: Array<{ value?: TValue; error?: Error }> = []
- const resolvers: Array<() => void> = []
-
- const result = {
- 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)
- }
-}
-
-function renderHook(
- callback: (props: TProps) => TResult,
- { initialProps, wrapper }: { initialProps?: TProps; wrapper?: React.ComponentType } = {}
-) {
- const { result, setValue, setError, addResolver } = resultContainer()
- const hookProps = { current: initialProps }
-
- const wrapUiIfNeeded = (innerElement: ReactNode) =>
- wrapper ? React.createElement(wrapper, hookProps.current, innerElement) : innerElement
-
- const toRender = () =>
- wrapUiIfNeeded(
- }>
-
- {setValue}
-
-
- ) as ReactElement
-
- let testRenderer: ReactTestRenderer
- act(() => {
- testRenderer = create(toRender())
- })
-
- function rerenderHook(newProps: typeof initialProps = hookProps.current) {
- hookProps.current = newProps
- act(() => {
- testRenderer.update(toRender())
- })
- }
-
- function unmountHook() {
- act(() => {
- removeCleanup(unmountHook)
- testRenderer.unmount()
- })
- }
-
- addCleanup(unmountHook)
-
- return {
- result,
- rerender: rerenderHook,
- unmount: unmountHook,
- ...asyncUtils(addResolver)
- }
-}
-
-export { renderHook, cleanup, addCleanup, removeCleanup, act }
diff --git a/src/server/index.ts b/src/server/index.ts
new file mode 100644
index 00000000..7d558c25
--- /dev/null
+++ b/src/server/index.ts
@@ -0,0 +1,5 @@
+import { autoRegisterCleanup } from '../core/cleanup'
+
+autoRegisterCleanup()
+
+export * from './pure'
diff --git a/src/server/pure.ts b/src/server/pure.ts
new file mode 100644
index 00000000..ec0737b1
--- /dev/null
+++ b/src/server/pure.ts
@@ -0,0 +1,66 @@
+import ReactDOMServer from 'react-dom/server'
+import ReactDOM from 'react-dom'
+import { act } from 'react-dom/test-utils'
+
+import { RendererProps } from '../types'
+import { RendererOptions } from '../types/react'
+
+import { createRenderHook, cleanup, addCleanup, removeCleanup } from '../core'
+import { createTestHarness } from '../helpers/createTestHarness'
+
+function createServerRenderer(
+ rendererProps: RendererProps,
+ { wrapper }: RendererOptions
+) {
+ const container = document.createElement('div')
+
+ const testHook = createTestHarness(rendererProps, wrapper, false)
+
+ let renderProps: TProps | undefined
+ let hydrated = false
+
+ return {
+ render(props?: TProps) {
+ renderProps = props
+ act(() => {
+ const serverOutput = ReactDOMServer.renderToString(testHook(props))
+ container.innerHTML = serverOutput
+ })
+ },
+ hydrate() {
+ if (hydrated) {
+ throw new Error('The component can only be hydrated once')
+ } else {
+ document.body.appendChild(container)
+ act(() => {
+ ReactDOM.hydrate(testHook(renderProps), container)
+ })
+ hydrated = true
+ }
+ },
+ rerender(props?: TProps) {
+ if (!hydrated) {
+ throw new Error('You must hydrate the component before you can rerender')
+ }
+ act(() => {
+ ReactDOM.render(testHook(props), container)
+ })
+ },
+ unmount() {
+ if (hydrated) {
+ act(() => {
+ ReactDOM.unmountComponentAtNode(container)
+ document.body.removeChild(container)
+ })
+ }
+ },
+ act
+ }
+}
+
+const renderHook = createRenderHook(createServerRenderer)
+
+export { renderHook, act, cleanup, addCleanup, removeCleanup }
+
+export * from '../types'
+export * from '../types/react'
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 00000000..cf151e5e
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,61 @@
+export type Renderer = {
+ render: (props?: TProps) => void
+ rerender: (props?: TProps) => void
+ unmount: () => void
+ act: Act
+}
+
+export type RendererProps = {
+ callback: (props: TProps) => TResult
+ setError: (error: Error) => void
+ setValue: (value: TResult) => void
+}
+
+export type CreateRenderer> = (
+ props: RendererProps,
+ options: TOptions
+) => TRenderer
+
+export type RenderResult = {
+ readonly all: (TValue | Error | undefined)[]
+ readonly current: TValue
+ readonly error?: Error
+}
+
+export type ResultContainer = {
+ result: RenderResult
+}
+
+export interface WaitOptions {
+ interval?: number
+ timeout?: number
+ suppressErrors?: boolean
+}
+
+export type AsyncUtils = {
+ waitFor: (callback: () => boolean | void, opts?: WaitOptions) => Promise
+ waitForNextUpdate: (opts?: Pick) => Promise
+ waitForValueToChange: (selector: () => unknown, options?: WaitOptions) => Promise
+}
+
+export type RenderHook<
+ TProps,
+ TValue,
+ TRenderer extends Renderer = Renderer
+> = ResultContainer &
+ Omit, 'render' | 'act'> &
+ Omit> &
+ AsyncUtils
+
+export interface ReactHooksRenderer {
+ renderHook: () => RenderHook
+ act: Act
+ cleanup: () => void
+ addCleanup: (callback: () => Promise | void) => () => void
+ removeCleanup: (callback: () => Promise | void) => void
+}
+
+export interface Act {
+ (callback: () => void | undefined): void
+ (callback: () => Promise): Promise
+}
diff --git a/src/types/internal.ts b/src/types/internal.ts
new file mode 100644
index 00000000..3d1a4152
--- /dev/null
+++ b/src/types/internal.ts
@@ -0,0 +1,12 @@
+import { RenderResult } from '.'
+
+export type ResultContainer = {
+ result: RenderResult
+ addResolver: (resolver: () => void) => void
+ setValue: (val: TValue) => void
+ setError: (error: Error) => void
+}
+
+export type RenderHookOptions = TOptions & {
+ initialProps?: TProps
+}
diff --git a/src/types/react.ts b/src/types/react.ts
new file mode 100644
index 00000000..09923286
--- /dev/null
+++ b/src/types/react.ts
@@ -0,0 +1,7 @@
+import { ComponentType } from 'react'
+
+export type WrapperComponent = ComponentType
+
+export type RendererOptions = {
+ wrapper?: WrapperComponent
+}
diff --git a/test/asyncHook.ts b/test/dom/asyncHook.ts
similarity index 99%
rename from test/asyncHook.ts
rename to test/dom/asyncHook.ts
index 5479db82..20559e4c 100644
--- a/test/asyncHook.ts
+++ b/test/dom/asyncHook.ts
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'
-import { renderHook } from '../src'
+import { renderHook } from '../../src/dom'
describe('async hook tests', () => {
const useSequence = (...values: string[]) => {
diff --git a/test/autoCleanup.disabled.ts b/test/dom/autoCleanup.disabled.ts
similarity index 76%
rename from test/autoCleanup.disabled.ts
rename to test/dom/autoCleanup.disabled.ts
index 35cbf91a..2c797345 100644
--- a/test/autoCleanup.disabled.ts
+++ b/test/dom/autoCleanup.disabled.ts
@@ -1,5 +1,7 @@
import { useEffect } from 'react'
+import { ReactHooksRenderer } from 'types'
+
// This verifies that if RHTL_SKIP_AUTO_CLEANUP is set
// then we DON'T auto-wire up the afterEach for folks
describe('skip auto cleanup (disabled) tests', () => {
@@ -8,8 +10,8 @@ describe('skip auto cleanup (disabled) tests', () => {
beforeAll(() => {
process.env.RHTL_SKIP_AUTO_CLEANUP = 'true'
- // eslint-disable-next-line
- renderHook = require('../src').renderHook
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ renderHook = (require('../../src/dom') as ReactHooksRenderer).renderHook
})
test('first', () => {
diff --git a/test/autoCleanup.noAfterEach.ts b/test/dom/autoCleanup.noAfterEach.ts
similarity index 79%
rename from test/autoCleanup.noAfterEach.ts
rename to test/dom/autoCleanup.noAfterEach.ts
index cd30a841..1c0821b4 100644
--- a/test/autoCleanup.noAfterEach.ts
+++ b/test/dom/autoCleanup.noAfterEach.ts
@@ -1,5 +1,7 @@
import { useEffect } from 'react'
+import { ReactHooksRenderer } from 'types'
+
// This verifies that if RHTL_SKIP_AUTO_CLEANUP is set
// then we DON'T auto-wire up the afterEach for folks
describe('skip auto cleanup (no afterEach) tests', () => {
@@ -10,8 +12,8 @@ describe('skip auto cleanup (no afterEach) tests', () => {
// @ts-expect-error Turning off AfterEach -- ignore Jest LifeCycle Type
// eslint-disable-next-line no-global-assign
afterEach = false
- // eslint-disable-next-line
- renderHook = require('../').renderHook
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ renderHook = (require('../../src/dom') as ReactHooksRenderer).renderHook
})
test('first', () => {
diff --git a/test/autoCleanup.ts b/test/dom/autoCleanup.ts
similarity index 92%
rename from test/autoCleanup.ts
rename to test/dom/autoCleanup.ts
index 5dcdc1d1..b5585350 100644
--- a/test/autoCleanup.ts
+++ b/test/dom/autoCleanup.ts
@@ -1,5 +1,5 @@
import { useEffect } from 'react'
-import { renderHook } from '../src'
+import { renderHook } from '../../src/dom'
// This verifies that by importing RHTL in an
// environment which supports afterEach (like Jest)
diff --git a/test/cleanup.ts b/test/dom/cleanup.ts
similarity index 99%
rename from test/cleanup.ts
rename to test/dom/cleanup.ts
index 1eafffbf..aafa877b 100644
--- a/test/cleanup.ts
+++ b/test/dom/cleanup.ts
@@ -1,5 +1,5 @@
import { useEffect } from 'react'
-import { renderHook, cleanup, addCleanup, removeCleanup } from '../src/pure'
+import { renderHook, cleanup, addCleanup, removeCleanup } from '../../src/dom/pure'
describe('cleanup tests', () => {
test('should flush effects on cleanup', async () => {
diff --git a/test/customHook.ts b/test/dom/customHook.ts
similarity index 93%
rename from test/customHook.ts
rename to test/dom/customHook.ts
index 871c5619..ab1b859d 100644
--- a/test/customHook.ts
+++ b/test/dom/customHook.ts
@@ -1,5 +1,5 @@
import { useState, useCallback } from 'react'
-import { renderHook, act } from '../src'
+import { renderHook, act } from '../../src/dom'
describe('custom hook tests', () => {
function useCounter() {
diff --git a/test/errorHook.ts b/test/dom/errorHook.ts
similarity index 86%
rename from test/errorHook.ts
rename to test/dom/errorHook.ts
index e507bb92..b0a5ba8c 100644
--- a/test/errorHook.ts
+++ b/test/dom/errorHook.ts
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'
-import { renderHook } from '../src'
+import { renderHook } from '../../src/dom'
describe('error hook tests', () => {
- function useError(throwError: boolean) {
+ function useError(throwError?: boolean) {
if (throwError) {
throw new Error('expected')
}
@@ -48,13 +48,13 @@ describe('error hook tests', () => {
})
test('should reset error', () => {
- const { result, rerender } = renderHook((throwError) => useError(throwError), {
- initialProps: true
+ const { result, rerender } = renderHook(({ throwError }) => useError(throwError), {
+ initialProps: { throwError: true }
})
expect(result.error).not.toBe(undefined)
- rerender(false)
+ rerender({ throwError: false })
expect(result.current).not.toBe(undefined)
expect(result.error).toBe(undefined)
@@ -91,17 +91,15 @@ describe('error hook tests', () => {
test('should reset async error', async () => {
const { result, waitForNextUpdate, rerender } = renderHook(
- (throwError) => useAsyncError(throwError),
- {
- initialProps: true
- }
+ ({ throwError }) => useAsyncError(throwError),
+ { initialProps: { throwError: true } }
)
await waitForNextUpdate()
expect(result.error).not.toBe(undefined)
- rerender(false)
+ rerender({ throwError: false })
await waitForNextUpdate()
@@ -138,13 +136,13 @@ describe('error hook tests', () => {
})
test('should reset effect error', () => {
- const { result, rerender } = renderHook((throwError) => useEffectError(throwError), {
- initialProps: true
+ const { result, rerender } = renderHook(({ throwError }) => useEffectError(throwError), {
+ initialProps: { throwError: true }
})
expect(result.error).not.toBe(undefined)
- rerender(false)
+ rerender({ throwError: false })
expect(result.current).not.toBe(undefined)
expect(result.error).toBe(undefined)
diff --git a/test/resultHistory.ts b/test/dom/resultHistory.ts
similarity index 94%
rename from test/resultHistory.ts
rename to test/dom/resultHistory.ts
index 80b9b10b..68c84741 100644
--- a/test/resultHistory.ts
+++ b/test/dom/resultHistory.ts
@@ -1,4 +1,4 @@
-import { renderHook } from '../src'
+import { renderHook } from '../../src/dom'
describe('result history tests', () => {
let count = 0
diff --git a/test/suspenseHook.ts b/test/dom/suspenseHook.ts
similarity index 96%
rename from test/suspenseHook.ts
rename to test/dom/suspenseHook.ts
index 8d696927..174d70b2 100644
--- a/test/suspenseHook.ts
+++ b/test/dom/suspenseHook.ts
@@ -1,4 +1,4 @@
-import { renderHook } from '../src'
+import { renderHook } from '../../src/dom'
describe('suspense hook tests', () => {
const cache: { value?: Promise | string | Error } = {}
diff --git a/test/useContext.tsx b/test/dom/useContext.tsx
similarity index 89%
rename from test/useContext.tsx
rename to test/dom/useContext.tsx
index 03bc19f4..0f88c548 100644
--- a/test/useContext.tsx
+++ b/test/dom/useContext.tsx
@@ -1,5 +1,5 @@
import React, { createContext, useContext } from 'react'
-import { renderHook } from '../src'
+import { renderHook } from '../../src/dom'
describe('useContext tests', () => {
test('should get default value from context', () => {
@@ -15,7 +15,7 @@ describe('useContext tests', () => {
test('should get value from context provider', () => {
const TestContext = createContext('foo')
- const wrapper: React.FC = ({ children } ) => (
+ const wrapper: React.FC = ({ children }) => (
{children}
)
@@ -45,8 +45,7 @@ describe('useContext tests', () => {
test('should update value in context when props are updated', () => {
const TestContext = createContext('foo')
-
- const wrapper: React.FC<{current: string}> = ({ current, children }) => (
+ const wrapper: React.FC<{ current: string }> = ({ current, children }) => (
{children}
)
diff --git a/test/useEffect.ts b/test/dom/useEffect.ts
similarity index 97%
rename from test/useEffect.ts
rename to test/dom/useEffect.ts
index cad9d0f3..b09c2fa6 100644
--- a/test/useEffect.ts
+++ b/test/dom/useEffect.ts
@@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect } from 'react'
-import { renderHook } from '../src'
+import { renderHook } from '../../src/dom'
describe('useEffect tests', () => {
test('should handle useEffect hook', () => {
diff --git a/test/useMemo.ts b/test/dom/useMemo.ts
similarity index 96%
rename from test/useMemo.ts
rename to test/dom/useMemo.ts
index b2c452ab..f8a7e86a 100644
--- a/test/useMemo.ts
+++ b/test/dom/useMemo.ts
@@ -1,5 +1,5 @@
import { useMemo, useCallback } from 'react'
-import { renderHook } from '../src'
+import { renderHook } from '../../src/dom'
describe('useCallback tests', () => {
test('should handle useMemo hook', () => {
diff --git a/test/useReducer.ts b/test/dom/useReducer.ts
similarity index 91%
rename from test/useReducer.ts
rename to test/dom/useReducer.ts
index 7b98431a..0e9ff9e8 100644
--- a/test/useReducer.ts
+++ b/test/dom/useReducer.ts
@@ -1,5 +1,5 @@
import { useReducer } from 'react'
-import { renderHook, act } from '../src'
+import { renderHook, act } from '../../src/dom'
describe('useReducer tests', () => {
test('should handle useReducer hook', () => {
diff --git a/test/useRef.ts b/test/dom/useRef.ts
similarity index 94%
rename from test/useRef.ts
rename to test/dom/useRef.ts
index 9d3851ff..baca0ead 100644
--- a/test/useRef.ts
+++ b/test/dom/useRef.ts
@@ -1,5 +1,5 @@
import { useRef, useImperativeHandle } from 'react'
-import { renderHook } from '../src'
+import { renderHook } from '../../src/dom'
describe('useHook tests', () => {
test('should handle useRef hook', () => {
diff --git a/test/useState.ts b/test/dom/useState.ts
similarity index 91%
rename from test/useState.ts
rename to test/dom/useState.ts
index 42f3f8b0..e25c8bbe 100644
--- a/test/useState.ts
+++ b/test/dom/useState.ts
@@ -1,5 +1,5 @@
import { useState } from 'react'
-import { renderHook, act } from '../src'
+import { renderHook, act } from '../../src/dom'
describe('useState tests', () => {
test('should use setState value', () => {
diff --git a/test/native/asyncHook.ts b/test/native/asyncHook.ts
new file mode 100644
index 00000000..18977b19
--- /dev/null
+++ b/test/native/asyncHook.ts
@@ -0,0 +1,269 @@
+import { useState, useRef, useEffect } from 'react'
+import { renderHook } from '../../src/native'
+
+describe('async hook tests', () => {
+ const useSequence = (...values: string[]) => {
+ const [first, ...otherValues] = values
+ const [value, setValue] = useState(first)
+ const index = useRef(0)
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setValue(otherValues[index.current++])
+ if (index.current === otherValues.length) {
+ clearInterval(interval)
+ }
+ }, 50)
+ return () => {
+ clearInterval(interval)
+ }
+ }, [otherValues])
+
+ return value
+ }
+
+ test('should wait for next update', async () => {
+ const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second'))
+
+ expect(result.current).toBe('first')
+
+ await waitForNextUpdate()
+
+ expect(result.current).toBe('second')
+ })
+
+ test('should wait for multiple updates', async () => {
+ const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second', 'third'))
+
+ expect(result.current).toBe('first')
+
+ await waitForNextUpdate()
+
+ expect(result.current).toBe('second')
+
+ await waitForNextUpdate()
+
+ expect(result.current).toBe('third')
+ })
+
+ test('should resolve all when updating', async () => {
+ const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second'))
+
+ expect(result.current).toBe('first')
+
+ await Promise.all([waitForNextUpdate(), waitForNextUpdate(), waitForNextUpdate()])
+
+ expect(result.current).toBe('second')
+ })
+
+ test('should reject if timeout exceeded when waiting for next update', async () => {
+ const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second'))
+
+ expect(result.current).toBe('first')
+
+ await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow(
+ Error('Timed out in waitForNextUpdate after 10ms.')
+ )
+ })
+
+ test('should wait for expectation to pass', async () => {
+ const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third'))
+
+ expect(result.current).toBe('first')
+
+ let complete = false
+ await waitFor(() => {
+ expect(result.current).toBe('third')
+ complete = true
+ })
+ expect(complete).toBe(true)
+ })
+
+ test('should wait for arbitrary expectation to pass', async () => {
+ const { waitFor } = renderHook(() => null)
+
+ let actual = 0
+ const expected = 1
+
+ setTimeout(() => {
+ actual = expected
+ }, 200)
+
+ let complete = false
+ await waitFor(
+ () => {
+ expect(actual).toBe(expected)
+ complete = true
+ },
+ { interval: 100 }
+ )
+
+ expect(complete).toBe(true)
+ })
+
+ test('should not hang if expectation is already passing', async () => {
+ const { result, waitFor } = renderHook(() => useSequence('first', 'second'))
+
+ expect(result.current).toBe('first')
+
+ let complete = false
+ await waitFor(() => {
+ expect(result.current).toBe('first')
+ complete = true
+ })
+ expect(complete).toBe(true)
+ })
+
+ test('should reject if callback throws error', async () => {
+ const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third'))
+
+ expect(result.current).toBe('first')
+
+ await expect(
+ waitFor(
+ () => {
+ if (result.current === 'second') {
+ throw new Error('Something Unexpected')
+ }
+ return result.current === 'third'
+ },
+ {
+ suppressErrors: false
+ }
+ )
+ ).rejects.toThrow(Error('Something Unexpected'))
+ })
+
+ test('should reject if callback immediately throws error', async () => {
+ const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third'))
+
+ expect(result.current).toBe('first')
+
+ await expect(
+ waitFor(
+ () => {
+ throw new Error('Something Unexpected')
+ },
+ {
+ suppressErrors: false
+ }
+ )
+ ).rejects.toThrow(Error('Something Unexpected'))
+ })
+
+ test('should wait for truthy value', async () => {
+ const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third'))
+
+ expect(result.current).toBe('first')
+
+ await waitFor(() => result.current === 'third')
+
+ expect(result.current).toBe('third')
+ })
+
+ test('should wait for arbitrary truthy value', async () => {
+ const { waitFor } = renderHook(() => null)
+
+ let actual = 0
+ const expected = 1
+
+ setTimeout(() => {
+ actual = expected
+ }, 200)
+
+ await waitFor(() => actual === 1, { interval: 100 })
+
+ expect(actual).toBe(expected)
+ })
+
+ test('should reject if timeout exceeded when waiting for expectation to pass', async () => {
+ const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third'))
+
+ expect(result.current).toBe('first')
+
+ await expect(
+ waitFor(
+ () => {
+ expect(result.current).toBe('third')
+ },
+ { timeout: 75 }
+ )
+ ).rejects.toThrow(Error('Timed out in waitFor after 75ms.'))
+ })
+
+ test('should wait for value to change', async () => {
+ const { result, waitForValueToChange } = renderHook(() =>
+ useSequence('first', 'second', 'third')
+ )
+
+ expect(result.current).toBe('first')
+
+ await waitForValueToChange(() => result.current === 'third')
+
+ expect(result.current).toBe('third')
+ })
+
+ test('should wait for arbitrary value to change', async () => {
+ const { waitForValueToChange } = renderHook(() => null)
+
+ let actual = 0
+ const expected = 1
+
+ setTimeout(() => {
+ actual = expected
+ }, 200)
+
+ await waitForValueToChange(() => actual, { interval: 100 })
+
+ expect(actual).toBe(expected)
+ })
+
+ test('should reject if timeout exceeded when waiting for value to change', async () => {
+ const { result, waitForValueToChange } = renderHook(() =>
+ useSequence('first', 'second', 'third')
+ )
+
+ expect(result.current).toBe('first')
+
+ await expect(
+ waitForValueToChange(() => result.current === 'third', {
+ timeout: 75
+ })
+ ).rejects.toThrow(Error('Timed out in waitForValueToChange after 75ms.'))
+ })
+
+ test('should reject if selector throws error', async () => {
+ const { result, waitForValueToChange } = renderHook(() => useSequence('first', 'second'))
+
+ expect(result.current).toBe('first')
+
+ await expect(
+ waitForValueToChange(() => {
+ if (result.current === 'second') {
+ throw new Error('Something Unexpected')
+ }
+ return result.current
+ })
+ ).rejects.toThrow(Error('Something Unexpected'))
+ })
+
+ test('should not reject if selector throws error and suppress errors option is enabled', async () => {
+ const { result, waitForValueToChange } = renderHook(() =>
+ useSequence('first', 'second', 'third')
+ )
+
+ expect(result.current).toBe('first')
+
+ await waitForValueToChange(
+ () => {
+ if (result.current === 'second') {
+ throw new Error('Something Unexpected')
+ }
+ return result.current === 'third'
+ },
+ { suppressErrors: true }
+ )
+
+ expect(result.current).toBe('third')
+ })
+})
diff --git a/test/native/autoCleanup.disabled.ts b/test/native/autoCleanup.disabled.ts
new file mode 100644
index 00000000..b43794d5
--- /dev/null
+++ b/test/native/autoCleanup.disabled.ts
@@ -0,0 +1,31 @@
+import { useEffect } from 'react'
+
+import { ReactHooksRenderer } from 'types'
+
+// This verifies that if RHTL_SKIP_AUTO_CLEANUP is set
+// then we DON'T auto-wire up the afterEach for folks
+describe('skip auto cleanup (disabled) tests', () => {
+ let cleanupCalled = false
+ let renderHook: (arg0: () => void) => void
+
+ beforeAll(() => {
+ process.env.RHTL_SKIP_AUTO_CLEANUP = 'true'
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ renderHook = (require('../../src/native') as ReactHooksRenderer).renderHook
+ })
+
+ test('first', () => {
+ const hookWithCleanup = () => {
+ useEffect(() => {
+ return () => {
+ cleanupCalled = true
+ }
+ })
+ }
+ renderHook(() => hookWithCleanup())
+ })
+
+ test('second', () => {
+ expect(cleanupCalled).toBe(false)
+ })
+})
diff --git a/test/native/autoCleanup.noAfterEach.ts b/test/native/autoCleanup.noAfterEach.ts
new file mode 100644
index 00000000..49b00b3d
--- /dev/null
+++ b/test/native/autoCleanup.noAfterEach.ts
@@ -0,0 +1,33 @@
+import { useEffect } from 'react'
+
+import { ReactHooksRenderer } from 'types'
+
+// This verifies that if RHTL_SKIP_AUTO_CLEANUP is set
+// then we DON'T auto-wire up the afterEach for folks
+describe('skip auto cleanup (no afterEach) tests', () => {
+ let cleanupCalled = false
+ let renderHook: (arg0: () => void) => void
+
+ beforeAll(() => {
+ // @ts-expect-error Turning off AfterEach -- ignore Jest LifeCycle Type
+ // eslint-disable-next-line no-global-assign
+ afterEach = false
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ renderHook = (require('../../src/native') as ReactHooksRenderer).renderHook
+ })
+
+ test('first', () => {
+ const hookWithCleanup = () => {
+ useEffect(() => {
+ return () => {
+ cleanupCalled = true
+ }
+ })
+ }
+ renderHook(() => hookWithCleanup())
+ })
+
+ test('second', () => {
+ expect(cleanupCalled).toBe(false)
+ })
+})
diff --git a/test/native/autoCleanup.ts b/test/native/autoCleanup.ts
new file mode 100644
index 00000000..2d7addf9
--- /dev/null
+++ b/test/native/autoCleanup.ts
@@ -0,0 +1,24 @@
+import { useEffect } from 'react'
+import { renderHook } from '../../src/native'
+
+// This verifies that by importing RHTL in an
+// environment which supports afterEach (like Jest)
+// we'll get automatic cleanup between tests.
+describe('auto cleanup tests', () => {
+ let cleanupCalled = false
+
+ test('first', () => {
+ const hookWithCleanup = () => {
+ useEffect(() => {
+ return () => {
+ cleanupCalled = true
+ }
+ })
+ }
+ renderHook(() => hookWithCleanup())
+ })
+
+ test('second', () => {
+ expect(cleanupCalled).toBe(true)
+ })
+})
diff --git a/test/native/cleanup.ts b/test/native/cleanup.ts
new file mode 100644
index 00000000..9eeed775
--- /dev/null
+++ b/test/native/cleanup.ts
@@ -0,0 +1,135 @@
+import { useEffect } from 'react'
+import { renderHook, cleanup, addCleanup, removeCleanup } from '../../src/native/pure'
+
+describe('cleanup tests', () => {
+ test('should flush effects on cleanup', async () => {
+ let cleanupCalled = false
+
+ const hookWithCleanup = () => {
+ useEffect(() => {
+ return () => {
+ cleanupCalled = true
+ }
+ })
+ }
+
+ renderHook(() => hookWithCleanup())
+
+ await cleanup()
+
+ expect(cleanupCalled).toBe(true)
+ })
+
+ test('should cleanup all rendered hooks', async () => {
+ const cleanupCalled: boolean[] = []
+ const hookWithCleanup = (id: number) => {
+ useEffect(() => {
+ return () => {
+ cleanupCalled[id] = true
+ }
+ })
+ }
+
+ renderHook(() => hookWithCleanup(1))
+ renderHook(() => hookWithCleanup(2))
+
+ await cleanup()
+
+ expect(cleanupCalled[1]).toBe(true)
+ expect(cleanupCalled[2]).toBe(true)
+ })
+
+ test('should call cleanups in reverse order', async () => {
+ const callSequence: string[] = []
+ addCleanup(() => {
+ callSequence.push('cleanup')
+ })
+ addCleanup(() => {
+ callSequence.push('another cleanup')
+ })
+ const hookWithCleanup = () => {
+ useEffect(() => {
+ return () => {
+ callSequence.push('unmount')
+ }
+ })
+ }
+ renderHook(() => hookWithCleanup())
+
+ await cleanup()
+
+ expect(callSequence).toEqual(['unmount', 'another cleanup', 'cleanup'])
+ })
+
+ test('should wait for async cleanup', async () => {
+ const callSequence: string[] = []
+ addCleanup(() => {
+ callSequence.push('cleanup')
+ })
+ addCleanup(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ callSequence.push('another cleanup')
+ })
+ const hookWithCleanup = () => {
+ useEffect(() => {
+ return () => {
+ callSequence.push('unmount')
+ }
+ })
+ }
+ renderHook(() => hookWithCleanup())
+
+ await cleanup()
+
+ expect(callSequence).toEqual(['unmount', 'another cleanup', 'cleanup'])
+ })
+
+ test('should remove cleanup using removeCleanup', async () => {
+ const callSequence: string[] = []
+ addCleanup(() => {
+ callSequence.push('cleanup')
+ })
+ const anotherCleanup = () => {
+ callSequence.push('another cleanup')
+ }
+ addCleanup(anotherCleanup)
+ const hookWithCleanup = () => {
+ useEffect(() => {
+ return () => {
+ callSequence.push('unmount')
+ }
+ })
+ }
+ renderHook(() => hookWithCleanup())
+
+ removeCleanup(anotherCleanup)
+
+ await cleanup()
+
+ expect(callSequence).toEqual(['unmount', 'cleanup'])
+ })
+
+ test('should remove cleanup using returned handler', async () => {
+ const callSequence: string[] = []
+ addCleanup(() => {
+ callSequence.push('cleanup')
+ })
+ const remove = addCleanup(() => {
+ callSequence.push('another cleanup')
+ })
+ const hookWithCleanup = () => {
+ useEffect(() => {
+ return () => {
+ callSequence.push('unmount')
+ }
+ })
+ }
+ renderHook(() => hookWithCleanup())
+
+ remove()
+
+ await cleanup()
+
+ expect(callSequence).toEqual(['unmount', 'cleanup'])
+ })
+})
diff --git a/test/native/customHook.ts b/test/native/customHook.ts
new file mode 100644
index 00000000..8d699188
--- /dev/null
+++ b/test/native/customHook.ts
@@ -0,0 +1,29 @@
+import { useState, useCallback } from 'react'
+import { renderHook, act } from '../../src/native'
+
+describe('custom hook tests', () => {
+ function useCounter() {
+ const [count, setCount] = useState(0)
+
+ const increment = useCallback(() => setCount(count + 1), [count])
+ const decrement = useCallback(() => setCount(count - 1), [count])
+
+ return { count, increment, decrement }
+ }
+
+ test('should increment counter', () => {
+ const { result } = renderHook(() => useCounter())
+
+ act(() => result.current.increment())
+
+ expect(result.current.count).toBe(1)
+ })
+
+ test('should decrement counter', () => {
+ const { result } = renderHook(() => useCounter())
+
+ act(() => result.current.decrement())
+
+ expect(result.current.count).toBe(-1)
+ })
+})
diff --git a/test/native/errorHook.ts b/test/native/errorHook.ts
new file mode 100644
index 00000000..078227c7
--- /dev/null
+++ b/test/native/errorHook.ts
@@ -0,0 +1,151 @@
+import { useState, useEffect } from 'react'
+import { renderHook } from '../../src/native'
+
+describe('error hook tests', () => {
+ function useError(throwError?: boolean) {
+ if (throwError) {
+ throw new Error('expected')
+ }
+ return true
+ }
+
+ function useAsyncError(throwError: boolean) {
+ const [value, setValue] = useState()
+ useEffect(() => {
+ const timeout = setTimeout(() => setValue(throwError), 100)
+ return () => clearTimeout(timeout)
+ }, [throwError])
+ return useError(value)
+ }
+
+ function useEffectError(throwError: boolean) {
+ useEffect(() => {
+ useError(throwError)
+ }, [throwError])
+ return true
+ }
+
+ describe('synchronous', () => {
+ test('should raise error', () => {
+ const { result } = renderHook(() => useError(true))
+
+ expect(() => {
+ expect(result.current).not.toBe(undefined)
+ }).toThrow(Error('expected'))
+ })
+
+ test('should capture error', () => {
+ const { result } = renderHook(() => useError(true))
+
+ expect(result.error).toEqual(Error('expected'))
+ })
+
+ test('should not capture error', () => {
+ const { result } = renderHook(() => useError(false))
+
+ expect(result.current).not.toBe(undefined)
+ expect(result.error).toBe(undefined)
+ })
+
+ test('should reset error', () => {
+ const { result, rerender } = renderHook(({ throwError }) => useError(throwError), {
+ initialProps: { throwError: true }
+ })
+
+ expect(result.error).not.toBe(undefined)
+
+ rerender({ throwError: false })
+
+ expect(result.current).not.toBe(undefined)
+ expect(result.error).toBe(undefined)
+ })
+ })
+
+ describe('asynchronous', () => {
+ test('should raise async error', async () => {
+ const { result, waitForNextUpdate } = renderHook(() => useAsyncError(true))
+
+ await waitForNextUpdate()
+
+ expect(() => {
+ expect(result.current).not.toBe(undefined)
+ }).toThrow(Error('expected'))
+ })
+
+ test('should capture async error', async () => {
+ const { result, waitForNextUpdate } = renderHook(() => useAsyncError(true))
+
+ await waitForNextUpdate()
+
+ expect(result.error).toEqual(Error('expected'))
+ })
+
+ test('should not capture async error', async () => {
+ const { result, waitForNextUpdate } = renderHook(() => useAsyncError(false))
+
+ await waitForNextUpdate()
+
+ expect(result.current).not.toBe(undefined)
+ expect(result.error).toBe(undefined)
+ })
+
+ test('should reset async error', async () => {
+ const { result, waitForNextUpdate, rerender } = renderHook(
+ ({ throwError }) => useAsyncError(throwError),
+ { initialProps: { throwError: true } }
+ )
+
+ await waitForNextUpdate()
+
+ expect(result.error).not.toBe(undefined)
+
+ rerender({ throwError: false })
+
+ await waitForNextUpdate()
+
+ expect(result.current).not.toBe(undefined)
+ expect(result.error).toBe(undefined)
+ })
+ })
+
+ /*
+ These tests capture error cases that are not currently being caught successfully.
+ Refer to https://github.com/testing-library/react-hooks-testing-library/issues/308
+ for more details.
+ */
+ // eslint-disable-next-line jest/no-disabled-tests
+ describe.skip('effect', () => {
+ test('should raise effect error', () => {
+ const { result } = renderHook(() => useEffectError(true))
+
+ expect(() => {
+ expect(result.current).not.toBe(undefined)
+ }).toThrow(Error('expected'))
+ })
+
+ test('should capture effect error', () => {
+ const { result } = renderHook(() => useEffectError(true))
+ expect(result.error).toEqual(Error('expected'))
+ })
+
+ test('should not capture effect error', () => {
+ const { result } = renderHook(() => useEffectError(false))
+
+ expect(result.current).not.toBe(undefined)
+ expect(result.error).toBe(undefined)
+ })
+
+ test('should reset effect error', () => {
+ const { result, rerender } = renderHook(({ throwError }) => useEffectError(throwError), {
+ initialProps: { throwError: true }
+ })
+
+ expect(result.error).not.toBe(undefined)
+
+ rerender({ throwError: false })
+
+ expect(result.current).not.toBe(undefined)
+ expect(result.error).toBe(undefined)
+ })
+ })
+})
diff --git a/test/native/resultHistory.ts b/test/native/resultHistory.ts
new file mode 100644
index 00000000..db01d5b7
--- /dev/null
+++ b/test/native/resultHistory.ts
@@ -0,0 +1,34 @@
+import { renderHook } from '../../src/native'
+
+describe('result history tests', () => {
+ let count = 0
+ function useCounter() {
+ const result = count++
+ if (result === 2) {
+ throw Error('expected')
+ }
+ return result
+ }
+
+ test('should capture all renders states of hook', () => {
+ const { result, rerender } = renderHook(() => useCounter())
+
+ expect(result.current).toEqual(0)
+ expect(result.all).toEqual([0])
+
+ rerender()
+
+ expect(result.current).toBe(1)
+ expect(result.all).toEqual([0, 1])
+
+ rerender()
+
+ expect(result.error).toEqual(Error('expected'))
+ expect(result.all).toEqual([0, 1, Error('expected')])
+
+ rerender()
+
+ expect(result.current).toBe(3)
+ expect(result.all).toEqual([0, 1, Error('expected'), 3])
+ })
+})
diff --git a/test/native/suspenseHook.ts b/test/native/suspenseHook.ts
new file mode 100644
index 00000000..76e49830
--- /dev/null
+++ b/test/native/suspenseHook.ts
@@ -0,0 +1,49 @@
+import { renderHook } from '../../src/native'
+
+describe('suspense hook tests', () => {
+ const cache: { value?: Promise | string | Error } = {}
+ const fetchName = (isSuccessful: boolean) => {
+ if (!cache.value) {
+ cache.value = new Promise((resolve, reject) => {
+ setTimeout(() => {
+ if (isSuccessful) {
+ resolve('Bob')
+ } else {
+ reject(new Error('Failed to fetch name'))
+ }
+ }, 50)
+ })
+ .then((value) => (cache.value = value))
+ .catch((e: Error) => (cache.value = e))
+ }
+ return cache.value
+ }
+
+ const useFetchName = (isSuccessful = true) => {
+ const name = fetchName(isSuccessful)
+ if (name instanceof Promise || name instanceof Error) {
+ throw name as unknown
+ }
+ return name
+ }
+
+ beforeEach(() => {
+ delete cache.value
+ })
+
+ test('should allow rendering to be suspended', async () => {
+ const { result, waitForNextUpdate } = renderHook(() => useFetchName(true))
+
+ await waitForNextUpdate()
+
+ expect(result.current).toBe('Bob')
+ })
+
+ test('should set error if suspense promise rejects', async () => {
+ const { result, waitForNextUpdate } = renderHook(() => useFetchName(false))
+
+ await waitForNextUpdate()
+
+ expect(result.error).toEqual(new Error('Failed to fetch name'))
+ })
+})
diff --git a/test/native/useContext.tsx b/test/native/useContext.tsx
new file mode 100644
index 00000000..c306fb21
--- /dev/null
+++ b/test/native/useContext.tsx
@@ -0,0 +1,63 @@
+import React, { createContext, useContext } from 'react'
+import { renderHook } from '../../src/native'
+
+describe('useContext tests', () => {
+ test('should get default value from context', () => {
+ const TestContext = createContext('foo')
+
+ const { result } = renderHook(() => useContext(TestContext))
+
+ const value = result.current
+
+ expect(value).toBe('foo')
+ })
+
+ test('should get value from context provider', () => {
+ const TestContext = createContext('foo')
+
+ const wrapper: React.FC = ({ children }) => (
+ {children}
+ )
+
+ const { result } = renderHook(() => useContext(TestContext), { wrapper })
+
+ expect(result.current).toBe('bar')
+ })
+
+ test('should update mutated value in context', () => {
+ const TestContext = createContext('foo')
+
+ const value = { current: 'bar' }
+
+ const wrapper: React.FC = ({ children }) => (
+ {children}
+ )
+
+ const { result, rerender } = renderHook(() => useContext(TestContext), { wrapper })
+
+ value.current = 'baz'
+
+ rerender()
+
+ expect(result.current).toBe('baz')
+ })
+
+ test('should update value in context when props are updated', () => {
+ const TestContext = createContext('foo')
+
+ const wrapper: React.FC<{ current: string }> = ({ current, children }) => (
+ {children}
+ )
+
+ const { result, rerender } = renderHook(() => useContext(TestContext), {
+ wrapper,
+ initialProps: {
+ current: 'bar'
+ }
+ })
+
+ rerender({ current: 'baz' })
+
+ expect(result.current).toBe('baz')
+ })
+})
diff --git a/test/native/useEffect.ts b/test/native/useEffect.ts
new file mode 100644
index 00000000..c9c4a8d9
--- /dev/null
+++ b/test/native/useEffect.ts
@@ -0,0 +1,62 @@
+import { useEffect, useLayoutEffect } from 'react'
+import { renderHook } from '../../src/native'
+
+describe('useEffect tests', () => {
+ test('should handle useEffect hook', () => {
+ const sideEffect: { [key: number]: boolean } = { 1: false, 2: false }
+
+ const { rerender, unmount } = renderHook(
+ ({ id }) => {
+ useEffect(() => {
+ sideEffect[id] = true
+ return () => {
+ sideEffect[id] = false
+ }
+ }, [id])
+ },
+ { initialProps: { id: 1 } }
+ )
+
+ expect(sideEffect[1]).toBe(true)
+ expect(sideEffect[2]).toBe(false)
+
+ rerender({ id: 2 })
+
+ expect(sideEffect[1]).toBe(false)
+ expect(sideEffect[2]).toBe(true)
+
+ unmount()
+
+ expect(sideEffect[1]).toBe(false)
+ expect(sideEffect[2]).toBe(false)
+ })
+
+ test('should handle useLayoutEffect hook', () => {
+ const sideEffect: { [key: number]: boolean } = { 1: false, 2: false }
+
+ const { rerender, unmount } = renderHook(
+ ({ id }) => {
+ useLayoutEffect(() => {
+ sideEffect[id] = true
+ return () => {
+ sideEffect[id] = false
+ }
+ }, [id])
+ },
+ { initialProps: { id: 1 } }
+ )
+
+ expect(sideEffect[1]).toBe(true)
+ expect(sideEffect[2]).toBe(false)
+
+ rerender({ id: 2 })
+
+ expect(sideEffect[1]).toBe(false)
+ expect(sideEffect[2]).toBe(true)
+
+ unmount()
+
+ expect(sideEffect[1]).toBe(false)
+ expect(sideEffect[2]).toBe(false)
+ })
+})
diff --git a/test/native/useMemo.ts b/test/native/useMemo.ts
new file mode 100644
index 00000000..465ef591
--- /dev/null
+++ b/test/native/useMemo.ts
@@ -0,0 +1,64 @@
+import { useMemo, useCallback } from 'react'
+import { renderHook } from '../../src/native'
+
+describe('useCallback tests', () => {
+ test('should handle useMemo hook', () => {
+ const { result, rerender } = renderHook(({ value }) => useMemo(() => ({ value }), [value]), {
+ initialProps: { value: 1 }
+ })
+
+ const value1 = result.current
+
+ expect(value1).toEqual({ value: 1 })
+
+ rerender()
+
+ const value2 = result.current
+
+ expect(value2).toEqual({ value: 1 })
+
+ expect(value2).toBe(value1)
+
+ rerender({ value: 2 })
+
+ const value3 = result.current
+
+ expect(value3).toEqual({ value: 2 })
+
+ expect(value3).not.toBe(value1)
+ })
+
+ test('should handle useCallback hook', () => {
+ const { result, rerender } = renderHook(
+ ({ value }) => {
+ const callback = () => ({ value })
+ return useCallback(callback, [value])
+ },
+ { initialProps: { value: 1 } }
+ )
+
+ const callback1 = result.current
+
+ const callbackValue1 = callback1()
+
+ expect(callbackValue1).toEqual({ value: 1 })
+
+ const callback2 = result.current
+
+ const callbackValue2 = callback2()
+
+ expect(callbackValue2).toEqual({ value: 1 })
+
+ expect(callback2).toBe(callback1)
+
+ rerender({ value: 2 })
+
+ const callback3 = result.current
+
+ const callbackValue3 = callback3()
+
+ expect(callbackValue3).toEqual({ value: 2 })
+
+ expect(callback3).not.toBe(callback1)
+ })
+})
diff --git a/test/native/useReducer.ts b/test/native/useReducer.ts
new file mode 100644
index 00000000..2de8c44d
--- /dev/null
+++ b/test/native/useReducer.ts
@@ -0,0 +1,20 @@
+import { useReducer } from 'react'
+import { renderHook, act } from '../../src/native'
+
+describe('useReducer tests', () => {
+ test('should handle useReducer hook', () => {
+ const reducer = (state: number, action: { type: string }) =>
+ action.type === 'inc' ? state + 1 : state
+ const { result } = renderHook(() => useReducer(reducer, 0))
+
+ const [initialState, dispatch] = result.current
+
+ expect(initialState).toBe(0)
+
+ act(() => dispatch({ type: 'inc' }))
+
+ const [state] = result.current
+
+ expect(state).toBe(1)
+ })
+})
diff --git a/test/native/useRef.ts b/test/native/useRef.ts
new file mode 100644
index 00000000..a1ca0e27
--- /dev/null
+++ b/test/native/useRef.ts
@@ -0,0 +1,27 @@
+import { useRef, useImperativeHandle } from 'react'
+import { renderHook } from '../../src/native'
+
+describe('useHook tests', () => {
+ test('should handle useRef hook', () => {
+ const { result } = renderHook(() => useRef())
+
+ const refContainer = result.current
+
+ expect(Object.keys(refContainer)).toEqual(['current'])
+ expect(refContainer.current).toBeUndefined()
+ })
+
+ test('should handle useImperativeHandle hook', () => {
+ const { result } = renderHook(() => {
+ const ref = useRef boolean>>({})
+ useImperativeHandle(ref, () => ({
+ fakeImperativeMethod: () => true
+ }))
+ return ref
+ })
+
+ const refContainer = result.current
+
+ expect(refContainer.current.fakeImperativeMethod()).toBe(true)
+ })
+})
diff --git a/test/native/useState.ts b/test/native/useState.ts
new file mode 100644
index 00000000..f384434f
--- /dev/null
+++ b/test/native/useState.ts
@@ -0,0 +1,24 @@
+import { useState } from 'react'
+import { renderHook, act } from '../../src/native'
+
+describe('useState tests', () => {
+ test('should use setState value', () => {
+ const { result } = renderHook(() => useState('foo'))
+
+ const [value] = result.current
+
+ expect(value).toBe('foo')
+ })
+
+ test('should update setState value using setter', () => {
+ const { result } = renderHook(() => useState('foo'))
+
+ const [ignoredValue, setValue] = result.current
+
+ act(() => setValue('bar'))
+
+ const [value] = result.current
+
+ expect(value).toBe('bar')
+ })
+})
diff --git a/test/server/asyncHook.ts b/test/server/asyncHook.ts
new file mode 100644
index 00000000..9c872430
--- /dev/null
+++ b/test/server/asyncHook.ts
@@ -0,0 +1,302 @@
+import { useState, useRef, useEffect } from 'react'
+
+import { renderHook } from '../../src/server'
+
+describe('async hook tests', () => {
+ const useSequence = (...values: string[]) => {
+ const [first, ...otherValues] = values
+ const [value, setValue] = useState(first)
+ const index = useRef(0)
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setValue(otherValues[index.current++])
+ if (index.current === otherValues.length) {
+ clearInterval(interval)
+ }
+ }, 50)
+ return () => {
+ clearInterval(interval)
+ }
+ }, [otherValues])
+
+ return value
+ }
+
+ test('should wait for next update', async () => {
+ const { result, hydrate, waitForNextUpdate } = renderHook(() => useSequence('first', 'second'))
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ await waitForNextUpdate()
+
+ expect(result.current).toBe('second')
+ })
+
+ test('should wait for multiple updates', async () => {
+ const { result, hydrate, waitForNextUpdate } = renderHook(() =>
+ useSequence('first', 'second', 'third')
+ )
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ await waitForNextUpdate()
+
+ expect(result.current).toBe('second')
+
+ await waitForNextUpdate()
+
+ expect(result.current).toBe('third')
+ })
+
+ test('should resolve all when updating', async () => {
+ const { result, hydrate, waitForNextUpdate } = renderHook(() => useSequence('first', 'second'))
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ await Promise.all([waitForNextUpdate(), waitForNextUpdate(), waitForNextUpdate()])
+
+ expect(result.current).toBe('second')
+ })
+
+ test('should reject if timeout exceeded when waiting for next update', async () => {
+ const { result, hydrate, waitForNextUpdate } = renderHook(() => useSequence('first', 'second'))
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow(
+ Error('Timed out in waitForNextUpdate after 10ms.')
+ )
+ })
+
+ test('should wait for expectation to pass', async () => {
+ const { result, hydrate, waitFor } = renderHook(() => useSequence('first', 'second', 'third'))
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ let complete = false
+ await waitFor(() => {
+ expect(result.current).toBe('third')
+ complete = true
+ })
+ expect(complete).toBe(true)
+ })
+
+ test('should wait for arbitrary expectation to pass', async () => {
+ const { waitFor, hydrate } = renderHook(() => null)
+
+ hydrate()
+
+ let actual = 0
+ const expected = 1
+
+ setTimeout(() => {
+ actual = expected
+ }, 200)
+
+ let complete = false
+ await waitFor(
+ () => {
+ expect(actual).toBe(expected)
+ complete = true
+ },
+ { interval: 100 }
+ )
+
+ expect(complete).toBe(true)
+ })
+
+ test('should not hang if expectation is already passing', async () => {
+ const { result, hydrate, waitFor } = renderHook(() => useSequence('first', 'second'))
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ let complete = false
+ await waitFor(() => {
+ expect(result.current).toBe('first')
+ complete = true
+ })
+ expect(complete).toBe(true)
+ })
+
+ test('should reject if callback throws error', async () => {
+ const { result, hydrate, waitFor } = renderHook(() => useSequence('first', 'second', 'third'))
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ await expect(
+ waitFor(
+ () => {
+ if (result.current === 'second') {
+ throw new Error('Something Unexpected')
+ }
+ return result.current === 'third'
+ },
+ {
+ suppressErrors: false
+ }
+ )
+ ).rejects.toThrow(Error('Something Unexpected'))
+ })
+
+ test('should reject if callback immediately throws error', async () => {
+ const { result, hydrate, waitFor } = renderHook(() => useSequence('first', 'second', 'third'))
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ await expect(
+ waitFor(
+ () => {
+ throw new Error('Something Unexpected')
+ },
+ {
+ suppressErrors: false
+ }
+ )
+ ).rejects.toThrow(Error('Something Unexpected'))
+ })
+
+ test('should wait for truthy value', async () => {
+ const { result, hydrate, waitFor } = renderHook(() => useSequence('first', 'second', 'third'))
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ await waitFor(() => result.current === 'third')
+
+ expect(result.current).toBe('third')
+ })
+
+ test('should reject if timeout exceeded when waiting for expectation to pass', async () => {
+ const { result, hydrate, waitFor } = renderHook(() => useSequence('first', 'second', 'third'))
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ await expect(
+ waitFor(
+ () => {
+ expect(result.current).toBe('third')
+ },
+ { timeout: 75 }
+ )
+ ).rejects.toThrow(Error('Timed out in waitFor after 75ms.'))
+ })
+
+ test('should wait for value to change', async () => {
+ const { result, hydrate, waitForValueToChange } = renderHook(() =>
+ useSequence('first', 'second', 'third')
+ )
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ await waitForValueToChange(() => result.current === 'third')
+
+ expect(result.current).toBe('third')
+ })
+
+ test('should reject if timeout exceeded when waiting for value to change', async () => {
+ const { result, hydrate, waitForValueToChange } = renderHook(() =>
+ useSequence('first', 'second', 'third')
+ )
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ await expect(
+ waitForValueToChange(() => result.current === 'third', {
+ timeout: 75
+ })
+ ).rejects.toThrow(Error('Timed out in waitForValueToChange after 75ms.'))
+ })
+
+ test('should reject if selector throws error', async () => {
+ const { result, hydrate, waitForValueToChange } = renderHook(() =>
+ useSequence('first', 'second')
+ )
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ await expect(
+ waitForValueToChange(() => {
+ if (result.current === 'second') {
+ throw new Error('Something Unexpected')
+ }
+ return result.current
+ })
+ ).rejects.toThrow(Error('Something Unexpected'))
+ })
+
+ test('should not reject if selector throws error and suppress errors option is enabled', async () => {
+ const { result, hydrate, waitForValueToChange } = renderHook(() =>
+ useSequence('first', 'second', 'third')
+ )
+
+ expect(result.current).toBe('first')
+
+ hydrate()
+
+ expect(result.current).toBe('first')
+
+ await waitForValueToChange(
+ () => {
+ if (result.current === 'second') {
+ throw new Error('Something Unexpected')
+ }
+ return result.current === 'third'
+ },
+ { suppressErrors: true }
+ )
+
+ expect(result.current).toBe('third')
+ })
+})
diff --git a/test/server/autoCleanup.disabled.ts b/test/server/autoCleanup.disabled.ts
new file mode 100644
index 00000000..00853a13
--- /dev/null
+++ b/test/server/autoCleanup.disabled.ts
@@ -0,0 +1,31 @@
+import { useEffect } from 'react'
+
+import { ReactHooksRenderer } from 'types'
+
+// This verifies that if RHTL_SKIP_AUTO_CLEANUP is set
+// then we DON'T auto-wire up the afterEach for folks
+describe('skip auto cleanup (disabled) tests', () => {
+ let cleanupCalled = false
+ let renderHook: (arg0: () => void) => void
+
+ beforeAll(() => {
+ process.env.RHTL_SKIP_AUTO_CLEANUP = 'true'
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ renderHook = (require('../../src/server') as ReactHooksRenderer).renderHook
+ })
+
+ test('first', () => {
+ const hookWithCleanup = () => {
+ useEffect(() => {
+ return () => {
+ cleanupCalled = true
+ }
+ })
+ }
+ renderHook(() => hookWithCleanup())
+ })
+
+ test('second', () => {
+ expect(cleanupCalled).toBe(false)
+ })
+})
diff --git a/test/server/autoCleanup.noAfterEach.ts b/test/server/autoCleanup.noAfterEach.ts
new file mode 100644
index 00000000..180dbea3
--- /dev/null
+++ b/test/server/autoCleanup.noAfterEach.ts
@@ -0,0 +1,33 @@
+import { useEffect } from 'react'
+
+import { ReactHooksRenderer } from 'types'
+
+// This verifies that if RHTL_SKIP_AUTO_CLEANUP is set
+// then we DON'T auto-wire up the afterEach for folks
+describe('skip auto cleanup (no afterEach) tests', () => {
+ let cleanupCalled = false
+ let renderHook: (arg0: () => void) => void
+
+ beforeAll(() => {
+ // @ts-expect-error Turning off AfterEach -- ignore Jest LifeCycle Type
+ // eslint-disable-next-line no-global-assign
+ afterEach = false
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ renderHook = (require('../../src/server') as ReactHooksRenderer).renderHook
+ })
+
+ test('first', () => {
+ const hookWithCleanup = () => {
+ useEffect(() => {
+ return () => {
+ cleanupCalled = true
+ }
+ })
+ }
+ renderHook(() => hookWithCleanup())
+ })
+
+ test('second', () => {
+ expect(cleanupCalled).toBe(false)
+ })
+})
diff --git a/test/server/autoCleanup.ts b/test/server/autoCleanup.ts
new file mode 100644
index 00000000..087c2af8
--- /dev/null
+++ b/test/server/autoCleanup.ts
@@ -0,0 +1,32 @@
+import { useEffect } from 'react'
+import { renderHook } from '../../src/server'
+
+// This verifies that by importing RHTL in an
+// environment which supports afterEach (like Jest)
+// we'll get automatic cleanup between tests.
+describe('auto cleanup tests', () => {
+ const cleanups: Record = {
+ ssr: false,
+ hydrated: false
+ }
+
+ test('first', () => {
+ const hookWithCleanup = (name: string) => {
+ useEffect(() => {
+ return () => {
+ cleanups[name] = true
+ }
+ })
+ }
+
+ renderHook(() => hookWithCleanup('ssr'))
+
+ const { hydrate } = renderHook(() => hookWithCleanup('hydrated'))
+ hydrate()
+ })
+
+ test('second', () => {
+ expect(cleanups.ssr).toBe(false)
+ expect(cleanups.hydrated).toBe(true)
+ })
+})
diff --git a/test/server/cleanup.ts b/test/server/cleanup.ts
new file mode 100644
index 00000000..e8033492
--- /dev/null
+++ b/test/server/cleanup.ts
@@ -0,0 +1,67 @@
+import { useEffect } from 'react'
+import { renderHook, cleanup } from '../../src/server'
+
+describe('cleanup tests', () => {
+ test('should flush effects on cleanup', async () => {
+ let cleanupCalled = false
+
+ const hookWithCleanup = () => {
+ useEffect(() => {
+ return () => {
+ cleanupCalled = true
+ }
+ })
+ }
+
+ const { hydrate } = renderHook(() => hookWithCleanup())
+
+ hydrate()
+
+ await cleanup()
+
+ expect(cleanupCalled).toBe(true)
+ })
+
+ test('should cleanup all rendered hooks', async () => {
+ let cleanupCalled = [false, false]
+ const hookWithCleanup = (id: number) => {
+ useEffect(() => {
+ return () => {
+ cleanupCalled = cleanupCalled.map((_, i) => (i === id ? true : _))
+ }
+ })
+ }
+
+ const { hydrate: hydrate1 } = renderHook(() => hookWithCleanup(0))
+ const { hydrate: hydrate2 } = renderHook(() => hookWithCleanup(1))
+
+ hydrate1()
+ hydrate2()
+
+ await cleanup()
+
+ expect(cleanupCalled[0]).toBe(true)
+ expect(cleanupCalled[1]).toBe(true)
+ })
+
+ test('should only cleanup hydrated hooks', async () => {
+ let cleanupCalled = [false, false]
+ const hookWithCleanup = (id: number) => {
+ useEffect(() => {
+ return () => {
+ cleanupCalled = cleanupCalled.map((_, i) => (i === id ? true : _))
+ }
+ })
+ }
+
+ renderHook(() => hookWithCleanup(0))
+ const { hydrate } = renderHook(() => hookWithCleanup(1))
+
+ hydrate()
+
+ await cleanup()
+
+ expect(cleanupCalled[0]).toBe(false)
+ expect(cleanupCalled[1]).toBe(true)
+ })
+})
diff --git a/test/server/customHook.ts b/test/server/customHook.ts
new file mode 100644
index 00000000..2fadd2d6
--- /dev/null
+++ b/test/server/customHook.ts
@@ -0,0 +1,33 @@
+import { useState, useCallback } from 'react'
+import { renderHook, act } from '../../src/server'
+
+describe('custom hook tests', () => {
+ function useCounter() {
+ const [count, setCount] = useState(0)
+
+ const increment = useCallback(() => setCount(count + 1), [count])
+ const decrement = useCallback(() => setCount(count - 1), [count])
+
+ return { count, increment, decrement }
+ }
+
+ test('should increment counter', () => {
+ const { result, hydrate } = renderHook(() => useCounter())
+
+ hydrate()
+
+ act(() => result.current.increment())
+
+ expect(result.current.count).toBe(1)
+ })
+
+ test('should decrement counter', () => {
+ const { result, hydrate } = renderHook(() => useCounter())
+
+ hydrate()
+
+ act(() => result.current.decrement())
+
+ expect(result.current.count).toBe(-1)
+ })
+})
diff --git a/test/server/errorHook.ts b/test/server/errorHook.ts
new file mode 100644
index 00000000..1fcbd34b
--- /dev/null
+++ b/test/server/errorHook.ts
@@ -0,0 +1,172 @@
+import { useState, useEffect } from 'react'
+
+import { renderHook } from '../../src/server'
+
+describe('error hook tests', () => {
+ function useError(throwError?: boolean) {
+ if (throwError) {
+ throw new Error('expected')
+ }
+ return true
+ }
+
+ function useAsyncError(throwError: boolean) {
+ const [value, setValue] = useState()
+ useEffect(() => {
+ const timeout = setTimeout(() => setValue(throwError), 100)
+ return () => clearTimeout(timeout)
+ }, [throwError])
+ return useError(value)
+ }
+
+ function useEffectError(throwError: boolean) {
+ useEffect(() => {
+ useError(throwError)
+ }, [throwError])
+ return true
+ }
+
+ describe('synchronous', () => {
+ test('should raise error', () => {
+ const { result } = renderHook(() => useError(true))
+
+ expect(() => {
+ expect(result.current).not.toBe(undefined)
+ }).toThrow(Error('expected'))
+ })
+
+ test('should capture error', () => {
+ const { result } = renderHook(() => useError(true))
+
+ expect(result.error).toEqual(Error('expected'))
+ })
+
+ test('should not capture error', () => {
+ const { result } = renderHook(() => useError(false))
+
+ expect(result.current).not.toBe(undefined)
+ expect(result.error).toBe(undefined)
+ })
+
+ test('should reset error', () => {
+ const { result, hydrate, rerender } = renderHook(({ throwError }) => useError(throwError), {
+ initialProps: { throwError: true }
+ })
+
+ expect(result.error).not.toBe(undefined)
+
+ hydrate()
+
+ rerender({ throwError: false })
+
+ expect(result.current).not.toBe(undefined)
+ expect(result.error).toBe(undefined)
+ })
+ })
+
+ describe('asynchronous', () => {
+ test('should raise async error', async () => {
+ const { result, hydrate, waitForNextUpdate } = renderHook(() => useAsyncError(true))
+
+ hydrate()
+
+ await waitForNextUpdate()
+
+ expect(() => {
+ expect(result.current).not.toBe(undefined)
+ }).toThrow(Error('expected'))
+ })
+
+ test('should capture async error', async () => {
+ const { result, hydrate, waitForNextUpdate } = renderHook(() => useAsyncError(true))
+
+ hydrate()
+
+ await waitForNextUpdate()
+
+ expect(result.error).toEqual(Error('expected'))
+ })
+
+ test('should not capture async error', async () => {
+ const { result, hydrate, waitForNextUpdate } = renderHook(() => useAsyncError(false))
+
+ hydrate()
+
+ await waitForNextUpdate()
+
+ expect(result.current).not.toBe(undefined)
+ expect(result.error).toBe(undefined)
+ })
+
+ test('should reset async error', async () => {
+ const { result, hydrate, waitForNextUpdate, rerender } = renderHook(
+ ({ throwError }) => useAsyncError(throwError),
+ { initialProps: { throwError: true } }
+ )
+
+ hydrate()
+
+ await waitForNextUpdate()
+
+ expect(result.error).not.toBe(undefined)
+
+ rerender({ throwError: false })
+
+ await waitForNextUpdate()
+
+ expect(result.current).not.toBe(undefined)
+ expect(result.error).toBe(undefined)
+ })
+ })
+
+ /*
+ These tests capture error cases that are not currently being caught successfully.
+ Refer to https://github.com/testing-library/react-hooks-testing-library/issues/308
+ for more details.
+ */
+ // eslint-disable-next-line jest/no-disabled-tests
+ describe.skip('effect', () => {
+ test('should raise effect error', () => {
+ const { result, hydrate } = renderHook(() => useEffectError(true))
+
+ hydrate()
+
+ expect(() => {
+ expect(result.current).not.toBe(undefined)
+ }).toThrow(Error('expected'))
+ })
+
+ test('should capture effect error', () => {
+ const { result, hydrate } = renderHook(() => useEffectError(true))
+
+ hydrate()
+
+ expect(result.error).toEqual(Error('expected'))
+ })
+
+ test('should not capture effect error', () => {
+ const { result, hydrate } = renderHook(() => useEffectError(false))
+
+ hydrate()
+
+ expect(result.current).not.toBe(undefined)
+ expect(result.error).toBe(undefined)
+ })
+
+ test('should reset effect error', () => {
+ const { result, hydrate, rerender } = renderHook(
+ ({ throwError }) => useEffectError(throwError),
+ { initialProps: { throwError: true } }
+ )
+
+ hydrate()
+
+ expect(result.error).not.toBe(undefined)
+
+ rerender({ throwError: false })
+
+ expect(result.current).not.toBe(undefined)
+ expect(result.error).toBe(undefined)
+ })
+ })
+})
diff --git a/test/server/hydrationErrors.ts b/test/server/hydrationErrors.ts
new file mode 100644
index 00000000..4b14dd0a
--- /dev/null
+++ b/test/server/hydrationErrors.ts
@@ -0,0 +1,29 @@
+import { useState, useCallback } from 'react'
+import { renderHook } from '../../src/server'
+
+describe('hydration errors tests', () => {
+ function useCounter() {
+ const [count, setCount] = useState(0)
+
+ const increment = useCallback(() => setCount(count + 1), [count])
+ const decrement = useCallback(() => setCount(count - 1), [count])
+
+ return { count, increment, decrement }
+ }
+
+ test('should throw error if component is rehydrated twice in a row', () => {
+ const { hydrate } = renderHook(() => useCounter())
+
+ hydrate()
+
+ expect(() => hydrate()).toThrow(Error('The component can only be hydrated once'))
+ })
+
+ test('should throw error if component tries to rerender without hydrating', () => {
+ const { rerender } = renderHook(() => useCounter())
+
+ expect(() => rerender()).toThrow(
+ Error('You must hydrate the component before you can rerender')
+ )
+ })
+})
diff --git a/test/server/useContext.tsx b/test/server/useContext.tsx
new file mode 100644
index 00000000..33c1008b
--- /dev/null
+++ b/test/server/useContext.tsx
@@ -0,0 +1,45 @@
+import React, { createContext, useContext } from 'react'
+import { renderHook } from '../../src/server'
+
+describe('useContext tests', () => {
+ test('should get default value from context', () => {
+ const TestContext = createContext('foo')
+
+ const { result } = renderHook(() => useContext(TestContext))
+
+ const value = result.current
+
+ expect(value).toBe('foo')
+ })
+
+ test('should get value from context provider', () => {
+ const TestContext = createContext('foo')
+
+ const wrapper: React.FC = ({ children }) => (
+ {children}
+ )
+
+ const { result } = renderHook(() => useContext(TestContext), { wrapper })
+
+ expect(result.current).toBe('bar')
+ })
+
+ test('should update value in context when props are updated', () => {
+ const TestContext = createContext('foo')
+
+ const wrapper: React.FC<{ contextValue: string }> = ({ contextValue, children }) => (
+ {children}
+ )
+
+ const { result, hydrate, rerender } = renderHook(() => useContext(TestContext), {
+ wrapper,
+ initialProps: { contextValue: 'bar' }
+ })
+
+ hydrate()
+
+ rerender({ contextValue: 'baz' })
+
+ expect(result.current).toBe('baz')
+ })
+})
diff --git a/test/server/useEffect.ts b/test/server/useEffect.ts
new file mode 100644
index 00000000..1adf23e4
--- /dev/null
+++ b/test/server/useEffect.ts
@@ -0,0 +1,38 @@
+import { useEffect } from 'react'
+import { renderHook } from '../../src/server'
+
+describe('useEffect tests', () => {
+ test('should handle useEffect hook', () => {
+ const sideEffect: { [key: number]: boolean } = { 1: false, 2: false }
+
+ const { hydrate, rerender, unmount } = renderHook(
+ ({ id }) => {
+ useEffect(() => {
+ sideEffect[id] = true
+ return () => {
+ sideEffect[id] = false
+ }
+ }, [id])
+ },
+ { initialProps: { id: 1 } }
+ )
+
+ expect(sideEffect[1]).toBe(false)
+ expect(sideEffect[2]).toBe(false)
+
+ hydrate()
+
+ expect(sideEffect[1]).toBe(true)
+ expect(sideEffect[2]).toBe(false)
+
+ rerender({ id: 2 })
+
+ expect(sideEffect[1]).toBe(false)
+ expect(sideEffect[2]).toBe(true)
+
+ unmount()
+
+ expect(sideEffect[1]).toBe(false)
+ expect(sideEffect[2]).toBe(false)
+ })
+})
diff --git a/test/server/useMemo.ts b/test/server/useMemo.ts
new file mode 100644
index 00000000..202db24c
--- /dev/null
+++ b/test/server/useMemo.ts
@@ -0,0 +1,87 @@
+import { useMemo, useCallback } from 'react'
+import { renderHook } from '../../src/server'
+
+describe('useCallback tests', () => {
+ test('should handle useMemo hook', () => {
+ const { result, hydrate, rerender } = renderHook(
+ ({ value }) => useMemo(() => ({ value }), [value]),
+ {
+ initialProps: { value: 1 }
+ }
+ )
+
+ const value1 = result.current
+
+ expect(value1).toEqual({ value: 1 })
+
+ hydrate()
+
+ const value2 = result.current
+
+ expect(value2).toEqual({ value: 1 })
+
+ expect(value2).not.toBe(value1)
+
+ rerender()
+
+ const value3 = result.current
+
+ expect(value3).toEqual({ value: 1 })
+
+ expect(value3).toBe(value2)
+
+ rerender({ value: 2 })
+
+ const value4 = result.current
+
+ expect(value4).toEqual({ value: 2 })
+
+ expect(value4).not.toBe(value2)
+ })
+
+ test('should handle useCallback hook', () => {
+ const { result, hydrate, rerender } = renderHook(
+ ({ value }) => {
+ const callback = () => ({ value })
+ return useCallback(callback, [value])
+ },
+ { initialProps: { value: 1 } }
+ )
+
+ const callback1 = result.current
+
+ const calbackValue1 = callback1()
+
+ expect(calbackValue1).toEqual({ value: 1 })
+
+ hydrate()
+
+ const callback2 = result.current
+
+ const calbackValue2 = callback2()
+
+ expect(calbackValue2).toEqual({ value: 1 })
+
+ expect(callback2).not.toBe(callback1)
+
+ rerender()
+
+ const callback3 = result.current
+
+ const calbackValue3 = callback3()
+
+ expect(calbackValue3).toEqual({ value: 1 })
+
+ expect(callback3).toBe(callback2)
+
+ rerender({ value: 2 })
+
+ const callback4 = result.current
+
+ const calbackValue4 = callback4()
+
+ expect(calbackValue4).toEqual({ value: 2 })
+
+ expect(callback4).not.toBe(callback2)
+ })
+})
diff --git a/test/server/useReducer.ts b/test/server/useReducer.ts
new file mode 100644
index 00000000..f11daf50
--- /dev/null
+++ b/test/server/useReducer.ts
@@ -0,0 +1,22 @@
+import { useReducer } from 'react'
+import { renderHook, act } from '../../src/server'
+
+describe('useReducer tests', () => {
+ test('should handle useReducer hook', () => {
+ const reducer = (state: number, action: { type: string }) =>
+ action.type === 'inc' ? state + 1 : state
+
+ const { result, hydrate } = renderHook(() => {
+ const [state, dispatch] = useReducer(reducer, 0)
+ return { state, dispatch }
+ })
+
+ hydrate()
+
+ expect(result.current.state).toBe(0)
+
+ act(() => result.current.dispatch({ type: 'inc' }))
+
+ expect(result.current.state).toBe(1)
+ })
+})
diff --git a/test/server/useRef.ts b/test/server/useRef.ts
new file mode 100644
index 00000000..26cdc323
--- /dev/null
+++ b/test/server/useRef.ts
@@ -0,0 +1,29 @@
+import { useRef, useImperativeHandle } from 'react'
+import { renderHook } from '../../src/server'
+
+describe('useHook tests', () => {
+ test('should handle useRef hook', () => {
+ const { result } = renderHook(() => useRef('foo'))
+
+ const refContainer = result.current
+
+ expect(Object.keys(refContainer)).toEqual(['current'])
+ expect(refContainer.current).toBe('foo')
+ })
+
+ test('should handle useImperativeHandle hook', () => {
+ const { result, hydrate } = renderHook(() => {
+ const ref = useRef boolean>>({})
+ useImperativeHandle(ref, () => ({
+ fakeImperativeMethod: () => true
+ }))
+ return ref
+ })
+
+ expect(result.current.current).toEqual({})
+
+ hydrate()
+
+ expect(result.current.current.fakeImperativeMethod()).toBe(true)
+ })
+})
diff --git a/test/server/useState.ts b/test/server/useState.ts
new file mode 100644
index 00000000..b3546357
--- /dev/null
+++ b/test/server/useState.ts
@@ -0,0 +1,39 @@
+import { useState } from 'react'
+import { renderHook, act } from '../../src/server'
+
+describe('useState tests', () => {
+ test('should use state value', () => {
+ const { result } = renderHook(() => {
+ const [value, setValue] = useState('foo')
+ return { value, setValue }
+ })
+
+ expect(result.current.value).toBe('foo')
+ })
+
+ test('should retain state value after hydration', () => {
+ const { result, hydrate } = renderHook(() => {
+ const [value, setValue] = useState('foo')
+ return { value, setValue }
+ })
+
+ hydrate()
+
+ expect(result.current.value).toBe('foo')
+ })
+
+ test('should update state value using setter', () => {
+ const { result, hydrate } = renderHook(() => {
+ const [value, setValue] = useState('foo')
+ return { value, setValue }
+ })
+
+ hydrate()
+
+ act(() => {
+ result.current.setValue('bar')
+ })
+
+ expect(result.current.value).toBe('bar')
+ })
+})
diff --git a/test/tsconfig.json b/test/tsconfig.json
index 48209b56..bbb2c4c6 100644
--- a/test/tsconfig.json
+++ b/test/tsconfig.json
@@ -1,8 +1,8 @@
{
- "extends": "../tsconfig.json",
+ "extends": "../tsconfig",
"compilerOptions": {
"declaration": false
},
"exclude": [],
- "include": ["."]
+ "include": ["./**/*.ts"]
}
diff --git a/tsconfig.json b/tsconfig.json
index 1337ac30..2b5e3606 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,6 +3,5 @@
"compilerOptions": {
"allowJs": true,
"target": "ES6"
- },
- "exclude": ["./test"]
+ }
}