From a25993fe1c08fea92b6d7a90666fc8c1c94b9855 Mon Sep 17 00:00:00 2001 From: Josh <37798644+joshuaellis@users.noreply.github.com> Date: Thu, 7 Jan 2021 20:31:18 +1100 Subject: [PATCH] feat: react-dom and SSR compatible rendering - Abstracted rendering out of library core to allow different types of renderers - Auto-detection of `react-test-renderer` or `react-dom` renderers. Submodules for: - `dom` (`react-dom`) - `native` (`react-test-renderer`) - `server` (`react-dom/server`) Co-authored-by: Michael Peyper BREAKING CHANGE: Importing from `renderHook` and `act` from `@testing-library/react-hooks` will now auto-detect which renderer to used based on the project's dependencies - `peerDependencies` are now optional to support different dependencies being required - This means there will be no warning if the dependency is not installed at all, but it will still warn if an incompatible version is installed --- .all-contributorsrc | 10 +- .eslintignore | 4 + .eslintrc | 3 +- .gitignore | 7 +- README.md | 4 +- jest.config.js | 2 +- package.json | 23 +- pure.js | 2 - scripts/generate-submodules.ts | 63 +++++ scripts/tsconfig.json | 8 + src/{ => core}/asyncUtils.ts | 34 +-- src/{ => core}/cleanup.ts | 11 +- src/core/index.ts | 78 ++++++ src/dom/index.ts | 5 + src/dom/pure.ts | 45 ++++ src/helpers/createTestHarness.tsx | 42 +++ src/helpers/error.ts | 7 + src/helpers/promises.ts | 9 + src/index.ts | 9 +- src/native/index.ts | 5 + src/native/pure.ts | 42 +++ src/pure.ts | 40 +++ src/pure.tsx | 124 --------- src/server/index.ts | 5 + src/server/pure.ts | 66 +++++ src/types/index.ts | 61 +++++ src/types/internal.ts | 12 + src/types/react.ts | 7 + test/{ => dom}/asyncHook.ts | 2 +- test/{ => dom}/autoCleanup.disabled.ts | 6 +- test/{ => dom}/autoCleanup.noAfterEach.ts | 6 +- test/{ => dom}/autoCleanup.ts | 2 +- test/{ => dom}/cleanup.ts | 2 +- test/{ => dom}/customHook.ts | 2 +- test/{ => dom}/errorHook.ts | 24 +- test/{ => dom}/resultHistory.ts | 2 +- test/{ => dom}/suspenseHook.ts | 2 +- test/{ => dom}/useContext.tsx | 2 +- test/{ => dom}/useEffect.ts | 2 +- test/{ => dom}/useMemo.ts | 2 +- test/{ => dom}/useReducer.ts | 2 +- test/{ => dom}/useRef.ts | 2 +- test/{ => dom}/useState.ts | 2 +- test/native/asyncHook.ts | 269 +++++++++++++++++++ test/native/autoCleanup.disabled.ts | 31 +++ test/native/autoCleanup.noAfterEach.ts | 33 +++ test/native/autoCleanup.ts | 24 ++ test/native/cleanup.ts | 135 ++++++++++ test/native/customHook.ts | 29 +++ test/native/errorHook.ts | 151 +++++++++++ test/native/resultHistory.ts | 34 +++ test/native/suspenseHook.ts | 49 ++++ test/native/useContext.tsx | 63 +++++ test/native/useEffect.ts | 62 +++++ test/native/useMemo.ts | 64 +++++ test/native/useReducer.ts | 20 ++ test/native/useRef.ts | 27 ++ test/native/useState.ts | 24 ++ test/server/asyncHook.ts | 302 ++++++++++++++++++++++ test/server/autoCleanup.disabled.ts | 31 +++ test/server/autoCleanup.noAfterEach.ts | 33 +++ test/server/autoCleanup.ts | 32 +++ test/server/cleanup.ts | 67 +++++ test/server/customHook.ts | 33 +++ test/server/errorHook.ts | 172 ++++++++++++ test/server/hydrationErrors.ts | 29 +++ test/server/useContext.tsx | 45 ++++ test/server/useEffect.ts | 38 +++ test/server/useMemo.ts | 87 +++++++ test/server/useReducer.ts | 22 ++ test/server/useRef.ts | 29 +++ test/server/useState.ts | 39 +++ test/tsconfig.json | 4 +- tsconfig.json | 3 +- 74 files changed, 2568 insertions(+), 201 deletions(-) delete mode 100644 pure.js create mode 100644 scripts/generate-submodules.ts create mode 100644 scripts/tsconfig.json rename src/{ => core}/asyncUtils.ts (79%) rename src/{ => core}/cleanup.ts (60%) create mode 100644 src/core/index.ts create mode 100644 src/dom/index.ts create mode 100644 src/dom/pure.ts create mode 100644 src/helpers/createTestHarness.tsx create mode 100644 src/helpers/error.ts create mode 100644 src/helpers/promises.ts create mode 100644 src/native/index.ts create mode 100644 src/native/pure.ts create mode 100644 src/pure.ts delete mode 100644 src/pure.tsx create mode 100644 src/server/index.ts create mode 100644 src/server/pure.ts create mode 100644 src/types/index.ts create mode 100644 src/types/internal.ts create mode 100644 src/types/react.ts rename test/{ => dom}/asyncHook.ts (99%) rename test/{ => dom}/autoCleanup.disabled.ts (76%) rename test/{ => dom}/autoCleanup.noAfterEach.ts (79%) rename test/{ => dom}/autoCleanup.ts (92%) rename test/{ => dom}/cleanup.ts (99%) rename test/{ => dom}/customHook.ts (93%) rename test/{ => dom}/errorHook.ts (86%) rename test/{ => dom}/resultHistory.ts (94%) rename test/{ => dom}/suspenseHook.ts (96%) rename test/{ => dom}/useContext.tsx (97%) rename test/{ => dom}/useEffect.ts (97%) rename test/{ => dom}/useMemo.ts (96%) rename test/{ => dom}/useReducer.ts (91%) rename test/{ => dom}/useRef.ts (94%) rename test/{ => dom}/useState.ts (91%) create mode 100644 test/native/asyncHook.ts create mode 100644 test/native/autoCleanup.disabled.ts create mode 100644 test/native/autoCleanup.noAfterEach.ts create mode 100644 test/native/autoCleanup.ts create mode 100644 test/native/cleanup.ts create mode 100644 test/native/customHook.ts create mode 100644 test/native/errorHook.ts create mode 100644 test/native/resultHistory.ts create mode 100644 test/native/suspenseHook.ts create mode 100644 test/native/useContext.tsx create mode 100644 test/native/useEffect.ts create mode 100644 test/native/useMemo.ts create mode 100644 test/native/useReducer.ts create mode 100644 test/native/useRef.ts create mode 100644 test/native/useState.ts create mode 100644 test/server/asyncHook.ts create mode 100644 test/server/autoCleanup.disabled.ts create mode 100644 test/server/autoCleanup.noAfterEach.ts create mode 100644 test/server/autoCleanup.ts create mode 100644 test/server/cleanup.ts create mode 100644 test/server/customHook.ts create mode 100644 test/server/errorHook.ts create mode 100644 test/server/hydrationErrors.ts create mode 100644 test/server/useContext.tsx create mode 100644 test/server/useEffect.ts create mode 100644 test/server/useMemo.ts create mode 100644 test/server/useReducer.ts create mode 100644 test/server/useRef.ts create mode 100644 test/server/useState.ts diff --git a/.all-contributorsrc b/.all-contributorsrc index 76a97954..fe512113 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -19,6 +19,8 @@ "code", "doc", "infra", + "maintenance", + "question", "test" ] }, @@ -199,7 +201,11 @@ "profile": "https://github.com/joshuaellis", "contributions": [ "doc", - "question" + "question", + "code", + "ideas", + "maintenance", + "test" ] }, { @@ -450,4 +456,4 @@ ] } ] -} +} \ 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 c5a944f7..4674bda3 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - + @@ -189,7 +189,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 9a7aecb2..3f1ec9d5 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 97% rename from test/useContext.tsx rename to test/dom/useContext.tsx index 3ded1acc..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', () => { 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"] + } }

Michael Peyper

💻 📖 🚇 ⚠️

Michael Peyper

💻 📖 🤔 🚇 🚧 💬 ⚠️

otofu-square

💻

Patrick P. Henley

🤔 👀

Matheus Marques

💻

Adam Seckel

💻

keiya sasaki

⚠️

Hu Chen

💻 📖 💡

Josh

📖 💬

Josh

📖 💬 💻 🤔 🚧 ⚠️

Na'aman Hirschfeld

💻