From 756c828e36a41926d0288b73e7b9d451bf15f26a Mon Sep 17 00:00:00 2001 From: Jason Quense Date: Wed, 13 May 2020 13:34:44 -0400 Subject: [PATCH 1/2] feat: add useThrottledEventHandler --- src/useForceUpdate.ts | 2 +- src/useStateAsync.ts | 4 +- src/useThrottledEventHandler.ts | 92 ++++++++++++++++++++++++++ test/useThrottledEventHandler.test.tsx | 60 +++++++++++++++++ 4 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 src/useThrottledEventHandler.ts create mode 100644 test/useThrottledEventHandler.test.tsx diff --git a/src/useForceUpdate.ts b/src/useForceUpdate.ts index f69b0b4..a1f441c 100644 --- a/src/useForceUpdate.ts +++ b/src/useForceUpdate.ts @@ -16,7 +16,7 @@ import { useReducer } from 'react' * return * ``` */ -export default function useForceUpdate() { +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 const [, dispatch] = useReducer((state: boolean) => !state, false) diff --git a/src/useStateAsync.ts b/src/useStateAsync.ts index a63ce1e..fed1efc 100644 --- a/src/useStateAsync.ts +++ b/src/useStateAsync.ts @@ -23,7 +23,7 @@ export type AsyncSetState = ( * * @param initialState initialize with some state value same as `useState` */ -function useStateAsync( +export default function useStateAsync( initialState: TState | (() => TState), ): [TState, AsyncSetState] { const [state, setState] = useState(initialState) @@ -67,5 +67,3 @@ function useStateAsync( ) return [state, setStateAsync] } - -export default useStateAsync diff --git a/src/useThrottledEventHandler.ts b/src/useThrottledEventHandler.ts new file mode 100644 index 0000000..bf89786 --- /dev/null +++ b/src/useThrottledEventHandler.ts @@ -0,0 +1,92 @@ +import { useRef, SyntheticEvent } from 'react' +import useMounted from './useMounted' +import useEventCallback from './useEventCallback' + +const isSyntheticEvent = (event: any): event is SyntheticEvent => + typeof event.persist === 'function' + +export type ThrottledHandler = ((event: TEvent) => void) & { + clear(): void +} + +/** + * Creates a event handler function throttled by `requestAnimationFrame` that + * returns the **most recent** event. Useful for noisy events that update react state. + * + * ```tsx + * function Component() { + * const [position, setPosition] = useState(); + * const handleMove = useThrottledEventHandler( + * (event) => { + * setPosition({ + * top: event.clientX, + * left: event.clientY, + * }) + * } + * ) + * + * return ( + *
+ *
+ *
+ * ); + * } + * ``` + * + * @param handler An event handler function + * @typeParam TEvent The event object passed to the handler function + * @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 { + const isMounted = useMounted() + const eventHandler = useEventCallback(handler) + + const nextPointRef = useRef<{ event: TEvent | null; handle: null | number }>({ + event: null, + handle: null, + }) + + const clear = () => { + cancelAnimationFrame(nextPointRef.current.handle!) + nextPointRef.current.handle = null + } + + const handlePointerMoveAnimation = () => { + const { current: next } = nextPointRef + + if (next.handle && next.event) { + if (isMounted()) { + next.handle = null + eventHandler(next.event) + } + } + next.event = null + } + + const throttledHandler = (event: TEvent) => { + if (!isMounted()) return + + if (isSyntheticEvent(event)) { + event.persist() + } + // Special handling for a React.Konva event which reuses the + // event object as it bubbles, setting target + else if ('evt' in event) { + event = { ...event } + } + + nextPointRef.current.event = event + if (!nextPointRef.current.handle) { + nextPointRef.current.handle = requestAnimationFrame( + handlePointerMoveAnimation, + ) + } + } + + throttledHandler.clear = clear + + return throttledHandler +} diff --git a/test/useThrottledEventHandler.test.tsx b/test/useThrottledEventHandler.test.tsx new file mode 100644 index 0000000..03c40d5 --- /dev/null +++ b/test/useThrottledEventHandler.test.tsx @@ -0,0 +1,60 @@ +import useThrottledEventHandler from '../src/useThrottledEventHandler' +import { renderHook } from './helpers' + +describe('useThrottledEventHandler', () => { + it('should throttle and use return the most recent event', done => { + const spy = jest.fn() + + const [handler, wrapper] = renderHook(() => + useThrottledEventHandler(spy), + ) + + const events = [ + new MouseEvent('pointermove'), + new MouseEvent('pointermove'), + new MouseEvent('pointermove'), + ] + + events.forEach(handler) + + expect(spy).not.toHaveBeenCalled() + + setTimeout(() => { + expect(spy).toHaveBeenCalledTimes(1) + + expect(spy).toHaveBeenCalledWith(events[events.length - 1]) + + wrapper.unmount() + + handler(new MouseEvent('pointermove')) + + setTimeout(() => { + expect(spy).toHaveBeenCalledTimes(1) + + done() + }, 20) + }, 20) + }) + + it('should clear pending handler calls', done => { + const spy = jest.fn() + + const [handler, wrapper] = renderHook(() => + useThrottledEventHandler(spy), + ) + ;[ + new MouseEvent('pointermove'), + new MouseEvent('pointermove'), + new MouseEvent('pointermove'), + ].forEach(handler) + + expect(spy).not.toHaveBeenCalled() + + handler.clear() + + setTimeout(() => { + expect(spy).toHaveBeenCalledTimes(0) + done() + }, 20) + }) +}) From 44afdb9e7dc8565a8b107bcaf20af53b6019b4c4 Mon Sep 17 00:00:00 2001 From: Jason Quense Date: Wed, 13 May 2020 14:29:44 -0400 Subject: [PATCH 2/2] naming --- src/useThrottledEventHandler.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/useThrottledEventHandler.ts b/src/useThrottledEventHandler.ts index bf89786..76ceccb 100644 --- a/src/useThrottledEventHandler.ts +++ b/src/useThrottledEventHandler.ts @@ -44,18 +44,21 @@ export default function useThrottledEventHandler( const isMounted = useMounted() const eventHandler = useEventCallback(handler) - const nextPointRef = useRef<{ event: TEvent | null; handle: null | number }>({ + const nextEventInfoRef = useRef<{ + event: TEvent | null + handle: null | number + }>({ event: null, handle: null, }) const clear = () => { - cancelAnimationFrame(nextPointRef.current.handle!) - nextPointRef.current.handle = null + cancelAnimationFrame(nextEventInfoRef.current.handle!) + nextEventInfoRef.current.handle = null } const handlePointerMoveAnimation = () => { - const { current: next } = nextPointRef + const { current: next } = nextEventInfoRef if (next.handle && next.event) { if (isMounted()) { @@ -78,9 +81,9 @@ export default function useThrottledEventHandler( event = { ...event } } - nextPointRef.current.event = event - if (!nextPointRef.current.handle) { - nextPointRef.current.handle = requestAnimationFrame( + nextEventInfoRef.current.event = event + if (!nextEventInfoRef.current.handle) { + nextEventInfoRef.current.handle = requestAnimationFrame( handlePointerMoveAnimation, ) }