diff --git a/apps/client/src/components/Canvas/DrawingCanvas/MainLayer.tsx b/apps/client/src/components/Canvas/DrawingCanvas/MainLayer.tsx index 81137514..a99c6c77 100644 --- a/apps/client/src/components/Canvas/DrawingCanvas/MainLayer.tsx +++ b/apps/client/src/components/Canvas/DrawingCanvas/MainLayer.tsx @@ -1,6 +1,6 @@ import { useCallback, useRef } from 'react'; import { Layer } from 'react-konva'; -import useEvent from '@/hooks/useEvent'; +import useEvent from '@/hooks/useEvent/useEvent'; import { getUnregisteredPointerPosition } from './helpers/stage'; import { safeJSONParse } from '@/utils/object'; import { duplicateNodesAtPosition } from '@/utils/node'; diff --git a/apps/client/src/components/Elements/ShapesThumbnail/ShapesThumbnail.tsx b/apps/client/src/components/Elements/ShapesThumbnail/ShapesThumbnail.tsx index dd7ac944..639ee755 100644 --- a/apps/client/src/components/Elements/ShapesThumbnail/ShapesThumbnail.tsx +++ b/apps/client/src/components/Elements/ShapesThumbnail/ShapesThumbnail.tsx @@ -7,7 +7,7 @@ import { getNodesMinMaxPoints, } from '@/utils/position'; import useForceUpdate from '@/hooks/useForceUpdate/useForceUpdate'; -import useEvent from '@/hooks/useEvent'; +import useEvent from '@/hooks/useEvent/useEvent'; import * as Styled from './ShapesThumbnail.styled'; import type { NodeObject, ThemeColorValue } from 'shared'; import type Konva from 'konva'; diff --git a/apps/client/src/contexts/theme.tsx b/apps/client/src/contexts/theme.tsx index 7fa26751..3044146c 100644 --- a/apps/client/src/contexts/theme.tsx +++ b/apps/client/src/contexts/theme.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import useEvent from '@/hooks/useEvent/useEvent'; import { LOCAL_STORAGE_THEME_KEY } from '@/constants/app'; import { storage } from '@/utils/storage'; import { createContext } from './createContext'; @@ -30,23 +31,20 @@ export const ThemeProvider = ({ children }: React.PropsWithChildren) => { } }, []); - useEffect(() => { - if (storage.get(LOCAL_STORAGE_THEME_KEY)) { - return; - } + const darkColorScheme = useMemo(() => prefersDarkColorScheme(), []); - const darkColorScheme = prefersDarkColorScheme(); + const handleColorSchemeChange = useCallback( + (event: MediaQueryListEvent) => { + if (storage.get(LOCAL_STORAGE_THEME_KEY)) { + return; + } - const handleColorSchemeChange = (event: MediaQueryListEvent) => { handleThemeChange(event.matches ? 'dark' : 'default', false); - }; - - darkColorScheme.addEventListener('change', handleColorSchemeChange); + }, + [handleThemeChange], + ); - return () => { - darkColorScheme.removeEventListener('change', handleColorSchemeChange); - }; - }, [handleThemeChange]); + useEvent('change', handleColorSchemeChange, darkColorScheme); return ( diff --git a/apps/client/src/hooks/useEvent.ts b/apps/client/src/hooks/useEvent.ts deleted file mode 100644 index 14cf8a02..00000000 --- a/apps/client/src/hooks/useEvent.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useEffect } from 'react'; -import { isBrowser } from '@/utils/is'; - -const defaultTarget = isBrowser ? window : null; - -function useEvent( - name: K, - handler?: (event: HTMLElementEventMap[K]) => void, - element: EventTarget | undefined | null = defaultTarget, - options?: { - eventOptions?: EventListenerOptions; - deps?: unknown[]; - }, -) { - useEffect(() => { - if (!element) return; - - element.addEventListener( - name, - handler as EventListenerOrEventListenerObject, - options?.eventOptions, - ); - - return () => { - element.removeEventListener( - name, - handler as EventListenerOrEventListenerObject, - options?.eventOptions, - ); - }; - }, [name, handler, element, JSON.stringify(options)]); -} - -export default useEvent; diff --git a/apps/client/src/hooks/useEvent/useEvent.test.ts b/apps/client/src/hooks/useEvent/useEvent.test.ts new file mode 100644 index 00000000..8fd31a8f --- /dev/null +++ b/apps/client/src/hooks/useEvent/useEvent.test.ts @@ -0,0 +1,109 @@ +import { fireEvent, renderHook } from '@testing-library/react'; +import useEvent from './useEvent'; + +describe('useEvent', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it('calls the event listener when the event is triggered', async () => { + const element = document.createElement('button'); + const handler = vi.fn(); + + renderHook(() => useEvent('click', handler, element)); + + fireEvent.click(element); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should bind/unbind the event listener to the window object if no element is provided', async () => { + const spyAddEventListener = vi.spyOn(window, 'addEventListener'); + const spyRemoveEventListener = vi.spyOn(window, 'removeEventListener'); + + const handler = vi.fn(); + const options = undefined; + + const { unmount } = renderHook(() => useEvent('click', handler)); + + expect(spyAddEventListener).toHaveBeenCalledTimes(1); + expect(spyAddEventListener).toHaveBeenCalledWith('click', handler, options); + + unmount(); + + expect(spyRemoveEventListener).toHaveBeenCalledTimes(1); + expect(spyRemoveEventListener).toHaveBeenCalledWith( + 'click', + handler, + options, + ); + }); + + it('should bind/unbind the event listener if element is provided', async () => { + const element = document.createElement('button'); + const spyAddEventListener = vi.spyOn(element, 'addEventListener'); + const spyRemoveEventListener = vi.spyOn(element, 'removeEventListener'); + + const handler = vi.fn(); + const options = undefined; + + const { unmount } = renderHook(() => + useEvent('click', handler, element, options), + ); + + expect(spyAddEventListener).toHaveBeenCalledTimes(1); + expect(spyAddEventListener).toHaveBeenCalledWith('click', handler, options); + + unmount(); + + expect(spyRemoveEventListener).toHaveBeenCalledTimes(1); + expect(spyRemoveEventListener).toHaveBeenCalledWith( + 'click', + handler, + options, + ); + }); + + it('should not bind the event listener if handler is undefined', async () => { + const element = document.createElement('button'); + const spyAddEventListener = vi.spyOn(element, 'addEventListener'); + const spyRemoveEventListener = vi.spyOn(element, 'removeEventListener'); + + const handler = undefined; + + const { unmount } = renderHook(() => useEvent('click', handler, element)); + + expect(spyAddEventListener).toHaveBeenCalledTimes(0); + + unmount(); + + expect(spyRemoveEventListener).toHaveBeenCalledTimes(0); + }); + + it('should pass options to the event listener', async () => { + const element = document.createElement('button'); + const spyAddEventListener = vi.spyOn(element, 'addEventListener'); + const spyRemoveEventListener = vi.spyOn(element, 'removeEventListener'); + + const handler = vi.fn(); + const options = { + capture: true, + passive: true, + once: true, + }; + + const { unmount } = renderHook(() => + useEvent('click', handler, element, { eventOptions: options }), + ); + + expect(spyAddEventListener).toHaveBeenCalledWith('click', handler, options); + + unmount(); + + expect(spyRemoveEventListener).toHaveBeenCalledWith( + 'click', + handler, + options, + ); + }); +}); diff --git a/apps/client/src/hooks/useEvent/useEvent.ts b/apps/client/src/hooks/useEvent/useEvent.ts new file mode 100644 index 00000000..9b570c80 --- /dev/null +++ b/apps/client/src/hooks/useEvent/useEvent.ts @@ -0,0 +1,48 @@ +import { isBrowser } from '@/utils/is'; +import { useEffect, useRef } from 'react'; + +type EventsMap = HTMLElementEventMap & + WindowEventMap & + DocumentEventMap & + MediaQueryListEventMap & + WebSocketEventMap; + +type HandlerElements = + | Document + | HTMLElement + | MediaQueryList + | Window + | WebSocket; + +const defaultTarget = isBrowser ? window : null; + +function useEvent( + name: K, + handler: ((event: EventsMap[K]) => void) | undefined, + element: HandlerElements | null = defaultTarget, + options?: { + eventOptions?: AddEventListenerOptions; + deps?: unknown[]; + }, +) { + const savedHandler = useRef(handler); + + useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + useEffect(() => { + if (!element || !savedHandler.current) return; + + const listener = savedHandler.current as EventListenerOrEventListenerObject; + const eventOptions = options?.eventOptions; + + element.addEventListener(name, listener, eventOptions); + + return () => { + element.removeEventListener(name, listener, eventOptions); + }; + }, [name, element, JSON.stringify(options)]); +} + +export default useEvent; diff --git a/apps/client/src/hooks/useNetworkState/useNetworkState.ts b/apps/client/src/hooks/useNetworkState/useNetworkState.ts index 1ccc4c1b..023e28de 100644 --- a/apps/client/src/hooks/useNetworkState/useNetworkState.ts +++ b/apps/client/src/hooks/useNetworkState/useNetworkState.ts @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; +import useEvent from '@/hooks/useEvent/useEvent'; type NetworkState = { online?: boolean; @@ -6,6 +7,8 @@ type NetworkState = { const isNavigator = typeof navigator !== 'undefined'; +const eventOptions = { passive: true }; + function getConnectionState(): NetworkState { return { online: isNavigator ? navigator.onLine : undefined, @@ -15,19 +18,12 @@ function getConnectionState(): NetworkState { function useNetworkState() { const [state, setState] = useState(getConnectionState()); - useEffect(() => { - const handleStateChange = () => { - setState(getConnectionState()); - }; - - window.addEventListener('online', handleStateChange, { passive: true }); - window.addEventListener('offline', handleStateChange, { passive: true }); + const handleStateChange = useCallback(() => { + setState(getConnectionState()); + }, []); - return () => { - window.removeEventListener('online', handleStateChange); - window.removeEventListener('offline', handleStateChange); - }; - }); + useEvent('online', handleStateChange, window, { eventOptions }); + useEvent('offline', handleStateChange, window, { eventOptions }); return state; }