@ic-reactor/react 3.7.0 → 3.9.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 (107) hide show
  1. package/README.md +158 -9
  2. package/dist/auth/authentication-manager.d.ts +37 -5
  3. package/dist/auth/authentication-manager.d.ts.map +1 -1
  4. package/dist/auth/authentication-manager.js +242 -52
  5. package/dist/auth/authentication-manager.js.map +1 -1
  6. package/dist/auth/createIdentityAttributeHooks.d.ts +2 -2
  7. package/dist/auth/createIdentityAttributeHooks.d.ts.map +1 -1
  8. package/dist/auth/identity-attributes-manager.d.ts +12 -5
  9. package/dist/auth/identity-attributes-manager.d.ts.map +1 -1
  10. package/dist/auth/identity-attributes-manager.js +42 -28
  11. package/dist/auth/identity-attributes-manager.js.map +1 -1
  12. package/dist/auth/identity-attributes.d.ts +1 -1
  13. package/dist/auth/identity-attributes.d.ts.map +1 -1
  14. package/dist/auth/identity-attributes.js +1 -1
  15. package/dist/auth/identity-attributes.js.map +1 -1
  16. package/dist/auth/index.d.ts +7 -7
  17. package/dist/auth/index.d.ts.map +1 -1
  18. package/dist/auth/index.js +7 -7
  19. package/dist/auth/index.js.map +1 -1
  20. package/dist/auth/types.d.ts +123 -16
  21. package/dist/auth/types.d.ts.map +1 -1
  22. package/dist/createActorHooks.d.ts +4 -4
  23. package/dist/createActorHooks.d.ts.map +1 -1
  24. package/dist/createActorHooks.js +6 -6
  25. package/dist/createActorHooks.js.map +1 -1
  26. package/dist/createInfiniteQuery.d.ts +12 -10
  27. package/dist/createInfiniteQuery.d.ts.map +1 -1
  28. package/dist/createInfiniteQuery.js +20 -17
  29. package/dist/createInfiniteQuery.js.map +1 -1
  30. package/dist/createMutation.d.ts +1 -1
  31. package/dist/createMutation.d.ts.map +1 -1
  32. package/dist/createMutation.js +22 -2
  33. package/dist/createMutation.js.map +1 -1
  34. package/dist/createQuery.d.ts +1 -1
  35. package/dist/createQuery.d.ts.map +1 -1
  36. package/dist/createQuery.js +6 -2
  37. package/dist/createQuery.js.map +1 -1
  38. package/dist/createSuspenseInfiniteQuery.d.ts +11 -10
  39. package/dist/createSuspenseInfiniteQuery.d.ts.map +1 -1
  40. package/dist/createSuspenseInfiniteQuery.js +18 -16
  41. package/dist/createSuspenseInfiniteQuery.js.map +1 -1
  42. package/dist/createSuspenseQuery.d.ts +1 -1
  43. package/dist/createSuspenseQuery.d.ts.map +1 -1
  44. package/dist/createSuspenseQuery.js +6 -2
  45. package/dist/createSuspenseQuery.js.map +1 -1
  46. package/dist/defineReactor.d.ts +54 -4
  47. package/dist/defineReactor.d.ts.map +1 -1
  48. package/dist/defineReactor.js +77 -7
  49. package/dist/defineReactor.js.map +1 -1
  50. package/dist/hooks/createAuthHooks.d.ts +2 -2
  51. package/dist/hooks/createAuthHooks.d.ts.map +1 -1
  52. package/dist/hooks/createAuthHooks.js +7 -2
  53. package/dist/hooks/createAuthHooks.js.map +1 -1
  54. package/dist/hooks/useActorInfiniteQuery.d.ts.map +1 -1
  55. package/dist/hooks/useActorInfiniteQuery.js +16 -2
  56. package/dist/hooks/useActorInfiniteQuery.js.map +1 -1
  57. package/dist/hooks/useActorMethod.d.ts.map +1 -1
  58. package/dist/hooks/useActorMethod.js +66 -33
  59. package/dist/hooks/useActorMethod.js.map +1 -1
  60. package/dist/hooks/useActorMutation.d.ts +8 -1
  61. package/dist/hooks/useActorMutation.d.ts.map +1 -1
  62. package/dist/hooks/useActorMutation.js +9 -1
  63. package/dist/hooks/useActorMutation.js.map +1 -1
  64. package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts.map +1 -1
  65. package/dist/hooks/useActorSuspenseInfiniteQuery.js +16 -2
  66. package/dist/hooks/useActorSuspenseInfiniteQuery.js.map +1 -1
  67. package/dist/index.d.ts +11 -11
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +11 -11
  70. package/dist/index.js.map +1 -1
  71. package/dist/types.d.ts +8 -2
  72. package/dist/types.d.ts.map +1 -1
  73. package/dist/utils.d.ts +14 -5
  74. package/dist/utils.d.ts.map +1 -1
  75. package/dist/utils.js +21 -10
  76. package/dist/utils.js.map +1 -1
  77. package/llms.txt +1 -1
  78. package/package.json +14 -9
  79. package/src/auth/authentication-manager.ts +267 -64
  80. package/src/auth/createIdentityAttributeHooks.ts +2 -2
  81. package/src/auth/identity-attributes-manager.ts +53 -44
  82. package/src/auth/identity-attributes.ts +2 -2
  83. package/src/auth/index.ts +7 -7
  84. package/src/auth/types.ts +134 -18
  85. package/src/createActorHooks.ts +9 -9
  86. package/src/createInfiniteQuery.ts +49 -35
  87. package/src/createMutation.ts +33 -3
  88. package/src/createQuery.ts +10 -3
  89. package/src/createSuspenseInfiniteQuery.ts +45 -33
  90. package/src/createSuspenseQuery.ts +10 -3
  91. package/src/defineReactor.ts +136 -14
  92. package/src/hooks/createAuthHooks.ts +8 -3
  93. package/src/hooks/useActorInfiniteQuery.ts +25 -3
  94. package/src/hooks/useActorMethod.ts +82 -34
  95. package/src/hooks/useActorMutation.ts +19 -4
  96. package/src/hooks/useActorSuspenseInfiniteQuery.ts +25 -3
  97. package/src/index.ts +11 -11
  98. package/src/types.ts +8 -2
  99. package/src/utils.ts +21 -10
  100. package/src/defineReactor.test.ts +0 -70
  101. package/src/hooks/useActorInfiniteQuery.test.tsx +0 -457
  102. package/src/hooks/useActorMethod.test.tsx +0 -798
  103. package/src/hooks/useActorMutation.test.tsx +0 -220
  104. package/src/hooks/useActorQuery.test.tsx +0 -287
  105. package/src/hooks/useActorSuspenseInfiniteQuery.test.tsx +0 -472
  106. package/src/hooks/useActorSuspenseQuery.test.tsx +0 -254
  107. package/src/utils.test.ts +0 -14
@@ -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,
@@ -34,8 +35,8 @@ import type {
34
35
  SuspenseQueryResult,
35
36
  SuspenseQueryFactoryConfig,
36
37
  NoInfer,
37
- } from "./types"
38
- import { buildChainedSelect } from "./utils"
38
+ } from "./types.js"
39
+ import { buildChainedSelect } 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
  )
@@ -33,8 +33,26 @@
33
33
  * name: "index",
34
34
  * idlFactory: indexIdl,
35
35
  * clientManager: ledger.clientManager,
36
+ * authentication: ledger.authentication, // one Internet Identity session
36
37
  * })
37
38
  * ```
39
+ *
40
+ * @example Internet Identity is wired up out of the box
41
+ * ```typescript
42
+ * const { useAuth, useIdentityAttributes } = defineReactor<_SERVICE>({
43
+ * name: "backend",
44
+ * idlFactory,
45
+ * // Needed when the app is served from more than one origin.
46
+ * auth: { derivationOrigin: "https://app.example.com" },
47
+ * })
48
+ *
49
+ * function LoginButton() {
50
+ * const { login, logout, isAuthenticated, principal } = useAuth()
51
+ * return isAuthenticated
52
+ * ? <button onClick={() => logout()}>{principal?.toText()}</button>
53
+ * : <button onClick={() => login()}>Sign in</button>
54
+ * }
55
+ * ```
38
56
  */
39
57
  import { ClientManager, Reactor, DisplayReactor } from "@ic-reactor/core"
40
58
  import type {
@@ -45,7 +63,14 @@ import type {
45
63
  DisplayReactorParameters,
46
64
  } from "@ic-reactor/core"
47
65
  import { QueryClient } from "@tanstack/react-query"
48
- import { createActorHooks, ActorHooks } from "./createActorHooks"
66
+ import { createActorHooks, ActorHooks } from "./createActorHooks.js"
67
+ import { AuthenticationManager } from "./auth/authentication-manager.js"
68
+ import { IdentityAttributesManager } from "./auth/identity-attributes-manager.js"
69
+ import { createIdentityAttributeHooks } from "./auth/createIdentityAttributeHooks.js"
70
+ import type { UseIdentityAttributesReturn } from "./auth/createIdentityAttributeHooks.js"
71
+ import { createAuthHooks } from "./hooks/createAuthHooks.js"
72
+ import type { CreateAuthHooksReturn } from "./hooks/createAuthHooks.js"
73
+ import type { AuthenticationManagerParameters } from "./auth/authentication-manager.js"
49
74
 
50
75
  /** Options shared by both the standard and display variants of defineReactor. */
51
76
  export interface DefineReactorSharedParameters
@@ -55,13 +80,35 @@ export interface DefineReactorSharedParameters
55
80
  /**
56
81
  * Reuse an existing ClientManager (e.g. to share one agent across canisters).
57
82
  * When omitted, a ClientManager is created from the agent options below.
83
+ *
84
+ * A supplied or adopted manager brings its own agent and QueryClient, so
85
+ * `agentOptions` and `queryClient` apply only when this call creates one.
58
86
  */
59
87
  clientManager?: ClientManager
60
88
  /**
61
- * Reuse an existing QueryClient. When omitted, the provided ClientManager's
62
- * QueryClient is reused, or a new one is created.
89
+ * QueryClient for a ClientManager created by this call.
90
+ *
91
+ * Ignored when `clientManager` or `authentication` is supplied — queries run
92
+ * against that manager's own QueryClient, which is what is returned.
63
93
  */
64
94
  queryClient?: QueryClient
95
+ /**
96
+ * Reuse an existing AuthenticationManager, so several reactors share one
97
+ * Internet Identity session. When omitted, one is created for this reactor.
98
+ *
99
+ * Its `clientManager` is adopted for this reactor, so sign-in updates the
100
+ * same agent the reactor calls through. Supplying a different `clientManager`
101
+ * alongside it is rejected.
102
+ */
103
+ authentication?: AuthenticationManager
104
+ /**
105
+ * Internet Identity options forwarded to the AuthenticationManager
106
+ * (`identityProvider`, `derivationOrigin`, `idleOptions`, `storage`, …).
107
+ *
108
+ * Mutually exclusive with `authentication`: a manager built elsewhere is
109
+ * already configured, so these could not be applied to it.
110
+ */
111
+ auth?: Omit<AuthenticationManagerParameters, "clientManager">
65
112
  }
66
113
 
67
114
  /** Parameters for a standard (raw Candid types) reactor. */
@@ -83,11 +130,17 @@ export type DefineReactorResult<
83
130
  Service,
84
131
  Transform extends TransformKey,
85
132
  R extends Reactor<Service, Transform>,
86
- > = ActorHooks<Service, Transform> & {
87
- reactor: R
88
- clientManager: ClientManager
89
- queryClient: QueryClient
90
- }
133
+ > = ActorHooks<Service, Transform> &
134
+ CreateAuthHooksReturn & {
135
+ reactor: R
136
+ clientManager: ClientManager
137
+ queryClient: QueryClient
138
+ /** Internet Identity session manager backing `useAuth`. */
139
+ authentication: AuthenticationManager
140
+ /** Signed identity attribute requests backing `useIdentityAttributes`. */
141
+ identityAttributes: IdentityAttributesManager
142
+ useIdentityAttributes: () => UseIdentityAttributesReturn
143
+ }
91
144
 
92
145
  export function defineReactor<Service = BaseActor>(
93
146
  params: DefineDisplayReactorParameters<Service>
@@ -104,6 +157,8 @@ export function defineReactor<Service = BaseActor>(
104
157
  const {
105
158
  clientManager: providedClientManager,
106
159
  queryClient: providedQueryClient,
160
+ authentication: providedAuthentication,
161
+ auth,
107
162
  display,
108
163
  agentOptions,
109
164
  name,
@@ -112,18 +167,44 @@ export function defineReactor<Service = BaseActor>(
112
167
  pollingOptions,
113
168
  } = params as DefineDisplayReactorParameters<Service>
114
169
 
115
- const queryClient =
116
- providedQueryClient ??
117
- providedClientManager?.queryClient ??
118
- new QueryClient()
170
+ // A shared AuthenticationManager updates the identity on its own
171
+ // ClientManager. Giving this reactor a different one would leave its calls
172
+ // anonymous after sign-in, so adopt the manager's rather than building a new
173
+ // one, and refuse an explicit mismatch instead of splitting them silently.
174
+ if (
175
+ providedClientManager &&
176
+ providedAuthentication &&
177
+ providedAuthentication.clientManager !== providedClientManager
178
+ ) {
179
+ throw new Error(
180
+ `[ic-reactor] defineReactor("${name}") received an \`authentication\` manager bound to a different \`clientManager\`. ` +
181
+ `Sign-in would update the authentication manager's agent while this reactor calls through another one, ` +
182
+ `leaving its calls anonymous. Pass \`clientManager: authentication.clientManager\`, or omit \`clientManager\` to adopt it.`
183
+ )
184
+ }
185
+
186
+ // `auth` configures a manager this call would build; an existing one is
187
+ // already constructed, so these options could only be dropped on the floor.
188
+ if (providedAuthentication && auth) {
189
+ throw new Error(
190
+ `[ic-reactor] defineReactor("${name}") received both \`authentication\` and \`auth\`. ` +
191
+ `The supplied manager is already configured, so \`auth\` (${Object.keys(auth).join(", ")}) would be ignored. ` +
192
+ `Pass those options where that AuthenticationManager is created, or drop \`authentication\` to build one here.`
193
+ )
194
+ }
119
195
 
120
196
  const clientManager =
121
197
  providedClientManager ??
198
+ providedAuthentication?.clientManager ??
122
199
  new ClientManager({
123
- queryClient,
200
+ queryClient: providedQueryClient ?? new QueryClient(),
124
201
  agentOptions,
125
202
  })
126
203
 
204
+ // Always report the QueryClient actually in use: when a ClientManager is
205
+ // supplied or adopted, its own QueryClient is the one queries run against.
206
+ const queryClient = clientManager.queryClient
207
+
127
208
  const reactorConfig = {
128
209
  clientManager,
129
210
  name,
@@ -146,5 +227,46 @@ export function defineReactor<Service = BaseActor>(
146
227
 
147
228
  const hooks = createActorHooks(reactor)
148
229
 
149
- return { ...hooks, reactor, clientManager, queryClient }
230
+ // Auth is built on first use. `AuthenticationManager` dynamically imports the
231
+ // optional `@icp-sdk/auth` peer as soon as it is constructed, and reactors
232
+ // that never touch authentication should not pay for that.
233
+ let authenticationInstance: AuthenticationManager | undefined
234
+ const getAuthentication = () =>
235
+ (authenticationInstance ??=
236
+ providedAuthentication ??
237
+ new AuthenticationManager({ ...auth, clientManager }))
238
+
239
+ let identityAttributesInstance: IdentityAttributesManager | undefined
240
+ const getIdentityAttributes = () =>
241
+ (identityAttributesInstance ??= new IdentityAttributesManager(
242
+ getAuthentication()
243
+ ))
244
+
245
+ let authHooks: CreateAuthHooksReturn | undefined
246
+ const getAuthHooks = () =>
247
+ (authHooks ??= createAuthHooks(getAuthentication()))
248
+
249
+ let attributeHooks:
250
+ ReturnType<typeof createIdentityAttributeHooks> | undefined
251
+ const getAttributeHooks = () =>
252
+ (attributeHooks ??= createIdentityAttributeHooks(getIdentityAttributes()))
253
+
254
+ return {
255
+ ...hooks,
256
+ reactor,
257
+ clientManager,
258
+ queryClient,
259
+ // Stable wrappers: the hook call order inside them never changes, so the
260
+ // rules of hooks still hold.
261
+ useAuth: () => getAuthHooks().useAuth(),
262
+ useAgentState: () => getAuthHooks().useAgentState(),
263
+ useUserPrincipal: () => getAuthHooks().useUserPrincipal(),
264
+ useIdentityAttributes: () => getAttributeHooks().useIdentityAttributes(),
265
+ get authentication() {
266
+ return getAuthentication()
267
+ },
268
+ get identityAttributes() {
269
+ return getIdentityAttributes()
270
+ },
271
+ }
150
272
  }
@@ -1,6 +1,6 @@
1
1
  import { useSyncExternalStore, useEffect, useRef, useMemo } from "react"
2
- import type { AuthenticationManager } from "../auth/authentication-manager"
3
- import type { AuthState, AuthenticationSignInOptions } from "../auth/types"
2
+ import type { AuthenticationManager } from "../auth/authentication-manager.js"
3
+ import type { AuthState, AuthenticationSignInOptions } from "../auth/types.js"
4
4
  import type { AgentState } from "@ic-reactor/core"
5
5
  import type { Principal } from "@icp-sdk/core/principal"
6
6
  import type { Identity } from "@icp-sdk/core/agent"
@@ -90,7 +90,9 @@ export const createAuthHooks = (
90
90
  // Track if we've already initialized to avoid duplicate calls
91
91
  const initializedRef = useRef(false)
92
92
 
93
- // Auto-initialize on first mount to restore previous session
93
+ // Auto-initialize on first mount to restore previous session.
94
+ // `prepareClient` also warms up the AuthClient so a later `login()` can
95
+ // open the identity provider window inside the click handler.
94
96
  useEffect(() => {
95
97
  if (!initializedRef.current) {
96
98
  initializedRef.current = true
@@ -99,6 +101,9 @@ export const createAuthHooks = (
99
101
  .catch(() => undefined)
100
102
  .then(() => clientManager.initialize())
101
103
  .then(() => authentication.authenticate())
104
+ // Failures are already reflected in authState/agentState; without
105
+ // this the rejection escapes as an unhandled promise rejection.
106
+ .catch(() => undefined)
102
107
  }
103
108
  }, [])
104
109
 
@@ -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"
19
+ import { mergeFactoryQueryKey, normalizeQueryData } from "../utils.js"
20
20
 
21
21
  /**
22
22
  * Parameters for useActorInfiniteQuery hook.
@@ -119,8 +119,30 @@ 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
+ functionName,
142
+ callConfig,
143
+ getArgs,
144
+ options.initialPageParam,
145
+ ]
124
146
  )
125
147
 
126
148
  // 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,30 @@ 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
+ [reactor, functionName, callConfig, customQueryKey]
212
+ )
213
+
214
+ const queryKey = useMemo(() => buildQueryKey(args), [buildQueryKey, args])
202
215
 
203
216
  // ============================================================================
204
217
  // Query Implementation
@@ -210,26 +223,51 @@ export function useActorMethod<
210
223
  >(
211
224
  {
212
225
  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
- },
226
+ // Callbacks deliberately do NOT live here: a queryFn runs once per fetch
227
+ // attempt, so `onError` fired on every retry (four times with the default
228
+ // QueryClient) and `onSuccess` never fired when data came from the cache
229
+ // or a deduped sibling. They are dispatched from the settled observer
230
+ // result below instead.
231
+ queryFn: () =>
232
+ reactor.callMethod({
233
+ functionName,
234
+ args,
235
+ callConfig,
236
+ }),
227
237
  enabled: isQuery && enabled,
228
238
  ...queryOptions,
229
239
  },
230
240
  reactor.queryClient
231
241
  )
232
242
 
243
+ // Dispatch the query callbacks from the settled observer result, once per
244
+ // distinct outcome. TanStack v5 removed `onSuccess`/`onError` from useQuery
245
+ // for exactly this reason, so the settle timestamps are what identify a new
246
+ // result: they also advance for a cache hit on a fresh mount, which is the
247
+ // case that previously never notified at all.
248
+ const notifiedSuccessAt = useRef<number | undefined>(undefined)
249
+ const notifiedErrorAt = useRef<number | undefined>(undefined)
250
+
251
+ const { status, data, error, dataUpdatedAt, errorUpdatedAt } = queryResult
252
+
253
+ useEffect(() => {
254
+ if (!isQuery) return
255
+ if (status === "success" && dataUpdatedAt !== notifiedSuccessAt.current) {
256
+ notifiedSuccessAt.current = dataUpdatedAt
257
+ onSuccessRef.current?.(data)
258
+ }
259
+ }, [isQuery, status, data, dataUpdatedAt])
260
+
261
+ useEffect(() => {
262
+ if (!isQuery) return
263
+ if (status === "error" && errorUpdatedAt !== notifiedErrorAt.current) {
264
+ notifiedErrorAt.current = errorUpdatedAt
265
+ onErrorRef.current?.(
266
+ error as ReactorReturnErr<Service, Method, Transform>
267
+ )
268
+ }
269
+ }, [isQuery, status, error, errorUpdatedAt])
270
+
233
271
  // ============================================================================
234
272
  // Mutation Implementation
235
273
  // ============================================================================
@@ -250,7 +288,7 @@ export function useActorMethod<
250
288
  return result
251
289
  },
252
290
  onSuccess: (data) => {
253
- onSuccess?.(data)
291
+ onSuccessRef.current?.(data)
254
292
  // Invalidate specified queries after successful mutation
255
293
  if (invalidateQueries && invalidateQueries.length > 0) {
256
294
  invalidateQueries.forEach((key) => {
@@ -259,7 +297,7 @@ export function useActorMethod<
259
297
  }
260
298
  },
261
299
  onError: (error) => {
262
- onError?.(error)
300
+ onErrorRef.current?.(error)
263
301
  },
264
302
  },
265
303
  reactor.queryClient
@@ -276,9 +314,14 @@ export function useActorMethod<
276
314
  if (isQuery) {
277
315
  // For queries, refetch with new args if provided
278
316
  if (callArgs !== undefined) {
317
+ // Key on the args actually being called. Reusing the hook's
318
+ // mount-time key would store this result under the previous args'
319
+ // entry — poisoning it for every other reader — and let fetchQuery
320
+ // dedupe onto an in-flight request for the old args, returning that
321
+ // response as though it answered this one.
279
322
  try {
280
323
  const result = await reactor.queryClient.fetchQuery({
281
- queryKey,
324
+ queryKey: buildQueryKey(callArgs),
282
325
  queryFn: () =>
283
326
  reactor.callMethod({
284
327
  functionName,
@@ -287,10 +330,17 @@ export function useActorMethod<
287
330
  }),
288
331
  staleTime: 0,
289
332
  })
290
- onSuccess?.(result)
333
+ // Dispatched here rather than by the observer effect: this result
334
+ // lands under the called args' key, which the mounted observer (bound
335
+ // to the hook's own args) does not watch. That separation is also why
336
+ // this can no longer double-fire the way it did when both wrote to
337
+ // the same key.
338
+ onSuccessRef.current?.(result)
291
339
  return result
292
340
  } catch (error) {
293
- onError?.(error as ReactorReturnErr<Service, Method, Transform>)
341
+ onErrorRef.current?.(
342
+ error as ReactorReturnErr<Service, Method, Transform>
343
+ )
294
344
  return undefined
295
345
  }
296
346
  }
@@ -309,11 +359,9 @@ export function useActorMethod<
309
359
  reactor,
310
360
  functionName,
311
361
  callConfig,
312
- queryKey,
362
+ buildQueryKey,
313
363
  queryResult,
314
364
  mutationResult,
315
- onSuccess,
316
- onError,
317
365
  ]
318
366
  )
319
367
 
@@ -35,7 +35,14 @@ export interface UseActorMutationParameters<
35
35
  reactor: Reactor<Service, Transform>
36
36
  functionName: Method
37
37
  callConfig?: CallConfig
38
- invalidateQueries?: QueryKey[]
38
+ /**
39
+ * Queries to invalidate upon successful mutation.
40
+ *
41
+ * `undefined` entries are skipped, so the common
42
+ * `[maybeQuery?.getQueryKey()]` idiom is safe when the optional query object
43
+ * is absent.
44
+ */
45
+ invalidateQueries?: (QueryKey | undefined)[]
39
46
  /**
40
47
  * Callback for canister-level business logic errors.
41
48
  * Called when the canister returns a Result { Err: E } variant.
@@ -126,10 +133,18 @@ export const useActorMutation = <
126
133
  >
127
134
  ) => {
128
135
  if (invalidateQueries) {
136
+ // Skip undefined entries. React Query reads `{ queryKey: undefined }`
137
+ // as "match everything", so a single undefined — which the natural
138
+ // `invalidateQueries: [maybeQuery?.getQueryKey()]` idiom produces
139
+ // whenever the optional query object is absent — would invalidate every
140
+ // query in the client, including an app's unrelated non-canister ones.
141
+ // `createMutation.invalidateAll` already filters the same way.
129
142
  await Promise.all(
130
- invalidateQueries.map((queryKey) =>
131
- reactor.queryClient.invalidateQueries({ queryKey })
132
- )
143
+ invalidateQueries
144
+ .filter((queryKey) => queryKey !== undefined)
145
+ .map((queryKey) =>
146
+ reactor.queryClient.invalidateQueries({ queryKey })
147
+ )
133
148
  )
134
149
  }
135
150
  await onSuccess?.(...params)
@@ -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"
19
+ import { mergeFactoryQueryKey, normalizeQueryData } from "../utils.js"
20
20
 
21
21
  /**
22
22
  * Parameters for useActorSuspenseInfiniteQuery hook.
@@ -129,8 +129,30 @@ export const useActorSuspenseInfiniteQuery = <
129
129
  // reactor/function identity. Using the custom key verbatim would cause cache
130
130
  // collisions if two different actors or methods share the same key string.
131
131
  const baseQueryKey = useMemo(
132
- () => reactor.generateQueryKey({ functionName, queryKey }, callConfig),
133
- [queryKey, reactor, functionName, callConfig]
132
+ () =>
133
+ reactor.generateQueryKey(
134
+ {
135
+ functionName,
136
+ // Fold the call arguments into the key. They live in the `getArgs`
137
+ // closure rather than in the config, so without this two hooks on the
138
+ // same method with different arguments share one cache entry and
139
+ // serve each other's pages.
140
+ queryKey: mergeFactoryQueryKey(
141
+ queryKey,
142
+ undefined,
143
+ getArgs(options.initialPageParam)
144
+ ),
145
+ },
146
+ callConfig
147
+ ),
148
+ [
149
+ queryKey,
150
+ reactor,
151
+ functionName,
152
+ callConfig,
153
+ getArgs,
154
+ options.initialPageParam,
155
+ ]
134
156
  )
135
157
 
136
158
  // Memoize queryFn to prevent recreation on every render
package/src/index.ts CHANGED
@@ -3,21 +3,21 @@
3
3
  export * from "@ic-reactor/core"
4
4
 
5
5
  // Re-export hooks
6
- export * from "./hooks"
6
+ export * from "./hooks/index.js"
7
7
 
8
8
  // Validation utilities for React
9
- export * from "./validation"
9
+ export * from "./validation.js"
10
10
 
11
11
  // React-specific exports
12
- export * from "./createActorHooks"
13
- export * from "./defineReactor"
12
+ export * from "./createActorHooks.js"
13
+ export * from "./defineReactor.js"
14
14
 
15
- export * from "./createQuery"
16
- export * from "./createSuspenseQuery"
17
- export * from "./createInfiniteQuery"
18
- export * from "./createSuspenseInfiniteQuery"
19
- export * from "./createMutation"
15
+ export * from "./createQuery.js"
16
+ export * from "./createSuspenseQuery.js"
17
+ export * from "./createInfiniteQuery.js"
18
+ export * from "./createSuspenseInfiniteQuery.js"
19
+ export * from "./createMutation.js"
20
20
 
21
- export * from "./auth"
21
+ export * from "./auth/index.js"
22
22
 
23
- export * from "./types"
23
+ export * from "./types.js"
package/src/types.ts CHANGED
@@ -356,8 +356,14 @@ export interface MutationConfig<
356
356
  functionName: Method
357
357
  /** Call configuration for the actor method */
358
358
  callConfig?: CallConfig
359
- /** Queries to invalidate upon successful mutation */
360
- invalidateQueries?: QueryKey[]
359
+ /**
360
+ * Queries to invalidate upon successful mutation.
361
+ *
362
+ * `undefined` entries are skipped, so the common
363
+ * `[maybeQuery?.getQueryKey()]` idiom is safe when the optional query object
364
+ * is absent.
365
+ */
366
+ invalidateQueries?: (QueryKey | undefined)[]
361
367
  /**
362
368
  * Callback for canister-level business logic errors.
363
369
  * Called when the canister returns a Result { Err: E } variant.