import { Context, createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, } from 'react'; import { ANIMATION_STATE, useAnimation } from './AnimationContext'; import useReducedMotion from '../useReducedMotion'; type RequestAnimationFrameProviderProps = { children: ReactNode; }; type RequestAnimationFrameType = { handleRequestAnimationFrame: () => void; onExternalDismiss: () => void; }; // CONTEXT const initialState = { handleRequestAnimationFrame: () => {}, onExternalDismiss: () => {}, } as const; const RequestAnimationFrameContext: Context = createContext(initialState); // HELPERS // requestAnimationFrame function getRequestAnimationFrame(callback: () => void): number { if ( typeof window === 'undefined' || !Object.prototype.hasOwnProperty.call(window, 'requestAnimationFrame') ) { if (callback) { callback(); } } /* the callback routine must itself call requestAnimationFrame() again to animate another frame at the next repaint. requestAnimationFrame() is 1 shot. */ let requestId: number | null; /* update the requestId each time requestAnimationFrame is called */ requestId = window.requestAnimationFrame(() => { requestId = window.requestAnimationFrame(() => { if (callback) { callback(); } }); }); return requestId; } // cancelAnimationFrame function cancelRequestAnimationFrame({ requestAnimationFrameId, }: { requestAnimationFrameId: number | null; }): number | null { if ( typeof window !== 'undefined' && Object.prototype.hasOwnProperty.call(window, 'cancelAnimationFrame') && requestAnimationFrameId ) { /* the cancellation uses the last requestId */ window.cancelAnimationFrame(requestAnimationFrameId); return null; } return requestAnimationFrameId; } // PROVIDER export default function RequestAnimationFrameProvider({ children, }: RequestAnimationFrameProviderProps) { const reducedMotion = useReducedMotion(); const { animationState, setAnimationState, handleExternalDismiss } = useAnimation(); const requestAnimationFrameId = useRef(null); /* Summary to understand what event controls requestAnimationFrame during the lifecycle of the component "in" animation animation starts (requestAnimationFrame): useEffect (mounting) animation ends (cancelAnimationFrame): handleRequestAnimationFrame (onAnimationEnd) "out" animation animation starts (requestAnimationFrame): onExternalDismiss (onDismiss) animation ends (cancelAnimationFrame): useEffect (unmounting) */ /* useEffect (mounting/unmounting) This useEffect controls requestAnimationFrame on the "in" animation when mounting and cancelAnimationFrame on the "out" animation when unmounting */ useEffect(() => { // requestAnimationFrame manages the initial rendering of the component when component is animated requestAnimationFrameId.current = getRequestAnimationFrame(() => { if (!reducedMotion && !!requestAnimationFrameId.current) { setAnimationState?.(ANIMATION_STATE.animatedOpening); } }); // On unmounting, cancelAnimationFrame always gets executed return () => { requestAnimationFrameId.current = cancelRequestAnimationFrame({ requestAnimationFrameId: requestAnimationFrameId.current, }); }; }, [reducedMotion, setAnimationState]); /* onExternalDismiss (onDismiss) onExternalDismiss controls requestAnimationFrame for the "out" animation onExternalDismiss can be accessed via render props in the API (onDismissStart) handleExternalDismiss is ultimately the callback that triggers unmounting with/without animation externally */ const onExternalDismiss = useCallback(() => { if (!reducedMotion) { requestAnimationFrameId.current = getRequestAnimationFrame(() => handleExternalDismiss()); } else { handleExternalDismiss(); } }, [reducedMotion, handleExternalDismiss]); /* handleRequestAnimationFrame (onAnimationEnd) handleRequestAnimationFrame controls cancelAnimationFrame only after the "in" animation ends, not after the "out" animation as the previous useEffect takes care of it when unmounting handleRequestAnimationFrame is only called within the onAnimationEnd event. handleAnimationEnd && handleRequestAnimationFrame are separate functions as handleRequestAnimationFrame only handles animations out, not both in/out */ const handleRequestAnimationFrame = useCallback(() => { if (!reducedMotion && animationState === ANIMATION_STATE.animatedOpening) { requestAnimationFrameId.current = cancelRequestAnimationFrame({ requestAnimationFrameId: requestAnimationFrameId.current, }); } }, [animationState, reducedMotion]); // We don't put RequestAnimationFrameProvider within AnimationProvider to prevent circular dependencies return ( ({ handleRequestAnimationFrame, onExternalDismiss }), [handleRequestAnimationFrame, onExternalDismiss], )} > {children} ); } // HOOK export function useRequestAnimationFrame(): RequestAnimationFrameType { return useContext(RequestAnimationFrameContext); }