diff --git a/docs/useRafLoop.md b/docs/useRafLoop.md index 978c5587ac..a326600728 100644 --- a/docs/useRafLoop.md +++ b/docs/useRafLoop.md @@ -1,26 +1,35 @@ # `useRafLoop` -React hook that calls given function inside the RAF loop without re-rendering parent component if not needed. Loop stops automatically on component unmount. -Provides controls to stop and start loop manually. +This hook call given function within the RAF loop without re-rendering parent component. +Loop stops automatically on component unmount. + +Additionally hook provides methods to start/stop loop and check current state. ## Usage ```jsx import * as React from 'react'; -import { useRafLoop } from 'react-use'; +import { useRafLoop, useUpdate } from 'react-use'; const Demo = () => { const [ticks, setTicks] = React.useState(0); + const [lastCall, setLastCall] = React.useState(0); + const update = useUpdate(); - const [loopStop, isActive, loopStart] = useRafLoop(() => { - setTicks(ticks + 1); - }, [ticks]); + const [loopStop, loopStart, isActive] = useRafLoop((time) => { + setTicks(ticks => ticks + 1); + setLastCall(time); + }); return (
RAF triggered: {ticks} (times)
+
Last high res timestamp: {lastCall}

- +
); }; @@ -29,7 +38,13 @@ const Demo = () => { ## Reference ```ts -const [stopLoop, isActive, startLoop] = useRafLoop(callback: CallableFunction, deps?: DependencyList); +const [stopLoop, startLoop, isActive] = useRafLoop(callback: FrameRequestCallback, initiallyActive = true); ``` -* `callback(time: number)` — function to call each RAF tick +* **`callback`**_: `(time: number)=>void`_ — function to call each RAF tick. + * **`time`**_: `number`_ — DOMHighResTimeStamp, which indicates the current time (based on the number of milliseconds since time origin). +* **`initiallyActive`**_: `boolean`_ — whether loop should be started at initial render. +* Return + * **`stopLoop`**_: `()=>void`_ — stop loop if it is active. + * **`startLoop`**_: `()=>void`_ — start loop if it was inactive. + * **`isActive`**_: `()=>boolean`_ — _true_ if loop is active. diff --git a/package.json b/package.json index 7d54acbd1e..e1187107aa 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test": "jest --maxWorkers 2", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "lint": "eslint '{src,tests}/**/*.{ts,tsx}'", + "lint": "eslint {src,tests}/**/*.{ts,tsx}", "lint:fix": "yarn lint --fix", "lint:types": "tsc --noEmit", "build:cjs": "tsc", diff --git a/src/useRafLoop.ts b/src/useRafLoop.ts index 9760ea6b7b..444d4ee14e 100644 --- a/src/useRafLoop.ts +++ b/src/useRafLoop.ts @@ -1,36 +1,45 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; -export type RafLoopReturns = [() => void, boolean, () => void]; +export type RafLoopReturns = [() => void, () => void, () => boolean]; -export default function useRafLoop(callback: CallableFunction): RafLoopReturns { +export default function useRafLoop(callback: FrameRequestCallback, initiallyActive = true): RafLoopReturns { const raf = useRef(null); - const [isActive, setIsActive] = useState(true); - - function loopStep(time: number) { - callback(time); - raf.current = requestAnimationFrame(loopStep); - } - - function loopStop() { - setIsActive(false); - } - - function loopStart() { - setIsActive(true); - } - - function clearCurrentLoop() { - raf.current && cancelAnimationFrame(raf.current); - } - - useEffect(() => clearCurrentLoop, []); + const rafActivity = useRef(false); + const rafCallback = useRef(callback); + rafCallback.current = callback; + + const step = useCallback((time: number) => { + if (rafActivity.current) { + rafCallback.current(time); + raf.current = requestAnimationFrame(step); + } + }, []); + + const result = useMemo(() => ([ + () => { // stop + if (rafActivity.current) { + rafActivity.current = false; + raf.current && cancelAnimationFrame(raf.current); + } + }, + () => { // start + if (!rafActivity.current) { + rafActivity.current = true; + raf.current = requestAnimationFrame(step); + } + }, + (): boolean => rafActivity.current // isActive + // eslint-disable-next-line react-hooks/exhaustive-deps + ] as RafLoopReturns), []); useEffect(() => { - clearCurrentLoop(); - isActive && (raf.current = requestAnimationFrame(loopStep)); + if (initiallyActive) { + result[1](); + } - return clearCurrentLoop; - }, [isActive, callback]); + return result[0]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - return [loopStop, isActive, loopStart]; + return result; } diff --git a/stories/useRafLoop.story.tsx b/stories/useRafLoop.story.tsx index 076e35a5f1..0f860c8ae7 100644 --- a/stories/useRafLoop.story.tsx +++ b/stories/useRafLoop.story.tsx @@ -1,20 +1,27 @@ import { storiesOf } from '@storybook/react'; import * as React from 'react'; -import { useRafLoop } from '../src'; +import { useRafLoop, useUpdate } from '../src'; import ShowDocs from './util/ShowDocs'; const Demo = () => { const [ticks, setTicks] = React.useState(0); + const [lastCall, setLastCall] = React.useState(0); + const update = useUpdate(); - const [loopStop, isActive, loopStart] = useRafLoop(() => { - setTicks(ticks + 1); + const [loopStop, loopStart, isActive] = useRafLoop((time) => { + setTicks(ticks => ticks + 1); + setLastCall(time); }); return (
RAF triggered: {ticks} (times)
+
Last high res timestamp: {lastCall}

- +
); }; diff --git a/tests/useRafLoop.test.tsx b/tests/useRafLoop.test.tsx index 36f7d4466f..e61e26835d 100644 --- a/tests/useRafLoop.test.tsx +++ b/tests/useRafLoop.test.tsx @@ -1,4 +1,4 @@ -import { act, renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; import { replaceRaf } from 'raf-stub'; import useRafLoop from '../src/useRafLoop'; @@ -23,96 +23,129 @@ describe('useRafLoop', () => { expect(useRafLoop).toBeDefined(); }); - it('should return stop function, start function and loop state', () => { + it('should return object with start, stop and isActive functions', () => { const hook = renderHook(() => useRafLoop(() => false), { initialProps: false }); - expect(typeof hook.result.current[0]).toEqual('function'); - expect(typeof hook.result.current[1]).toEqual('boolean'); - expect(typeof hook.result.current[2]).toEqual('function'); + expect(hook.result.current).toStrictEqual([ + expect.any(Function), + expect.any(Function), + expect.any(Function), + ]); }); - it('should call a callback constantly inside the raf loop', () => { + it('should constantly call callback inside the raf loop', () => { const spy = jest.fn(); renderHook(() => useRafLoop(spy), { initialProps: false }); expect(spy).not.toBeCalled(); - requestAnimationFrame.step(); - requestAnimationFrame.step(); + requestAnimationFrame.step(2); expect(spy).toBeCalledTimes(2); + requestAnimationFrame.step(2); + expect(spy).toBeCalledTimes(4); }); - it('first element call should stop the loop', () => { + it('should not start the loop if 2nd hook parameter is falsy', () => { const spy = jest.fn(); - const hook = renderHook(() => useRafLoop(spy), { initialProps: false }); + renderHook(() => useRafLoop(spy, false), { initialProps: false }); expect(spy).not.toBeCalled(); - - act(() => { - hook.result.current[0](); - }); - requestAnimationFrame.step(); + requestAnimationFrame.step(2); expect(spy).not.toBeCalled(); }); - it('second element should represent loop state', () => { + it('should pass the time argument to given callback', () => { const spy = jest.fn(); - const hook = renderHook(() => useRafLoop(spy), { initialProps: false }); - - expect(hook.result.current[1]).toBe(true); + renderHook(() => useRafLoop(spy), { initialProps: false }); - // stop the loop - act(() => { - hook.result.current[0](); - }); - expect(hook.result.current[1]).toBe(false); + expect(spy).not.toBeCalled(); + requestAnimationFrame.step(); + expect(typeof spy.mock.calls[0][0]).toBe('number'); }); - it('third element call should restart loop', () => { + it('should stop the loop on component unmount', () => { const spy = jest.fn(); const hook = renderHook(() => useRafLoop(spy), { initialProps: false }); expect(spy).not.toBeCalled(); - // stop the loop - act(() => { - hook.result.current[0](); - }); - requestAnimationFrame.step(); - expect(spy).not.toBeCalled(); + requestAnimationFrame.step(2); + expect(spy).toBeCalledTimes(2); - // start the loop - act(() => { - hook.result.current[2](); - }); + hook.unmount(); - requestAnimationFrame.step(); - requestAnimationFrame.step(); + requestAnimationFrame.step(2); expect(spy).toBeCalledTimes(2); }); - it('loop should stop itself on unmount', () => { - const spy = jest.fn(); - const hook = renderHook(() => useRafLoop(spy), { initialProps: false }); + it('should call the actual callback when it changed', () => { + const spy1 = jest.fn(); + const spy2 = jest.fn(); + const hook = renderHook(({cb}) => useRafLoop(cb), { initialProps: {cb: spy1} }); - hook.unmount(); + expect(spy1).not.toBeCalled(); + requestAnimationFrame.step(2); + expect(spy1).toBeCalledTimes(2); - requestAnimationFrame.step(); + hook.rerender({cb: spy2}); - expect(spy).not.toBeCalled(); + requestAnimationFrame.step(2); + expect(spy1).toBeCalledTimes(2); + expect(spy2).toBeCalledTimes(2); }); - it('should pass timestamp as 1st argument of callback', () => { - const spy = jest.fn(); - const hook = renderHook(() => useRafLoop(spy), { initialProps: false }); + describe('returned methods', () => { + it('stop method should stop the loop', () => { + const spy = jest.fn(); + const hook = renderHook(() => useRafLoop(spy), { initialProps: false }); - requestAnimationFrame.step(); + const [stop] = hook.result.current; - act(() => { - hook.result.current[0](); + expect(spy).not.toBeCalled(); + requestAnimationFrame.step(2); + expect(spy).toBeCalledTimes(2); + + stop(); + + requestAnimationFrame.step(2); + expect(spy).toBeCalledTimes(2); }); - requestAnimationFrame.step(); + it('start method should start stopped loop', () => { + const spy = jest.fn(); + const hook = renderHook(() => useRafLoop(spy, false), { initialProps: false }); - expect(spy).toHaveBeenCalled(); - expect(typeof spy.mock.calls[0][0]).toBe('number'); + const [stop, start] = hook.result.current; + + expect(spy).not.toBeCalled(); + requestAnimationFrame.step(2); + expect(spy).not.toBeCalled(); + + start(); + + requestAnimationFrame.step(2); + expect(spy).toBeCalledTimes(2); + + stop(); + + requestAnimationFrame.step(2); + expect(spy).toBeCalledTimes(2); + + start(); + + requestAnimationFrame.step(2); + expect(spy).toBeCalledTimes(4); + }); + + it('isActive method should return current loop state', () => { + const spy = jest.fn(); + const hook = renderHook(() => useRafLoop(spy, false), { initialProps: false }); + + const [stop, start, isActive] = hook.result.current; + + expect(isActive()).toBe(false); + start(); + expect(isActive()).toBe(true); + stop(); + expect(isActive()).toBe(false); + }); }); });