From e79725b76e1d622593ee76a61a5940e5d26b4d1f Mon Sep 17 00:00:00 2001 From: AlenaAlyona Date: Thu, 6 Apr 2023 10:03:24 +0200 Subject: [PATCH 1/9] feature: add useAnimationLoop hook --- .../useAnimationLoop.stories.mdx | 61 +++++++++++++++++++ .../useAnimationLoop.stories.tsx | 48 +++++++++++++++ .../useAnimationLoop.test.tsx | 10 +++ .../useAnimationLoop/useAnimationLoop.ts | 41 +++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx create mode 100644 src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx create mode 100644 src/hooks/useAnimationLoop/useAnimationLoop.test.tsx create mode 100644 src/hooks/useAnimationLoop/useAnimationLoop.ts diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx b/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx new file mode 100644 index 0000000..366f83f --- /dev/null +++ b/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx @@ -0,0 +1,61 @@ +import { Meta } from '@storybook/blocks'; + + + +# useAnimationLoop + +useAnimationLoop hook is a wrapper around requestAnimationFrame method. This hook will execute a +callback function every next frame. + +## Reference + +```ts +function useAnimationLoop(callback: (delta: number) => void, enabled = false): void; +``` + +### Parameters + +- `callback` - function accepts `delta` parameter which represents time passed since last + invocation; - `callback` function will be called on every animation frame +- `enabled` - boolean which is used to play and pause the requestAnimationFrame + +### Returns + +- `void` + +## Usage + +```tsx +function DemoComponent(): ReactElement { + const [currentTimestamp, setCurrentTimestamp] = useState(Date.now); + const [state, toggle] = useToggle(true); + useAnimationLoop(() => { + setCurrentTimestamp(Date.now); + }, state); + + return ( +
+
+

Instructions!

+

Click on the button to toggle the animation loop

+
+
+
Test Area
+
+

{currentTimestamp}

+ + +
+
+
+ ); +} +``` diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx b/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx new file mode 100644 index 0000000..b980592 --- /dev/null +++ b/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx @@ -0,0 +1,48 @@ +/* eslint-disable react/jsx-no-literals */ +import type { StoryObj } from '@storybook/react'; +import { useState, type ReactElement } from 'react'; +import { useToggle } from '../useToggle/useToggle.js'; +import { useAnimationLoop } from './useAnimationLoop.js'; + +export default { + title: 'hooks/useAnimationLoop', +}; + +function DemoComponent(): ReactElement { + const [currentTimestamp, setCurrentTimestamp] = useState(Date.now); + const [state, toggle] = useToggle(true); + useAnimationLoop(() => { + setCurrentTimestamp(Date.now); + }, state); + + return ( +
+
+

Instructions!

+

Click on the button to toggle the animation loop

+
+
+
Test Area
+
+

{currentTimestamp}

+ + +
+
+
+ ); +} + +export const Demo: StoryObj = { + render() { + return ; + }, +}; diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx b/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx new file mode 100644 index 0000000..6f09b04 --- /dev/null +++ b/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx @@ -0,0 +1,10 @@ +import { renderHook } from '@testing-library/react'; +import { useAnimationLoop } from './useAnimationLoop.js'; + +describe('useAnimationLoop', () => { + it('should not crash', async () => { + renderHook(useAnimationLoop, { + initialProps: undefined, + }); + }); +}); diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.ts b/src/hooks/useAnimationLoop/useAnimationLoop.ts new file mode 100644 index 0000000..fddd1e9 --- /dev/null +++ b/src/hooks/useAnimationLoop/useAnimationLoop.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useRefValue } from '../useRefValue/useRefValue.js'; + +/** + * useAnimationLoop hook is a wrapper around requestAnimationFrame method. + * This hook will execute a callback function every next frame. + * + * @param callback - callback function with @param delta which represents time passed since last invocation + * @param enabled - boolean which is used to play and pause the requestAnimationFrame + */ +export function useAnimationLoop(callback: (delta: number) => void, enabled = false): void { + const animationFrameRef = useRef(0); + const lastTimeRef = useRef(0); + const callbackRef = useRefValue(callback); + + const tick = useCallback((time: number): void => { + const delta = time - lastTimeRef.current; + lastTimeRef.current = time; + + callbackRef.current?.(delta); + + animationFrameRef.current = requestAnimationFrame(tick); + }, []); + + const play = useCallback(() => { + lastTimeRef.current = performance.now(); + requestAnimationFrame(tick); + }, [tick]); + + const pause = useCallback(() => { + cancelAnimationFrame(animationFrameRef.current); + }, []); + + useEffect(() => { + if (enabled) { + play(); + } + + return pause; + }, [enabled, pause, play]); +} From 78fb423ec9e89788a265233250889038391cf347 Mon Sep 17 00:00:00 2001 From: AlenaAlyona Date: Thu, 6 Apr 2023 10:46:52 +0200 Subject: [PATCH 2/9] fix: useAnimationLoop story description --- src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx b/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx index 366f83f..b95e0bf 100644 --- a/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx +++ b/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx @@ -4,8 +4,8 @@ import { Meta } from '@storybook/blocks'; # useAnimationLoop -useAnimationLoop hook is a wrapper around requestAnimationFrame method. This hook will execute a -callback function every next frame. +`useAnimationLoop` hook is a wrapper around requestAnimationFrame function. This hook will execute +the passed callback function every next frame. ## Reference @@ -16,7 +16,7 @@ function useAnimationLoop(callback: (delta: number) => void, enabled = false): v ### Parameters - `callback` - function accepts `delta` parameter which represents time passed since last - invocation; - `callback` function will be called on every animation frame + invocation; - `enabled` - boolean which is used to play and pause the requestAnimationFrame ### Returns From f292c547be31fab3591bc45a821c91189f8b6bee Mon Sep 17 00:00:00 2001 From: AlenaAlyona Date: Thu, 6 Apr 2023 10:50:12 +0200 Subject: [PATCH 3/9] fix: give clear names for states in useToggle --- src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx | 6 +++--- src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx b/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx index b95e0bf..4233ddb 100644 --- a/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx +++ b/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx @@ -28,10 +28,10 @@ function useAnimationLoop(callback: (delta: number) => void, enabled = false): v ```tsx function DemoComponent(): ReactElement { const [currentTimestamp, setCurrentTimestamp] = useState(Date.now); - const [state, toggle] = useToggle(true); + const [isRunning, toggleIsRunning] = useToggle(true); useAnimationLoop(() => { setCurrentTimestamp(Date.now); - }, state); + }, isRunning); return (
@@ -48,7 +48,7 @@ function DemoComponent(): ReactElement { type="button" // eslint-disable-next-line react/jsx-handler-names, react/jsx-no-bind onClick={(): void => { - toggle(); + toggleIsRunning(); }} > Toggle animation loop diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx b/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx index b980592..1378881 100644 --- a/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx +++ b/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx @@ -10,10 +10,10 @@ export default { function DemoComponent(): ReactElement { const [currentTimestamp, setCurrentTimestamp] = useState(Date.now); - const [state, toggle] = useToggle(true); + const [isRunning, toggleIsRunning] = useToggle(true); useAnimationLoop(() => { setCurrentTimestamp(Date.now); - }, state); + }, isRunning); return (
@@ -30,7 +30,7 @@ function DemoComponent(): ReactElement { type="button" // eslint-disable-next-line react/jsx-handler-names, react/jsx-no-bind onClick={(): void => { - toggle(); + toggleIsRunning(); }} > Toggle animation loop From 50cb296457e7d9278abc86a86c18a6a7532603e7 Mon Sep 17 00:00:00 2001 From: AlenaAlyona Date: Thu, 6 Apr 2023 12:47:20 +0200 Subject: [PATCH 4/9] fix: typo --- src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx b/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx index 4233ddb..5eaa337 100644 --- a/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx +++ b/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx @@ -5,7 +5,7 @@ import { Meta } from '@storybook/blocks'; # useAnimationLoop `useAnimationLoop` hook is a wrapper around requestAnimationFrame function. This hook will execute -the passed callback function every next frame. +the passed callback function every frame. ## Reference From 143d5519b00955ca9b1ea5cb3b3cee769d93261a Mon Sep 17 00:00:00 2001 From: AlenaAlyona Date: Thu, 6 Apr 2023 16:16:47 +0200 Subject: [PATCH 5/9] feature: add tests --- .../useAnimationLoop.test.tsx | 100 +++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx b/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx index 6f09b04..72363d6 100644 --- a/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx +++ b/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx @@ -1,4 +1,5 @@ -import { renderHook } from '@testing-library/react'; +import { jest } from '@jest/globals'; +import { renderHook, waitFor } from '@testing-library/react'; import { useAnimationLoop } from './useAnimationLoop.js'; describe('useAnimationLoop', () => { @@ -7,4 +8,101 @@ describe('useAnimationLoop', () => { initialProps: undefined, }); }); + + it('should not execute the callback function when enabled is not passed', async () => { + const spy = jest.fn(); + renderHook( + ({ callback }) => { + useAnimationLoop(callback); + }, + { + initialProps: { + callback: () => { + spy(); + }, + }, + }, + ); + + waitFor(() => { + expect(spy).toBeCalledTimes(0); + }); + }); + + it('should execute the callback function when useAnimationLoop is enabled', async () => { + const spy = jest.fn(); + renderHook( + ({ callback, enabled }) => { + useAnimationLoop(callback, enabled); + }, + { + initialProps: { + callback: () => { + spy(); + }, + enabled: true, + }, + }, + ); + + expect(spy).toBeCalledTimes(0); + waitFor(() => { + expect(spy).toBeCalled(); + }); + }); + + it('should execute another callback function when it is passed to useAnimationLoop and not execute previously passed callback function', async () => { + const spyFirstRender = jest.fn(); + const spySecondRender = jest.fn(); + const { rerender } = renderHook( + ({ callback, enabled }) => { + useAnimationLoop(callback, enabled); + }, + { + initialProps: { + callback: () => { + spyFirstRender(); + }, + enabled: true, + }, + }, + ); + + waitFor(() => { + expect(spyFirstRender).toBeCalled(); + expect(spySecondRender).toBeCalledTimes(0); + }); + + rerender({ callback: spySecondRender, enabled: true }); + waitFor(() => { + expect(spySecondRender).toBeCalled(); + expect(spyFirstRender).toBeCalledTimes(0); + }); + }); + + it('should execute the callback function when useAnimationLoop is enabled on the first render and should not execute the callback function when useAnimationLoop is disabled on the second render', async () => { + const spy = jest.fn(); + const { rerender } = renderHook( + ({ callback, enabled }) => { + useAnimationLoop(callback, enabled); + }, + { + initialProps: { + callback: () => { + spy(); + }, + enabled: true, + }, + }, + ); + + waitFor(() => { + expect(spy).toBeCalled(); + }); + + rerender({ callback: spy, enabled: false }); + waitFor(() => { + expect(spy).toBeCalledTimes(0); + }); + }); }); From 50a8085f9d97322cd2922140f4d4cf60bee392fe Mon Sep 17 00:00:00 2001 From: AlenaAlyona Date: Fri, 7 Apr 2023 12:24:39 +0200 Subject: [PATCH 6/9] fix: await waitFor --- src/hooks/useAnimationLoop/useAnimationLoop.test.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx b/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx index 72363d6..7a2eb64 100644 --- a/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx +++ b/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx @@ -46,7 +46,7 @@ describe('useAnimationLoop', () => { ); expect(spy).toBeCalledTimes(0); - waitFor(() => { + await waitFor(() => { expect(spy).toBeCalled(); }); }); @@ -68,15 +68,16 @@ describe('useAnimationLoop', () => { }, ); - waitFor(() => { + await waitFor(() => { expect(spyFirstRender).toBeCalled(); expect(spySecondRender).toBeCalledTimes(0); }); rerender({ callback: spySecondRender, enabled: true }); - waitFor(() => { + const amountOfCalls = spyFirstRender.mock.calls.length; + await waitFor(() => { + expect(spyFirstRender).toBeCalledTimes(amountOfCalls); expect(spySecondRender).toBeCalled(); - expect(spyFirstRender).toBeCalledTimes(0); }); }); From 926e091643e7e62a15d87139ccfbbb791c66e09b Mon Sep 17 00:00:00 2001 From: AlenaAlyona Date: Tue, 11 Apr 2023 10:53:55 +0200 Subject: [PATCH 7/9] fix: passing callback as initialProps --- .../useAnimationLoop/useAnimationLoop.test.tsx | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx b/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx index 7a2eb64..68dea4a 100644 --- a/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx +++ b/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx @@ -17,14 +17,12 @@ describe('useAnimationLoop', () => { }, { initialProps: { - callback: () => { - spy(); - }, + callback: spy, }, }, ); - waitFor(() => { + await waitFor(() => { expect(spy).toBeCalledTimes(0); }); }); @@ -37,9 +35,7 @@ describe('useAnimationLoop', () => { }, { initialProps: { - callback: () => { - spy(); - }, + callback: spy, enabled: true, }, }, @@ -60,9 +56,7 @@ describe('useAnimationLoop', () => { }, { initialProps: { - callback: () => { - spyFirstRender(); - }, + callback: spyFirstRender, enabled: true, }, }, @@ -89,9 +83,7 @@ describe('useAnimationLoop', () => { }, { initialProps: { - callback: () => { - spy(); - }, + callback: spy, enabled: true, }, }, From de98f6ace31a629c96139fef992ac804ff634aaa Mon Sep 17 00:00:00 2001 From: Leroy Korterink Date: Mon, 15 May 2023 18:35:32 +0200 Subject: [PATCH 8/9] Add delta time to test implementation in storybook --- .../useAnimationLoop/useAnimationLoop.stories.tsx | 13 ++++++++++--- src/hooks/useAnimationLoop/useAnimationLoop.ts | 15 +++++++++------ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx b/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx index 1378881..06a2770 100644 --- a/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx +++ b/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx @@ -9,10 +9,16 @@ export default { }; function DemoComponent(): ReactElement { - const [currentTimestamp, setCurrentTimestamp] = useState(Date.now); + const [delta, setDelta] = useState(0); + const [currentTimestamp, setCurrentTimestamp] = useState(0); + const [isRunning, toggleIsRunning] = useToggle(true); + useAnimationLoop(() => { - setCurrentTimestamp(Date.now); + const timestamp = Date.now(); + + setDelta(timestamp - currentTimestamp); + setCurrentTimestamp(timestamp); }, isRunning); return ( @@ -24,7 +30,8 @@ function DemoComponent(): ReactElement {
Test Area
-

{currentTimestamp}

+

Current time: {currentTimestamp}

+

Delta: {delta}