From 4120b4d6a50abfdb9c959a608e4765b85296054a Mon Sep 17 00:00:00 2001 From: debabin Date: Sat, 21 Dec 2024 18:39:49 +0700 Subject: [PATCH] =?UTF-8?q?main=20=F0=9F=A7=8A=20add=20test=20for=20use=20?= =?UTF-8?q?query,=20add=20retry=20delay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/index.ts | 2 +- src/hooks/useClipboard/useClipboard.test.ts | 2 +- .../useDeviceOrientation.test.ts | 4 +- src/hooks/useInterval/useInterval.test.ts | 12 +- src/hooks/usePageLeave/usePageLeave.test.ts | 8 +- .../usePostMessage/usePostMessage.demo.tsx | 31 +- src/hooks/useQuery/useQuery.demo.tsx | 2 +- src/hooks/useQuery/useQuery.test.ts | 339 ++++++++++++------ src/hooks/useQuery/useQuery.ts | 28 +- src/hooks/useShare/useShare.test.ts | 4 +- 10 files changed, 280 insertions(+), 152 deletions(-) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 5d059a35..27d81bc4 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -53,7 +53,6 @@ export * from './useMap/useMap'; export * from './useMeasure/useMeasure'; export * from './useMediaQuery/useMediaQuery'; export * from './useMemory/useMemory'; -export * from './useMessage/usePostMessage'; export * from './useMount/useMount'; export * from './useMouse/useMouse'; export * from './useMutation/useMutation'; @@ -70,6 +69,7 @@ export * from './usePaint/usePaint'; export * from './useParallax/useParallax'; export * from './usePermission/usePermission'; export * from './usePointerLock/usePointerLock'; +export * from './usePostMessage/usePostMessage'; export * from './usePreferredColorScheme/usePreferredColorScheme'; export * from './usePreferredContrast/usePreferredContrast'; export * from './usePreferredDark/usePreferredDark'; diff --git a/src/hooks/useClipboard/useClipboard.test.ts b/src/hooks/useClipboard/useClipboard.test.ts index 8c6b8b83..01c4928b 100644 --- a/src/hooks/useClipboard/useClipboard.test.ts +++ b/src/hooks/useClipboard/useClipboard.test.ts @@ -23,7 +23,7 @@ it('Should use copy to clipboard', () => { const { result } = renderHook(useClipboard); expect(result.current.value).toBeNull(); - expect(result.current.supported).toBe(true); + expect(result.current.supported).toBeTruthy(); expect(typeof result.current.copy).toBe('function'); }); diff --git a/src/hooks/useDeviceOrientation/useDeviceOrientation.test.ts b/src/hooks/useDeviceOrientation/useDeviceOrientation.test.ts index 1b3c1541..7aa0cf00 100644 --- a/src/hooks/useDeviceOrientation/useDeviceOrientation.test.ts +++ b/src/hooks/useDeviceOrientation/useDeviceOrientation.test.ts @@ -13,7 +13,7 @@ beforeAll(() => { it('Should use on device orientation', () => { const { result } = renderHook(useDeviceOrientation); - expect(result.current.supported).toBe(true); + expect(result.current.supported).toBeTruthy(); expect(result.current.value.alpha).toBeNull(); expect(result.current.value.beta).toBeNull(); expect(result.current.value.gamma).toBeNull(); @@ -38,5 +38,5 @@ it('Should set new values when device orientation change', () => { expect(result.current.value.alpha).toBe(30); expect(result.current.value.beta).toBe(60); expect(result.current.value.gamma).toBe(90); - expect(result.current.value.absolute).toBe(true); + expect(result.current.value.absolute).toBeTruthy(); }); diff --git a/src/hooks/useInterval/useInterval.test.ts b/src/hooks/useInterval/useInterval.test.ts index f18123f2..7d772d1c 100644 --- a/src/hooks/useInterval/useInterval.test.ts +++ b/src/hooks/useInterval/useInterval.test.ts @@ -8,7 +8,7 @@ beforeEach(() => { it('Should use interval', () => { const { result } = renderHook(() => useInterval(vi.fn, 1000)); - expect(result.current.active).toBe(true); + expect(result.current.active).toBeTruthy(); expect(typeof result.current.pause).toBe('function'); expect(typeof result.current.resume).toBe('function'); }); @@ -17,24 +17,24 @@ it('Should pause and resume properly', () => { const { result } = renderHook(() => useInterval(() => {}, 1000)); const { pause, resume } = result.current; - expect(result.current.active).toBe(true); + expect(result.current.active).toBeTruthy(); act(pause); - expect(result.current.active).toBe(false); + expect(result.current.active).toBeFalsy(); act(resume); - expect(result.current.active).toBe(true); + expect(result.current.active).toBeTruthy(); }); it('Should not be active when disabled', () => { const { result } = renderHook(() => useInterval(() => {}, 1000, { enabled: false })); - expect(result.current.active).toBe(false); + expect(result.current.active).toBeFalsy(); }); it('Should call callback on interval', () => { const callback = vi.fn(); const { result } = renderHook(() => useInterval(callback, 1000)); - expect(result.current.active).toBe(true); + expect(result.current.active).toBeTruthy(); act(() => vi.advanceTimersByTime(1000)); expect(callback).toBeCalledTimes(1); diff --git a/src/hooks/usePageLeave/usePageLeave.test.ts b/src/hooks/usePageLeave/usePageLeave.test.ts index d4bfe4bd..e33d7970 100644 --- a/src/hooks/usePageLeave/usePageLeave.test.ts +++ b/src/hooks/usePageLeave/usePageLeave.test.ts @@ -11,16 +11,16 @@ it('Should use page leave', () => { it('Should call the callback on page leave', () => { const callback = vi.fn(); const { result } = renderHook(() => usePageLeave(callback)); - expect(result.current).toBe(false); + expect(result.current).toBeFalsy(); act(() => document.dispatchEvent(new Event('mouseleave'))); expect(callback).toBeCalledTimes(1); - expect(result.current).toBe(true); + expect(result.current).toBeTruthy(); act(() => document.dispatchEvent(new Event('mouseenter'))); - expect(result.current).toBe(false); + expect(result.current).toBeFalsy(); act(() => document.dispatchEvent(new Event('mouseleave'))); expect(callback).toBeCalledTimes(2); - expect(result.current).toBe(true); + expect(result.current).toBeTruthy(); }); diff --git a/src/hooks/usePostMessage/usePostMessage.demo.tsx b/src/hooks/usePostMessage/usePostMessage.demo.tsx index db016c4d..4608ed3f 100644 --- a/src/hooks/usePostMessage/usePostMessage.demo.tsx +++ b/src/hooks/usePostMessage/usePostMessage.demo.tsx @@ -5,17 +5,20 @@ import { usePostMessage } from './usePostMessage'; const Demo = () => { const [messages, setMessages] = useState([]); - const postMessage = usePostMessage<{ type: 'delete' } | { type: 'send'; value: string }>('*', (message) => { - console.log('Message received', message); - - if (message.type === 'send') { - setMessages((prevMessages) => [...prevMessages, message.value]); - } - - if (message.type === 'delete') { - setMessages((prevMessages) => prevMessages.slice(0, prevMessages.length - 1)); + const postMessage = usePostMessage<{ type: 'delete' } | { type: 'send'; value: string }>( + '*', + (message) => { + console.log('Message received', message); + + if (message.type === 'send') { + setMessages((prevMessages) => [...prevMessages, message.value]); + } + + if (message.type === 'delete') { + setMessages((prevMessages) => prevMessages.slice(0, prevMessages.length - 1)); + } } - }); + ); const onSendClick = () => postMessage({ type: 'send', value: (Math.random() + 1).toString(36).substring(3) }); @@ -35,8 +38,12 @@ const Demo = () => { )} - - + + ); }; diff --git a/src/hooks/useQuery/useQuery.demo.tsx b/src/hooks/useQuery/useQuery.demo.tsx index 5fc5e29d..e420f7a0 100644 --- a/src/hooks/useQuery/useQuery.demo.tsx +++ b/src/hooks/useQuery/useQuery.demo.tsx @@ -7,7 +7,7 @@ interface Pokemon { } const getPokemon = (id: number) => - fetch(`https://pokeapi.co/api/v2/pokemon/${id}`).then((res) => res.json()) as Promise; + fetch(`https://pokeapi.co/api/v2/pokemon2/${id}`).then((res) => res.json()) as Promise; const Demo = () => { const counter = useCounter(1); diff --git a/src/hooks/useQuery/useQuery.test.ts b/src/hooks/useQuery/useQuery.test.ts index 94ef8dbc..fe9ee8d0 100644 --- a/src/hooks/useQuery/useQuery.test.ts +++ b/src/hooks/useQuery/useQuery.test.ts @@ -1,152 +1,263 @@ import { act, renderHook, waitFor } from '@testing-library/react'; -import { useState } from 'react'; import { useQuery } from './useQuery'; -describe('useQuery', () => { - it('should initialize with loading state', () => { - const { result } = renderHook(() => useQuery(() => Promise.resolve('data'))); +it('Should use query', () => { + const { result } = renderHook(() => useQuery(() => Promise.resolve('data'))); - expect(result.current.isLoading).toBe(true); - expect(result.current.isError).toBe(false); - expect(result.current.isRefetching).toBe(false); - expect(result.current.data).toBeUndefined(); - }); + expect(result.current.isLoading).toBeTruthy(); + expect(result.current.isFetching).toBeTruthy(); + expect(result.current.isError).toBeFalsy(); + expect(result.current.isRefetching).toBeFalsy(); + expect(result.current.isSuccess).toBeFalsy(); + expect(result.current.refetch).toBeTypeOf('function'); + expect(result.current.abort).toBeTypeOf('function'); + expect(result.current.error).toBeUndefined(); + expect(result.current.data).toBeUndefined(); +}); - it('should fetch data successfully', async () => { - const { result } = renderHook(() => useQuery(() => Promise.resolve('data'))); +it('Should fetch data successfully', async () => { + const { result } = renderHook(() => useQuery(() => Promise.resolve('data'))); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - expect(result.current.isError).toBe(false); - expect(result.current.data).toBe('data'); - }); - }); + expect(result.current.isLoading).toBeTruthy(); + expect(result.current.isFetching).toBeTruthy(); + expect(result.current.data).toBeUndefined(); - it('should handle errors', async () => { - const { result } = renderHook(() => useQuery(() => Promise.reject(new Error('error')))); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - expect(result.current.isError).toBe(true); - expect(result.current.error).toEqual(new Error('error')); - }); + await waitFor(() => { + expect(result.current.isFetching).toBeFalsy(); + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.data).toBe('data'); }); +}); - it('should refetch data', async () => { - const { result } = renderHook(() => useQuery(() => Promise.resolve('data'))); +it('Should handle errors', async () => { + const { result } = renderHook(() => useQuery(() => Promise.reject(new Error('error')))); - expect(result.current.isLoading).toBe(true); - expect(result.current.isRefetching).toBe(false); + expect(result.current.isLoading).toBeTruthy(); + expect(result.current.isFetching).toBeTruthy(); + expect(result.current.isError).toBeFalsy(); + expect(result.current.error).toBeUndefined(); + + await waitFor(() => { + expect(result.current.isFetching).toBeFalsy(); + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.isError).toBeTruthy(); + expect(result.current.error).toEqual(new Error('error')); expect(result.current.data).toBeUndefined(); + }); +}); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - expect(result.current.data).toBe('data'); - }); +it('Should refetch data', async () => { + const { result } = renderHook(() => useQuery(() => Promise.resolve('data'))); - act(() => { - result.current.refetch(); - }); + expect(result.current.isLoading).toBeTruthy(); + expect(result.current.isFetching).toBeTruthy(); + expect(result.current.isRefetching).toBeFalsy(); + expect(result.current.data).toBeUndefined(); - expect(result.current.isLoading).toBe(false); - expect(result.current.isRefetching).toBe(true); + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.isFetching).toBeFalsy(); + expect(result.current.isRefetching).toBeFalsy(); expect(result.current.data).toBe('data'); - - await waitFor(() => { - expect(result.current.isRefetching).toBe(false); - expect(result.current.data).toBe('data'); - }); }); - it('should abort request', async () => { - const fetchWithAbort = ({ signal }: { signal: AbortSignal }) => - new Promise((resolve, reject) => { - signal.addEventListener('abort', () => reject(new Error('Aborted'))); - setTimeout(() => resolve('data'), 100); - }); + act(() => result.current.refetch()); - const { result } = renderHook(() => useQuery(({ signal }) => fetchWithAbort({ signal }))); + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.isFetching).toBeTruthy(); + expect(result.current.isRefetching).toBeTruthy(); + expect(result.current.data).toBe('data'); - expect(result.current.isLoading).toBe(true); - expect(result.current.aborted).toBe(false); - expect(result.current.data).toBeUndefined(); + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.isFetching).toBeFalsy(); + expect(result.current.isRefetching).toBeFalsy(); + expect(result.current.data).toBe('data'); + }); +}); - act(() => { - result.current.abort(); +it('Should abort request', async () => { + const fetchWithAbort = ({ signal }: { signal: AbortSignal }) => + new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted'))); + setTimeout(() => resolve('data'), 0); }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - expect(result.current.isError).toBe(true); - expect(result.current.error).toEqual(new Error('Aborted')); - expect(result.current.aborted).toBe(true); - expect(result.current.data).toBeUndefined(); - }); + const { result } = renderHook(() => useQuery(({ signal }) => fetchWithAbort({ signal }))); - act(() => { - result.current.refetch(); - }); + expect(result.current.isLoading).toBeTruthy(); + expect(result.current.data).toBeUndefined(); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - expect(result.current.aborted).toBe(false); - }); + act(() => result.current.abort()); + + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.isError).toBeTruthy(); + expect(result.current.error).toEqual(new Error('aborted')); + expect(result.current.data).toBeUndefined(); }); - it('should triggered onSuccess callback', async () => { - const { result } = renderHook(() => - useQuery(() => Promise.resolve('data'), { - onSuccess(data) { - expect(data).toBe('data'); - } - }) - ); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); + act(() => result.current.refetch()); + + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.data).toBe('data'); }); +}); - it('should triggered onError callback', async () => { - const { result } = renderHook(() => - useQuery(() => Promise.reject(new Error('error')), { - onError(error) { - expect(error).toEqual(new Error('error')); - } - }) - ); - - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); +it('Should triggered onSuccess callback', async () => { + const { result } = renderHook(() => + useQuery(() => Promise.resolve('data'), { + onSuccess: (data) => expect(data).toBe('data') + }) + ); + + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); +}); + +it('Should triggered onError callback', async () => { + const { result } = renderHook(() => + useQuery(() => Promise.reject(new Error('error')), { + onError: (error) => expect(error).toEqual(new Error('error')) + }) + ); + + await waitFor(() => expect(result.current.isError).toBeTruthy()); +}); + +it('Should select data', async () => { + const { result } = renderHook(() => + useQuery(() => Promise.resolve('data'), { + select: (data) => `selected-${data}` + }) + ); + + await waitFor(() => expect(result.current.data).toBe('selected-data')); +}); + +it('Should set placeholder data', async () => { + const { result } = renderHook(() => + useQuery(() => Promise.resolve('data'), { + placeholderData: 'placeholder' + }) + ); + + expect(result.current.data).toBe('placeholder'); + + await waitFor(() => expect(result.current.data).toBe('data')); +}); + +it('Should retry on error once', async () => { + let retries = 0; + + const { result } = renderHook(() => + useQuery( + () => + new Promise((resolve, reject) => { + if (retries === 1) resolve('data'); + retries++; + reject(new Error('error')); + }), + { + retry: true + } + ) + ); + + expect(result.current.data).toBeUndefined(); + + await waitFor(() => expect(result.current.data).toBe('data')); +}); + +it('Should retry on error multiple times', async () => { + let retries = 0; + + const { result } = renderHook(() => + useQuery( + () => + new Promise((resolve, reject) => { + if (retries === 2) resolve('data'); + retries++; + reject(new Error('error')); + }), + { + retry: 2 + } + ) + ); + + expect(result.current.data).toBeUndefined(); + + await waitFor(() => expect(result.current.data).toBe('data')); +}); + +it('Should be listen enabled prop', async () => { + const { result, rerender } = renderHook( + ({ enabled }) => useQuery(() => Promise.resolve('data'), { enabled }), + { + initialProps: { enabled: false } + } + ); + + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.isFetching).toBeFalsy(); + expect(result.current.isRefetching).toBeFalsy(); + expect(result.current.data).toBeUndefined(); + + rerender({ enabled: true }); + + expect(result.current.isLoading).toBeTruthy(); + expect(result.current.isFetching).toBeTruthy(); + expect(result.current.isRefetching).toBeFalsy(); + expect(result.current.data).toBeUndefined(); + + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.isFetching).toBeFalsy(); + expect(result.current.isRefetching).toBeFalsy(); + expect(result.current.data).toBe('data'); }); - it('should refresh data when keys change', async () => { - const { result } = renderHook(() => { - const [key, setKey] = useState('initial'); + rerender({ enabled: false }); - const query = useQuery(({ keys }) => Promise.resolve(`data-${keys[0]}`), { - keys: [key] - }); + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.isFetching).toBeFalsy(); + expect(result.current.isRefetching).toBeFalsy(); + expect(result.current.data).toBe('data'); - return { - ...query, - setKey - }; - }); + rerender({ enabled: true }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - expect(result.current.data).toBe('data-initial'); - }); + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.isFetching).toBeTruthy(); + expect(result.current.isRefetching).toBeTruthy(); + expect(result.current.data).toBe('data'); - act(() => { - result.current.setKey('new'); - }); + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.isFetching).toBeFalsy(); + expect(result.current.isRefetching).toBeFalsy(); + expect(result.current.data).toBe('data'); + }); +}); - await waitFor(() => { - expect(result.current.data).toBe('data-new'); - }); +it('Should refresh data when keys change', async () => { + const { result, rerender } = renderHook( + ({ keys }) => + useQuery(({ keys }) => Promise.resolve(`data-${keys[0]}`), { + keys + }), + { + initialProps: { keys: ['initial'] } + } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.data).toBe('data-initial'); }); + + rerender({ keys: ['new'] }); + + await waitFor(() => expect(result.current.data).toBe('data-new')); }); diff --git a/src/hooks/useQuery/useQuery.ts b/src/hooks/useQuery/useQuery.ts index 5f29d6b8..08c35fe0 100644 --- a/src/hooks/useQuery/useQuery.ts +++ b/src/hooks/useQuery/useQuery.ts @@ -21,6 +21,8 @@ export interface UseQueryOptions { refetchInterval?: number; /* The retry count of requests */ retry?: boolean | number; + /* The retry delay of requests */ + retryDelay?: ((retry: number, error: Error) => number) | number; /* The callback function to be invoked on error */ onError?: (error: Error) => void; /* The callback function to be invoked on success */ @@ -40,8 +42,6 @@ interface UseQueryCallbackParams { export interface UseQueryReturn { /* The abort function */ abort: AbortController['abort']; - /* The aborted state of the query */ - aborted: boolean; /* The state of the query */ data?: Data; /* The success state of the query */ @@ -86,13 +86,13 @@ export const useQuery = ( ): UseQueryReturn => { const enabled = options?.enabled ?? true; const retryCountRef = useRef(options?.retry ? getRetry(options.retry) : 0); + const alreadyRequested = useRef(false); const [isFetching, setIsFetching] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); const [isRefetching, setIsRefetching] = useState(false); const [isSuccess, setIsSuccess] = useState(!!options?.initialData); - const [aborted, setAborted] = useState(!!options?.initialData); const [error, setError] = useState(undefined); const [data, setData] = useState(options?.initialData); @@ -105,15 +105,16 @@ export const useQuery = ( const abort = () => { abortControllerRef.current.abort(); abortControllerRef.current = new AbortController(); - abortControllerRef.current.signal.onabort = () => setAborted(true); }; const request = (action: 'init' | 'refetch') => { abort(); setIsFetching(true); - setAborted(false); - if (action === 'init') setIsLoading(true); + if (action === 'init') { + alreadyRequested.current = true; + setIsLoading(true); + } if (action === 'refetch') setIsRefetching(true); callback({ signal: abortControllerRef.current.signal, keys }) .then((response) => { @@ -130,6 +131,16 @@ export const useQuery = ( .catch((error: Error) => { if (retryCountRef.current > 0) { retryCountRef.current -= 1; + const retryDelay = + typeof options?.retryDelay === 'function' + ? options?.retryDelay(retryCountRef.current, error) + : options?.retryDelay; + + if (retryDelay) { + setTimeout(() => request(action), retryDelay); + return; + } + return request(action); } options?.onError?.(error); @@ -160,7 +171,7 @@ export const useQuery = ( useDidUpdate(() => { if (!enabled) return; - request('refetch'); + request(alreadyRequested.current ? 'refetch' : 'init'); }, [enabled, ...keys]); useEffect(() => { @@ -185,7 +196,6 @@ export const useQuery = ( isLoading, isError, isSuccess, - isRefetching, - aborted + isRefetching }; }; diff --git a/src/hooks/useShare/useShare.test.ts b/src/hooks/useShare/useShare.test.ts index cdce5c44..c692fa70 100644 --- a/src/hooks/useShare/useShare.test.ts +++ b/src/hooks/useShare/useShare.test.ts @@ -13,13 +13,13 @@ it('Should use share', () => { const { result } = renderHook(useShare); expect(typeof result.current.share).toBe('function'); - expect(result.current.supported).toBe(true); + expect(result.current.supported).toBeTruthy(); }); // it('Should use share on server side', () => { // const { result } = renderHookServer(useShare); -// expect(result.current.supported).toBe(false); +// expect(result.current.supported).toBeFalsy(); // }); it('Should share data', () => {