@ic-reactor/react 3.12.4 → 3.13.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.
Files changed (152) hide show
  1. package/README.md +410 -44
  2. package/dist/auth/auth-client-compat.d.ts +122 -0
  3. package/dist/auth/auth-client-compat.d.ts.map +1 -0
  4. package/dist/auth/auth-client-compat.js +162 -0
  5. package/dist/auth/auth-client-compat.js.map +1 -0
  6. package/dist/auth/authentication-manager.d.ts +287 -5
  7. package/dist/auth/authentication-manager.d.ts.map +1 -1
  8. package/dist/auth/authentication-manager.js +920 -150
  9. package/dist/auth/authentication-manager.js.map +1 -1
  10. package/dist/auth/createIdentityAttributeHooks.d.ts.map +1 -1
  11. package/dist/auth/createIdentityAttributeHooks.js +36 -20
  12. package/dist/auth/createIdentityAttributeHooks.js.map +1 -1
  13. package/dist/auth/identity-attributes-manager.d.ts +2 -1
  14. package/dist/auth/identity-attributes-manager.d.ts.map +1 -1
  15. package/dist/auth/identity-attributes-manager.js +90 -6
  16. package/dist/auth/identity-attributes-manager.js.map +1 -1
  17. package/dist/auth/identity-attributes.d.ts.map +1 -1
  18. package/dist/auth/identity-attributes.js +57 -0
  19. package/dist/auth/identity-attributes.js.map +1 -1
  20. package/dist/auth/local-ii-probe.d.ts +12 -1
  21. package/dist/auth/local-ii-probe.d.ts.map +1 -1
  22. package/dist/auth/local-ii-probe.js +22 -3
  23. package/dist/auth/local-ii-probe.js.map +1 -1
  24. package/dist/auth/types.d.ts +48 -5
  25. package/dist/auth/types.d.ts.map +1 -1
  26. package/dist/createActorHooks.d.ts +9 -20
  27. package/dist/createActorHooks.d.ts.map +1 -1
  28. package/dist/createActorHooks.js.map +1 -1
  29. package/dist/createInfiniteQuery.d.ts +51 -10
  30. package/dist/createInfiniteQuery.d.ts.map +1 -1
  31. package/dist/createInfiniteQuery.js +39 -15
  32. package/dist/createInfiniteQuery.js.map +1 -1
  33. package/dist/createMutation.d.ts +4 -1
  34. package/dist/createMutation.d.ts.map +1 -1
  35. package/dist/createMutation.js +121 -84
  36. package/dist/createMutation.js.map +1 -1
  37. package/dist/createQuery.d.ts +35 -2
  38. package/dist/createQuery.d.ts.map +1 -1
  39. package/dist/createQuery.js +104 -17
  40. package/dist/createQuery.js.map +1 -1
  41. package/dist/createReactorProvider.d.ts +158 -0
  42. package/dist/createReactorProvider.d.ts.map +1 -0
  43. package/dist/createReactorProvider.js +256 -0
  44. package/dist/createReactorProvider.js.map +1 -0
  45. package/dist/createSuspenseInfiniteQuery.d.ts +16 -9
  46. package/dist/createSuspenseInfiniteQuery.d.ts.map +1 -1
  47. package/dist/createSuspenseInfiniteQuery.js +59 -27
  48. package/dist/createSuspenseInfiniteQuery.js.map +1 -1
  49. package/dist/createSuspenseQuery.d.ts +23 -2
  50. package/dist/createSuspenseQuery.d.ts.map +1 -1
  51. package/dist/createSuspenseQuery.js +68 -21
  52. package/dist/createSuspenseQuery.js.map +1 -1
  53. package/dist/defineDisplayReactor.d.ts +43 -0
  54. package/dist/defineDisplayReactor.d.ts.map +1 -0
  55. package/dist/defineDisplayReactor.js +42 -0
  56. package/dist/defineDisplayReactor.js.map +1 -0
  57. package/dist/defineReactor.d.ts +46 -72
  58. package/dist/defineReactor.d.ts.map +1 -1
  59. package/dist/defineReactor.js +11 -176
  60. package/dist/defineReactor.js.map +1 -1
  61. package/dist/defineReactorShared.d.ts +84 -0
  62. package/dist/defineReactorShared.d.ts.map +1 -0
  63. package/dist/defineReactorShared.js +139 -0
  64. package/dist/defineReactorShared.js.map +1 -0
  65. package/dist/hooks/createAuthHooks.d.ts +9 -2
  66. package/dist/hooks/createAuthHooks.d.ts.map +1 -1
  67. package/dist/hooks/createAuthHooks.js +184 -24
  68. package/dist/hooks/createAuthHooks.js.map +1 -1
  69. package/dist/hooks/useActorInfiniteQuery.d.ts +36 -8
  70. package/dist/hooks/useActorInfiniteQuery.d.ts.map +1 -1
  71. package/dist/hooks/useActorInfiniteQuery.js +54 -21
  72. package/dist/hooks/useActorInfiniteQuery.js.map +1 -1
  73. package/dist/hooks/useActorMethod.d.ts +37 -4
  74. package/dist/hooks/useActorMethod.d.ts.map +1 -1
  75. package/dist/hooks/useActorMethod.js +201 -57
  76. package/dist/hooks/useActorMethod.js.map +1 -1
  77. package/dist/hooks/useActorMutation.d.ts +15 -12
  78. package/dist/hooks/useActorMutation.d.ts.map +1 -1
  79. package/dist/hooks/useActorMutation.js +14 -13
  80. package/dist/hooks/useActorMutation.js.map +1 -1
  81. package/dist/hooks/useActorQuery.d.ts +17 -4
  82. package/dist/hooks/useActorQuery.d.ts.map +1 -1
  83. package/dist/hooks/useActorQuery.js +30 -9
  84. package/dist/hooks/useActorQuery.js.map +1 -1
  85. package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts +17 -5
  86. package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts.map +1 -1
  87. package/dist/hooks/useActorSuspenseInfiniteQuery.js +37 -17
  88. package/dist/hooks/useActorSuspenseInfiniteQuery.js.map +1 -1
  89. package/dist/hooks/useActorSuspenseQuery.d.ts +2 -2
  90. package/dist/hooks/useActorSuspenseQuery.d.ts.map +1 -1
  91. package/dist/hooks/useActorSuspenseQuery.js +20 -9
  92. package/dist/hooks/useActorSuspenseQuery.js.map +1 -1
  93. package/dist/index.d.ts +4 -0
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +6 -0
  96. package/dist/index.js.map +1 -1
  97. package/dist/ownedAuthentication.d.ts +52 -0
  98. package/dist/ownedAuthentication.d.ts.map +1 -0
  99. package/dist/ownedAuthentication.js +49 -0
  100. package/dist/ownedAuthentication.js.map +1 -0
  101. package/dist/server.d.ts +21 -0
  102. package/dist/server.d.ts.map +1 -0
  103. package/dist/server.js +23 -0
  104. package/dist/server.js.map +1 -0
  105. package/dist/testing.d.ts +19 -0
  106. package/dist/testing.d.ts.map +1 -0
  107. package/dist/testing.js +19 -0
  108. package/dist/testing.js.map +1 -0
  109. package/dist/types.d.ts +428 -21
  110. package/dist/types.d.ts.map +1 -1
  111. package/dist/types.js +1 -1
  112. package/dist/utils.d.ts +159 -3
  113. package/dist/utils.d.ts.map +1 -1
  114. package/dist/utils.js +301 -1
  115. package/dist/utils.js.map +1 -1
  116. package/dist/validation.d.ts +12 -7
  117. package/dist/validation.d.ts.map +1 -1
  118. package/dist/validation.js +34 -15
  119. package/dist/validation.js.map +1 -1
  120. package/llms.txt +259 -33
  121. package/package.json +17 -5
  122. package/src/auth/auth-client-compat.ts +273 -0
  123. package/src/auth/authentication-manager.ts +918 -96
  124. package/src/auth/createIdentityAttributeHooks.ts +47 -21
  125. package/src/auth/identity-attributes-manager.ts +100 -5
  126. package/src/auth/identity-attributes.ts +75 -0
  127. package/src/auth/local-ii-probe.ts +29 -3
  128. package/src/auth/types.ts +49 -6
  129. package/src/createActorHooks.ts +50 -42
  130. package/src/createInfiniteQuery.ts +120 -28
  131. package/src/createMutation.ts +213 -132
  132. package/src/createQuery.ts +164 -32
  133. package/src/createReactorProvider.ts +365 -0
  134. package/src/createSuspenseInfiniteQuery.ts +93 -43
  135. package/src/createSuspenseQuery.ts +102 -32
  136. package/src/defineDisplayReactor.ts +62 -0
  137. package/src/defineReactor.ts +81 -263
  138. package/src/defineReactorShared.ts +268 -0
  139. package/src/hooks/createAuthHooks.ts +210 -28
  140. package/src/hooks/useActorInfiniteQuery.ts +156 -55
  141. package/src/hooks/useActorMethod.ts +295 -92
  142. package/src/hooks/useActorMutation.ts +42 -30
  143. package/src/hooks/useActorQuery.ts +43 -10
  144. package/src/hooks/useActorSuspenseInfiniteQuery.ts +110 -54
  145. package/src/hooks/useActorSuspenseQuery.ts +30 -15
  146. package/src/index.ts +8 -0
  147. package/src/ownedAuthentication.ts +81 -0
  148. package/src/server.ts +23 -0
  149. package/src/testing.ts +18 -0
  150. package/src/types.ts +492 -22
  151. package/src/utils.ts +387 -3
  152. package/src/validation.ts +43 -19
@@ -10,6 +10,13 @@ export interface UseAuthReturn {
10
10
  login: (options?: AuthenticationSignInOptions) => Promise<void>
11
11
  logout: (options?: { returnTo?: string }) => Promise<void>
12
12
  isAuthenticated: boolean
13
+ /**
14
+ * `true` while a sign-in or sign-out is in progress, and until the session
15
+ * restore the first `useAuth()` starts has settled: on the first render, in
16
+ * a server render and while the stored session is read. A restore that fails
17
+ * settles it too. Show a loading state while it is `true` rather than
18
+ * treating `isAuthenticated: false` as signed out.
19
+ */
13
20
  isAuthenticating: boolean
14
21
  /**
15
22
  * The signed-in user's principal, or `null` while signed out. A signed-out
@@ -27,6 +34,31 @@ export interface CreateAuthHooksReturn {
27
34
  useAuth: () => UseAuthReturn
28
35
  }
29
36
 
37
+ /**
38
+ * The managers whose session a `useAuth()` has already started restoring.
39
+ *
40
+ * Restoring ends in `authenticate()`, which publishes `isAuthenticating: true`
41
+ * and then the restored state. It used to run once per mounted `useAuth()`
42
+ * rather than once per manager, so every consumer that mounted while signed out
43
+ * flipped `isAuthenticating` for the whole app, and one that mounted while a
44
+ * sign-in popup was open cleared `isAuthenticating` before the sign-in ended. A
45
+ * component that hides a `useAuth()` consumer while `isAuthenticating` never
46
+ * settled: each restore unmounted the consumer, and each remount restored
47
+ * again. Both clients answer from memory once loaded, so that loop ran on the
48
+ * microtask queue and the page never painted again.
49
+ *
50
+ * Keyed by manager rather than by `createAuthHooks` call, because reactors that
51
+ * share one `AuthenticationManager` each build their own hooks.
52
+ */
53
+ const restoredSessions = new WeakSet<AuthenticationManager>()
54
+
55
+ /**
56
+ * How many `useAuth()` consumers of each manager are mounted, so that a
57
+ * restore can tell a manager that was discarded from one StrictMode released
58
+ * and mounted again; see `useAuth()`.
59
+ */
60
+ const mountedConsumers = new WeakMap<AuthenticationManager, number>()
61
+
30
62
  /**
31
63
  * The principal both hooks return.
32
64
  *
@@ -47,6 +79,31 @@ function usePrincipal(
47
79
  )
48
80
  }
49
81
 
82
+ /**
83
+ * The auth state a server render shows, and so the one hydration shows.
84
+ *
85
+ * `useSyncExternalStore` renders `getServerSnapshot` on the server and again
86
+ * while hydrating, and both renders have to produce the HTML the server sent.
87
+ * The hooks passed the live state for both. A server holds no session, so its
88
+ * HTML is signed out. A component that hydrates after the session is restored,
89
+ * such as one inside a Suspense boundary whose code arrives later, read the
90
+ * restored state and no longer matched that HTML. React then reported a
91
+ * hydration error and threw the boundary's server HTML away to render it again
92
+ * on the client. A constant also keeps a server render from showing whatever a
93
+ * manager shared across requests happens to hold.
94
+ *
95
+ * It is the state the hooks report before the session has been checked: a
96
+ * server cannot know the session, so it renders the "checking" state that a
97
+ * browser shows until its restore settles. Once hydrated, React compares it
98
+ * with the live state and renders again with that.
99
+ */
100
+ const SERVER_AUTH_STATE: AuthState = Object.freeze({
101
+ identity: null,
102
+ isAuthenticating: true,
103
+ isAuthenticated: false,
104
+ error: undefined,
105
+ })
106
+
50
107
  /**
51
108
  * Create authentication hooks for managing user sessions with Internet Identity.
52
109
  *
@@ -57,8 +114,8 @@ function usePrincipal(
57
114
  * const { login, logout, principal, isAuthenticated } = useAuth()
58
115
  *
59
116
  * return isAuthenticated
60
- * ? <button onClick={logout}>Logout {principal?.toText()}</button>
61
- * : <button onClick={login}>Login with II</button>
117
+ * ? <button onClick={() => logout()}>Logout {principal?.toText()}</button>
118
+ * : <button onClick={() => login()}>Login with II</button>
62
119
  * }
63
120
  */
64
121
  export const createAuthHooks = (
@@ -83,16 +140,71 @@ export const createAuthHooks = (
83
140
  }
84
141
 
85
142
  const { clientManager } = authentication
143
+
144
+ // The agent state a manager starts in, for the reason given at
145
+ // SERVER_AUTH_STATE: `useAuth()` initializes the agent once hydrated, so a
146
+ // component hydrating later read `isInitialized: true` against server HTML
147
+ // rendered before any initialization. The network is kept: it comes from the
148
+ // agent's host, which initialization does not change.
149
+ const serverAgentState: AgentState = Object.freeze({
150
+ isInitialized: false,
151
+ isInitializing: false,
152
+ error: undefined,
153
+ network: clientManager.network,
154
+ isLocalhost: clientManager.isLocal,
155
+ })
156
+
157
+ // What every consumer hands `useSyncExternalStore`, built once rather than
158
+ // on each render. React unsubscribes and subscribes again whenever the
159
+ // subscribe function changes, and each unsubscribe filters the manager's
160
+ // whole subscriber list. An auth state change re-renders every consumer, so
161
+ // a function per render cost k re-subscriptions and about k²/2 subscriber
162
+ // visits for k consumers: two million for 2,000 `useUserPrincipal()` rows.
163
+ const subscribeAgentState = (callback: () => void) =>
164
+ clientManager.subscribeAgentState(callback)
165
+ const getAgentState = () => clientManager.agentState
166
+ const getServerAgentState = () => serverAgentState
167
+
168
+ // Until the session has been checked, `authState` is the signed-out state
169
+ // the manager starts in, and reporting it as the answer sent signed-in users
170
+ // to the login page (#621). `useAuth()` starts the check from an effect, so
171
+ // on the first render nothing had been checked, yet `isAuthenticating` was
172
+ // false: a guard that redirects when `!isAuthenticating && !isAuthenticated`
173
+ // redirected before the session was ever read. The hooks report
174
+ // `isAuthenticating: true` until the check settles instead. The snapshot is
175
+ // kept per source state, because `useSyncExternalStore` needs the same object
176
+ // back until something changes.
177
+ let unchecked: { source: AuthState; state: AuthState } | undefined
178
+ const subscribeAuthState = (callback: () => void) => {
179
+ const unsubscribeState = authentication.subscribeAuthState(callback)
180
+ const unsubscribeChecked = authentication.subscribeSessionChecked(callback)
181
+ return () => {
182
+ unsubscribeState()
183
+ unsubscribeChecked()
184
+ }
185
+ }
186
+ const getAuthState = () => {
187
+ const state = authentication.authState
188
+ if (authentication.sessionChecked || state.isAuthenticating) return state
189
+ if (unchecked?.source !== state) {
190
+ unchecked = {
191
+ source: state,
192
+ state: Object.freeze({ ...state, isAuthenticating: true }),
193
+ }
194
+ }
195
+ return unchecked.state
196
+ }
197
+ const getServerAuthState = () => SERVER_AUTH_STATE
198
+
86
199
  /**
87
200
  * Subscribe to agent state changes.
88
201
  * Returns the current agent state (agent, isInitialized, etc.)
89
202
  */
90
203
  const useAgentState = (): AgentState =>
91
204
  useSyncExternalStore(
92
- (callback) => clientManager.subscribeAgentState(callback),
93
- () => clientManager.agentState,
94
- // Server snapshot - provide initial state for SSR
95
- () => clientManager.agentState
205
+ subscribeAgentState,
206
+ getAgentState,
207
+ getServerAgentState
96
208
  )
97
209
 
98
210
  /**
@@ -100,26 +212,26 @@ export const createAuthHooks = (
100
212
  * Returns auth state (isAuthenticated, isAuthenticating, identity, error)
101
213
  */
102
214
  const useAuthState = (): AuthState =>
103
- useSyncExternalStore(
104
- (callback) => authentication.subscribeAuthState(callback),
105
- () => authentication.authState,
106
- // Server snapshot - provide initial state for SSR
107
- () => authentication.authState
108
- )
215
+ useSyncExternalStore(subscribeAuthState, getAuthState, getServerAuthState)
109
216
 
110
217
  /**
111
218
  * Main authentication hook that provides login/logout methods and auth state.
112
219
  * Automatically initializes the session on first use, restoring any previous session.
113
220
  *
221
+ * `isAuthenticating` is `true` until that restore has settled, including on
222
+ * the first render and in a server render, so a guard that shows a spinner
223
+ * while `isAuthenticating` never takes the state before the restore for a
224
+ * signed-out user. A restore that fails settles it too.
225
+ *
114
226
  * @example
115
227
  * function AuthButton() {
116
228
  * const { login, logout, isAuthenticated, isAuthenticating } = useAuth()
117
229
  *
118
230
  * if (isAuthenticated) {
119
- * return <button onClick={logout}>Logout</button>
231
+ * return <button onClick={() => logout()}>Logout</button>
120
232
  * }
121
233
  * return (
122
- * <button onClick={login} disabled={isAuthenticating}>
234
+ * <button onClick={() => login()} disabled={isAuthenticating}>
123
235
  * {isAuthenticating ? "Connecting..." : "Login"}
124
236
  * </button>
125
237
  * )
@@ -130,24 +242,94 @@ export const createAuthHooks = (
130
242
  const { isAuthenticated, isAuthenticating, identity, error } =
131
243
  useAuthState()
132
244
 
133
- // Track if we've already initialized to avoid duplicate calls
245
+ // Keeps a StrictMode re-run of the effect below from repeating it.
134
246
  const initializedRef = useRef(false)
135
247
 
136
- // Auto-initialize on first mount to restore previous session.
137
- // `prepareClient` also warms up the AuthClient so a later `login()` can
138
- // open the identity provider window inside the click handler.
248
+ // Restore the previous session when the first consumer of this manager
249
+ // mounts. `prepareClient` also warms up the AuthClient so a later
250
+ // `login()` can open the identity provider window inside the click handler.
139
251
  useEffect(() => {
140
- if (!initializedRef.current) {
141
- initializedRef.current = true
142
- authentication
143
- .prepareClient()
144
- .catch(() => undefined)
145
- .then(() => clientManager.initialize())
146
- .then(() => authentication.authenticate())
147
- // Failures are already reflected in authState/agentState; without
148
- // this the rejection escapes as an unhandled promise rejection.
149
- .catch(() => undefined)
252
+ mountedConsumers.set(
253
+ authentication,
254
+ (mountedConsumers.get(authentication) ?? 0) + 1
255
+ )
256
+ const unmount = () => {
257
+ mountedConsumers.set(
258
+ authentication,
259
+ (mountedConsumers.get(authentication) ?? 1) - 1
260
+ )
150
261
  }
262
+
263
+ if (initializedRef.current && restoredSessions.has(authentication)) {
264
+ return unmount
265
+ }
266
+ const firstRun = !initializedRef.current
267
+ initializedRef.current = true
268
+
269
+ if (restoredSessions.has(authentication)) {
270
+ // Restored already. A live session is still checked again, which
271
+ // notices a delegation that has lapsed since and publishes nothing
272
+ // while it is valid.
273
+ if (firstRun && authentication.authState.isAuthenticated) {
274
+ authentication.authenticate().catch(() => undefined)
275
+ }
276
+ return unmount
277
+ }
278
+ restoredSessions.add(authentication)
279
+
280
+ // A provider that builds its managers per mount disposes the manager
281
+ // from its cleanup. One that unmounted before this restore had built
282
+ // the client still got one, built after `dispose()` and never released,
283
+ // so each quick remount left a live client listening to the page. The
284
+ // restore stops instead once the manager has been disposed since it
285
+ // began and no `useAuth()` of it is mounted, releasing what it built.
286
+ // StrictMode's cleanup disposes the manager too, but mounts this
287
+ // consumer again before the restore looks, and that restore goes on.
288
+ // One that stopped counts as not having run, so a manager mounted again,
289
+ // as `<Activity>` mounts a tree it showed again, restores.
290
+ const releases = authentication.releaseCount
291
+ const abandoned = () => {
292
+ if (
293
+ authentication.releaseCount === releases ||
294
+ (mountedConsumers.get(authentication) ?? 0) > 0
295
+ ) {
296
+ return false
297
+ }
298
+ authentication.dispose()
299
+ restoredSessions.delete(authentication)
300
+ return true
301
+ }
302
+
303
+ void authentication
304
+ .prepareClient()
305
+ .catch(() => undefined)
306
+ .then(async () => {
307
+ if (abandoned()) return false
308
+ await clientManager.initialize()
309
+ if (abandoned()) return false
310
+ // A check that has settled already and found no session, such as
311
+ // the one a manager runs over a client handed to its constructor,
312
+ // or a route loader's `authenticate()`, answered what this restore
313
+ // would. Running it again only flashed `isAuthenticating`, and a
314
+ // guard redirected a second time. A live session is still checked,
315
+ // which publishes nothing while it is valid.
316
+ const { isAuthenticated, error } = authentication.authState
317
+ if (authentication.sessionChecked && !isAuthenticated && !error) {
318
+ return true
319
+ }
320
+ await authentication.authenticate().catch(() => undefined)
321
+ return !abandoned()
322
+ })
323
+ // Failures are already reflected in authState/agentState; without
324
+ // this the rejection escapes as an unhandled promise rejection. A
325
+ // restore that failed before it reached `authenticate()`, such as a
326
+ // root key that could not be fetched, has settled all the same.
327
+ .catch(() => true)
328
+ .then((settled) => {
329
+ if (settled) authentication.markSessionChecked()
330
+ })
331
+
332
+ return unmount
151
333
  }, [])
152
334
 
153
335
  const principal = usePrincipal(isAuthenticated, identity)
@@ -1,10 +1,12 @@
1
- import { useMemo, useCallback } from "react"
1
+ import { useMemo } from "react"
2
2
  import {
3
3
  QueryKey,
4
4
  useInfiniteQuery,
5
+ skipToken,
5
6
  UseInfiniteQueryResult,
6
7
  UseInfiniteQueryOptions,
7
8
  InfiniteData,
9
+ type SkipToken,
8
10
  } from "@tanstack/react-query"
9
11
  import {
10
12
  FunctionName,
@@ -16,7 +18,14 @@ import {
16
18
  ReactorReturnErr,
17
19
  } from "@ic-reactor/core"
18
20
  import { CallConfig } from "@icp-sdk/core/agent"
19
- import { mergeFactoryQueryKey, normalizeQueryData } from "../utils.js"
21
+ import {
22
+ callConfigForKey,
23
+ mergeFactoryQueryKey,
24
+ normalizeQueryData,
25
+ retryOption,
26
+ skippedQueryKey,
27
+ useMountQueryClient,
28
+ } from "../utils.js"
20
29
 
21
30
  /**
22
31
  * Parameters for useActorInfiniteQuery hook.
@@ -45,8 +54,27 @@ export interface UseActorInfiniteQueryParameters<
45
54
  reactor: Reactor<Service, Transform>
46
55
  /** The method name to call on the canister */
47
56
  functionName: Method
48
- /** Function to get args from page parameter */
49
- getArgs: (pageParam: TPageParam) => ReactorArgs<Service, Method, Transform>
57
+ /**
58
+ * Function to get args from page parameter, or TanStack Query's `skipToken`
59
+ * while the args are not known: the query then waits without fetching, in
60
+ * an entry of its own under its method and `queryKey`, which no call's key
61
+ * shares, so it shows no data until the args arrive.
62
+ */
63
+ getArgs:
64
+ | ((pageParam: TPageParam) => ReactorArgs<Service, Method, Transform>)
65
+ | SkipToken
66
+ /**
67
+ * Narrows what the cache key derives from the call arguments.
68
+ *
69
+ * By default the key is scoped by `getArgs(initialPageParam)`, so two
70
+ * infinite queries on the same method with different arguments stay in
71
+ * separate cache entries. Supply this when those args embed the cursor and
72
+ * only part of them identifies the query — return the stable, serializable
73
+ * portion (typically everything except the pagination field). It is required
74
+ * when `initialPageParam` changes between renders, as `Date.now()` does:
75
+ * without it every render keys a new query.
76
+ */
77
+ getKeyArgs?: (args: ReactorArgs<Service, Method, Transform>) => unknown
50
78
  /** Agent call configuration (effectiveCanisterId, etc.) */
51
79
  callConfig?: CallConfig
52
80
  /** Custom query key (auto-generated if not provided) */
@@ -67,8 +95,18 @@ export type UseActorInfiniteQueryConfig<
67
95
  Method extends FunctionName<Service>,
68
96
  Transform extends TransformKey = "candid",
69
97
  TPageParam = unknown,
98
+ Selected = InfiniteData<
99
+ ReactorQueryData<ReactorReturnOk<Service, Method, Transform>>,
100
+ TPageParam
101
+ >,
70
102
  > = Omit<
71
- UseActorInfiniteQueryParameters<Service, Method, Transform, TPageParam>,
103
+ UseActorInfiniteQueryParameters<
104
+ Service,
105
+ Method,
106
+ Transform,
107
+ TPageParam,
108
+ Selected
109
+ >,
72
110
  "reactor"
73
111
  >
74
112
 
@@ -77,11 +115,12 @@ export type UseActorInfiniteQueryResult<
77
115
  Method extends FunctionName<Service>,
78
116
  Transform extends TransformKey = "candid",
79
117
  TPageParam = unknown,
80
- > = UseInfiniteQueryResult<
81
- InfiniteData<
118
+ Selected = InfiniteData<
82
119
  ReactorQueryData<ReactorReturnOk<Service, Method, Transform>>,
83
120
  TPageParam
84
121
  >,
122
+ > = UseInfiniteQueryResult<
123
+ Selected,
85
124
  ReactorReturnErr<Service, Method, Transform>
86
125
  >
87
126
 
@@ -89,10 +128,21 @@ export type UseActorInfiniteQueryResult<
89
128
  * Hook for executing infinite/paginated query calls on a canister.
90
129
  *
91
130
  * @example
92
- * const { data, fetchNextPage, hasNextPage } = useActorInfiniteQuery({
131
+ * const { data, fetchNextPage, hasNextPage } = useReactorInfiniteQuery({
93
132
  * reactor,
94
133
  * functionName: "getItems",
95
- * getArgs: (pageParam) => [{ offset: pageParam, limit: 10 }],
134
+ * getArgs: (pageParam) => [{ offset: pageParam, limit: 10 }] as const,
135
+ * initialPageParam: 0,
136
+ * getNextPageParam: (lastPage) => lastPage.nextOffset,
137
+ * })
138
+ *
139
+ * // Wait for the args: no fetch until userId is known
140
+ * const { data } = useReactorInfiniteQuery({
141
+ * reactor,
142
+ * functionName: "getUserItems",
143
+ * getArgs: userId
144
+ * ? (pageParam) => [{ userId, offset: pageParam, limit: 10 }] as const
145
+ * : skipToken,
96
146
  * initialPageParam: 0,
97
147
  * getNextPageParam: (lastPage) => lastPage.nextOffset,
98
148
  * })
@@ -102,10 +152,15 @@ export const useActorInfiniteQuery = <
102
152
  Method extends FunctionName<Service>,
103
153
  Transform extends TransformKey = "candid",
104
154
  TPageParam = unknown,
155
+ Selected = InfiniteData<
156
+ ReactorQueryData<ReactorReturnOk<Service, Method, Transform>>,
157
+ TPageParam
158
+ >,
105
159
  >({
106
160
  reactor,
107
161
  functionName,
108
162
  getArgs,
163
+ getKeyArgs,
109
164
  callConfig,
110
165
  queryKey,
111
166
  ...options
@@ -113,65 +168,111 @@ export const useActorInfiniteQuery = <
113
168
  Service,
114
169
  Method,
115
170
  Transform,
116
- TPageParam
117
- >): UseActorInfiniteQueryResult<Service, Method, Transform, TPageParam> => {
171
+ TPageParam,
172
+ Selected
173
+ >): UseActorInfiniteQueryResult<
174
+ Service,
175
+ Method,
176
+ Transform,
177
+ TPageParam,
178
+ Selected
179
+ > => {
180
+ useMountQueryClient(reactor.queryClient)
181
+
118
182
  // Always pass queryKey through generateQueryKey so it is merged with the
119
183
  // reactor/function identity. Using the custom key verbatim would cause cache
120
184
  // collisions if two different actors or methods share the same key string.
121
- const baseQueryKey = useMemo(
122
- () =>
123
- reactor.generateQueryKey(
124
- {
125
- functionName,
126
- // Fold the call arguments into the key. They live in the `getArgs`
127
- // closure rather than in the config, so without this two hooks on the
128
- // same method with different arguments share one cache entry and
129
- // serve each other's pages.
130
- queryKey: mergeFactoryQueryKey(
131
- queryKey,
132
- undefined,
133
- getArgs(options.initialPageParam)
134
- ),
135
- },
136
- callConfig
137
- ),
138
- [
139
- queryKey,
140
- reactor,
141
- // `canisterId` is mutable reactor state that `setCanisterId` can change,
142
- // while `reactor` itself stays the same object — so it has to be a
143
- // dependency in its own right or the key stays pinned to the old canister
144
- // while the queryFn already calls the new one.
145
- reactor.canisterId?.toString(),
146
- functionName,
147
- callConfig,
148
- getArgs,
149
- options.initialPageParam,
150
- ]
151
- )
185
+ const baseQueryKey = useMemo(() => {
186
+ // Waiting for its args: an entry of its own under the method and the
187
+ // custom key, which every key the args will give extends; see
188
+ // skippedQueryKey.
189
+ if (getArgs === skipToken) {
190
+ return skippedQueryKey(
191
+ reactor.generateQueryKey(
192
+ { functionName, queryKey: mergeFactoryQueryKey(queryKey) },
193
+ callConfig
194
+ ),
195
+ "infinite"
196
+ )
197
+ }
198
+ // Fold the call arguments into the key. They live in the `getArgs`
199
+ // closure rather than in the config, so without this two hooks on the
200
+ // same method with different arguments share one cache entry and serve
201
+ // each other's pages. `getKeyArgs` narrows them exactly as it does for the
202
+ // factories, which the bound hook's config type always accepted: it used
203
+ // to be ignored here, so the cursor stayed in the key, and an
204
+ // `initialPageParam` that changes every render (`Date.now()`) keyed a new
205
+ // query on every render — an endless loop of first-page fetches.
206
+ const initialArgs = getArgs(options.initialPageParam)
207
+ const keyArgs = getKeyArgs?.(initialArgs) ?? initialArgs
152
208
 
153
- // Memoize queryFn to prevent recreation on every render
154
- const queryFn = useCallback(
155
- async ({ pageParam }: { pageParam: TPageParam }) => {
156
- const args = getArgs(pageParam)
157
- const result = await reactor.callMethod({
209
+ return reactor.generateQueryKey(
210
+ {
158
211
  functionName,
159
- args,
160
- callConfig,
161
- })
162
- return normalizeQueryData<ReactorReturnOk<Service, Method, Transform>>(
163
- result as ReactorReturnOk<Service, Method, Transform>
164
- )
165
- },
212
+ queryKey: mergeFactoryQueryKey(queryKey, undefined, keyArgs),
213
+ },
214
+ callConfig
215
+ )
216
+ }, [
217
+ queryKey,
218
+ reactor,
219
+ // `canisterId` is mutable reactor state that `setCanisterId` can change,
220
+ // while `reactor` itself stays the same object — so it has to be a
221
+ // dependency in its own right or the key stays pinned to the old canister
222
+ // while the queryFn already calls the new one.
223
+ reactor.canisterId?.toString(),
224
+ functionName,
225
+ callConfig,
226
+ getArgs,
227
+ getKeyArgs,
228
+ options.initialPageParam,
229
+ ])
230
+
231
+ // Memoize queryFn to prevent recreation on every render
232
+ const queryFn = useMemo(
233
+ () =>
234
+ getArgs === skipToken
235
+ ? skipToken
236
+ : async ({
237
+ pageParam,
238
+ queryKey: fetchedKey,
239
+ }: {
240
+ pageParam: TPageParam
241
+ queryKey: QueryKey
242
+ }) => {
243
+ const args = getArgs(pageParam)
244
+ const result = await reactor.callMethod({
245
+ functionName,
246
+ args,
247
+ callConfig: callConfigForKey(fetchedKey, callConfig),
248
+ })
249
+ return normalizeQueryData<
250
+ ReactorReturnOk<Service, Method, Transform>
251
+ >(result as ReactorReturnOk<Service, Method, Transform>)
252
+ },
166
253
  [reactor, functionName, getArgs, callConfig]
167
254
  )
168
255
 
256
+ // The method's default `retry`: for an update method, only failures that
257
+ // prove the canister never ran the call; see `Reactor.getQueryRetry`.
258
+ const defaultRetry = useMemo(
259
+ () => reactor.getQueryRetry(functionName, baseQueryKey),
260
+ [reactor, functionName, baseQueryKey]
261
+ )
262
+
169
263
  return useInfiniteQuery(
170
264
  {
171
265
  queryKey: baseQueryKey,
172
266
  queryFn,
173
267
  ...options,
268
+ ...retryOption(options.retry, defaultRetry),
174
269
  } as any,
175
270
  reactor.queryClient
176
- ) as UseActorInfiniteQueryResult<Service, Method, Transform, TPageParam>
271
+ ) as UseActorInfiniteQueryResult<
272
+ Service,
273
+ Method,
274
+ Transform,
275
+ TPageParam,
276
+ Selected
277
+ >
177
278
  }