@ic-reactor/react 3.8.0 → 3.10.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 (77) hide show
  1. package/README.md +84 -10
  2. package/dist/auth/authentication-manager.d.ts.map +1 -1
  3. package/dist/auth/authentication-manager.js +146 -21
  4. package/dist/auth/authentication-manager.js.map +1 -1
  5. package/dist/auth/createIdentityAttributeHooks.d.ts.map +1 -1
  6. package/dist/auth/createIdentityAttributeHooks.js +47 -5
  7. package/dist/auth/createIdentityAttributeHooks.js.map +1 -1
  8. package/dist/createInfiniteQuery.d.ts +11 -9
  9. package/dist/createInfiniteQuery.d.ts.map +1 -1
  10. package/dist/createInfiniteQuery.js +20 -17
  11. package/dist/createInfiniteQuery.js.map +1 -1
  12. package/dist/createMutation.d.ts.map +1 -1
  13. package/dist/createMutation.js +37 -3
  14. package/dist/createMutation.js.map +1 -1
  15. package/dist/createQuery.d.ts.map +1 -1
  16. package/dist/createQuery.js +7 -3
  17. package/dist/createQuery.js.map +1 -1
  18. package/dist/createSuspenseInfiniteQuery.d.ts +10 -9
  19. package/dist/createSuspenseInfiniteQuery.d.ts.map +1 -1
  20. package/dist/createSuspenseInfiniteQuery.js +18 -16
  21. package/dist/createSuspenseInfiniteQuery.js.map +1 -1
  22. package/dist/createSuspenseQuery.d.ts.map +1 -1
  23. package/dist/createSuspenseQuery.js +7 -3
  24. package/dist/createSuspenseQuery.js.map +1 -1
  25. package/dist/defineReactor.d.ts.map +1 -1
  26. package/dist/defineReactor.js +17 -2
  27. package/dist/defineReactor.js.map +1 -1
  28. package/dist/hooks/createAuthHooks.d.ts.map +1 -1
  29. package/dist/hooks/createAuthHooks.js +13 -0
  30. package/dist/hooks/createAuthHooks.js.map +1 -1
  31. package/dist/hooks/index.d.ts.map +1 -1
  32. package/dist/hooks/index.js +6 -0
  33. package/dist/hooks/index.js.map +1 -1
  34. package/dist/hooks/useActorInfiniteQuery.d.ts.map +1 -1
  35. package/dist/hooks/useActorInfiniteQuery.js +21 -2
  36. package/dist/hooks/useActorInfiniteQuery.js.map +1 -1
  37. package/dist/hooks/useActorMethod.d.ts.map +1 -1
  38. package/dist/hooks/useActorMethod.js +76 -33
  39. package/dist/hooks/useActorMethod.js.map +1 -1
  40. package/dist/hooks/useActorMutation.d.ts +8 -1
  41. package/dist/hooks/useActorMutation.d.ts.map +1 -1
  42. package/dist/hooks/useActorMutation.js +23 -7
  43. package/dist/hooks/useActorMutation.js.map +1 -1
  44. package/dist/hooks/useActorQuery.d.ts.map +1 -1
  45. package/dist/hooks/useActorQuery.js +12 -1
  46. package/dist/hooks/useActorQuery.js.map +1 -1
  47. package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts.map +1 -1
  48. package/dist/hooks/useActorSuspenseInfiniteQuery.js +21 -2
  49. package/dist/hooks/useActorSuspenseInfiniteQuery.js.map +1 -1
  50. package/dist/hooks/useActorSuspenseQuery.d.ts.map +1 -1
  51. package/dist/hooks/useActorSuspenseQuery.js +12 -1
  52. package/dist/hooks/useActorSuspenseQuery.js.map +1 -1
  53. package/dist/types.d.ts +8 -2
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/utils.d.ts +27 -5
  56. package/dist/utils.d.ts.map +1 -1
  57. package/dist/utils.js +65 -9
  58. package/dist/utils.js.map +1 -1
  59. package/package.json +8 -2
  60. package/src/auth/authentication-manager.ts +162 -21
  61. package/src/auth/createIdentityAttributeHooks.ts +54 -5
  62. package/src/createInfiniteQuery.ts +48 -34
  63. package/src/createMutation.ts +48 -2
  64. package/src/createQuery.ts +17 -10
  65. package/src/createSuspenseInfiniteQuery.ts +44 -32
  66. package/src/createSuspenseQuery.ts +17 -10
  67. package/src/defineReactor.ts +24 -2
  68. package/src/hooks/createAuthHooks.ts +18 -0
  69. package/src/hooks/index.ts +6 -0
  70. package/src/hooks/useActorInfiniteQuery.ts +30 -3
  71. package/src/hooks/useActorMethod.ts +91 -34
  72. package/src/hooks/useActorMutation.ts +35 -12
  73. package/src/hooks/useActorQuery.ts +11 -1
  74. package/src/hooks/useActorSuspenseInfiniteQuery.ts +30 -3
  75. package/src/hooks/useActorSuspenseQuery.ts +11 -1
  76. package/src/types.ts +8 -2
  77. package/src/utils.ts +66 -10
@@ -76,6 +76,8 @@ const createMutationImpl = <
76
76
  onSuccess: factoryOnSuccess,
77
77
  onCanisterError: factoryOnCanisterError,
78
78
  onError: factoryOnError,
79
+ onMutate: factoryOnMutate,
80
+ onSettled: factoryOnSettled,
79
81
  ...factoryOptions
80
82
  } = config
81
83
 
@@ -91,16 +93,46 @@ const createMutationImpl = <
91
93
 
92
94
  /**
93
95
  * Imperative execution for non-React usage.
94
- * Calls the canister method and invalidates factory-level queries.
96
+ *
97
+ * Runs the same factory-level chain the hook does — invalidation, then
98
+ * `onSuccess`, or `onCanisterError`/`onError` on failure — so a mutation
99
+ * object behaves the same through both call paths. Only hook-level callbacks
100
+ * are absent, because there is no hook here to supply them.
101
+ *
102
+ * The error is rethrown after the callbacks run, so `await execute(...)`
103
+ * still rejects for the caller.
104
+ *
95
105
  * Use this in route loaders, scripts, or server-side code.
96
106
  */
97
107
  const execute = async (
98
108
  args: ReactorArgs<Service, Method, Transform>
99
109
  ): Promise<ReactorReturnOk<Service, Method, Transform>> => {
100
- const result = await callFn(args)
110
+ let result: ReactorReturnOk<Service, Method, Transform>
111
+ try {
112
+ result = await callFn(args)
113
+ } catch (error) {
114
+ if (isCanisterError(error)) {
115
+ factoryOnCanisterError?.(error, args)
116
+ }
117
+ // No mutation context or instance exists on the imperative path.
118
+ factoryOnError?.(
119
+ error as Parameters<NonNullable<typeof factoryOnError>>[0],
120
+ args,
121
+ undefined as never,
122
+ undefined as never
123
+ )
124
+ throw error
125
+ }
126
+
101
127
  if (factoryInvalidateQueries) {
102
128
  await invalidateAll(reactor.queryClient, factoryInvalidateQueries)
103
129
  }
130
+ await factoryOnSuccess?.(
131
+ result,
132
+ args,
133
+ undefined as never,
134
+ undefined as never
135
+ )
104
136
  return result
105
137
  }
106
138
 
@@ -145,6 +177,20 @@ const createMutationImpl = <
145
177
  factoryOnError?.(error, variables, context, mutation)
146
178
  restOptions.onError?.(error, variables, context, mutation)
147
179
  },
180
+ // `onMutate` and `onSettled` are composed like `onSuccess`/`onError`
181
+ // above. They used to arrive through the `...restOptions` spread, so a
182
+ // hook-level one silently replaced the factory's — factory teardown,
183
+ // telemetry or logging simply vanished the moment any call site passed
184
+ // its own, with no warning and no type error. Chaining is what a
185
+ // reader who has seen `onSuccess` chain already expects.
186
+ onMutate: async (...params) => {
187
+ await factoryOnMutate?.(...params)
188
+ return await restOptions.onMutate?.(...params)
189
+ },
190
+ onSettled: async (...params) => {
191
+ await factoryOnSettled?.(...params)
192
+ await restOptions.onSettled?.(...params)
193
+ },
148
194
  },
149
195
  reactor.queryClient
150
196
  )
@@ -30,6 +30,7 @@ import type {
30
30
  ReactorArgs,
31
31
  TransformKey,
32
32
  } from "@ic-reactor/core"
33
+ import { useMemo } from "react"
33
34
  import {
34
35
  QueryKey,
35
36
  useQuery,
@@ -45,7 +46,7 @@ import type {
45
46
  QueryFactoryConfig,
46
47
  NoInfer,
47
48
  } from "./types.js"
48
- import { buildChainedSelect } from "./utils.js"
49
+ import { buildChainedSelect, createBoundedCache } from "./utils.js"
49
50
 
50
51
  // ============================================================================
51
52
  // Internal Implementation
@@ -110,6 +111,12 @@ const createQueryImpl = <
110
111
 
111
112
  const useQueryHook = ((options?: UseQueryHookOptions) => {
112
113
  const baseOptions = reactor.getQueryOptions(params)
114
+ // Memoized so the observer's select-result cache can hit; see
115
+ // buildChainedSelect. `select` comes from the factory config and is stable.
116
+ const chainedSelect = useMemo(
117
+ () => buildChainedSelect(select, options?.select),
118
+ [options?.select]
119
+ )
113
120
  return useQuery(
114
121
  {
115
122
  queryKey: baseOptions.queryKey,
@@ -117,7 +124,7 @@ const createQueryImpl = <
117
124
  ...rest,
118
125
  ...options,
119
126
  queryFn: baseOptions.queryFn,
120
- select: buildChainedSelect(select, options?.select),
127
+ select: chainedSelect,
121
128
  },
122
129
  reactor.queryClient
123
130
  )
@@ -198,14 +205,14 @@ export function createQueryFactory<
198
205
  Selected,
199
206
  QueryError<Service, Method, Transform>
200
207
  > {
201
- const cache = new Map<
202
- string,
203
- QueryResult<
204
- QueryFnData<Service, Method, Transform>,
205
- Selected,
206
- QueryError<Service, Method, Transform>
207
- >
208
- >()
208
+ const cache =
209
+ createBoundedCache<
210
+ QueryResult<
211
+ QueryFnData<Service, Method, Transform>,
212
+ Selected,
213
+ QueryError<Service, Method, Transform>
214
+ >
215
+ >()
209
216
 
210
217
  return (args: ReactorArgs<Service, Method, Transform>) => {
211
218
  const key = reactor.generateQueryKey({
@@ -24,6 +24,7 @@
24
24
  * const allPosts = data.pages.flatMap(page => page.posts)
25
25
  */
26
26
 
27
+ import { useMemo } from "react"
27
28
  import type {
28
29
  Reactor,
29
30
  FunctionName,
@@ -46,7 +47,11 @@ import {
46
47
  } from "@tanstack/react-query"
47
48
  import { CallConfig } from "@icp-sdk/core/agent"
48
49
  import { NoInfer } from "./types.js"
49
- import { mergeFactoryQueryKey, normalizeQueryData } from "./utils.js"
50
+ import {
51
+ buildChainedSelect,
52
+ mergeFactoryQueryKey,
53
+ normalizeQueryData,
54
+ } from "./utils.js"
50
55
 
51
56
  type SuspenseInfiniteFactoryCallOptions = {
52
57
  queryKey?: QueryKey
@@ -137,6 +142,15 @@ export interface SuspenseInfiniteQueryConfig<
137
142
  queryKey?: QueryKey
138
143
  /** Function to get args from page parameter */
139
144
  getArgs: (pageParam: TPageParam) => ReactorArgs<Service, Method, Transform>
145
+ /**
146
+ * Narrows what the cache key derives from the call arguments.
147
+ *
148
+ * By default the key is scoped by `getArgs(initialPageParam)`, so two
149
+ * infinite queries on the same method with different arguments stay in
150
+ * separate cache entries. Supply this when those args embed the cursor and
151
+ * only part of them identifies the query.
152
+ */
153
+ getKeyArgs?: (args: ReactorArgs<Service, Method, Transform>) => unknown
140
154
  }
141
155
 
142
156
  /**
@@ -152,17 +166,11 @@ export type SuspenseInfiniteQueryFactoryConfig<
152
166
  TPageParam
153
167
  >,
154
168
  > = Omit<
169
+ // `getArgs` is supplied per factory call. `getKeyArgs` is inherited from
170
+ // SuspenseInfiniteQueryConfig, which now applies it for every infinite query.
155
171
  SuspenseInfiniteQueryConfig<Service, Method, Transform, TPageParam, Selected>,
156
172
  "getArgs"
157
- > & {
158
- /**
159
- * Optional key-args derivation for factory calls.
160
- * Receives the resolved args from `getArgs(initialPageParam)` and should return
161
- * a stable serializable representation of the logical query identity
162
- * (typically excluding pagination/cursor fields).
163
- */
164
- getKeyArgs?: (args: ReactorArgs<Service, Method, Transform>) => unknown
165
- }
173
+ >
166
174
 
167
175
  // ============================================================================
168
176
  // Hook Interface
@@ -301,6 +309,7 @@ const createSuspenseInfiniteQueryImpl = <
301
309
  queryKey: customQueryKey,
302
310
  initialPageParam,
303
311
  getArgs,
312
+ getKeyArgs,
304
313
  getNextPageParam,
305
314
  getPreviousPageParam,
306
315
  maxPages,
@@ -309,12 +318,18 @@ const createSuspenseInfiniteQueryImpl = <
309
318
  ...rest
310
319
  } = config
311
320
 
312
- // Get query key from actor manager
321
+ // Fold the call arguments into the key — see the matching comment in
322
+ // createInfiniteQuery. Without them every infinite query on a method shares
323
+ // one cache entry, because the arguments live in a `getArgs` closure rather
324
+ // than in the config.
313
325
  const getQueryKey = (): QueryKey => {
326
+ const initialArgs = getArgs(initialPageParam)
327
+ const keyArgs = getKeyArgs?.(initialArgs) ?? initialArgs
328
+
314
329
  return reactor.generateQueryKey(
315
330
  {
316
331
  functionName,
317
- queryKey: customQueryKey,
332
+ queryKey: mergeFactoryQueryKey(customQueryKey, undefined, keyArgs),
318
333
  },
319
334
  callConfig
320
335
  )
@@ -370,14 +385,12 @@ const createSuspenseInfiniteQueryImpl = <
370
385
  Selected,
371
386
  TError
372
387
  > = (options: any): any => {
373
- // Chain the selects: raw -> config.select -> options.select
374
- const chainedSelect = (rawData: TInfiniteData) => {
375
- const firstPass = select ? select(rawData) : rawData
376
- if (options?.select) {
377
- return options.select(firstPass)
378
- }
379
- return firstPass
380
- }
388
+ // Memoized and identity-preserving; see buildChainedSelect for why the
389
+ // function identity matters to the observer's select-result cache.
390
+ const chainedSelect = useMemo(
391
+ () => buildChainedSelect(select as never, options?.select),
392
+ [options?.select]
393
+ )
381
394
 
382
395
  return useSuspenseInfiniteQuery(
383
396
  {
@@ -550,12 +563,13 @@ export function createSuspenseInfiniteQueryFactory<
550
563
  getArgs: (pageParam: TPageParam) => ReactorArgs<Service, Method, Transform>,
551
564
  options?: SuspenseInfiniteFactoryCallOptions
552
565
  ) => {
553
- const initialArgs = getArgs(config.initialPageParam)
554
- const keyArgs = config.getKeyArgs?.(initialArgs) ?? initialArgs
566
+ // Only the caller-supplied segments are merged here; the args-derived
567
+ // segment is added by the impl, which does it for every infinite query.
568
+ // Applying it in both places would append the args twice.
555
569
  const queryKey = mergeFactoryQueryKey(
556
570
  config.queryKey,
557
571
  options?.queryKey,
558
- keyArgs
572
+ undefined
559
573
  )
560
574
 
561
575
  return createSuspenseInfiniteQueryImpl<
@@ -565,15 +579,13 @@ export function createSuspenseInfiniteQueryFactory<
565
579
  TPageParam,
566
580
  Selected
567
581
  >(reactor, {
568
- ...(({ getKeyArgs: _getKeyArgs, ...rest }) => rest)(
569
- config as SuspenseInfiniteQueryFactoryConfig<
570
- Service,
571
- Method,
572
- Transform,
573
- TPageParam,
574
- Selected
575
- >
576
- ),
582
+ ...(config as SuspenseInfiniteQueryFactoryConfig<
583
+ Service,
584
+ Method,
585
+ Transform,
586
+ TPageParam,
587
+ Selected
588
+ >),
577
589
  queryKey,
578
590
  getArgs,
579
591
  } as SuspenseInfiniteQueryConfig<
@@ -25,6 +25,7 @@ import type {
25
25
  ReactorArgs,
26
26
  TransformKey,
27
27
  } from "@ic-reactor/core"
28
+ import { useMemo } from "react"
28
29
  import { useSuspenseQuery } from "@tanstack/react-query"
29
30
  import type {
30
31
  QueryFnData,
@@ -35,7 +36,7 @@ import type {
35
36
  SuspenseQueryFactoryConfig,
36
37
  NoInfer,
37
38
  } from "./types.js"
38
- import { buildChainedSelect } from "./utils.js"
39
+ import { buildChainedSelect, createBoundedCache } from "./utils.js"
39
40
 
40
41
  // ============================================================================
41
42
  // Internal Implementation
@@ -95,6 +96,12 @@ const createSuspenseQueryImpl = <
95
96
  TError
96
97
  > = (options: any): any => {
97
98
  const baseOptions = reactor.getQueryOptions(params)
99
+ // Memoized so the observer's select-result cache can hit; see
100
+ // buildChainedSelect. `select` comes from the factory config and is stable.
101
+ const chainedSelect = useMemo(
102
+ () => buildChainedSelect(select, options?.select),
103
+ [options?.select]
104
+ )
98
105
  return useSuspenseQuery(
99
106
  {
100
107
  queryKey: baseOptions.queryKey,
@@ -102,7 +109,7 @@ const createSuspenseQueryImpl = <
102
109
  ...rest,
103
110
  ...options,
104
111
  queryFn: baseOptions.queryFn,
105
- select: buildChainedSelect(select, options?.select),
112
+ select: chainedSelect,
106
113
  },
107
114
  reactor.queryClient
108
115
  )
@@ -188,14 +195,14 @@ export function createSuspenseQueryFactory<
188
195
  Selected,
189
196
  QueryError<Service, Method, Transform>
190
197
  > {
191
- const cache = new Map<
192
- string,
193
- SuspenseQueryResult<
194
- QueryFnData<Service, Method, Transform>,
195
- Selected,
196
- QueryError<Service, Method, Transform>
197
- >
198
- >()
198
+ const cache =
199
+ createBoundedCache<
200
+ SuspenseQueryResult<
201
+ QueryFnData<Service, Method, Transform>,
202
+ Selected,
203
+ QueryError<Service, Method, Transform>
204
+ >
205
+ >()
199
206
 
200
207
  return (args: ReactorArgs<Service, Method, Transform>) => {
201
208
  const key = reactor.generateQueryKey({
@@ -54,7 +54,12 @@
54
54
  * }
55
55
  * ```
56
56
  */
57
- import { ClientManager, Reactor, DisplayReactor } from "@ic-reactor/core"
57
+ import {
58
+ ClientManager,
59
+ Reactor,
60
+ DisplayReactor,
61
+ reactorRetry,
62
+ } from "@ic-reactor/core"
58
63
  import type {
59
64
  BaseActor,
60
65
  TransformKey,
@@ -142,6 +147,23 @@ export type DefineReactorResult<
142
147
  useIdentityAttributes: () => UseIdentityAttributesReturn
143
148
  }
144
149
 
150
+ /**
151
+ * The QueryClient this module creates when the caller does not supply one.
152
+ *
153
+ * React Query retries every failure three times by default, which for canister
154
+ * calls means four attempts and several seconds of backoff on outcomes that
155
+ * cannot change — a canister `Err`, a validation failure, a Candid encode
156
+ * error that never reached the network. `reactorRetry` keeps the same three
157
+ * attempts for transport failures and stops immediately on the rest.
158
+ *
159
+ * A caller-supplied `queryClient` is left exactly as given; opt in there with
160
+ * `defaultOptions: { queries: { retry: reactorRetry } }`.
161
+ */
162
+ const createDefaultQueryClient = () =>
163
+ new QueryClient({
164
+ defaultOptions: { queries: { retry: reactorRetry } },
165
+ })
166
+
145
167
  export function defineReactor<Service = BaseActor>(
146
168
  params: DefineDisplayReactorParameters<Service>
147
169
  ): DefineReactorResult<Service, "display", DisplayReactor<Service>>
@@ -197,7 +219,7 @@ export function defineReactor<Service = BaseActor>(
197
219
  providedClientManager ??
198
220
  providedAuthentication?.clientManager ??
199
221
  new ClientManager({
200
- queryClient: providedQueryClient ?? new QueryClient(),
222
+ queryClient: providedQueryClient ?? createDefaultQueryClient(),
201
223
  agentOptions,
202
224
  })
203
225
 
@@ -39,6 +39,24 @@ export interface CreateAuthHooksReturn {
39
39
  export const createAuthHooks = (
40
40
  authentication: AuthenticationManager
41
41
  ): CreateAuthHooksReturn => {
42
+ // Passing a ClientManager here is the natural mistake — it is what
43
+ // `AuthenticationManager` is built from, and the two are adjacent in every
44
+ // setup snippet. TypeScript rejects it, but a JS caller got no error until
45
+ // render, where it surfaced as "Cannot destructure property 'isAuthenticated'
46
+ // of 'useAuthState(...)' as it is undefined" — which names neither the cause
47
+ // nor this function.
48
+ if (
49
+ !authentication ||
50
+ typeof authentication.subscribeAuthState !== "function"
51
+ ) {
52
+ throw new TypeError(
53
+ "[ic-reactor] createAuthHooks() expects an AuthenticationManager, not a " +
54
+ "ClientManager. Build one first: " +
55
+ "`new AuthenticationManager({ clientManager })`, or take it from " +
56
+ "`defineReactor(...).authentication`."
57
+ )
58
+ }
59
+
42
60
  const { clientManager } = authentication
43
61
  /**
44
62
  * Subscribe to agent state changes.
@@ -1,5 +1,11 @@
1
1
  // ============================================================================
2
2
  // Hooks
3
+ //
4
+ // These are the RAW hooks: they take the reactor as a property. They are
5
+ // therefore exported as `useReactor*` only — the `useActor*` names belong to
6
+ // the bound hooks `createActorHooks` returns, which take no reactor. Aliasing
7
+ // the raw implementations under the bound names would hand a caller following
8
+ // the documented `useActorQuery({ functionName })` shape an undefined reactor.
3
9
  // ============================================================================
4
10
 
5
11
  // useActorQuery / useReactorQuery
@@ -16,7 +16,7 @@ import {
16
16
  ReactorReturnErr,
17
17
  } from "@ic-reactor/core"
18
18
  import { CallConfig } from "@icp-sdk/core/agent"
19
- import { normalizeQueryData } from "../utils.js"
19
+ import { mergeFactoryQueryKey, normalizeQueryData } from "../utils.js"
20
20
 
21
21
  /**
22
22
  * Parameters for useActorInfiniteQuery hook.
@@ -119,8 +119,35 @@ export const useActorInfiniteQuery = <
119
119
  // reactor/function identity. Using the custom key verbatim would cause cache
120
120
  // collisions if two different actors or methods share the same key string.
121
121
  const baseQueryKey = useMemo(
122
- () => reactor.generateQueryKey({ functionName, queryKey }, callConfig),
123
- [queryKey, reactor, functionName, callConfig]
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
+ ]
124
151
  )
125
152
 
126
153
  // Memoize queryFn to prevent recreation on every render
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo } from "react"
1
+ import { useCallback, useEffect, useMemo, useRef } from "react"
2
2
  import {
3
3
  useQuery,
4
4
  useMutation,
@@ -188,17 +188,39 @@ export function useActorMethod<
188
188
 
189
189
  const functionType: FunctionType = isQuery ? "query" : "update"
190
190
 
191
- // Generate query key
192
- const queryKey = useMemo(() => {
193
- if (customQueryKey) return customQueryKey
194
- return reactor.generateQueryKey(
195
- {
196
- functionName,
197
- args,
198
- },
199
- callConfig
200
- )
201
- }, [reactor, functionName, args, callConfig, customQueryKey])
191
+ // Latest callbacks, read at dispatch time: this keeps a rerendered closure
192
+ // from being ignored, and keeps callback identity out of the effect deps.
193
+ const onSuccessRef = useRef(onSuccess)
194
+ const onErrorRef = useRef(onError)
195
+ onSuccessRef.current = onSuccess
196
+ onErrorRef.current = onError
197
+
198
+ // Build the key for a given set of call arguments.
199
+ //
200
+ // A custom `queryKey` is appended to the canister/function prefix rather than
201
+ // replacing it, matching `useActorQuery`. Used verbatim it would carry no
202
+ // canister id, so the entry would be invisible to the canister-scoped
203
+ // invalidation `ClientManager.updateAgent` runs on identity change and would
204
+ // keep serving the previous principal's data after sign-in or sign-out.
205
+ const buildQueryKey = useCallback(
206
+ (keyArgs: ReactorArgs<Service, Method, Transform> | undefined): QueryKey =>
207
+ reactor.generateQueryKey(
208
+ { functionName, args: keyArgs, queryKey: customQueryKey },
209
+ callConfig
210
+ ),
211
+ // `canisterId` is mutable reactor state that `setCanisterId` can change,
212
+ // while `reactor` itself stays the same object — so it has to be a
213
+ // dependency in its own right or the key stays pinned to the old canister.
214
+ [
215
+ reactor,
216
+ reactor.canisterId?.toString(),
217
+ functionName,
218
+ callConfig,
219
+ customQueryKey,
220
+ ]
221
+ )
222
+
223
+ const queryKey = useMemo(() => buildQueryKey(args), [buildQueryKey, args])
202
224
 
203
225
  // ============================================================================
204
226
  // Query Implementation
@@ -210,26 +232,51 @@ export function useActorMethod<
210
232
  >(
211
233
  {
212
234
  queryKey,
213
- queryFn: async () => {
214
- try {
215
- const result = await reactor.callMethod({
216
- functionName,
217
- args,
218
- callConfig,
219
- })
220
- onSuccess?.(result)
221
- return result
222
- } catch (error) {
223
- onError?.(error as ReactorReturnErr<Service, Method, Transform>)
224
- throw error
225
- }
226
- },
235
+ // Callbacks deliberately do NOT live here: a queryFn runs once per fetch
236
+ // attempt, so `onError` fired on every retry (four times with the default
237
+ // QueryClient) and `onSuccess` never fired when data came from the cache
238
+ // or a deduped sibling. They are dispatched from the settled observer
239
+ // result below instead.
240
+ queryFn: () =>
241
+ reactor.callMethod({
242
+ functionName,
243
+ args,
244
+ callConfig,
245
+ }),
227
246
  enabled: isQuery && enabled,
228
247
  ...queryOptions,
229
248
  },
230
249
  reactor.queryClient
231
250
  )
232
251
 
252
+ // Dispatch the query callbacks from the settled observer result, once per
253
+ // distinct outcome. TanStack v5 removed `onSuccess`/`onError` from useQuery
254
+ // for exactly this reason, so the settle timestamps are what identify a new
255
+ // result: they also advance for a cache hit on a fresh mount, which is the
256
+ // case that previously never notified at all.
257
+ const notifiedSuccessAt = useRef<number | undefined>(undefined)
258
+ const notifiedErrorAt = useRef<number | undefined>(undefined)
259
+
260
+ const { status, data, error, dataUpdatedAt, errorUpdatedAt } = queryResult
261
+
262
+ useEffect(() => {
263
+ if (!isQuery) return
264
+ if (status === "success" && dataUpdatedAt !== notifiedSuccessAt.current) {
265
+ notifiedSuccessAt.current = dataUpdatedAt
266
+ onSuccessRef.current?.(data)
267
+ }
268
+ }, [isQuery, status, data, dataUpdatedAt])
269
+
270
+ useEffect(() => {
271
+ if (!isQuery) return
272
+ if (status === "error" && errorUpdatedAt !== notifiedErrorAt.current) {
273
+ notifiedErrorAt.current = errorUpdatedAt
274
+ onErrorRef.current?.(
275
+ error as ReactorReturnErr<Service, Method, Transform>
276
+ )
277
+ }
278
+ }, [isQuery, status, error, errorUpdatedAt])
279
+
233
280
  // ============================================================================
234
281
  // Mutation Implementation
235
282
  // ============================================================================
@@ -250,7 +297,7 @@ export function useActorMethod<
250
297
  return result
251
298
  },
252
299
  onSuccess: (data) => {
253
- onSuccess?.(data)
300
+ onSuccessRef.current?.(data)
254
301
  // Invalidate specified queries after successful mutation
255
302
  if (invalidateQueries && invalidateQueries.length > 0) {
256
303
  invalidateQueries.forEach((key) => {
@@ -259,7 +306,7 @@ export function useActorMethod<
259
306
  }
260
307
  },
261
308
  onError: (error) => {
262
- onError?.(error)
309
+ onErrorRef.current?.(error)
263
310
  },
264
311
  },
265
312
  reactor.queryClient
@@ -276,9 +323,14 @@ export function useActorMethod<
276
323
  if (isQuery) {
277
324
  // For queries, refetch with new args if provided
278
325
  if (callArgs !== undefined) {
326
+ // Key on the args actually being called. Reusing the hook's
327
+ // mount-time key would store this result under the previous args'
328
+ // entry — poisoning it for every other reader — and let fetchQuery
329
+ // dedupe onto an in-flight request for the old args, returning that
330
+ // response as though it answered this one.
279
331
  try {
280
332
  const result = await reactor.queryClient.fetchQuery({
281
- queryKey,
333
+ queryKey: buildQueryKey(callArgs),
282
334
  queryFn: () =>
283
335
  reactor.callMethod({
284
336
  functionName,
@@ -287,10 +339,17 @@ export function useActorMethod<
287
339
  }),
288
340
  staleTime: 0,
289
341
  })
290
- onSuccess?.(result)
342
+ // Dispatched here rather than by the observer effect: this result
343
+ // lands under the called args' key, which the mounted observer (bound
344
+ // to the hook's own args) does not watch. That separation is also why
345
+ // this can no longer double-fire the way it did when both wrote to
346
+ // the same key.
347
+ onSuccessRef.current?.(result)
291
348
  return result
292
349
  } catch (error) {
293
- onError?.(error as ReactorReturnErr<Service, Method, Transform>)
350
+ onErrorRef.current?.(
351
+ error as ReactorReturnErr<Service, Method, Transform>
352
+ )
294
353
  return undefined
295
354
  }
296
355
  }
@@ -309,11 +368,9 @@ export function useActorMethod<
309
368
  reactor,
310
369
  functionName,
311
370
  callConfig,
312
- queryKey,
371
+ buildQueryKey,
313
372
  queryResult,
314
373
  mutationResult,
315
- onSuccess,
316
- onError,
317
374
  ]
318
375
  )
319
376