diff --git a/src/__stories__/useSpring.story.tsx b/src/__stories__/useSpring.story.tsx index cc7a2d9a..0727e5d7 100644 --- a/src/__stories__/useSpring.story.tsx +++ b/src/__stories__/useSpring.story.tsx @@ -1,6 +1,6 @@ import { storiesOf } from '@storybook/react'; import * as React from 'react'; -import { useSpring } from '..'; +import useSpring from '../useSpring'; import ShowDocs from './util/ShowDocs'; const Demo = () => { diff --git a/src/__tests__/useGetSetState.test.ts b/src/__tests__/useGetSetState.test.ts index e9eb6aef..46a27fbf 100644 --- a/src/__tests__/useGetSetState.test.ts +++ b/src/__tests__/useGetSetState.test.ts @@ -100,8 +100,7 @@ it('should log an error if set with a patch different than an object', () => { const [, set] = result.current; expect(mockConsoleError).not.toHaveBeenCalled(); - // @ts-ignore - act(() => set('not an object')); + act(() => set('not an object' as any)); expect(mockConsoleError).toHaveBeenCalledTimes(1); expect(mockConsoleError).toHaveBeenCalledWith('useGetSetState setter patch must be an object.'); diff --git a/src/__tests__/useMap.test.ts b/src/__tests__/useMap.test.ts index 242df55f..cc9d57a8 100644 --- a/src/__tests__/useMap.test.ts +++ b/src/__tests__/useMap.test.ts @@ -1,7 +1,7 @@ import { act, renderHook } from '@testing-library/react-hooks'; import useMap from '../useMap'; -const setUp = (initialMap?: object) => renderHook(() => useMap(initialMap)); +const setUp = (initialMap?: T) => renderHook(() => useMap(initialMap)); it('should init map and utils', () => { const { result } = setUp({ foo: 'bar', a: 1 }); @@ -28,7 +28,6 @@ it('should get corresponding value for existing provided key', () => { let value; act(() => { - // @ts-ignore value = utils.get('a'); }); @@ -36,12 +35,11 @@ it('should get corresponding value for existing provided key', () => { }); it('should get undefined for non-existing provided key', () => { - const { result } = setUp({ foo: 'bar', a: 1 }); + const { result } = setUp<{ foo: string; a: number; nonExisting?: any }>({ foo: 'bar', a: 1 }); const [, utils] = result.current; let value; act(() => { - // @ts-ignore value = utils.get('nonExisting'); }); @@ -49,11 +47,10 @@ it('should get undefined for non-existing provided key', () => { }); it('should set new key-value pair', () => { - const { result } = setUp({ foo: 'bar', a: 1 }); + const { result } = setUp<{ foo: string; a: number; newKey?: number }>({ foo: 'bar', a: 1 }); const [, utils] = result.current; act(() => { - // @ts-ignore utils.set('newKey', 99); }); @@ -65,11 +62,10 @@ it('should override current value if setting existing key', () => { const [, utils] = result.current; act(() => { - // @ts-ignore - utils.set('foo', 99); + utils.set('foo', 'qux'); }); - expect(result.current[0]).toEqual({ foo: 99, a: 1 }); + expect(result.current[0]).toEqual({ foo: 'qux', a: 1 }); }); it('should remove corresponding key-value pair for existing provided key', () => { @@ -77,7 +73,6 @@ it('should remove corresponding key-value pair for existing provided key', () => const [, utils] = result.current; act(() => { - // @ts-ignore utils.remove('foo'); }); @@ -85,11 +80,10 @@ it('should remove corresponding key-value pair for existing provided key', () => }); it('should do nothing if removing non-existing provided key', () => { - const { result } = setUp({ foo: 'bar', a: 1 }); + const { result } = setUp<{ foo: string; a: number; nonExisting?: any }>({ foo: 'bar', a: 1 }); const [, utils] = result.current; act(() => { - // @ts-ignore utils.remove('nonExisting'); }); @@ -97,11 +91,10 @@ it('should do nothing if removing non-existing provided key', () => { }); it('should reset map to initial object provided', () => { - const { result } = setUp({ foo: 'bar', a: 1 }); + const { result } = setUp<{ foo: string; a: number; z?: number }>({ foo: 'bar', a: 1 }); const [, utils] = result.current; act(() => { - // @ts-ignore utils.set('z', 99); }); diff --git a/src/__tests__/useRaf.test.ts b/src/__tests__/useRaf.test.ts new file mode 100644 index 00000000..3d1936e3 --- /dev/null +++ b/src/__tests__/useRaf.test.ts @@ -0,0 +1,153 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { replaceRaf } from 'raf-stub'; +import useRaf from '../useRaf'; + +/** + * New requestAnimationFrame after being replaced with raf-stub for testing purposes. + */ +interface RequestAnimationFrame { + reset(): void; + step(): void; +} +declare var requestAnimationFrame: RequestAnimationFrame; + +replaceRaf(); +const fixedStart = 1564949709496; +const spyDateNow = jest.spyOn(Date, 'now').mockImplementation(() => fixedStart); + +beforeEach(() => { + jest.useFakeTimers(); + requestAnimationFrame.reset(); +}); + +afterEach(() => { + jest.clearAllTimers(); + requestAnimationFrame.reset(); +}); + +it('should init percentage of time elapsed', () => { + const { result } = renderHook(() => useRaf()); + const timeElapsed = result.current; + + expect(timeElapsed).toBe(0); +}); + +it('should return corresponding percentage of time elapsed for default ms', () => { + const { result } = renderHook(() => useRaf()); + expect(result.current).toBe(0); + + act(() => { + jest.runOnlyPendingTimers(); // start after delay + spyDateNow.mockImplementationOnce(() => fixedStart + 1e12 * 0.25); // 25% + requestAnimationFrame.step(); + }); + expect(result.current).toBe(0.25); + + act(() => { + spyDateNow.mockImplementationOnce(() => fixedStart + 1e12 * 0.5); // 50% + requestAnimationFrame.step(); + }); + expect(result.current).toBe(0.5); + + act(() => { + spyDateNow.mockImplementationOnce(() => fixedStart + 1e12 * 0.75); // 75% + requestAnimationFrame.step(); + }); + expect(result.current).toBe(0.75); + + act(() => { + spyDateNow.mockImplementationOnce(() => fixedStart + 1e12); // 100% + requestAnimationFrame.step(); + }); + expect(result.current).toBe(1); +}); + +it('should return corresponding percentage of time elapsed for custom ms', () => { + const customMs = 2000; + + const { result } = renderHook(() => useRaf(customMs)); + expect(result.current).toBe(0); + + act(() => { + jest.runOnlyPendingTimers(); // start after delay + spyDateNow.mockImplementationOnce(() => fixedStart + customMs * 0.25); // 25% + requestAnimationFrame.step(); + }); + expect(result.current).toBe(0.25); + + act(() => { + spyDateNow.mockImplementationOnce(() => fixedStart + customMs * 0.5); // 50% + requestAnimationFrame.step(); + }); + expect(result.current).toBe(0.5); + + act(() => { + spyDateNow.mockImplementationOnce(() => fixedStart + customMs * 0.75); // 75% + requestAnimationFrame.step(); + }); + expect(result.current).toBe(0.75); + + act(() => { + spyDateNow.mockImplementationOnce(() => fixedStart + customMs); // 100% + requestAnimationFrame.step(); + }); + expect(result.current).toBe(1); +}); + +it('should return always 1 after corresponding ms reached', () => { + const { result } = renderHook(() => useRaf()); + expect(result.current).toBe(0); + + act(() => { + jest.runOnlyPendingTimers(); // start after delay + spyDateNow.mockImplementationOnce(() => fixedStart + 1e12); // 100% + requestAnimationFrame.step(); + }); + expect(result.current).toBe(1); + + act(() => { + spyDateNow.mockImplementationOnce(() => fixedStart + 1e12 * 1.1); // 110% + requestAnimationFrame.step(); + }); + expect(result.current).toBe(1); + + act(() => { + spyDateNow.mockImplementationOnce(() => fixedStart + 1e12 * 3); // 300% + requestAnimationFrame.step(); + }); + expect(result.current).toBe(1); +}); + +it('should wait until delay reached to start calculating elapsed percentage', () => { + const { result } = renderHook(() => useRaf(undefined, 500)); + + expect(result.current).toBe(0); + + act(() => { + jest.advanceTimersByTime(250); // fast-forward only half of custom delay + }); + expect(result.current).toBe(0); + + act(() => { + jest.advanceTimersByTime(249); // fast-forward 1ms less than custom delay + }); + expect(result.current).toBe(0); + + act(() => { + jest.advanceTimersByTime(1); // fast-forward exactly to custom delay + }); + expect(result.current).not.toBe(0); +}); + +it('should clear pending timers on unmount', () => { + const spyRafStop = jest.spyOn(global, 'cancelAnimationFrame' as any); + const { unmount } = renderHook(() => useRaf()); + + expect(clearTimeout).not.toHaveBeenCalled(); + expect(spyRafStop).not.toHaveBeenCalled(); + + unmount(); + + expect(clearTimeout).toHaveBeenCalledTimes(2); + expect(spyRafStop).toHaveBeenCalledTimes(1); +}); diff --git a/src/__tests__/useSpring.test.ts b/src/__tests__/useSpring.test.ts new file mode 100644 index 00000000..a6a1c926 --- /dev/null +++ b/src/__tests__/useSpring.test.ts @@ -0,0 +1,119 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import useSpring from '../useSpring'; +import { Spring } from 'rebound'; + +// simulate Spring for testing +const mockSetCurrentValue = jest.fn(); +const mockAddListener = jest.fn(); +const mockSetEndValue = jest.fn(); +const mockRemoveListener = jest.fn(); +let triggerSpringUpdate = () => {}; +let springListener: Listener = { onSpringUpdate: () => {} }; + +interface Listener { + onSpringUpdate: (currentSpring: Spring) => void; +} + +const mockCreateSpring: Spring = jest.fn().mockImplementation(() => { + let currentValue = 0; + let endValue = 0; + + const getCloserValue = (a, b) => Math.round((a + b) / 2); + + const getCurrentValue = () => { + currentValue = getCloserValue(currentValue, endValue); + return currentValue; + }; + + triggerSpringUpdate = () => { + if (currentValue !== endValue) { + springListener.onSpringUpdate({ getCurrentValue } as any); + } + }; + + return { + setCurrentValue: val => { + currentValue = val; + mockSetCurrentValue(val); + }, + addListener: newListener => { + springListener = newListener; + mockAddListener(newListener); + }, + setEndValue: val => { + endValue = val; + mockSetEndValue(val); + }, + removeListener: mockRemoveListener, + }; +}) as any; + +jest.mock('rebound', () => { + return { + Sprint: {}, + SpringSystem: jest.fn().mockImplementation(() => { + return { createSpring: mockCreateSpring }; + }), + }; +}); + +it('should init value to provided target', () => { + const { result } = renderHook(() => useSpring(70)); + + expect(result.current).toBe(70); + expect(mockSetCurrentValue).toHaveBeenCalledTimes(1); + expect(mockSetCurrentValue).toHaveBeenCalledWith(70); + expect(mockCreateSpring).toHaveBeenCalledTimes(1); + expect(mockCreateSpring).toHaveBeenCalledWith(50, 3); +}); + +it('should create spring with custom tension and friction args provided', () => { + renderHook(() => useSpring(500, 20, 7)); + + expect(mockCreateSpring).toHaveBeenCalledTimes(1); + expect(mockCreateSpring).toHaveBeenCalledWith(20, 7); +}); + +it('should subscribe only once', () => { + const { rerender } = renderHook(() => useSpring()); + + expect(mockAddListener).toHaveBeenCalledTimes(1); + expect(mockAddListener).toHaveBeenCalledWith(springListener); + + rerender(); + + expect(mockAddListener).toHaveBeenCalledTimes(1); +}); + +it('should handle spring update', () => { + let targetValue = 70; + let lastSpringValue = targetValue; + const { result, rerender } = renderHook(() => useSpring(targetValue)); + + targetValue = 100; + rerender(); + expect(result.current).toBe(lastSpringValue); + + act(() => { + triggerSpringUpdate(); // simulate new spring value + }); + expect(result.current).toBeGreaterThan(lastSpringValue); + expect(result.current).toBeLessThanOrEqual(targetValue); + + lastSpringValue = result.current; + act(() => { + triggerSpringUpdate(); // simulate another new spring value + }); + expect(result.current).toBeGreaterThan(lastSpringValue); + expect(result.current).toBeLessThanOrEqual(targetValue); +}); + +it('should remove listener on unmount', () => { + const { unmount } = renderHook(() => useSpring()); + expect(mockRemoveListener).not.toHaveBeenCalled(); + + unmount(); + + expect(mockRemoveListener).toHaveBeenCalledTimes(1); + expect(mockRemoveListener).toHaveBeenCalledWith(springListener); +}); diff --git a/src/__tests__/useTimeout.test.ts b/src/__tests__/useTimeout.test.ts index 905b4022..5a852f81 100644 --- a/src/__tests__/useTimeout.test.ts +++ b/src/__tests__/useTimeout.test.ts @@ -2,137 +2,135 @@ import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks' import { useTimeout } from '../index'; import { UseTimeoutReturn } from '../useTimeout'; -describe('useTimeout', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.clearAllTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - it('should be defined', () => { - expect(useTimeout).toBeDefined(); - }); - - it('should return three functions', () => { - const hook = renderHook(() => useTimeout(5)); - - expect(hook.result.current.length).toBe(3); - expect(typeof hook.result.current[0]).toBe('function'); - expect(typeof hook.result.current[1]).toBe('function'); - expect(typeof hook.result.current[2]).toBe('function'); - }); - - function getHook(ms: number = 5): [jest.Mock, RenderHookResult<{ delay: number }, UseTimeoutReturn>] { - const spy = jest.fn(); - return [ - spy, - renderHook( - ({ delay = 5 }) => { - spy(); - return useTimeout(delay); - }, - { initialProps: { delay: ms } } - ), - ]; - } - - it('should re-render component after given amount of time', done => { - const [spy, hook] = getHook(); - expect(spy).toHaveBeenCalledTimes(1); - hook.waitForNextUpdate().then(() => { - expect(spy).toHaveBeenCalledTimes(2); - done(); - }); - jest.advanceTimersByTime(5); - }); - - it('should cancel timeout on unmount', () => { - const [spy, hook] = getHook(); - - expect(spy).toHaveBeenCalledTimes(1); - hook.unmount(); - jest.advanceTimersByTime(5); - expect(spy).toHaveBeenCalledTimes(1); - }); - - it('first function should return actual state of timeout', done => { - let [, hook] = getHook(); - let [isReady] = hook.result.current; - - expect(isReady()).toBe(false); - hook.unmount(); - expect(isReady()).toBe(null); - - [, hook] = getHook(); - [isReady] = hook.result.current; - hook.waitForNextUpdate().then(() => { - expect(isReady()).toBe(true); - - done(); - }); - jest.advanceTimersByTime(5); - }); - - it('second function should cancel timeout', () => { - const [spy, hook] = getHook(); - const [isReady, cancel] = hook.result.current; - - expect(spy).toHaveBeenCalledTimes(1); - expect(isReady()).toBe(false); - - act(() => { - cancel(); - }); - jest.advanceTimersByTime(5); - - expect(spy).toHaveBeenCalledTimes(1); - expect(isReady()).toBe(null); - }); - - it('third function should reset timeout', done => { - const [spy, hook] = getHook(); - const [isReady, cancel, reset] = hook.result.current; - - expect(isReady()).toBe(false); - - act(() => { - cancel(); - }); - jest.advanceTimersByTime(5); - - expect(isReady()).toBe(null); - - act(() => { - reset(); - }); - expect(isReady()).toBe(false); - - hook.waitForNextUpdate().then(() => { - expect(spy).toHaveBeenCalledTimes(2); - expect(isReady()).toBe(true); - - done(); - }); - jest.advanceTimersByTime(5); - }); - - it('should reset timeout on delay change', done => { - const [spy, hook] = getHook(15); - - expect(spy).toHaveBeenCalledTimes(1); - hook.rerender({ delay: 5 }); - - hook.waitForNextUpdate().then(() => { - expect(spy).toHaveBeenCalledTimes(3); - - done(); - }); - jest.advanceTimersByTime(15); - }); +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.clearAllTimers(); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +it('should be defined', () => { + expect(useTimeout).toBeDefined(); +}); + +it('should return three functions', () => { + const hook = renderHook(() => useTimeout(5)); + + expect(hook.result.current.length).toBe(3); + expect(typeof hook.result.current[0]).toBe('function'); + expect(typeof hook.result.current[1]).toBe('function'); + expect(typeof hook.result.current[2]).toBe('function'); +}); + +function getHook(ms: number = 5): [jest.Mock, RenderHookResult<{ delay: number }, UseTimeoutReturn>] { + const spy = jest.fn(); + return [ + spy, + renderHook( + ({ delay = 5 }) => { + spy(); + return useTimeout(delay); + }, + { initialProps: { delay: ms } } + ), + ]; +} + +it('should re-render component after given amount of time', done => { + const [spy, hook] = getHook(); + expect(spy).toHaveBeenCalledTimes(1); + hook.waitForNextUpdate().then(() => { + expect(spy).toHaveBeenCalledTimes(2); + done(); + }); + jest.advanceTimersByTime(5); +}); + +it('should cancel timeout on unmount', () => { + const [spy, hook] = getHook(); + + expect(spy).toHaveBeenCalledTimes(1); + hook.unmount(); + jest.advanceTimersByTime(5); + expect(spy).toHaveBeenCalledTimes(1); +}); + +it('first function should return actual state of timeout', done => { + let [, hook] = getHook(); + let [isReady] = hook.result.current; + + expect(isReady()).toBe(false); + hook.unmount(); + expect(isReady()).toBe(null); + + [, hook] = getHook(); + [isReady] = hook.result.current; + hook.waitForNextUpdate().then(() => { + expect(isReady()).toBe(true); + + done(); + }); + jest.advanceTimersByTime(5); +}); + +it('second function should cancel timeout', () => { + const [spy, hook] = getHook(); + const [isReady, cancel] = hook.result.current; + + expect(spy).toHaveBeenCalledTimes(1); + expect(isReady()).toBe(false); + + act(() => { + cancel(); + }); + jest.advanceTimersByTime(5); + + expect(spy).toHaveBeenCalledTimes(1); + expect(isReady()).toBe(null); +}); + +it('third function should reset timeout', done => { + const [spy, hook] = getHook(); + const [isReady, cancel, reset] = hook.result.current; + + expect(isReady()).toBe(false); + + act(() => { + cancel(); + }); + jest.advanceTimersByTime(5); + + expect(isReady()).toBe(null); + + act(() => { + reset(); + }); + expect(isReady()).toBe(false); + + hook.waitForNextUpdate().then(() => { + expect(spy).toHaveBeenCalledTimes(2); + expect(isReady()).toBe(true); + + done(); + }); + jest.advanceTimersByTime(5); +}); + +it('should reset timeout on delay change', done => { + const [spy, hook] = getHook(15); + + expect(spy).toHaveBeenCalledTimes(1); + hook.rerender({ delay: 5 }); + + hook.waitForNextUpdate().then(() => { + expect(spy).toHaveBeenCalledTimes(3); + + done(); + }); + jest.advanceTimersByTime(15); }); diff --git a/src/__tests__/useTween.test.ts b/src/__tests__/useTween.test.ts new file mode 100644 index 00000000..18a49833 --- /dev/null +++ b/src/__tests__/useTween.test.ts @@ -0,0 +1,56 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useTween from '../useTween'; +import * as useRaf from '../useRaf'; +import { easing } from 'ts-easing'; + +let spyUseRaf; +let spyEasingInCirc; +let spyEasingOutCirc; + +beforeEach(() => { + spyUseRaf = jest.spyOn(useRaf, 'default').mockReturnValue(17); + spyEasingInCirc = jest.spyOn(easing, 'inCirc').mockReturnValue(999999); + spyEasingOutCirc = jest.spyOn(easing, 'outCirc').mockReturnValue(101010); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +it('should init corresponding utils with default values', () => { + const { result } = renderHook(() => useTween()); + + expect(result.current).toBe(999999); + expect(spyEasingInCirc).toHaveBeenCalledTimes(1); + expect(spyEasingInCirc).toHaveBeenCalledWith(17); + expect(spyUseRaf).toHaveBeenCalledTimes(1); + expect(spyUseRaf).toHaveBeenCalledWith(200, 0); +}); + +it('should init corresponding utils with custom values', () => { + const { result } = renderHook(() => useTween('outCirc', 500, 15)); + + expect(result.current).toBe(101010); + expect(spyEasingOutCirc).toHaveBeenCalledTimes(1); + expect(spyEasingOutCirc).toHaveBeenCalledWith(17); + expect(spyUseRaf).toHaveBeenCalledTimes(1); + expect(spyUseRaf).toHaveBeenCalledWith(500, 15); +}); + +describe('when invalid easing name is provided', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'trace').mockImplementation(() => {}); + }); + + it('should log an error', () => { + const { result } = renderHook(() => useTween('grijanderl')); + + expect(result.current).toBe(0); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('useTween() expected "easingName" property to be a valid easing function name') + ); + expect(console.trace).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/useUpdate.test.ts b/src/__tests__/useUpdate.test.ts new file mode 100644 index 00000000..e7d35191 --- /dev/null +++ b/src/__tests__/useUpdate.test.ts @@ -0,0 +1,26 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import useUpdate from '../useUpdate'; + +it('should init update function', () => { + const { result } = renderHook(() => useUpdate()); + const update = result.current; + + expect(update).toBeInstanceOf(Function); +}); + +it('should forces a re-render every time update function is called', () => { + let renderCount = 0; + const { result } = renderHook(() => { + renderCount++; + return useUpdate(); + }); + const update = result.current; + + expect(renderCount).toBe(1); + + act(() => update()); + expect(renderCount).toBe(2); + + act(() => update()); + expect(renderCount).toBe(3); +}); diff --git a/src/useSpring.ts b/src/useSpring.ts index 3567f2ee..7f4dee2a 100644 --- a/src/useSpring.ts +++ b/src/useSpring.ts @@ -1,31 +1,37 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { Spring, SpringSystem } from 'rebound'; const useSpring = (targetValue: number = 0, tension: number = 50, friction: number = 3) => { const [spring, setSpring] = useState(null); const [value, setValue] = useState(targetValue); - useEffect(() => { - const listener = { + // memoize listener to being able to unsubscribe later properly, otherwise + // listener fn will be different on each re-render and wouldn't unsubscribe properly. + const listener = useMemo( + () => ({ onSpringUpdate: currentSpring => { const newValue = currentSpring.getCurrentValue(); setValue(newValue); }, - }; + }), + [] + ); + useEffect(() => { if (!spring) { const newSpring = new SpringSystem().createSpring(tension, friction); newSpring.setCurrentValue(targetValue); setSpring(newSpring); newSpring.addListener(listener); - return; } return () => { - spring.removeListener(listener); - setSpring(null); + if (spring) { + spring.removeListener(listener); + setSpring(null); + } }; - }, [tension, friction]); + }, [tension, friction, spring]); useEffect(() => { if (spring) {