Skip to content

Commit d153ea4

Browse files
feat: add useInfiniteDataLoader (#2282)
* feat: add useInfiniteDataLoader * fix: hasNextPage return value * feat: tests enabled false to true * feat: all good
1 parent 33bc16f commit d153ea4

File tree

6 files changed

+627
-46
lines changed

6 files changed

+627
-46
lines changed

.changeset/ninety-pianos-tan.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@scaleway/use-dataloader": minor
3+
---
4+
5+
Add useInfiniteDataLoader

packages/use-dataloader/src/DataLoaderProvider.tsx

+19-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
KEY_IS_NOT_STRING_ERROR,
66
} from './constants'
77
import DataLoader from './dataloader'
8+
import { marshalQueryKey } from './helpers'
89
import type { OnErrorFn, PromiseType } from './types'
910

1011
type CachedData = Record<string, unknown>
@@ -39,6 +40,7 @@ export type IDataLoaderContext = {
3940
key: string,
4041
args: UseDataLoaderInitializerArgs<ResultType>,
4142
) => DataLoader<ResultType, ErrorType>
43+
computeKey: (key: string) => string
4244
cacheKeyPrefix?: string
4345
onError?: (error: Error) => void | Promise<void>
4446
clearAllCachedData: () => void
@@ -50,6 +52,7 @@ export type IDataLoaderContext = {
5052
) => DataLoader<ResultType, ErrorType>
5153
reload: (key?: string) => Promise<void>
5254
reloadAll: () => Promise<void>
55+
reloadGroup: (startKey?: string) => Promise<void>
5356
}
5457

5558
// @ts-expect-error we force the context to undefined, should be corrected with default values
@@ -64,14 +67,14 @@ type DataLoaderProviderProps = {
6467

6568
const DataLoaderProvider = ({
6669
children,
67-
cacheKeyPrefix = '',
70+
cacheKeyPrefix,
6871
onError,
6972
maxConcurrentRequests = DEFAULT_MAX_CONCURRENT_REQUESTS,
7073
}: DataLoaderProviderProps): ReactElement => {
7174
const requestsRef = useRef<Requests>({})
7275

7376
const computeKey = useCallback(
74-
(key: string) => `${cacheKeyPrefix ? `${cacheKeyPrefix}-` : ''}${key}`,
77+
(key: string) => marshalQueryKey([cacheKeyPrefix, key]),
7578
[cacheKeyPrefix],
7679
)
7780

@@ -136,6 +139,16 @@ const DataLoaderProvider = ({
136139
[getRequest],
137140
)
138141

142+
const reloadGroup = useCallback(async (startPrefix?: string) => {
143+
if (startPrefix && typeof startPrefix === 'string') {
144+
await Promise.all(
145+
Object.values(requestsRef.current)
146+
.filter(request => request.key.startsWith(startPrefix))
147+
.map(request => request.load(true)),
148+
)
149+
} else throw new Error(KEY_IS_NOT_STRING_ERROR)
150+
}, [])
151+
139152
const reloadAll = useCallback(async () => {
140153
await Promise.all(
141154
Object.values(requestsRef.current).map(request => request.load(true)),
@@ -189,6 +202,8 @@ const DataLoaderProvider = ({
189202
onError,
190203
reload,
191204
reloadAll,
205+
reloadGroup,
206+
computeKey,
192207
}),
193208
[
194209
addRequest,
@@ -202,6 +217,8 @@ const DataLoaderProvider = ({
202217
onError,
203218
reload,
204219
reloadAll,
220+
reloadGroup,
221+
computeKey,
205222
],
206223
)
207224

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { renderHook, waitFor } from '@testing-library/react'
2+
import { type ReactNode, act } from 'react'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import DataLoaderProvider from '../DataLoaderProvider'
5+
import type { UseInfiniteDataLoaderConfig } from '../types'
6+
import { useInfiniteDataLoader } from '../useInfiniteDataLoader'
7+
8+
const config: UseInfiniteDataLoaderConfig<
9+
{ nextPage: number; data: string },
10+
Error,
11+
{ page: number },
12+
'page'
13+
> = {
14+
getNextPage: result => result.nextPage,
15+
enabled: true,
16+
}
17+
18+
const getPrerequisite = (key: string) => {
19+
let counter = 1
20+
let canResolve = false
21+
const getNextData = vi.fn(
22+
() =>
23+
new Promise<{ nextPage: number; data: string }>(resolve => {
24+
const resolvePromise = () => {
25+
if (canResolve) {
26+
counter += 1
27+
resolve({ nextPage: counter, data: `Page ${counter - 1} data` })
28+
} else {
29+
setTimeout(() => {
30+
resolvePromise()
31+
}, 100)
32+
}
33+
}
34+
resolvePromise()
35+
}),
36+
)
37+
38+
return {
39+
initialProps: {
40+
baseParams: {
41+
page: 1,
42+
},
43+
config: {
44+
enabled: true,
45+
},
46+
key,
47+
method: getNextData,
48+
},
49+
setCanResolve: (newState: boolean) => {
50+
canResolve = newState
51+
},
52+
resetCounter: () => {
53+
counter = 1
54+
},
55+
canResolve,
56+
counter,
57+
}
58+
}
59+
const wrapper = ({ children }: { children?: ReactNode }) => (
60+
<DataLoaderProvider>{children}</DataLoaderProvider>
61+
)
62+
63+
describe('useInfinitDataLoader', () => {
64+
it('should get the first page on mount while enabled', async () => {
65+
const { setCanResolve, initialProps } = getPrerequisite('test1')
66+
const { result } = renderHook(
67+
props =>
68+
useInfiniteDataLoader(
69+
props.key,
70+
props.method,
71+
props.baseParams,
72+
'page',
73+
config,
74+
),
75+
{
76+
initialProps,
77+
wrapper,
78+
},
79+
)
80+
expect(result.current.data).toBe(undefined)
81+
expect(result.current.isLoading).toBe(true)
82+
expect(initialProps.method).toHaveBeenCalledTimes(1)
83+
setCanResolve(true)
84+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
85+
expect(initialProps.method).toHaveBeenCalledTimes(1)
86+
expect(result.current.data).toStrictEqual([
87+
{ nextPage: 2, data: 'Page 1 data' },
88+
])
89+
expect(result.current.isLoading).toBe(false)
90+
})
91+
92+
it('should get the first and loadMore one page on mount while enabled', async () => {
93+
const { setCanResolve, initialProps } = getPrerequisite('test2')
94+
const { result } = renderHook(
95+
props =>
96+
useInfiniteDataLoader(
97+
props.key,
98+
props.method,
99+
props.baseParams,
100+
'page',
101+
config,
102+
),
103+
{
104+
initialProps,
105+
wrapper,
106+
},
107+
)
108+
expect(result.current.data).toBe(undefined)
109+
expect(result.current.isLoading).toBe(true)
110+
expect(initialProps.method).toHaveBeenCalledTimes(1)
111+
expect(initialProps.method).toHaveBeenCalledWith({
112+
page: 1,
113+
})
114+
setCanResolve(true)
115+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
116+
expect(initialProps.method).toHaveBeenCalledTimes(1)
117+
expect(result.current.data).toStrictEqual([
118+
{ nextPage: 2, data: 'Page 1 data' },
119+
])
120+
expect(result.current.isLoading).toBe(false)
121+
setCanResolve(false)
122+
act(() => {
123+
result.current.loadMore()
124+
})
125+
expect(result.current.data).toStrictEqual([
126+
{ nextPage: 2, data: 'Page 1 data' },
127+
])
128+
await waitFor(() => expect(result.current.isLoading).toBe(true))
129+
expect(initialProps.method).toHaveBeenCalledTimes(2)
130+
expect(initialProps.method).toHaveBeenCalledWith({
131+
page: 2,
132+
})
133+
setCanResolve(true)
134+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
135+
expect(result.current.data).toStrictEqual([
136+
{ nextPage: 2, data: 'Page 1 data' },
137+
{ nextPage: 3, data: 'Page 2 data' },
138+
])
139+
})
140+
141+
it('should get the first and loadMore one page on mount while enabled then reload', async () => {
142+
const { setCanResolve, initialProps, resetCounter } =
143+
getPrerequisite('test3')
144+
const { result } = renderHook(
145+
props =>
146+
useInfiniteDataLoader(
147+
props.key,
148+
props.method,
149+
props.baseParams,
150+
'page',
151+
config,
152+
),
153+
{
154+
initialProps,
155+
wrapper,
156+
},
157+
)
158+
expect(result.current.data).toBe(undefined)
159+
expect(result.current.isLoading).toBe(true)
160+
expect(initialProps.method).toHaveBeenCalledTimes(1)
161+
expect(initialProps.method).toHaveBeenCalledWith({
162+
page: 1,
163+
})
164+
setCanResolve(true)
165+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
166+
setCanResolve(false)
167+
expect(initialProps.method).toHaveBeenCalledTimes(1)
168+
expect(result.current.data).toStrictEqual([
169+
{ nextPage: 2, data: 'Page 1 data' },
170+
])
171+
expect(result.current.isLoading).toBe(false)
172+
act(() => {
173+
result.current.loadMore()
174+
})
175+
await waitFor(() => expect(result.current.isLoading).toBe(true))
176+
expect(result.current.data).toStrictEqual([
177+
{ nextPage: 2, data: 'Page 1 data' },
178+
])
179+
expect(initialProps.method).toHaveBeenCalledTimes(2)
180+
expect(initialProps.method).toHaveBeenCalledWith({
181+
page: 2,
182+
})
183+
setCanResolve(true)
184+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
185+
expect(result.current.data).toStrictEqual([
186+
{ nextPage: 2, data: 'Page 1 data' },
187+
{ nextPage: 3, data: 'Page 2 data' },
188+
])
189+
setCanResolve(false)
190+
resetCounter()
191+
act(() => {
192+
result.current.reload().catch(() => null)
193+
})
194+
await waitFor(() => expect(result.current.isLoading).toBe(true))
195+
setCanResolve(true)
196+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
197+
expect(result.current.data).toStrictEqual([
198+
{ nextPage: 2, data: 'Page 1 data' },
199+
{ nextPage: 3, data: 'Page 2 data' },
200+
])
201+
})
202+
203+
it('should get the first and loadMore one page on mount while not enabled then enabled then reload', async () => {
204+
const { setCanResolve, initialProps, resetCounter } =
205+
getPrerequisite('test4')
206+
const localInitialProps = {
207+
...initialProps,
208+
config: {
209+
...config,
210+
enabled: false,
211+
},
212+
}
213+
const { result, rerender } = renderHook(
214+
props =>
215+
useInfiniteDataLoader(
216+
props.key,
217+
props.method,
218+
props.baseParams,
219+
'page',
220+
props.config,
221+
),
222+
{
223+
initialProps: localInitialProps,
224+
wrapper,
225+
},
226+
)
227+
expect(result.current.data).toBe(undefined)
228+
expect(result.current.isLoading).toBe(false)
229+
expect(initialProps.method).toHaveBeenCalledTimes(0)
230+
rerender(localInitialProps)
231+
expect(result.current.data).toBe(undefined)
232+
expect(result.current.isLoading).toBe(false)
233+
expect(initialProps.method).toHaveBeenCalledTimes(0)
234+
rerender({ ...localInitialProps, config: { ...config, enabled: true } })
235+
expect(result.current.data).toBe(undefined)
236+
await waitFor(() => expect(result.current.isLoading).toBe(true))
237+
expect(initialProps.method).toHaveBeenCalledTimes(1)
238+
expect(initialProps.method).toHaveBeenCalledWith({
239+
page: 1,
240+
})
241+
setCanResolve(true)
242+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
243+
setCanResolve(false)
244+
expect(initialProps.method).toHaveBeenCalledTimes(1)
245+
expect(result.current.data).toStrictEqual([
246+
{ nextPage: 2, data: 'Page 1 data' },
247+
])
248+
expect(result.current.isLoading).toBe(false)
249+
act(() => {
250+
result.current.loadMore()
251+
})
252+
await waitFor(() => expect(result.current.isLoading).toBe(true))
253+
expect(result.current.data).toStrictEqual([
254+
{ nextPage: 2, data: 'Page 1 data' },
255+
])
256+
expect(initialProps.method).toHaveBeenCalledTimes(2)
257+
expect(initialProps.method).toHaveBeenCalledWith({
258+
page: 2,
259+
})
260+
setCanResolve(true)
261+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
262+
expect(result.current.data).toStrictEqual([
263+
{ nextPage: 2, data: 'Page 1 data' },
264+
{ nextPage: 3, data: 'Page 2 data' },
265+
])
266+
setCanResolve(false)
267+
resetCounter()
268+
act(() => {
269+
result.current.reload().catch(() => null)
270+
})
271+
await waitFor(() => expect(result.current.isLoading).toBe(true))
272+
setCanResolve(true)
273+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
274+
expect(result.current.data).toStrictEqual([
275+
{ nextPage: 2, data: 'Page 1 data' },
276+
{ nextPage: 3, data: 'Page 2 data' },
277+
])
278+
})
279+
})

packages/use-dataloader/src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ export {
33
useDataLoaderContext,
44
} from './DataLoaderProvider'
55
export { useDataLoader } from './useDataLoader'
6-
export type { UseDataLoaderConfig } from './types'
6+
export type { UseDataLoaderConfig, UseInfiniteDataLoaderConfig } from './types'
77
export { DATALIFE_TIME, POLLING_INTERVAL } from './constants'
8+
export { useInfiniteDataLoader } from './useInfiniteDataLoader'

0 commit comments

Comments
 (0)