diff --git a/change/@fluentui-react-utilities-8a73f07d-6553-402e-b292-938e7141264a.json b/change/@fluentui-react-utilities-8a73f07d-6553-402e-b292-938e7141264a.json new file mode 100644 index 00000000000000..f21e13631a15df --- /dev/null +++ b/change/@fluentui-react-utilities-8a73f07d-6553-402e-b292-938e7141264a.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: create a new useAnimationFrame hook", + "packageName": "@fluentui/react-utilities", + "email": "marcosvmmoura@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-utilities/etc/react-utilities.api.md b/packages/react-components/react-utilities/etc/react-utilities.api.md index 0c237a9665b025..832fd9488e27ff 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -294,6 +294,9 @@ export type UnknownSlotProps = Pick, 'childr as?: keyof JSX.IntrinsicElements; }; +// @internal +export function useAnimationFrame(): readonly [(fn: () => void, delay?: number | undefined) => number, () => void]; + // @internal export const useControllableState: (options: UseControllableStateOptions) => [State, React_2.Dispatch>]; @@ -350,7 +353,7 @@ export function useScrollbarWidth(options: UseScrollbarWidthOptions): number | u export function useSelection(params: SelectionHookParams): readonly [Set, SelectionMethods]; // @internal -export function useTimeout(): readonly [(fn: () => void, delay: number) => void, () => void]; +export function useTimeout(): readonly [(fn: () => void, delay?: number | undefined) => number, () => void]; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-utilities/src/hooks/index.ts b/packages/react-components/react-utilities/src/hooks/index.ts index e5928f400aa78f..018e521c02bb6b 100644 --- a/packages/react-components/react-utilities/src/hooks/index.ts +++ b/packages/react-components/react-utilities/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useAnimationFrame'; export * from './useControllableState'; export * from './useEventCallback'; export * from './useFirstMount'; diff --git a/packages/react-components/react-utilities/src/hooks/useAnimationFrame.test.ts b/packages/react-components/react-utilities/src/hooks/useAnimationFrame.test.ts new file mode 100644 index 00000000000000..23d721d6343cd3 --- /dev/null +++ b/packages/react-components/react-utilities/src/hooks/useAnimationFrame.test.ts @@ -0,0 +1,101 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useAnimationFrame } from './useAnimationFrame'; + +describe('useAnimationFrame', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('calls the callback only on the next frame', () => { + const [setTestRequestAnimationFrame] = renderHook(() => useAnimationFrame()).result.current; + const callback = jest.fn(); + + setTestRequestAnimationFrame(callback); + + expect(callback).not.toHaveBeenCalled(); + + jest.runAllTimers(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('does not call the callback if cancel is called', () => { + const [setTestRequestAnimationFrame, cancelTEstRequestAnimationFrame] = renderHook(() => useAnimationFrame()).result + .current; + const callback = jest.fn(); + + setTestRequestAnimationFrame(callback); + cancelTEstRequestAnimationFrame(); + + jest.runAllTimers(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('cancel the previous requestAnimationFrame if set is called again', () => { + const [setTestRequestAnimationFrame] = renderHook(() => useAnimationFrame()).result.current; + const callbackA = jest.fn(); + const callbackB = jest.fn(); + + setTestRequestAnimationFrame(callbackA); + setTestRequestAnimationFrame(callbackB); + + jest.runAllTimers(); + + expect(callbackA).not.toHaveBeenCalledTimes(1); + expect(callbackB).toHaveBeenCalledTimes(1); + }); + + it('allows another requestAnimationFrame to be set after the previous has run', () => { + const [setTestRequestAnimationFrame] = renderHook(() => useAnimationFrame()).result.current; + const callbackA = jest.fn(); + const callbackB = jest.fn(); + + setTestRequestAnimationFrame(callbackA); + + jest.runAllTimers(); + + setTestRequestAnimationFrame(callbackB); + + jest.runAllTimers(); + + expect(callbackA).toHaveBeenCalledTimes(1); + expect(callbackB).toHaveBeenCalledTimes(1); + }); + + it('does not cancel the requestAnimationFrame between renders', () => { + const { result, rerender } = renderHook(() => useAnimationFrame()); + const [setTestRequestAnimationFrame] = result.current; + const callback = jest.fn(); + + setTestRequestAnimationFrame(callback); + + rerender(); + + jest.runAllTimers(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('cancel the requestAnimationFrame when the component is unmounted', () => { + const { result, unmount } = renderHook(() => useAnimationFrame()); + const [setTestRequestAnimationFrame] = result.current; + const callback = jest.fn(); + + setTestRequestAnimationFrame(callback); + + unmount(); + + jest.runAllTimers(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('returns the same functions every render', () => { + const { result, rerender } = renderHook(() => useAnimationFrame()); + const [setTestRequestAnimationFrame, cancelTEstRequestAnimationFrame] = result.current; + + rerender(); + + expect(result.current).toStrictEqual([setTestRequestAnimationFrame, cancelTEstRequestAnimationFrame]); + }); +}); diff --git a/packages/react-components/react-utilities/src/hooks/useAnimationFrame.ts b/packages/react-components/react-utilities/src/hooks/useAnimationFrame.ts new file mode 100644 index 00000000000000..f2453bd545a4d9 --- /dev/null +++ b/packages/react-components/react-utilities/src/hooks/useAnimationFrame.ts @@ -0,0 +1,14 @@ +import { useBrowserTimer } from './useBrowserTimer'; + +/** + * @internal + * Helper to manage a browser requestAnimationFrame. + * Ensures that the requestAnimationFrame isn't set multiple times at once and is cleaned up + * when the component is unloaded. + * + * @returns A pair of [requestAnimationFrame, cancelAnimationFrame] that are stable between renders. + */ +export function useAnimationFrame() { + // TODO: figure it out a way to not call global.requestAnimationFrame and instead infer window from some context + return useBrowserTimer(requestAnimationFrame, cancelAnimationFrame); +} diff --git a/packages/react-components/react-utilities/src/hooks/useBrowserTimer.test.ts b/packages/react-components/react-utilities/src/hooks/useBrowserTimer.test.ts new file mode 100644 index 00000000000000..d42e1558104df5 --- /dev/null +++ b/packages/react-components/react-utilities/src/hooks/useBrowserTimer.test.ts @@ -0,0 +1,61 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useBrowserTimer } from './useBrowserTimer'; + +const setTimer = jest.fn((callback: jest.Func) => { + callback(); + return 0; +}); + +const cancelTimer = jest.fn(() => { + return; +}); + +describe('useBrowserTimer', () => { + it('should return array with functions', () => { + const hookValues = renderHook(() => useBrowserTimer(setTimer, cancelTimer)).result.current; + + expect(hookValues).toHaveLength(2); + expect(typeof hookValues[0]).toBe('function'); + expect(typeof hookValues[1]).toBe('function'); + }); + + it('calls the setter only n times', () => { + const [setTestTimer] = renderHook(() => useBrowserTimer(setTimer, cancelTimer)).result.current; + const callback = jest.fn(); + + setTestTimer(callback); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).not.toHaveBeenCalledTimes(2); + expect(setTimer).toHaveBeenCalledTimes(1); + expect(setTimer).not.toHaveBeenCalledTimes(2); + }); + + it('setter should return timer id', () => { + const [setTestTimer] = renderHook(() => useBrowserTimer(setTimer, cancelTimer)).result.current; + const callback = jest.fn(); + + const timerId = setTestTimer(callback); + + expect(callback).toHaveBeenCalledTimes(1); + expect(timerId).toBe(0); + }); + + it('should not call the cancel callback if no setter was called', () => { + const [, cancelTestTimer] = renderHook(() => useBrowserTimer(setTimer, cancelTimer)).result.current; + + cancelTestTimer(); + + expect(cancelTimer).not.toHaveBeenCalledTimes(1); + }); + + it('calls the cancel only n times', () => { + const [setTestTimer, cancelTestTimer] = renderHook(() => useBrowserTimer(setTimer, cancelTimer)).result.current; + + setTestTimer(jest.fn()); + cancelTestTimer(); + + expect(cancelTimer).toHaveBeenCalledTimes(1); + expect(cancelTimer).not.toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/react-components/react-utilities/src/hooks/useBrowserTimer.ts b/packages/react-components/react-utilities/src/hooks/useBrowserTimer.ts new file mode 100644 index 00000000000000..a6b527e60ee85d --- /dev/null +++ b/packages/react-components/react-utilities/src/hooks/useBrowserTimer.ts @@ -0,0 +1,49 @@ +import * as React from 'react'; + +type BrowserTimerSetter = + | ((fn: () => void, duration?: number, ...args: Record[]) => number) + | ((fn: () => void) => number); + +/** + * @internal + * Helper to manage a browser timer. + * Ensures that the timer isn't set multiple times at once, + * and is cleaned up when the component is unloaded. + * + * @param setTimer - The timer setter function + * @param cancelTimer - The timer cancel function + * @returns A pair of [setTimer, cancelTimer] that are stable between renders. + * + * @example + * const [setTimer, cancelTimer] = useBrowserTimer(setTimeout, cancelTimeout); + * + * setTimer(() => console.log('Hello world!'), 1000); + * cancelTimer(); + */ +export function useBrowserTimer(setTimer: BrowserTimerSetter, cancelTimer: (id: number) => void) { + const id = React.useRef(undefined); + + const set = React.useCallback( + (fn: () => void, delay?: number) => { + if (id.current !== undefined) { + cancelTimer(id.current); + } + + id.current = setTimer(fn, delay); + return id.current; + }, + [cancelTimer, setTimer], + ); + + const cancel = React.useCallback(() => { + if (id.current !== undefined) { + cancelTimer(id.current); + id.current = undefined; + } + }, [cancelTimer]); + + // Clean up the timeout when the component is unloaded + React.useEffect(() => cancel, [cancel]); + + return [set, cancel] as const; +} diff --git a/packages/react-components/react-utilities/src/hooks/useTimeout.ts b/packages/react-components/react-utilities/src/hooks/useTimeout.ts index 250c64fdf9b9df..36c0813ef2107b 100644 --- a/packages/react-components/react-utilities/src/hooks/useTimeout.ts +++ b/packages/react-components/react-utilities/src/hooks/useTimeout.ts @@ -1,30 +1,14 @@ -import * as React from 'react'; +import { useBrowserTimer } from './useBrowserTimer'; /** * @internal * Helper to manage a browser timeout. - * Ensures that the timeout isn't set multiple times at once, - * and is cleaned up when the component is unloaded. + * Ensures that the timeout isn't set multiple times at once and is cleaned up + * when the component is unloaded. * * @returns A pair of [setTimeout, clearTimeout] that are stable between renders. */ export function useTimeout() { - const [timeout] = React.useState(() => ({ - id: undefined as ReturnType | undefined, - set: (fn: () => void, delay: number) => { - timeout.clear(); - timeout.id = setTimeout(fn, delay); - }, - clear: () => { - if (timeout.id !== undefined) { - clearTimeout(timeout.id); - timeout.id = undefined; - } - }, - })); - - // Clean up the timeout when the component is unloaded - React.useEffect(() => timeout.clear, [timeout]); - - return [timeout.set, timeout.clear] as const; + // TODO: figure it out a way to not call global.setTimeout and instead infer window from some context + return useBrowserTimer(setTimeout, clearTimeout); } diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts index ca628ac61ce07b..97fc981ebeb745 100644 --- a/packages/react-components/react-utilities/src/index.ts +++ b/packages/react-components/react-utilities/src/index.ts @@ -30,6 +30,7 @@ export type { export { IdPrefixProvider, resetIdsForTests, + useAnimationFrame, useControllableState, useEventCallback, useFirstMount,