@ic-reactor/react 3.0.0-beta.7 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -10
- package/dist/createActorHooks.d.ts +2 -0
- package/dist/createActorHooks.d.ts.map +1 -1
- package/dist/createActorHooks.js +2 -0
- package/dist/createActorHooks.js.map +1 -1
- package/dist/createMutation.d.ts.map +1 -1
- package/dist/createMutation.js +4 -0
- package/dist/createMutation.js.map +1 -1
- package/dist/hooks/index.d.ts +18 -5
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +15 -5
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/useActorInfiniteQuery.d.ts +13 -11
- package/dist/hooks/useActorInfiniteQuery.d.ts.map +1 -1
- package/dist/hooks/useActorInfiniteQuery.js.map +1 -1
- package/dist/hooks/useActorMethod.d.ts +105 -0
- package/dist/hooks/useActorMethod.d.ts.map +1 -0
- package/dist/hooks/useActorMethod.js +192 -0
- package/dist/hooks/useActorMethod.js.map +1 -0
- package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts +13 -10
- package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts.map +1 -1
- package/dist/hooks/useActorSuspenseInfiniteQuery.js.map +1 -1
- package/package.json +9 -8
- package/src/createActorHooks.ts +146 -0
- package/src/createAuthHooks.ts +137 -0
- package/src/createInfiniteQuery.ts +471 -0
- package/src/createMutation.ts +163 -0
- package/src/createQuery.ts +197 -0
- package/src/createSuspenseInfiniteQuery.ts +478 -0
- package/src/createSuspenseQuery.ts +215 -0
- package/src/hooks/index.ts +93 -0
- package/src/hooks/useActorInfiniteQuery.test.tsx +457 -0
- package/src/hooks/useActorInfiniteQuery.ts +134 -0
- package/src/hooks/useActorMethod.test.tsx +798 -0
- package/src/hooks/useActorMethod.ts +397 -0
- package/src/hooks/useActorMutation.test.tsx +220 -0
- package/src/hooks/useActorMutation.ts +124 -0
- package/src/hooks/useActorQuery.test.tsx +287 -0
- package/src/hooks/useActorQuery.ts +110 -0
- package/src/hooks/useActorSuspenseInfiniteQuery.test.tsx +472 -0
- package/src/hooks/useActorSuspenseInfiniteQuery.ts +137 -0
- package/src/hooks/useActorSuspenseQuery.test.tsx +254 -0
- package/src/hooks/useActorSuspenseQuery.ts +112 -0
- package/src/index.ts +21 -0
- package/src/types.ts +435 -0
- package/src/validation.ts +202 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest"
|
|
2
|
+
import { renderHook, waitFor, act } from "@testing-library/react"
|
|
3
|
+
import React, { Suspense } from "react"
|
|
4
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|
5
|
+
import { useActorSuspenseInfiniteQuery } from "./useActorSuspenseInfiniteQuery"
|
|
6
|
+
import { ActorMethod } from "@icp-sdk/core/agent"
|
|
7
|
+
import { Reactor } from "@ic-reactor/core"
|
|
8
|
+
|
|
9
|
+
// Define a test actor type with paginated methods
|
|
10
|
+
type TestActor = {
|
|
11
|
+
getPosts: ActorMethod<[{ page: number; limit: number }], string[]>
|
|
12
|
+
getItems: ActorMethod<
|
|
13
|
+
[number],
|
|
14
|
+
{ items: string[]; nextCursor: number | null }
|
|
15
|
+
>
|
|
16
|
+
getMessages: ActorMethod<
|
|
17
|
+
[string],
|
|
18
|
+
{ messages: string[]; nextToken: string | null }
|
|
19
|
+
>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Mock data generators
|
|
23
|
+
const generatePosts = (page: number, limit: number): string[] => {
|
|
24
|
+
const start = (page - 1) * limit
|
|
25
|
+
return Array.from({ length: limit }, (_, i) => `Post ${start + i + 1}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const generateItems = (
|
|
29
|
+
cursor: number
|
|
30
|
+
): { items: string[]; nextCursor: number | null } => {
|
|
31
|
+
if (cursor >= 50) {
|
|
32
|
+
return { items: [], nextCursor: null }
|
|
33
|
+
}
|
|
34
|
+
const items = Array.from({ length: 10 }, (_, i) => `Item ${cursor + i + 1}`)
|
|
35
|
+
return { items, nextCursor: cursor + 10 }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Mock Reactor
|
|
39
|
+
const createMockReactor = (queryClient: QueryClient) => {
|
|
40
|
+
const callMethod = vi
|
|
41
|
+
.fn()
|
|
42
|
+
.mockImplementation(async ({ functionName, args }) => {
|
|
43
|
+
if (functionName === "getPosts") {
|
|
44
|
+
const { page, limit } = args[0]
|
|
45
|
+
return generatePosts(page, limit)
|
|
46
|
+
}
|
|
47
|
+
if (functionName === "getItems") {
|
|
48
|
+
const cursor = args[0]
|
|
49
|
+
return generateItems(cursor)
|
|
50
|
+
}
|
|
51
|
+
if (functionName === "getMessages") {
|
|
52
|
+
const token = args[0]
|
|
53
|
+
const pageNum = token === "start" ? 0 : parseInt(token, 10)
|
|
54
|
+
const messages = Array.from(
|
|
55
|
+
{ length: 5 },
|
|
56
|
+
(_, i) => `Message ${pageNum * 5 + i + 1}`
|
|
57
|
+
)
|
|
58
|
+
const nextToken = pageNum < 3 ? String(pageNum + 1) : null
|
|
59
|
+
return { messages, nextToken }
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Mock ensureInfiniteQueryData for suspense behavior if needed,
|
|
65
|
+
// but useSuspenseInfiniteQuery handles its own suspense mechanism via options.
|
|
66
|
+
// We just need to make sure callMethod returns a promise.
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
queryClient,
|
|
70
|
+
callMethod,
|
|
71
|
+
generateQueryKey: vi
|
|
72
|
+
.fn()
|
|
73
|
+
.mockImplementation(({ functionName, args }) => [
|
|
74
|
+
"test-canister",
|
|
75
|
+
functionName,
|
|
76
|
+
...(args ? [JSON.stringify(args)] : []),
|
|
77
|
+
]),
|
|
78
|
+
} as unknown as Reactor<TestActor>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("useActorSuspenseInfiniteQuery", () => {
|
|
82
|
+
let queryClient: QueryClient
|
|
83
|
+
let mockReactor: Reactor<TestActor>
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
queryClient = new QueryClient({
|
|
87
|
+
defaultOptions: {
|
|
88
|
+
queries: {
|
|
89
|
+
retry: false,
|
|
90
|
+
gcTime: 0,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
mockReactor = createMockReactor(queryClient)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
98
|
+
<QueryClientProvider client={queryClient}>
|
|
99
|
+
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
|
100
|
+
</QueryClientProvider>
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
describe("basic functionality", () => {
|
|
104
|
+
it("should fetch initial page", async () => {
|
|
105
|
+
const { result } = renderHook(
|
|
106
|
+
() =>
|
|
107
|
+
useActorSuspenseInfiniteQuery({
|
|
108
|
+
reactor: mockReactor,
|
|
109
|
+
functionName: "getPosts",
|
|
110
|
+
initialPageParam: 1,
|
|
111
|
+
getArgs: (page) => [{ page, limit: 10 }] as const,
|
|
112
|
+
getNextPageParam: (lastPage, allPages) => {
|
|
113
|
+
if (lastPage.length < 10) return undefined
|
|
114
|
+
return allPages.length + 1
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
{ wrapper }
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
await waitFor(() => {
|
|
121
|
+
expect(result.current.isSuccess).toBe(true)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
expect(result.current.data.pages).toHaveLength(1)
|
|
125
|
+
expect(result.current.data.pages[0]).toHaveLength(10)
|
|
126
|
+
expect(result.current.data.pages[0][0]).toBe("Post 1")
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("should support fetchNextPage", async () => {
|
|
130
|
+
const { result } = renderHook(
|
|
131
|
+
() =>
|
|
132
|
+
useActorSuspenseInfiniteQuery({
|
|
133
|
+
reactor: mockReactor,
|
|
134
|
+
functionName: "getPosts",
|
|
135
|
+
initialPageParam: 1,
|
|
136
|
+
getArgs: (page) => [{ page, limit: 10 }] as const,
|
|
137
|
+
getNextPageParam: (lastPage, allPages) => {
|
|
138
|
+
if (lastPage.length < 10) return undefined
|
|
139
|
+
return allPages.length + 1
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
{ wrapper }
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
await waitFor(() => {
|
|
146
|
+
expect(result.current.isSuccess).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
expect(result.current.hasNextPage).toBe(true)
|
|
150
|
+
|
|
151
|
+
// fetchNextPage should be available and callable
|
|
152
|
+
expect(typeof result.current.fetchNextPage).toBe("function")
|
|
153
|
+
expect(result.current.isFetchingNextPage).toBe(false)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("should call reactor.callMethod with correct args", async () => {
|
|
157
|
+
const { result } = renderHook(
|
|
158
|
+
() =>
|
|
159
|
+
useActorSuspenseInfiniteQuery({
|
|
160
|
+
reactor: mockReactor,
|
|
161
|
+
functionName: "getPosts",
|
|
162
|
+
initialPageParam: 1,
|
|
163
|
+
getArgs: (page) => [{ page, limit: 5 }] as const,
|
|
164
|
+
getNextPageParam: () => undefined,
|
|
165
|
+
}),
|
|
166
|
+
{ wrapper }
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(result.current.isSuccess).toBe(true)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
expect(mockReactor.callMethod).toHaveBeenCalledWith({
|
|
174
|
+
functionName: "getPosts",
|
|
175
|
+
args: [{ page: 1, limit: 5 }],
|
|
176
|
+
callConfig: undefined,
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe("cursor-based pagination", () => {
|
|
182
|
+
it("should work with cursor-based pagination", async () => {
|
|
183
|
+
const { result } = renderHook(
|
|
184
|
+
() =>
|
|
185
|
+
useActorSuspenseInfiniteQuery<
|
|
186
|
+
TestActor,
|
|
187
|
+
"getItems",
|
|
188
|
+
"candid",
|
|
189
|
+
number
|
|
190
|
+
>({
|
|
191
|
+
reactor: mockReactor as unknown as Reactor<TestActor>,
|
|
192
|
+
functionName: "getItems",
|
|
193
|
+
initialPageParam: 0,
|
|
194
|
+
getArgs: (cursor) => [cursor],
|
|
195
|
+
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
196
|
+
}),
|
|
197
|
+
{ wrapper }
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
await waitFor(() => {
|
|
201
|
+
expect(result.current.isSuccess).toBe(true)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
expect(result.current.data.pages[0].items[0]).toBe("Item 1")
|
|
205
|
+
expect(result.current.hasNextPage).toBe(true)
|
|
206
|
+
|
|
207
|
+
await act(async () => {
|
|
208
|
+
await result.current.fetchNextPage()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
await waitFor(() => {
|
|
212
|
+
expect(result.current.data.pages).toHaveLength(2)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
expect(result.current.data.pages[1].items[0]).toBe("Item 11")
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it("should determine hasNextPage based on getNextPageParam", async () => {
|
|
219
|
+
// Create a mock that returns null nextCursor to indicate no more pages
|
|
220
|
+
const noMorePagesMock = createMockReactor(
|
|
221
|
+
queryClient
|
|
222
|
+
) as unknown as Reactor<TestActor>
|
|
223
|
+
;(
|
|
224
|
+
noMorePagesMock.callMethod as ReturnType<typeof vi.fn>
|
|
225
|
+
).mockImplementation(async () => {
|
|
226
|
+
return { items: ["Last Item"], nextCursor: null }
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const { result } = renderHook(
|
|
230
|
+
() =>
|
|
231
|
+
useActorSuspenseInfiniteQuery<
|
|
232
|
+
TestActor,
|
|
233
|
+
"getItems",
|
|
234
|
+
"candid",
|
|
235
|
+
number
|
|
236
|
+
>({
|
|
237
|
+
reactor: noMorePagesMock,
|
|
238
|
+
functionName: "getItems",
|
|
239
|
+
initialPageParam: 0,
|
|
240
|
+
getArgs: (cursor) => [cursor],
|
|
241
|
+
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
242
|
+
}),
|
|
243
|
+
{ wrapper }
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
await waitFor(() => {
|
|
247
|
+
expect(result.current.isSuccess).toBe(true)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// When getNextPageParam returns null, hasNextPage should be false
|
|
251
|
+
expect(result.current.hasNextPage).toBe(false)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe("token-based pagination", () => {
|
|
256
|
+
it("should work with string token pagination", async () => {
|
|
257
|
+
const { result } = renderHook(
|
|
258
|
+
() =>
|
|
259
|
+
useActorSuspenseInfiniteQuery({
|
|
260
|
+
reactor: mockReactor,
|
|
261
|
+
functionName: "getMessages",
|
|
262
|
+
initialPageParam: "start",
|
|
263
|
+
getArgs: (token) => [token] as const,
|
|
264
|
+
getNextPageParam: (lastPage) => lastPage.nextToken,
|
|
265
|
+
}),
|
|
266
|
+
{ wrapper }
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
expect(result.current.isSuccess).toBe(true)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
expect(result.current.data.pages[0].messages[0]).toBe("Message 1")
|
|
274
|
+
expect(result.current.data.pages[0].messages[1]).toBe("Message 2")
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
describe("query options", () => {
|
|
279
|
+
it("should use custom staleTime", async () => {
|
|
280
|
+
const { result } = renderHook(
|
|
281
|
+
() =>
|
|
282
|
+
useActorSuspenseInfiniteQuery({
|
|
283
|
+
reactor: mockReactor,
|
|
284
|
+
functionName: "getPosts",
|
|
285
|
+
initialPageParam: 1,
|
|
286
|
+
getArgs: (page) => [{ page, limit: 10 }] as const,
|
|
287
|
+
getNextPageParam: () => undefined,
|
|
288
|
+
staleTime: 12345,
|
|
289
|
+
}),
|
|
290
|
+
{ wrapper }
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
await waitFor(() => {
|
|
294
|
+
expect(result.current.isSuccess).toBe(true)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const queryState = queryClient.getQueryCache().find({
|
|
298
|
+
queryKey: result.current.data ? ["test-canister", "getPosts"] : [],
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
expect(queryState).toBeDefined()
|
|
302
|
+
|
|
303
|
+
// Need to find the exact query. The mock key generator produces ["test-canister", "getPosts", args...]
|
|
304
|
+
// But queryKey logic in hook adds parameters.
|
|
305
|
+
// Actually mock generateQueryKey adds args if present, but the hook passes method only to generateQueryKey
|
|
306
|
+
// `const baseQueryKey = queryKey ?? reactor.generateQueryKey({ method })`
|
|
307
|
+
// So the key should be ["test-canister", "getPosts"].
|
|
308
|
+
// Wait, `useInfiniteQuery` appends parameters? No, `queryKey` is fixed base, `pageParam` handled internally.
|
|
309
|
+
|
|
310
|
+
const queries = queryClient.getQueryCache().findAll()
|
|
311
|
+
const query = queries[0]
|
|
312
|
+
expect(query.observers[0].options.staleTime).toBe(12345)
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
describe("getPreviousPageParam", () => {
|
|
317
|
+
it("should support bi-directional pagination", async () => {
|
|
318
|
+
const { result } = renderHook(
|
|
319
|
+
() =>
|
|
320
|
+
useActorSuspenseInfiniteQuery({
|
|
321
|
+
reactor: mockReactor,
|
|
322
|
+
functionName: "getPosts",
|
|
323
|
+
initialPageParam: 3,
|
|
324
|
+
getArgs: (page) => [{ page, limit: 10 }] as const,
|
|
325
|
+
getNextPageParam: (lastPage, allPages) => {
|
|
326
|
+
if (lastPage.length < 10) return undefined
|
|
327
|
+
const lastPageParam = allPages.length + 2 // Started at page 3
|
|
328
|
+
return lastPageParam + 1
|
|
329
|
+
},
|
|
330
|
+
getPreviousPageParam: (_firstPage, _allPages, firstPageParam) => {
|
|
331
|
+
if (firstPageParam <= 1) return undefined
|
|
332
|
+
return firstPageParam - 1
|
|
333
|
+
},
|
|
334
|
+
}),
|
|
335
|
+
{ wrapper }
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
await waitFor(() => {
|
|
339
|
+
expect(result.current.isSuccess).toBe(true)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
// Started at page 3, should have previous pages
|
|
343
|
+
expect(result.current.hasPreviousPage).toBe(true)
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe("select function", () => {
|
|
348
|
+
it("should apply select transformation to infinite data", async () => {
|
|
349
|
+
const { result } = renderHook(
|
|
350
|
+
() =>
|
|
351
|
+
useActorSuspenseInfiniteQuery({
|
|
352
|
+
reactor: mockReactor,
|
|
353
|
+
functionName: "getPosts",
|
|
354
|
+
initialPageParam: 1,
|
|
355
|
+
getArgs: (page) => [{ page, limit: 10 }] as const,
|
|
356
|
+
getNextPageParam: () => undefined,
|
|
357
|
+
select: (data) => ({
|
|
358
|
+
...data,
|
|
359
|
+
pages: data.pages.map((page) =>
|
|
360
|
+
page.map((post) => post.toUpperCase())
|
|
361
|
+
),
|
|
362
|
+
}),
|
|
363
|
+
}),
|
|
364
|
+
{ wrapper }
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
await waitFor(() => {
|
|
368
|
+
expect(result.current.isSuccess).toBe(true)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
expect(result.current.data.pages[0][0]).toBe("POST 1")
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
describe("refetching", () => {
|
|
376
|
+
it("should support manual refetch", async () => {
|
|
377
|
+
const { result } = renderHook(
|
|
378
|
+
() =>
|
|
379
|
+
useActorSuspenseInfiniteQuery({
|
|
380
|
+
reactor: mockReactor,
|
|
381
|
+
functionName: "getPosts",
|
|
382
|
+
initialPageParam: 1,
|
|
383
|
+
getArgs: (page) => [{ page, limit: 10 }] as const,
|
|
384
|
+
getNextPageParam: () => undefined,
|
|
385
|
+
}),
|
|
386
|
+
{ wrapper }
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
await waitFor(() => {
|
|
390
|
+
expect(result.current.isSuccess).toBe(true)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
const initialCallCount = (
|
|
394
|
+
mockReactor.callMethod as ReturnType<typeof vi.fn>
|
|
395
|
+
).mock.calls.length
|
|
396
|
+
|
|
397
|
+
await act(async () => {
|
|
398
|
+
await result.current.refetch()
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
expect(
|
|
402
|
+
(mockReactor.callMethod as ReturnType<typeof vi.fn>).mock.calls.length
|
|
403
|
+
).toBeGreaterThan(initialCallCount)
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
describe("maxPages option", () => {
|
|
408
|
+
it("should accept maxPages option", async () => {
|
|
409
|
+
const { result } = renderHook(
|
|
410
|
+
() =>
|
|
411
|
+
useActorSuspenseInfiniteQuery({
|
|
412
|
+
reactor: mockReactor,
|
|
413
|
+
functionName: "getPosts",
|
|
414
|
+
initialPageParam: 1,
|
|
415
|
+
getArgs: (page) => [{ page, limit: 10 }] as const,
|
|
416
|
+
getNextPageParam: (lastPage, allPages) => {
|
|
417
|
+
if (lastPage.length < 10) return undefined
|
|
418
|
+
return allPages.length + 1
|
|
419
|
+
},
|
|
420
|
+
maxPages: 2,
|
|
421
|
+
}),
|
|
422
|
+
{ wrapper }
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
await waitFor(() => {
|
|
426
|
+
expect(result.current.isSuccess).toBe(true)
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
// Initial page should be loaded
|
|
430
|
+
expect(result.current.data.pages).toHaveLength(1)
|
|
431
|
+
// maxPages option should be respected by tanstack query
|
|
432
|
+
expect(result.current.hasNextPage).toBe(true)
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
describe("data structure", () => {
|
|
437
|
+
it("should return data in InfiniteData format", async () => {
|
|
438
|
+
const { result } = renderHook(
|
|
439
|
+
() =>
|
|
440
|
+
useActorSuspenseInfiniteQuery({
|
|
441
|
+
reactor: mockReactor,
|
|
442
|
+
functionName: "getPosts",
|
|
443
|
+
initialPageParam: 1,
|
|
444
|
+
getArgs: (page) => [{ page, limit: 10 }] as const,
|
|
445
|
+
getNextPageParam: (lastPage, allPages) => {
|
|
446
|
+
if (lastPage.length < 10) return undefined
|
|
447
|
+
return allPages.length + 1
|
|
448
|
+
},
|
|
449
|
+
}),
|
|
450
|
+
{ wrapper }
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
await waitFor(() => {
|
|
454
|
+
expect(result.current.isSuccess).toBe(true)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
// Data should have pages array and pageParams array
|
|
458
|
+
expect(result.current.data).toBeDefined()
|
|
459
|
+
expect(Array.isArray(result.current.data.pages)).toBe(true)
|
|
460
|
+
expect(Array.isArray(result.current.data.pageParams)).toBe(true)
|
|
461
|
+
|
|
462
|
+
// Initial page should be loaded
|
|
463
|
+
expect(result.current.data.pages).toHaveLength(1)
|
|
464
|
+
expect(result.current.data.pages[0]).toHaveLength(10)
|
|
465
|
+
|
|
466
|
+
// Pages can be flattened
|
|
467
|
+
const allPosts = result.current.data.pages.flat()
|
|
468
|
+
expect(allPosts).toHaveLength(10)
|
|
469
|
+
expect(allPosts?.[0]).toBe("Post 1")
|
|
470
|
+
})
|
|
471
|
+
})
|
|
472
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useMemo, useCallback } from "react"
|
|
2
|
+
import {
|
|
3
|
+
QueryKey,
|
|
4
|
+
useSuspenseInfiniteQuery,
|
|
5
|
+
UseSuspenseInfiniteQueryResult,
|
|
6
|
+
UseSuspenseInfiniteQueryOptions,
|
|
7
|
+
InfiniteData,
|
|
8
|
+
} from "@tanstack/react-query"
|
|
9
|
+
import {
|
|
10
|
+
FunctionName,
|
|
11
|
+
Reactor,
|
|
12
|
+
TransformKey,
|
|
13
|
+
ReactorArgs,
|
|
14
|
+
ReactorReturnOk,
|
|
15
|
+
ReactorReturnErr,
|
|
16
|
+
} from "@ic-reactor/core"
|
|
17
|
+
import { CallConfig } from "@icp-sdk/core/agent"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parameters for useActorSuspenseInfiniteQuery hook.
|
|
21
|
+
* Extends react-query's UseSuspenseInfiniteQueryOptions with custom reactor params.
|
|
22
|
+
*/
|
|
23
|
+
export interface UseActorSuspenseInfiniteQueryParameters<
|
|
24
|
+
A,
|
|
25
|
+
M extends FunctionName<A>,
|
|
26
|
+
T extends TransformKey = "candid",
|
|
27
|
+
TPageParam = unknown,
|
|
28
|
+
TSelected = InfiniteData<ReactorReturnOk<A, M, T>, TPageParam>,
|
|
29
|
+
> extends Omit<
|
|
30
|
+
UseSuspenseInfiniteQueryOptions<
|
|
31
|
+
ReactorReturnOk<A, M, T>,
|
|
32
|
+
ReactorReturnErr<A, M, T>,
|
|
33
|
+
TSelected,
|
|
34
|
+
QueryKey,
|
|
35
|
+
TPageParam
|
|
36
|
+
>,
|
|
37
|
+
"queryKey" | "queryFn" | "getNextPageParam" | "initialPageParam"
|
|
38
|
+
> {
|
|
39
|
+
/** The reactor instance to use for method calls */
|
|
40
|
+
reactor: Reactor<A, T>
|
|
41
|
+
/** The method name to call on the canister */
|
|
42
|
+
functionName: M
|
|
43
|
+
/** Function to get args from page parameter */
|
|
44
|
+
getArgs: (pageParam: TPageParam) => ReactorArgs<A, M, T>
|
|
45
|
+
/** Agent call configuration (effectiveCanisterId, etc.) */
|
|
46
|
+
callConfig?: CallConfig
|
|
47
|
+
/** Custom query key (auto-generated if not provided) */
|
|
48
|
+
queryKey?: QueryKey
|
|
49
|
+
/** Initial page parameter */
|
|
50
|
+
initialPageParam: TPageParam
|
|
51
|
+
/** Function to determine next page parameter */
|
|
52
|
+
getNextPageParam: (
|
|
53
|
+
lastPage: ReactorReturnOk<A, M, T>,
|
|
54
|
+
allPages: ReactorReturnOk<A, M, T>[],
|
|
55
|
+
lastPageParam: TPageParam,
|
|
56
|
+
allPageParams: TPageParam[]
|
|
57
|
+
) => TPageParam | undefined | null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type UseActorSuspenseInfiniteQueryConfig<
|
|
61
|
+
A,
|
|
62
|
+
M extends FunctionName<A>,
|
|
63
|
+
T extends TransformKey = "candid",
|
|
64
|
+
TPageParam = unknown,
|
|
65
|
+
> = Omit<
|
|
66
|
+
UseActorSuspenseInfiniteQueryParameters<A, M, T, TPageParam>,
|
|
67
|
+
"reactor"
|
|
68
|
+
>
|
|
69
|
+
|
|
70
|
+
export type UseActorSuspenseInfiniteQueryResult<
|
|
71
|
+
A,
|
|
72
|
+
M extends FunctionName<A>,
|
|
73
|
+
T extends TransformKey = "candid",
|
|
74
|
+
TPageParam = unknown,
|
|
75
|
+
> = UseSuspenseInfiniteQueryResult<
|
|
76
|
+
InfiniteData<ReactorReturnOk<A, M, T>, TPageParam>,
|
|
77
|
+
ReactorReturnErr<A, M, T>
|
|
78
|
+
>
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Hook for executing suspense-enabled infinite/paginated query calls on a canister.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* const { data, fetchNextPage, hasNextPage } = useActorSuspenseInfiniteQuery({
|
|
85
|
+
* reactor,
|
|
86
|
+
* functionName: "getItems",
|
|
87
|
+
* getArgs: (pageParam) => [{ offset: pageParam, limit: 10 }],
|
|
88
|
+
* initialPageParam: 0,
|
|
89
|
+
* getNextPageParam: (lastPage) => lastPage.nextOffset,
|
|
90
|
+
* })
|
|
91
|
+
*/
|
|
92
|
+
export const useActorSuspenseInfiniteQuery = <
|
|
93
|
+
A,
|
|
94
|
+
M extends FunctionName<A>,
|
|
95
|
+
T extends TransformKey = "candid",
|
|
96
|
+
TPageParam = unknown,
|
|
97
|
+
>({
|
|
98
|
+
reactor,
|
|
99
|
+
functionName,
|
|
100
|
+
getArgs,
|
|
101
|
+
callConfig,
|
|
102
|
+
queryKey,
|
|
103
|
+
...options
|
|
104
|
+
}: UseActorSuspenseInfiniteQueryParameters<
|
|
105
|
+
A,
|
|
106
|
+
M,
|
|
107
|
+
T,
|
|
108
|
+
TPageParam
|
|
109
|
+
>): UseActorSuspenseInfiniteQueryResult<A, M, T, TPageParam> => {
|
|
110
|
+
// Memoize queryKey to prevent unnecessary re-calculations
|
|
111
|
+
const baseQueryKey = useMemo(
|
|
112
|
+
() => queryKey ?? reactor.generateQueryKey({ functionName }),
|
|
113
|
+
[queryKey, reactor, functionName]
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// Memoize queryFn to prevent recreation on every render
|
|
117
|
+
const queryFn = useCallback(
|
|
118
|
+
async ({ pageParam }: { pageParam: TPageParam }) => {
|
|
119
|
+
const args = getArgs(pageParam)
|
|
120
|
+
return reactor.callMethod({
|
|
121
|
+
functionName,
|
|
122
|
+
args,
|
|
123
|
+
callConfig,
|
|
124
|
+
})
|
|
125
|
+
},
|
|
126
|
+
[reactor, functionName, getArgs, callConfig]
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return useSuspenseInfiniteQuery(
|
|
130
|
+
{
|
|
131
|
+
queryKey: baseQueryKey,
|
|
132
|
+
queryFn,
|
|
133
|
+
...options,
|
|
134
|
+
} as any,
|
|
135
|
+
reactor.queryClient
|
|
136
|
+
) as UseActorSuspenseInfiniteQueryResult<A, M, T, TPageParam>
|
|
137
|
+
}
|