From 06de42884868d8f5353c420e17d6503669f5066b Mon Sep 17 00:00:00 2001 From: Jon Koops Date: Wed, 23 Feb 2022 11:29:50 +0100 Subject: [PATCH] Add 'useSetTimeout' hook to manage timers (#2134) --- src/components/alert/Alerts.tsx | 2 ++ src/utils/useSetTimeout.test.ts | 61 +++++++++++++++++++++++++++++++++ src/utils/useSetTimeout.ts | 34 ++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 src/utils/useSetTimeout.test.ts create mode 100644 src/utils/useSetTimeout.ts diff --git a/src/components/alert/Alerts.tsx b/src/components/alert/Alerts.tsx index ec34386090..0ada79f079 100644 --- a/src/components/alert/Alerts.tsx +++ b/src/components/alert/Alerts.tsx @@ -4,6 +4,7 @@ import { AlertVariant } from "@patternfly/react-core"; import type { AxiosError } from "axios"; import useRequiredContext from "../../utils/useRequiredContext"; +import useSetTimeout from "../../utils/useSetTimeout"; import { AlertPanel, AlertType } from "./AlertPanel"; type AlertProps = { @@ -23,6 +24,7 @@ export const useAlerts = () => useRequiredContext(AlertContext); export const AlertProvider: FunctionComponent = ({ children }) => { const { t } = useTranslation(); const [alerts, setAlerts] = useState([]); + const setTimeout = useSetTimeout(); const createId = () => new Date().getTime(); diff --git a/src/utils/useSetTimeout.test.ts b/src/utils/useSetTimeout.test.ts new file mode 100644 index 0000000000..879da96c7c --- /dev/null +++ b/src/utils/useSetTimeout.test.ts @@ -0,0 +1,61 @@ +/** + * @jest-environment jsdom + */ +import { renderHook } from "@testing-library/react-hooks"; +import useSetTimeout from "./useSetTimeout"; + +jest.useFakeTimers(); + +describe("useSetTimeout", () => { + it("schedules timeouts and triggers the callbacks", () => { + const { result } = renderHook(() => useSetTimeout()); + const setTimeoutSpy = jest.spyOn(global, "setTimeout"); + + // Schedule some timeouts... + const callback1 = jest.fn(); + const callback2 = jest.fn(); + result.current(callback1, 1000); + result.current(callback2, 500); + + // Ensure that setTimeout was actually called with the correct arguments. + expect(setTimeoutSpy).toHaveBeenCalledTimes(2); + expect(setTimeoutSpy).toBeCalledWith(expect.any(Function), 1000); + expect(setTimeoutSpy).toBeCalledWith(expect.any(Function), 500); + + // Ensure callbacks are called after timers run. + expect(callback2).not.toBeCalled(); + jest.advanceTimersByTime(500); + expect(callback1).not.toBeCalled(); + expect(callback2).toBeCalled(); + jest.advanceTimersByTime(500); + expect(callback1).toBeCalled(); + + setTimeoutSpy.mockRestore(); + }); + + it("throws if a timeout is scheduled after the component has unmounted", () => { + const { result, unmount } = renderHook(() => useSetTimeout()); + + unmount(); + + expect(() => result.current(jest.fn(), 1000)).toThrowError( + "Can't schedule a timeout on an unmounted component." + ); + }); + + it("clears a timeout if the component unmounts", () => { + const { result, unmount } = renderHook(() => useSetTimeout()); + const timerId = 42; + const setTimeoutSpy = jest + .spyOn(global, "setTimeout") + .mockReturnValueOnce(timerId as unknown as NodeJS.Timeout); + const clearTimeoutSpy = jest.spyOn(global, "clearTimeout"); + + result.current(jest.fn(), 1000); + unmount(); + + expect(clearTimeoutSpy).toBeCalledWith(timerId); + + setTimeoutSpy.mockRestore(); + }); +}); diff --git a/src/utils/useSetTimeout.ts b/src/utils/useSetTimeout.ts new file mode 100644 index 0000000000..c00441fe6e --- /dev/null +++ b/src/utils/useSetTimeout.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef } from "react"; + +export default function useSetTimeout() { + const didUnmountRef = useRef(false); + const { current: scheduledTimers } = useRef(new Set()); + + useEffect( + () => () => { + didUnmountRef.current = true; + clearAll(); + }, + [] + ); + + function clearAll() { + scheduledTimers.forEach((timer) => clearTimeout(timer)); + scheduledTimers.clear(); + } + + return function scheduleTimeout(callback: () => void, delay: number) { + if (didUnmountRef.current) { + throw new Error("Can't schedule a timeout on an unmounted component."); + } + + const timer = Number(setTimeout(handleCallback, delay)); + + scheduledTimers.add(timer); + + function handleCallback() { + scheduledTimers.delete(timer); + callback(); + } + }; +}