Skip to content

Commit fb59d7f

Browse files
feat: add usePromiseStore to prevent duplicate async requests
1 parent 76a2fec commit fb59d7f

3 files changed

Lines changed: 88 additions & 4 deletions

File tree

__tests__/use-visitor-data.test.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useVisitorData, UseVisitorDataReturn } from '../src'
22
import { render, renderHook } from '@testing-library/react'
3-
import { actWait, createWrapper } from './helpers'
3+
import { actWait, createWrapper, wait } from './helpers'
44
import { act } from 'react-dom/test-utils'
55
import { useEffect, useState } from 'react'
66
import userEvent from '@testing-library/user-event'
@@ -71,6 +71,35 @@ describe('useVisitorData', () => {
7171
)
7272
})
7373

74+
it('should avoid duplicate requests if one is already pending', async () => {
75+
mockGet.mockImplementation(async () => {
76+
await wait(250)
77+
return mockGetResult
78+
})
79+
80+
const wrapper = createWrapper()
81+
const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper })
82+
expect(result.current).toMatchObject(
83+
expect.objectContaining({
84+
isLoading: false,
85+
data: undefined,
86+
})
87+
)
88+
89+
await Promise.all([result.current.getData(), result.current.getData()])
90+
91+
await actWait(500)
92+
93+
expect(mockStart).toHaveBeenCalled()
94+
expect(mockGet).toHaveBeenCalledTimes(1)
95+
expect(result.current).toMatchObject(
96+
expect.objectContaining({
97+
isLoading: false,
98+
data: mockGetResult,
99+
})
100+
)
101+
})
102+
74103
it("shouldn't call getData on mount if 'immediate' option is set to false", async () => {
75104
mockGet.mockImplementation(() => mockGetResult)
76105

src/components/fp-provider.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as packageInfo from '../../package.json'
55
import { isSSR } from '../ssr'
66
import { WithEnvironment } from './with-environment'
77
import type { EnvDetails } from '../env.types'
8+
import { usePromiseStore } from '../utils/use-promise-store'
89

910
export interface FpProviderOptions extends StartOptions {
1011
/**
@@ -86,16 +87,20 @@ function ProviderWithEnv({
8687
return clientRef.current
8788
}, [createClient])
8889

90+
const { doRequest } = usePromiseStore()
91+
8992
const getVisitorData = useCallback(
9093
(options?: GetOptions) => {
9194
const client = getClient()
9295

93-
return client.get({
96+
const mergedOptions = {
9497
...getOptions,
9598
...options,
96-
})
99+
}
100+
101+
return doRequest(async () => client.get(mergedOptions), mergedOptions)
97102
},
98-
[getClient, getOptions]
103+
[doRequest, getClient, getOptions]
99104
)
100105

101106
const contextValue = useMemo(() => {

src/utils/use-promise-store.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { GetOptions, GetResult } from '@fingerprint/agent'
2+
import { useCallback, useRef } from 'react'
3+
4+
function getCacheKey(options?: GetOptions) {
5+
if (!options) {
6+
return ''
7+
}
8+
9+
return `${options.tag}-${options.linkedId}-${options.timeout}`
10+
}
11+
12+
export type UsePromiseStoreReturn = {
13+
/**
14+
* Accepts a callback that returns a promise (`requestCallback`)
15+
* and optional parameters (`options`). Ensures that the same request identified by a cache key is
16+
* only executed once at a time, and returns the stored promise for the request. The promise is
17+
* removed from the store once it is resolved or rejected.
18+
* */
19+
doRequest: (requestCallback: () => Promise<GetResult>, options?: GetOptions) => Promise<GetResult>
20+
}
21+
22+
/**
23+
* Manages a store of promises to handle unique asynchronous requests, ensuring that
24+
* requests with the same key are not duplicated while they are still pending.
25+
*/
26+
export function usePromiseStore(): UsePromiseStoreReturn {
27+
const store = useRef(new Map<string, Promise<GetResult>>()).current
28+
29+
const doRequest = useCallback(
30+
(requestCallback: () => Promise<GetResult>, options?: GetOptions) => {
31+
const cacheKey = getCacheKey(options)
32+
let cachedPromise = store.get(cacheKey)
33+
34+
if (!cachedPromise) {
35+
cachedPromise = requestCallback().finally(() => {
36+
store.delete(cacheKey)
37+
})
38+
39+
store.set(cacheKey, cachedPromise)
40+
}
41+
42+
return cachedPromise
43+
},
44+
[store]
45+
)
46+
47+
return {
48+
doRequest,
49+
}
50+
}

0 commit comments

Comments
 (0)