@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
@@ -1,7 +1,14 @@
1
- import { useCallback, useEffect, useMemo, useRef } from "react"
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useInsertionEffect,
5
+ useMemo,
6
+ useRef,
7
+ } from "react"
2
8
  import {
3
9
  useQuery,
4
10
  useMutation,
11
+ hashKey,
5
12
  type UseQueryResult,
6
13
  type UseMutationResult,
7
14
  type QueryKey,
@@ -19,7 +26,13 @@ import {
19
26
  FunctionType,
20
27
  } from "@ic-reactor/core"
21
28
  import { CallConfig } from "@icp-sdk/core/agent"
22
- import { normalizeQueryData } from "../utils.js"
29
+ import {
30
+ invalidateTargets,
31
+ normalizeQueryData,
32
+ pickFetchOptions,
33
+ useMountQueryClient,
34
+ } from "../utils.js"
35
+ import type { InvalidationTarget } from "../types.js"
23
36
 
24
37
  /**
25
38
  * Configuration for useActorMethod hook.
@@ -28,6 +41,13 @@ import { normalizeQueryData } from "../utils.js"
28
41
  * This is a unified hook that handles both query and mutation methods.
29
42
  * Query-specific options (like refetchInterval) only apply to query methods.
30
43
  * Mutation-specific options (like invalidateQueries) only apply to mutation methods.
44
+ *
45
+ * `retry`, `retryDelay`, `networkMode` and `meta` decide how a call runs, so
46
+ * they apply to both. An update method's call is a mutation: without a `retry`
47
+ * here it follows the QueryClient's mutation defaults, which retry nothing
48
+ * unless the app set `mutations.retry`, because each attempt runs the update
49
+ * on the canister again. Its query defaults, such as `reactorRetry`, do not
50
+ * apply to it.
31
51
  */
32
52
  export interface UseActorMethodParameters<
33
53
  Service = BaseActor,
@@ -76,10 +96,19 @@ export interface UseActorMethodParameters<
76
96
  onError?: (error: ReactorReturnErr<Service, Method, Transform>) => void
77
97
 
78
98
  /**
79
- * Query keys to invalidate after a successful mutation.
99
+ * Queries to invalidate after a successful mutation.
80
100
  * Only applies to mutation methods (updates).
101
+ *
102
+ * Each entry is a query key, a query object or query factory, or a
103
+ * `{ functionName, args? }` method of `reactor`; see
104
+ * {@link InvalidationTarget}. `undefined` entries are skipped.
105
+ *
106
+ * The invalidation is awaited before `onSuccess` runs, as in
107
+ * `useActorMutation` and `createMutation`, so `onSuccess` sees the
108
+ * refetched data, and `call()` resolves once the invalidated queries in use
109
+ * have refetched.
81
110
  */
82
- invalidateQueries?: QueryKey[]
111
+ invalidateQueries?: InvalidationTarget<Service, Transform>[]
83
112
  }
84
113
 
85
114
  /**
@@ -103,8 +132,9 @@ export interface UseActorMethodResult<
103
132
  > {
104
133
  /**
105
134
  * The returned data from the method call. A method's `undefined` result is
106
- * `null` here, as in `useActorQuery`, so `undefined` only means no call has
107
- * settled yet.
135
+ * `null` here, as in `useActorQuery`, so `undefined` means that no call has
136
+ * settled yet, that the last call failed, or that an update call is
137
+ * running. A query keeps its earlier data when a refetch fails.
108
138
  */
109
139
  data:
110
140
  ReactorQueryData<ReactorReturnOk<Service, Method, Transform>> | undefined
@@ -134,6 +164,15 @@ export interface UseActorMethodResult<
134
164
  * Call the method with optional arguments.
135
165
  * For queries: triggers a refetch
136
166
  * For mutations: executes the mutation with the provided args
167
+ *
168
+ * For a query method, `call(args)` fetches those args from the canister
169
+ * even when they are cached, and reports the result to `onSuccess` or
170
+ * `onError` itself. `call()` refetches the hook's own args, as `refetch()`
171
+ * does. Either resolves `undefined` when its fetch fails. A sign-in or
172
+ * sign-out while either is in flight cancels the fetch, which then runs
173
+ * again for the principal signed in, so the call resolves with that
174
+ * principal's answer rather than the previous one's; see
175
+ * `ClientManager.fetchAcrossIdentitySwitch`.
137
176
  */
138
177
  call: (
139
178
  args?: ReactorArgs<Service, Method, Transform>
@@ -153,6 +192,12 @@ export interface UseActorMethodResult<
153
192
 
154
193
  /**
155
194
  * For queries only: Refetch the query
195
+ *
196
+ * Resolves with the answer, or `undefined` when the refetch fails, whose
197
+ * error the hook reports to `onError`; `data` keeps the last answer. A
198
+ * sign-in or sign-out while it is in flight makes it refetch for the
199
+ * principal signed in, and it resolves with that principal's answer, as
200
+ * `call()` does.
156
201
  */
157
202
  refetch: () => Promise<
158
203
  ReactorQueryData<ReactorReturnOk<Service, Method, Transform>> | undefined
@@ -173,6 +218,29 @@ export interface UseActorMethodResult<
173
218
  >
174
219
  }
175
220
 
221
+ /**
222
+ * A ref to `value` as of the last committed render, for code that runs outside
223
+ * render: event handlers, timers, effects and TanStack callbacks.
224
+ *
225
+ * Assigning the ref during render would also publish renders that React throws
226
+ * away, such as a transition that suspends or one that a more urgent update
227
+ * interrupts. The tree still on screen would then act on props it never showed,
228
+ * like calling a method it does not render. An insertion effect runs only when
229
+ * its render commits, and before every layout and passive effect of that
230
+ * commit, so no effect anywhere in the tree reads the previous commit's value.
231
+ * React's own `useEffectEvent` also swaps in its new function during the
232
+ * commit, ahead of layout effects. Unlike `useLayoutEffect`, an insertion
233
+ * effect does not warn in a React 18 server render. The initial value serves
234
+ * anything that runs before the first commit.
235
+ */
236
+ function useCommittedRef<T>(value: T): { readonly current: T } {
237
+ const ref = useRef(value)
238
+ useInsertionEffect(() => {
239
+ ref.current = value
240
+ })
241
+ return ref
242
+ }
243
+
176
244
  /**
177
245
  * A unified hook for calling canister methods that automatically handles
178
246
  * both query and mutation methods based on the Candid interface.
@@ -208,12 +276,13 @@ export function useActorMethod<
208
276
 
209
277
  const functionType: FunctionType = isQuery ? "query" : "update"
210
278
 
211
- // Latest callbacks, read at dispatch time: this keeps a rerendered closure
212
- // from being ignored, and keeps callback identity out of the effect deps.
213
- const onSuccessRef = useRef(onSuccess)
214
- const onErrorRef = useRef(onError)
215
- onSuccessRef.current = onSuccess
216
- onErrorRef.current = onError
279
+ useMountQueryClient(reactor.queryClient)
280
+
281
+ // The committed render's callbacks, read at dispatch time: this keeps a
282
+ // rerendered closure from being ignored, and keeps callback identity out of
283
+ // the effect deps.
284
+ const onSuccessRef = useCommittedRef(onSuccess)
285
+ const onErrorRef = useCommittedRef(onError)
217
286
 
218
287
  // Build the key for a given set of call arguments.
219
288
  //
@@ -284,15 +353,31 @@ export function useActorMethod<
284
353
  const notifiedSuccessAt = useRef<number | undefined>(undefined)
285
354
  const notifiedErrorAt = useRef<number | undefined>(undefined)
286
355
 
287
- const { status, data, error, dataUpdatedAt, errorUpdatedAt } = queryResult
356
+ const {
357
+ status,
358
+ data,
359
+ error,
360
+ dataUpdatedAt,
361
+ errorUpdatedAt,
362
+ isPlaceholderData,
363
+ isFetched,
364
+ } = queryResult
288
365
 
289
366
  useEffect(() => {
290
367
  if (!isQuery) return
368
+ // Placeholder data also has `status: "success"`, but no call has returned
369
+ // it: it is the `placeholderData` option, or with `keepPreviousData` the
370
+ // previous args' result standing in while this call is in flight.
371
+ if (isPlaceholderData) return
372
+ // So does `initialData`: an entry starts with it, and `reset()` puts it
373
+ // back, before any fetch of the entry has settled. The first fetch that
374
+ // settles makes the entry fetched, and its result is reported below.
375
+ if (!isFetched) return
291
376
  if (status === "success" && dataUpdatedAt !== notifiedSuccessAt.current) {
292
377
  notifiedSuccessAt.current = dataUpdatedAt
293
378
  onSuccessRef.current?.(data)
294
379
  }
295
- }, [isQuery, status, data, dataUpdatedAt])
380
+ }, [isQuery, status, data, dataUpdatedAt, isPlaceholderData, isFetched])
296
381
 
297
382
  useEffect(() => {
298
383
  if (!isQuery) return
@@ -304,6 +389,50 @@ export function useActorMethod<
304
389
  }
305
390
  }, [isQuery, status, error, errorUpdatedAt])
306
391
 
392
+ // `call(args)` reports its own outcome, once per call, because the args it
393
+ // fetched usually key an entry this observer does not watch. When they key
394
+ // the entry it does watch, the effects above see the same settle, and each
395
+ // callback used to fire twice for one call. So the settle that ends a call's
396
+ // fetch is marked as notified when it lands in the observed entry, and the
397
+ // effects skip it.
398
+ //
399
+ // A query cache listener sets that mark while TanStack dispatches the
400
+ // settle, before any render can show it. Matching a settle up by its
401
+ // timestamp afterwards could not tell settles apart: two in one millisecond
402
+ // share a timestamp. And a call that an identity switch cancels settles
403
+ // nothing: TanStack reverts the entry to its previous result, timestamp
404
+ // included, so the call's outcome looked reported already, and neither the
405
+ // call nor the effects reported it.
406
+ const observedKeyRef = useCommittedRef(queryKey)
407
+ const markCallSettle = (calledKey: QueryKey): (() => void) => {
408
+ const { queryClient } = reactor
409
+ // The hash TanStack files the called entry under, worked out the way
410
+ // `fetchQuery` does. The listener compares it with hashes TanStack has
411
+ // already computed rather than hashing keys itself: the QueryClient may be
412
+ // shared with the app, whose keys a custom `queryKeyHashFn` can let hold
413
+ // values that `hashKey` throws on, inside TanStack's dispatch.
414
+ const calledHash = queryClient.defaultQueryOptions({
415
+ queryKey: calledKey,
416
+ }).queryHash
417
+ let settled = false
418
+ return queryClient.getQueryCache().subscribe((event) => {
419
+ if (settled || event.type !== "updated") return
420
+ const { action, query } = event
421
+ // A fetch settles with one of these. `setQueryData` dispatches a
422
+ // success too, flagged manual, and it does not end this call's fetch.
423
+ const isSettle =
424
+ action.type === "error" || (action.type === "success" && !action.manual)
425
+ if (!isSettle || query.queryHash !== calledHash) return
426
+ settled = true
427
+ if (hashKey(calledKey) !== hashKey(observedKeyRef.current)) return
428
+ if (action.type === "success") {
429
+ notifiedSuccessAt.current = query.state.dataUpdatedAt
430
+ } else {
431
+ notifiedErrorAt.current = query.state.errorUpdatedAt
432
+ }
433
+ })
434
+ }
435
+
307
436
  // ============================================================================
308
437
  // Mutation Implementation
309
438
  // ============================================================================
@@ -315,6 +444,16 @@ export function useActorMethod<
315
444
  >(
316
445
  {
317
446
  mutationKey: queryKey,
447
+ // The hook's `retry`, `retryDelay`, `networkMode` and `meta`, the options
448
+ // that decide how a call runs, as the query branch's `call()` applies
449
+ // them. They reached only the query branch, so an update's call ignored
450
+ // them: it stayed paused offline under `networkMode: "always"` and
451
+ // reached the MutationCache callbacks without the hook's `meta`. Unset
452
+ // ones are left to the QueryClient's mutation defaults, which retry
453
+ // nothing unless the app set `mutations.retry`: each attempt of an
454
+ // update is a new call the canister runs, so only a `retry` given here
455
+ // or in those defaults sends one again.
456
+ ...pickFetchOptions(queryOptions),
318
457
  // Normalized like the query branch, so `data`, `call()` and `onSuccess`
319
458
  // mean the same thing for both kinds of method. The hook cannot type the
320
459
  // two branches apart: a Candid service type does not say which methods
@@ -327,14 +466,16 @@ export function useActorMethod<
327
466
  callConfig,
328
467
  })) as TData
329
468
  ),
330
- onSuccess: (data) => {
469
+ // Invalidation first, then `onSuccess`, as `useActorMutation` and
470
+ // `createMutation` do. TanStack Query waits for the promise returned
471
+ // here before it settles the mutation, so `call()` resolves, and
472
+ // `isPending` turns false, once the invalidated queries that are in use
473
+ // have refetched, and `onSuccess` reads the refetched data. A refetch
474
+ // that fails does not reject `invalidateQueries`, so it cannot turn the
475
+ // update, which has already run on the canister, into a failure.
476
+ onSuccess: async (data) => {
477
+ await invalidateTargets(reactor, invalidateQueries, callConfig)
331
478
  onSuccessRef.current?.(data)
332
- // Invalidate specified queries after successful mutation
333
- if (invalidateQueries && invalidateQueries.length > 0) {
334
- invalidateQueries.forEach((key) => {
335
- void reactor.queryClient.invalidateQueries({ queryKey: key })
336
- })
337
- }
338
479
  },
339
480
  onError: (error) => {
340
481
  onErrorRef.current?.(error)
@@ -344,75 +485,126 @@ export function useActorMethod<
344
485
  )
345
486
 
346
487
  // ============================================================================
347
- // Unified Call Function
488
+ // Refetch and Call Functions
348
489
  // ============================================================================
349
490
 
350
- const call = useCallback(
351
- async (
352
- callArgs?: ReactorArgs<Service, Method, Transform>
353
- ): Promise<TQueryData | undefined> => {
354
- if (isQuery) {
355
- // For queries, refetch with new args if provided
356
- if (callArgs !== undefined) {
357
- // Key on the args actually being called. Reusing the hook's
358
- // mount-time key would store this result under the previous args'
359
- // entry — poisoning it for every other reader — and let fetchQuery
360
- // dedupe onto an in-flight request for the old args, returning that
361
- // response as though it answered this one.
362
- try {
363
- const result = await reactor.queryClient.fetchQuery<TQueryData>({
364
- queryKey: buildQueryKey(callArgs),
365
- // Normalize for the same reason as the observer's queryFn.
366
- queryFn: async () =>
367
- normalizeQueryData<TData>(
368
- (await reactor.callMethod({
369
- functionName,
370
- args: callArgs,
371
- callConfig,
372
- })) as TData
373
- ),
374
- staleTime: 0,
375
- })
376
- // Dispatched here rather than by the observer effect: this result
377
- // lands under the called args' key, which the mounted observer (bound
378
- // to the hook's own args) does not watch. That separation is also why
379
- // this can no longer double-fire the way it did when both wrote to
380
- // the same key.
381
- onSuccessRef.current?.(result)
382
- return result
383
- } catch (error) {
384
- onErrorRef.current?.(
385
- error as ReactorReturnErr<Service, Method, Transform>
386
- )
387
- return undefined
388
- }
491
+ // A query's `call()` and `refetch()` resolve with the answer for the
492
+ // principal signed in when they settle. `ClientManager.updateAgent` cancels
493
+ // a fetch in flight when a sign-in or sign-out switches the principal, so
494
+ // that the previous principal's answer is never cached. TanStack then
495
+ // resolves the fetch with the data it puts the entry back to, which is the
496
+ // previous principal's, or rejects it with a `CancelledError` when the entry
497
+ // had none, and a call used to resolve with, and report, whichever it got.
498
+ // Through `fetchAcrossIdentitySwitch` it runs again for the principal signed
499
+ // in, as `reactor.fetchQuery()` and the factories' `fetch()` do. When the
500
+ // principal switches during that run too, and during each of two more, that
501
+ // rejects with a `CallError`, which is reported to `onError` like any other
502
+ // failure.
503
+
504
+ const refetchLatest = async (): Promise<TQueryData | undefined> => {
505
+ if (!isQuery) return undefined
506
+ let runs = 0
507
+ try {
508
+ const result = await reactor.clientManager.fetchAcrossIdentitySwitch(() =>
509
+ // A run after a switch joins the refetch the switch started for
510
+ // this entry, rather than cancel it and start another.
511
+ queryResult.refetch(runs++ === 0 ? undefined : { cancelRefetch: false })
512
+ )
513
+ // TanStack's `refetch()` resolves even when the fetch fails, with the
514
+ // entry's last answer still in the result. After a sign-in or sign-out
515
+ // that is the previous principal's: the entry is put back to it when
516
+ // the switch cancels a fetch, and keeps it when the new principal's
517
+ // refetch fails. So a failed refetch resolves `undefined`, as a failed
518
+ // `call(args)` does, and the effects report the failure to `onError`.
519
+ // The hook's `data` keeps the last answer, as TanStack's does.
520
+ return result.isError ? undefined : result.data
521
+ } catch (error) {
522
+ // Only that CallError: `refetch()` itself never rejects. The effects
523
+ // report what the entry settles with, and it settles nothing for this.
524
+ onErrorRef.current?.(
525
+ error as ReactorReturnErr<Service, Method, Transform>
526
+ )
527
+ return undefined
528
+ }
529
+ }
530
+
531
+ const callLatest = async (
532
+ callArgs?: ReactorArgs<Service, Method, Transform>
533
+ ): Promise<TQueryData | undefined> => {
534
+ if (isQuery) {
535
+ // For queries, refetch with new args if provided
536
+ if (callArgs !== undefined) {
537
+ // Key on the args actually being called. Reusing the hook's
538
+ // mount-time key would store this result under the previous args'
539
+ // entry — poisoning it for every other reader — and let fetchQuery
540
+ // dedupe onto an in-flight request for the old args, returning that
541
+ // response as though it answered this one.
542
+ const calledKey = buildQueryKey(callArgs)
543
+ // Reported here rather than by the observer effects: this result
544
+ // usually lands under a key the mounted observer (bound to the hook's
545
+ // own args) does not watch. When it is that key, `markCallSettle` has
546
+ // already kept the effects from reporting it as well. Either way the
547
+ // callbacks get what the call settles with.
548
+ try {
549
+ const result = await reactor.clientManager.fetchAcrossIdentitySwitch(
550
+ async () => {
551
+ // Each run marks the settle of its own fetch. A run a switch
552
+ // overtook may have settled with the previous principal's answer,
553
+ // which the effects must not report either.
554
+ const stopMarking = markCallSettle(calledKey)
555
+ try {
556
+ return await reactor.queryClient.fetchQuery<
557
+ TQueryData,
558
+ ReactorReturnErr<Service, Method, Transform>
559
+ >({
560
+ // The options the hook's own fetches run with: `retry`,
561
+ // `retryDelay`, `networkMode` and `meta`. With only a key and
562
+ // a function, a call ran on the QueryClient's defaults. It
563
+ // failed on the first error the hook retried through, stayed
564
+ // paused offline under `networkMode: "always"`, and reached
565
+ // the QueryCache callbacks without the hook's `meta`.
566
+ ...pickFetchOptions(queryOptions),
567
+ queryKey: calledKey,
568
+ // Normalize for the same reason as the observer's queryFn.
569
+ queryFn: async () =>
570
+ normalizeQueryData<TData>(
571
+ (await reactor.callMethod({
572
+ functionName,
573
+ args: callArgs,
574
+ callConfig,
575
+ })) as TData
576
+ ),
577
+ staleTime: 0,
578
+ })
579
+ } finally {
580
+ stopMarking()
581
+ }
582
+ }
583
+ )
584
+ onSuccessRef.current?.(result)
585
+ return result
586
+ } catch (error) {
587
+ onErrorRef.current?.(
588
+ error as ReactorReturnErr<Service, Method, Transform>
589
+ )
590
+ return undefined
389
591
  }
390
- // Otherwise just refetch
391
- const { data } = await queryResult.refetch()
392
- return data
393
- } else {
394
- // For mutations, execute with provided args
395
- return mutationResult
396
- .mutateAsync(callArgs as ReactorArgs<Service, Method, Transform>)
397
- .catch(() => undefined)
398
592
  }
399
- },
400
- [
401
- isQuery,
402
- reactor,
403
- functionName,
404
- callConfig,
405
- buildQueryKey,
406
- queryResult,
407
- mutationResult,
408
- ]
409
- )
593
+ // Otherwise just refetch
594
+ return refetchLatest()
595
+ } else {
596
+ // For mutations, execute with provided args
597
+ return mutationResult
598
+ .mutateAsync(callArgs as ReactorArgs<Service, Method, Transform>)
599
+ .catch(() => undefined)
600
+ }
601
+ }
410
602
 
411
603
  // ============================================================================
412
604
  // Reset Function
413
605
  // ============================================================================
414
606
 
415
- const reset = useCallback(() => {
607
+ const resetLatest = () => {
416
608
  if (isQuery) {
417
609
  // Reset, not remove. `removeQueries` drops the entry without notifying
418
610
  // its observers, so this hook kept rendering the old data while bound to
@@ -425,19 +617,30 @@ export function useActorMethod<
425
617
  } else {
426
618
  mutationResult.reset()
427
619
  }
428
- }, [isQuery, reactor, queryKey, mutationResult])
620
+ }
429
621
 
430
- // ============================================================================
431
- // Refetch Function
432
- // ============================================================================
622
+ // `call`, `reset` and `refetch` keep one identity for the life of the
623
+ // component, as TanStack's own `refetch`, `mutate` and `reset` do, and run
624
+ // the latest committed render's implementation above when invoked. They used
625
+ // to be `useCallback`s listing the query and mutation results, which TanStack
626
+ // Query returns fresh every render, so they changed every render too. An
627
+ // effect that lists one — `react-hooks/exhaustive-deps` requires it as soon
628
+ // as the effect calls it — then re-ran after every render its own call
629
+ // caused: an unbounded loop of canister calls, state-changing ones for an
630
+ // update method.
631
+ const latest = useCommittedRef({
632
+ call: callLatest,
633
+ reset: resetLatest,
634
+ refetch: refetchLatest,
635
+ })
433
636
 
434
- const refetch = useCallback(async () => {
435
- if (isQuery) {
436
- const result = await queryResult.refetch()
437
- return result.data
438
- }
439
- return undefined
440
- }, [isQuery, queryResult])
637
+ const call = useCallback(
638
+ (callArgs?: ReactorArgs<Service, Method, Transform>) =>
639
+ latest.current.call(callArgs),
640
+ []
641
+ )
642
+ const reset = useCallback(() => latest.current.reset(), [])
643
+ const refetch = useCallback(() => latest.current.refetch(), [])
441
644
 
442
645
  // ============================================================================
443
646
  // Return Unified Result