@ic-reactor/react 3.8.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 (53) hide show
  1. package/README.md +68 -3
  2. package/dist/auth/authentication-manager.d.ts.map +1 -1
  3. package/dist/auth/authentication-manager.js +137 -21
  4. package/dist/auth/authentication-manager.js.map +1 -1
  5. package/dist/createInfiniteQuery.d.ts +11 -9
  6. package/dist/createInfiniteQuery.d.ts.map +1 -1
  7. package/dist/createInfiniteQuery.js +20 -17
  8. package/dist/createInfiniteQuery.js.map +1 -1
  9. package/dist/createMutation.d.ts.map +1 -1
  10. package/dist/createMutation.js +22 -2
  11. package/dist/createMutation.js.map +1 -1
  12. package/dist/createQuery.d.ts.map +1 -1
  13. package/dist/createQuery.js +5 -1
  14. package/dist/createQuery.js.map +1 -1
  15. package/dist/createSuspenseInfiniteQuery.d.ts +10 -9
  16. package/dist/createSuspenseInfiniteQuery.d.ts.map +1 -1
  17. package/dist/createSuspenseInfiniteQuery.js +18 -16
  18. package/dist/createSuspenseInfiniteQuery.js.map +1 -1
  19. package/dist/createSuspenseQuery.d.ts.map +1 -1
  20. package/dist/createSuspenseQuery.js +5 -1
  21. package/dist/createSuspenseQuery.js.map +1 -1
  22. package/dist/hooks/useActorInfiniteQuery.d.ts.map +1 -1
  23. package/dist/hooks/useActorInfiniteQuery.js +16 -2
  24. package/dist/hooks/useActorInfiniteQuery.js.map +1 -1
  25. package/dist/hooks/useActorMethod.d.ts.map +1 -1
  26. package/dist/hooks/useActorMethod.js +66 -33
  27. package/dist/hooks/useActorMethod.js.map +1 -1
  28. package/dist/hooks/useActorMutation.d.ts +8 -1
  29. package/dist/hooks/useActorMutation.d.ts.map +1 -1
  30. package/dist/hooks/useActorMutation.js +9 -1
  31. package/dist/hooks/useActorMutation.js.map +1 -1
  32. package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts.map +1 -1
  33. package/dist/hooks/useActorSuspenseInfiniteQuery.js +16 -2
  34. package/dist/hooks/useActorSuspenseInfiniteQuery.js.map +1 -1
  35. package/dist/types.d.ts +8 -2
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/utils.d.ts +14 -5
  38. package/dist/utils.d.ts.map +1 -1
  39. package/dist/utils.js +21 -10
  40. package/dist/utils.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/auth/authentication-manager.ts +152 -21
  43. package/src/createInfiniteQuery.ts +48 -34
  44. package/src/createMutation.ts +32 -2
  45. package/src/createQuery.ts +8 -1
  46. package/src/createSuspenseInfiniteQuery.ts +44 -32
  47. package/src/createSuspenseQuery.ts +8 -1
  48. package/src/hooks/useActorInfiniteQuery.ts +25 -3
  49. package/src/hooks/useActorMethod.ts +82 -34
  50. package/src/hooks/useActorMutation.ts +19 -4
  51. package/src/hooks/useActorSuspenseInfiniteQuery.ts +25 -3
  52. package/src/types.ts +8 -2
  53. package/src/utils.ts +21 -10
@@ -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,
@@ -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
  )
@@ -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,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.js"
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/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.
package/src/utils.ts CHANGED
@@ -46,20 +46,31 @@ export function mergeFactoryQueryKey(
46
46
  }
47
47
 
48
48
  /**
49
- * Build a chained select function that first applies the config-level select
50
- * (if any) and then the hook-level select (if any).
49
+ * Build a chained select that applies the config-level select (if any) and then
50
+ * the hook-level select (if any).
51
51
  *
52
- * This enables `createQuery` / `createSuspenseQuery` to support two-level
53
- * select chaining without duplicating the logic.
52
+ * Returns the caller's own function untouched when only one of the two is
53
+ * present, and `undefined` when neither is — allocating a wrapper only for the
54
+ * genuinely chained case. Identity matters here: `QueryObserver` memoizes a
55
+ * select result on `options.select === previousSelectFn`, so a wrapper rebuilt
56
+ * on every render defeats that check. The select then re-runs each render, and
57
+ * for a select returning a non-plain value (a `Map`, a `Date`, a `Principal`)
58
+ * `replaceEqualDeep` cannot structurally share the result either, so `data`
59
+ * gets a fresh reference every render and a `useEffect([data])` that sets state
60
+ * becomes an unbounded loop.
61
+ *
62
+ * Callers should still memoize the result, since the chained case allocates.
54
63
  */
55
64
  export function buildChainedSelect<TData, TSelected, TFinal = TSelected>(
56
65
  configSelect: ((data: TData) => TSelected) | undefined,
57
66
  hookSelect: ((data: TSelected) => TFinal) | undefined
58
- ): (rawData: TData) => TSelected | TFinal {
59
- return (rawData: TData) => {
60
- const firstPass = configSelect
61
- ? configSelect(rawData)
62
- : (rawData as unknown as TSelected)
63
- return hookSelect ? hookSelect(firstPass) : firstPass
67
+ ): ((rawData: TData) => TSelected | TFinal) | undefined {
68
+ if (!configSelect) {
69
+ // `hookSelect` receives the raw data when there is no config-level select,
70
+ // matching the previous behaviour.
71
+ return hookSelect as unknown as
72
+ ((rawData: TData) => TSelected | TFinal) | undefined
64
73
  }
74
+ if (!hookSelect) return configSelect
75
+ return (rawData: TData) => hookSelect(configSelect(rawData))
65
76
  }