diff --git a/.babelrc b/.babelrc index 31e852b..a99562b 100644 --- a/.babelrc +++ b/.babelrc @@ -9,10 +9,11 @@ "modules": false } ] + ] }, "test": { - "presets": [["@4c", { "development": true }]] + "presets": [["@4c", { "development": true }], "@babel/typescript"] } } } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7e399e..b0506e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v1 with: - node-version: 12.x + node-version: 18.x - name: Install Dependencies run: yarn bootstrap - name: Run Tests diff --git a/package.json b/package.json index f7e4b38..e297dad 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,9 @@ }, "jest": { "preset": "@4c", - "rootDir": "./test", + "testEnvironment": "jsdom", "setupFilesAfterEnv": [ - "./setup.js" + "./test/setup.js" ] }, "prettier": { @@ -62,38 +62,37 @@ "react": ">=16.8.0" }, "devDependencies": { - "@4c/babel-preset": "^7.3.3", - "@4c/cli": "^2.1.12", - "@4c/jest-preset": "^1.5.4", - "@4c/rollout": "^2.1.11", - "@4c/tsconfig": "^0.3.1", - "@babel/cli": "^7.12.10", - "@babel/core": "^7.12.10", - "@babel/preset-typescript": "^7.12.7", + "@4c/babel-preset": "^10.2.1", + "@4c/cli": "^4.0.4", + "@4c/jest-preset": "^1.8.1", + "@4c/rollout": "^4.0.2", + "@4c/tsconfig": "^0.4.1", + "@babel/cli": "^7.22.9", + "@babel/core": "^7.22.9", + "@babel/preset-typescript": "^7.22.5", + "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.0", - "@types/enzyme": "^3.10.8", - "@types/jest": "^26.0.19", - "@types/lodash": "^4.14.167", - "@types/react": "^17.0.0", - "babel-jest": "^26.6.3", + "@types/jest": "^29.5.3", + "@types/lodash": "^4.14.195", + "@types/react": "^18.2.15", + "babel-jest": "^29.6.1", "babel-plugin-transform-rename-import": "^2.3.0", "cherry-pick": "^0.5.0", - "codecov": "^3.8.1", - "enzyme": "^3.10.0", - "enzyme-adapter-react-16": "^1.15.5", - "eslint": "^7.17.0", + "codecov": "^3.8.3", + "eslint": "^8.44.0", "gh-pages": "^3.1.0", "husky": "^4.3.6", - "jest": "^26.6.3", - "lint-staged": "^10.5.3", + "jest": "^29.6.1", + "jest-environment-jsdom": "^29.6.1", + "lint-staged": "^13.2.3", "mq-polyfill": "^1.1.8", - "prettier": "^2.2.1", + "prettier": "^3.0.0", "react": "^16.13.0", "react-dom": "^16.13.0", - "rimraf": "^3.0.2", - "typescript": "^4.1.3" + "rimraf": "^5.0.1", + "typescript": "^5.1.6" }, "dependencies": { - "dequal": "^2.0.2" + "dequal": "^2.0.3" } } diff --git a/src/useForceUpdate.ts b/src/useForceUpdate.ts index a1f441c..ab633df 100644 --- a/src/useForceUpdate.ts +++ b/src/useForceUpdate.ts @@ -18,7 +18,7 @@ import { useReducer } from 'react' */ export default function useForceUpdate(): () => void { // The toggling state value is designed to defeat React optimizations for skipping - // updates when they are stricting equal to the last state value + // updates when they are strictly equal to the last state value const [, dispatch] = useReducer((state: boolean) => !state, false) return dispatch as () => void } diff --git a/src/useIsInitialRenderRef.ts b/src/useIsInitialRenderRef.ts new file mode 100644 index 0000000..76c9247 --- /dev/null +++ b/src/useIsInitialRenderRef.ts @@ -0,0 +1,23 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' + +/** + * Returns ref that is `true` on the initial render and `false` on subsequent renders. It + * is StrictMode safe, so will reset correctly if the component is unmounted and remounted + */ +export default function useIsInitialRenderRef() { + const isInitialRenderRef = useRef(true) + + useLayoutEffect(() => { + isInitialRenderRef.current = false + }) + + // Strict mode handling in React 18 + useEffect( + () => () => { + isInitialRenderRef.current = true + }, + [], + ) + + return isInitialRenderRef +} diff --git a/src/useMergeStateFromProps.ts b/src/useMergeStateFromProps.ts index 1c6b9d7..4ab0279 100644 --- a/src/useMergeStateFromProps.ts +++ b/src/useMergeStateFromProps.ts @@ -5,7 +5,7 @@ type Mapper = ( state: TState, ) => null | Partial -export default function useMergeStateFromProps( +export default function useMergeStateFromProps( props: TProps, gDSFP: Mapper, initialState: TState, diff --git a/src/useRefWithInitialValueFactory.ts b/src/useRefWithInitialValueFactory.ts index 6ca1f88..5ee0203 100644 --- a/src/useRefWithInitialValueFactory.ts +++ b/src/useRefWithInitialValueFactory.ts @@ -1,10 +1,10 @@ import { useRef } from 'react' -const dft: any = Symbol('default value sigil') +const dft: unique symbol = Symbol('default value sigil') /** * Exactly the same as `useRef` except that the initial value is set via a - * factroy function. Useful when the default is relatively costly to construct. + * factory function. Useful when the default is relatively costly to construct. * * ```ts * const ref = useRefWithInitialValueFactory(() => constructExpensiveValue()) @@ -17,7 +17,7 @@ const dft: any = Symbol('default value sigil') export default function useRefWithInitialValueFactory( initialValueFactory: () => T, ) { - const ref = useRef(dft) + const ref = useRef(dft as T) if (ref.current === dft) { ref.current = initialValueFactory() } diff --git a/src/useThrottledEventHandler.ts b/src/useThrottledEventHandler.ts index 76ceccb..0ab71cf 100644 --- a/src/useThrottledEventHandler.ts +++ b/src/useThrottledEventHandler.ts @@ -38,9 +38,9 @@ export type ThrottledHandler = ((event: TEvent) => void) & { * @returns The event handler with a `clear` method attached for clearing any in-flight handler calls * */ -export default function useThrottledEventHandler( - handler: (event: TEvent) => void, -): ThrottledHandler { +export default function useThrottledEventHandler< + TEvent extends object = SyntheticEvent +>(handler: (event: TEvent) => void): ThrottledHandler { const isMounted = useMounted() const eventHandler = useEventCallback(handler) diff --git a/test/helpers.tsx b/test/helpers.tsx index 5bf05c5..eb951bc 100644 --- a/test/helpers.tsx +++ b/test/helpers.tsx @@ -1,18 +1,24 @@ -import { ReactWrapper, mount } from 'enzyme' +import { renderHook as baseRenderHook } from '@testing-library/react-hooks' import React from 'react' +type ReactWrapper

= { + setProps(props: Partial

): void + unmount(): void +} + export function renderHook any, P = any>( fn: T, initialProps?: P, ): [ReturnType, ReactWrapper

] { - const result = Array(2) as any - - function Wrapper(props: any) { - result[0] = fn(props) - return - } - - result[1] = mount() + const { rerender, result, unmount } = baseRenderHook(fn, { initialProps }) - return result + return [ + result.current, + { + unmount, + setProps(props: P) { + rerender({ ...initialProps, ...props }) + }, + }, + ] } diff --git a/test/setup.js b/test/setup.js index 94ef01e..6f5764c 100644 --- a/test/setup.js +++ b/test/setup.js @@ -1,9 +1,5 @@ -import Enzyme from 'enzyme' -import Adapter from 'enzyme-adapter-react-16' import matchMediaPolyfill from 'mq-polyfill' -Enzyme.configure({ adapter: new Adapter() }) - // https://github.com/bigslycat/mq-polyfill if (typeof window !== 'undefined') { @@ -32,7 +28,7 @@ function onError(e) { actualErrors += 1 } -expect.errors = num => { +expect.errors = (num) => { expectedErrors = num } diff --git a/test/useBreakpoint.test.tsx b/test/useBreakpoint.test.tsx index d0b406c..f25c005 100644 --- a/test/useBreakpoint.test.tsx +++ b/test/useBreakpoint.test.tsx @@ -3,8 +3,7 @@ import useBreakpoint, { createBreakpointHook, } from '../src/useBreakpoint' -import React from 'react' -import { renderHook, act } from '@testing-library/react-hooks' +import { renderHook } from '@testing-library/react-hooks' interface Props { breakpoint: DefaultBreakpointMap diff --git a/test/useCallbackRef.test.tsx b/test/useCallbackRef.test.tsx index b91e32c..7b3722e 100644 --- a/test/useCallbackRef.test.tsx +++ b/test/useCallbackRef.test.tsx @@ -1,6 +1,5 @@ import React, { useEffect } from 'react' -import { act } from 'react-dom/test-utils' -import { mount } from 'enzyme' +import { render, act } from '@testing-library/react' import useCallbackRef from '../src/useCallbackRef' @@ -20,18 +19,18 @@ describe('useCallbackRef', () => { return toggle ?

: } - const wrapper = mount() + const wrapper = render() - expect(wrapper.children().type()).toEqual('span') + expect(wrapper.container.getElementsByTagName('span')).toHaveLength(1) expect(effectSpy).toHaveBeenLastCalledWith( expect.objectContaining({ tagName: 'SPAN' }), ) act(() => { - wrapper.setProps({ toggle: true }) + wrapper.rerender() }) - expect(wrapper.children().type()).toEqual('div') + expect(wrapper.container.getElementsByTagName('div')).toHaveLength(1) expect(effectSpy).toHaveBeenLastCalledWith( expect.objectContaining({ tagName: 'DIV' }), ) diff --git a/test/useDebouncedCallback.test.tsx b/test/useDebouncedCallback.test.tsx index d36b20f..f7fa4e3 100644 --- a/test/useDebouncedCallback.test.tsx +++ b/test/useDebouncedCallback.test.tsx @@ -1,28 +1,23 @@ -import React from 'react' -import { mount } from 'enzyme' import useDebouncedCallback from '../src/useDebouncedCallback' +import { renderHook, act } from '@testing-library/react-hooks' describe('useDebouncedCallback', () => { it('should return a function that debounces input callback', () => { jest.useFakeTimers() const spy = jest.fn() - let debouncedFn; + const { result } = renderHook(() => useDebouncedCallback(spy, 500)) - function Wrapper() { - debouncedFn = useDebouncedCallback(spy, 500) - return - } + act(() => { + result.current(1) + result.current(2) + result.current(3) + }) - mount() - - debouncedFn(1) - debouncedFn(2) - debouncedFn(3) expect(spy).not.toHaveBeenCalled() jest.runOnlyPendingTimers() - + expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledWith(3) }) diff --git a/test/useDebouncedState.test.tsx b/test/useDebouncedState.test.tsx index 6c11cee..bd82fc0 100644 --- a/test/useDebouncedState.test.tsx +++ b/test/useDebouncedState.test.tsx @@ -1,6 +1,5 @@ import React from 'react' -import { mount } from 'enzyme' -import { act } from 'react-dom/test-utils' +import { render, act } from '@testing-library/react' import useDebouncedState from '../src/useDebouncedState' describe('useDebouncedState', () => { @@ -15,8 +14,8 @@ describe('useDebouncedState', () => { return {value} } - const wrapper = mount() - expect(wrapper.text()).toBe('0') + const wrapper = render() + expect(wrapper.getByText('0')).toBeTruthy() outerSetValue((cur: number) => cur + 1) outerSetValue((cur: number) => cur + 1) @@ -24,12 +23,11 @@ describe('useDebouncedState', () => { outerSetValue((cur: number) => cur + 1) outerSetValue((cur: number) => cur + 1) - expect(wrapper.text()).toBe('0') + expect(wrapper.getByText('0')).toBeTruthy() act(() => { jest.runOnlyPendingTimers() }) - - expect(wrapper.text()).toBe('1') + expect(wrapper.getByText('1')).toBeTruthy() }) }) diff --git a/test/useDebouncedValue.test.tsx b/test/useDebouncedValue.test.tsx index 0170419..d434a9b 100644 --- a/test/useDebouncedValue.test.tsx +++ b/test/useDebouncedValue.test.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react' -import { mount } from 'enzyme' -import { act } from 'react-dom/test-utils' + import useDebouncedValue from '../src/useDebouncedValue' +import { act, render } from '@testing-library/react' describe('useDebouncedValue', () => { it('should return a function that debounces input callback', () => { @@ -18,21 +18,22 @@ describe('useDebouncedValue', () => { return {debouncedValue} } + const { rerender, getByText } = render() + act(() => { - const wrapper = mount() - expect(wrapper.text()).toBe('0') + expect(getByText('0')).toBeTruthy() - wrapper.setProps({ value: 1 }) - wrapper.setProps({ value: 2 }) - wrapper.setProps({ value: 3 }) - wrapper.setProps({ value: 4 }) - wrapper.setProps({ value: 5 }) + rerender() + rerender() + rerender() + rerender() + rerender() - expect(wrapper.text()).toBe('0') + expect(getByText('0')).toBeTruthy() jest.runAllTimers() - expect(wrapper.text()).toBe('5') + expect(getByText('5')).toBeTruthy() expect(count).toBe(2) }) }) diff --git a/test/useForceUpdate.test.tsx b/test/useForceUpdate.test.tsx index f87b8c4..8837f83 100644 --- a/test/useForceUpdate.test.tsx +++ b/test/useForceUpdate.test.tsx @@ -1,19 +1,18 @@ -import { act } from 'react-dom/test-utils' +import { act, renderHook } from '@testing-library/react-hooks' import useForceUpdate from '../src/useForceUpdate' -import { renderHook } from './helpers' describe('useForceUpdate', () => { it('should return a function that returns mount state', () => { let count = 0 - const [forceUpdate] = renderHook(() => { + const { result } = renderHook(() => { count++ return useForceUpdate() }) expect(count).toEqual(1) act(() => { - forceUpdate() + result.current() }) expect(count).toEqual(2) diff --git a/test/useIsomorphicEffect.test.tsx b/test/useIsomorphicEffect.test.tsx index 9142bc1..7424f8c 100644 --- a/test/useIsomorphicEffect.test.tsx +++ b/test/useIsomorphicEffect.test.tsx @@ -1,6 +1,6 @@ -import React, { useLayoutEffect } from 'react' +import { useLayoutEffect } from 'react' -import { mount } from 'enzyme' +import { renderHook } from '@testing-library/react-hooks' import useIsomorphicEffect from '../src/useIsomorphicEffect' describe('useIsomorphicEffect', () => { @@ -9,12 +9,10 @@ describe('useIsomorphicEffect', () => { expect(useIsomorphicEffect).toEqual(useLayoutEffect) - const Wrapper = () => { + renderHook(() => { useIsomorphicEffect(spy) - return null - } + }) - mount() expect(spy).toBeCalled() }) }) diff --git a/test/useMap.test.tsx b/test/useMap.test.tsx index be15a7a..3af1b11 100644 --- a/test/useMap.test.tsx +++ b/test/useMap.test.tsx @@ -1,8 +1,7 @@ +import { act, render } from '@testing-library/react' import useMap, { ObservableMap } from '../src/useMap' import React from 'react' -import { act } from 'react-dom/test-utils' -import { mount } from 'enzyme' describe('useMap', () => { describe('ObservableMap', () => { @@ -65,24 +64,24 @@ describe('useMap', () => { return {JSON.stringify(Array.from(map.entries()))} } - const wrapper = mount() + const wrapper = render() act(() => { map.set('foo', true) }) - expect(wrapper.text()).toEqual('[["foo",true]]') + expect(wrapper.getByText('[["foo",true]]')).toBeTruthy() act(() => { map.set('bar', true) }) - expect(wrapper.text()).toEqual('[["foo",true],["bar",true]]') + expect(wrapper.getByText('[["foo",true],["bar",true]]')).toBeTruthy() act(() => { map.clear() }) - expect(wrapper.text()).toEqual('[]') + expect(wrapper.getByText('[]')).toBeTruthy() }) }) diff --git a/test/useMediaQuery.test.tsx b/test/useMediaQuery.test.tsx index 21b3bd5..22d099e 100644 --- a/test/useMediaQuery.test.tsx +++ b/test/useMediaQuery.test.tsx @@ -1,40 +1,34 @@ -import React from 'react' -import { act } from 'react-dom/test-utils' -import { mount } from 'enzyme' import useMediaQuery from '../src/useMediaQuery' +import { renderHook, act } from '@testing-library/react-hooks' describe('useMediaQuery', () => { it('should match immediately if possible', () => { - let matches - const Wrapper = ({ media }) => { - matches = useMediaQuery(media) - return null - } - - const wrapper = mount() + const wrapper = renderHook(useMediaQuery, { + initialProps: 'min-width: 100px', + }) expect(window.innerWidth).toBeGreaterThanOrEqual(100) - expect(matches).toEqual(true) + expect(wrapper.result.current).toEqual(true) - wrapper.setProps({ media: 'min-width: 2000px' }) + act(() => { + wrapper.rerender('min-width: 2000px') + }) expect(window.innerWidth).toBeLessThanOrEqual(2000) - expect(matches).toEqual(false) + expect(wrapper.result.current).toEqual(false) }) it('should clear if no media is passed', () => { - let matches - const Wrapper = ({ media }) => { - matches = useMediaQuery(media) - return null - } - - const wrapper = mount() + const wrapper = renderHook(useMediaQuery, { + initialProps: null, + }) - expect(matches).toEqual(false) + expect(wrapper.result.current).toEqual(false) - wrapper.setProps({ media: '' }) + act(() => { + wrapper.rerender('') + }) - expect(matches).toEqual(false) + expect(wrapper.result.current).toEqual(false) }) }) diff --git a/test/useMergeStateFromProps.test.tsx b/test/useMergeStateFromProps.test.tsx index b3c4f97..cf9e796 100644 --- a/test/useMergeStateFromProps.test.tsx +++ b/test/useMergeStateFromProps.test.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { mount } from 'enzyme' import useMergeStateFromProps from '../src/useMergeStateFromProps' +import { render } from '@testing-library/react' describe('useMergeStateFromProps', () => { it('should adjust state when props change', () => { @@ -24,21 +24,22 @@ describe('useMergeStateFromProps', () => { return
{JSON.stringify(state)}
} - const wrapper = mount() + const wrapper = render() expect(updates[0].state).toEqual({ lastFoo: 1 }) - wrapper.setProps({ foo: 2 }) + wrapper.rerender() // render with new props, rerender with state change expect(updates).toHaveLength(3) expect(updates[2].state).toEqual({ lastFoo: 2, bar: 3 }) - wrapper.setProps({ foo: 2, biz: true }) + // @ts-ignore + wrapper.rerender() // render with props, no update expect(updates).toHaveLength(4) - wrapper.setProps({ foo: 3 }) + wrapper.rerender() // render with new props, rerender with state change expect(updates).toHaveLength(6) @@ -47,7 +48,7 @@ describe('useMergeStateFromProps', () => { it('should adjust state when props change', () => { type Props = { foo: number } type State = { lastFoo: number } - const updates = [] + const updates = [] as any[] function Foo(props: { foo: any }) { const [state, setState] = useMergeStateFromProps( @@ -64,8 +65,8 @@ describe('useMergeStateFromProps', () => { return
{JSON.stringify(state)}
} - const wrapper = mount() + const wrapper = render() - wrapper.setProps({ foo: 2 }) + wrapper.rerender() }) }) diff --git a/test/useMergedRefs.test.tsx b/test/useMergedRefs.test.tsx index b1f364f..080d204 100644 --- a/test/useMergedRefs.test.tsx +++ b/test/useMergedRefs.test.tsx @@ -1,7 +1,7 @@ import React from 'react' -import { mount } from 'enzyme' import { useCallbackRef } from '../src' import useMergedRefs from '../src/useMergedRefs' +import { render } from '@testing-library/react' describe('useMergedRefs', () => { it('should return a function that returns mount state', () => { @@ -17,11 +17,7 @@ describe('useMergedRefs', () => { return