diff --git a/__tests__/use-visitor-data.test.tsx b/__tests__/use-visitor-data.test.tsx index 0694b31..423d698 100644 --- a/__tests__/use-visitor-data.test.tsx +++ b/__tests__/use-visitor-data.test.tsx @@ -1,7 +1,7 @@ -import { useVisitorData, UseVisitorDataReturn } from '../src' -import { act, render, renderHook, screen } from '@testing-library/react' +import { FingerprintContext, useVisitorData, UseVisitorDataReturn } from '../src' +import { act, render, renderHook, screen, waitFor } from '@testing-library/react' import { actWait, createWrapper, wait } from './helpers' -import { useEffect, useState } from 'react' +import { type PropsWithChildren, useEffect, useState } from 'react' import userEvent from '@testing-library/user-event' import * as agent from '@fingerprint/agent' import { GetResult } from '@fingerprint/agent' @@ -25,6 +25,24 @@ vi.mock('@fingerprint/agent', { spy: true }) const mockStart = vi.mocked(agent.start) +function createDeferred() { + let resolvePromise: ((value: T) => void) | undefined + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + + return { + promise, + resolve(value: T) { + if (resolvePromise === undefined) { + throw new Error('Promise resolver was not initialized') + } + + resolvePromise(value) + }, + } +} + describe('useVisitorData', () => { beforeEach(() => { vi.resetAllMocks() @@ -191,6 +209,43 @@ describe('useVisitorData', () => { expect(mockGet).toHaveBeenNthCalledWith(2, { tag: 2 }) }) + it('should enter the loading state when the automatic request source changes', async () => { + const secondResult = { ...mockGetResult, visitor_id: 'second-visitor' } + const secondRequest = createDeferred() + let getVisitorData: ReturnType Promise>> = vi.fn(() => Promise.resolve(mockGetResult)) + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ) + const hook = renderHook(() => useVisitorData(), { wrapper }) + + await waitFor(() => { + expect(hook.result.current.data).toEqual(mockGetResult) + }) + + getVisitorData = vi.fn(() => secondRequest.promise) + hook.rerender() + + expect(hook.result.current).toMatchObject({ + isLoading: true, + isFetched: false, + data: undefined, + }) + await waitFor(() => { + expect(getVisitorData).toHaveBeenCalledTimes(1) + }) + + await act(async () => { + secondRequest.resolve(secondResult) + await secondRequest.promise + }) + + expect(hook.result.current).toMatchObject({ + isLoading: false, + isFetched: true, + data: secondResult, + }) + }) + it('should correctly pass errors from agent', async () => { const ERROR_CLIENT_TIMEOUT = 'timeout' mockGet.mockRejectedValue(new Error(ERROR_CLIENT_TIMEOUT)) diff --git a/src/use-visitor-data.ts b/src/use-visitor-data.ts index 7792c78..855a837 100644 --- a/src/use-visitor-data.ts +++ b/src/use-visitor-data.ts @@ -40,17 +40,17 @@ export type UseVisitorDataReturn = VisitorQueryResult & { export function useVisitorData( { immediate, ...getOptions }: UseVisitorDataOptions = { immediate: true } ): UseVisitorDataReturn { - assertIsDefined(getOptions, 'getOptions') - const { getVisitorData } = useContext(FingerprintContext) - const [currentGetOptions, setCurrentGetOptions] = useState(getOptions) + const [currentConfig, setCurrentConfig] = useState({ immediate, getOptions, getVisitorData }) const [queryState, setQueryState] = useState({ isLoading: immediate, data: undefined, isFetched: false, error: undefined, }) + const currentGetOptions = currentConfig.getOptions + const currentGetVisitorData = currentConfig.getVisitorData const getData = useCallback( async (params = {}) => { @@ -69,7 +69,7 @@ export function useVisitorData( ...params, } - const result = await getVisitorData(getDataOptions) + const result = await currentGetVisitorData(getDataOptions) setQueryState({ isLoading: false, isFetched: true, @@ -91,21 +91,56 @@ export function useVisitorData( throw error } }, - [currentGetOptions, getVisitorData] + [currentGetOptions, currentGetVisitorData, setQueryState] ) useEffect(() => { if (immediate) { - // TODO: refactor mount fetch to avoid synchronous setState inside the effect - // eslint-disable-next-line react-hooks/set-state-in-effect -- preserve existing getData() mount behavior - getData().catch((unknownError: unknown) => { - console.error(`Failed to fetch visitor data on mount: ${String(unknownError)}`) - }) + void Promise.resolve() + .then(() => currentGetVisitorData(currentGetOptions)) + .then( + (result) => { + setQueryState({ + isLoading: false, + isFetched: true, + data: result, + error: undefined, + }) + }, + (unknownError: unknown) => { + const error = toError(unknownError) + + setQueryState({ + isLoading: false, + isFetched: false, + data: undefined, + error, + }) + console.error(`Failed to fetch visitor data on mount: ${String(error)}`) + } + ) } - }, [immediate, getData]) + }, [immediate, currentGetOptions, currentGetVisitorData, setQueryState]) + + const getOptionsChanged = !Object.is(currentGetOptions, getOptions) && !deepEquals(currentGetOptions, getOptions) + + const requestSourceChanged = currentGetVisitorData !== getVisitorData - if (!Object.is(currentGetOptions, getOptions) && !deepEquals(currentGetOptions, getOptions)) { - setCurrentGetOptions(getOptions) + if (currentConfig.immediate !== immediate || getOptionsChanged || requestSourceChanged) { + setCurrentConfig({ + immediate, + getOptions: getOptionsChanged ? getOptions : currentGetOptions, + getVisitorData, + }) + + if (immediate) { + setQueryState({ + isLoading: true, + isFetched: false, + data: undefined, + error: undefined, + }) + } } return useMemo(