@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
package/src/utils.ts CHANGED
@@ -1,10 +1,94 @@
1
1
  /**
2
- * Shared internal utilities for query and mutation factories.
2
+ * Shared internal utilities for the query and mutation hooks and factories.
3
3
  */
4
4
 
5
- import type { QueryKey } from "@tanstack/react-query"
6
- import type { ReactorQueryData } from "@ic-reactor/core"
5
+ import { useEffect } from "react"
6
+ import type { QueryClient, QueryKey } from "@tanstack/react-query"
7
+ import type { CallConfig } from "@icp-sdk/core/agent"
8
+ import type {
9
+ ClientManager,
10
+ FunctionName,
11
+ Reactor,
12
+ ReactorArgs,
13
+ ReactorQueryData,
14
+ TransformKey,
15
+ } from "@ic-reactor/core"
7
16
  import { generateKey } from "@ic-reactor/core"
17
+ import type {
18
+ InvalidationTarget,
19
+ OptimisticRollback,
20
+ QueryCacheControls,
21
+ QueryFactoryMethods,
22
+ QueryKeySource,
23
+ } from "./types.js"
24
+
25
+ /**
26
+ * Keep `queryClient` mounted while the calling component is.
27
+ *
28
+ * `QueryClient.mount()` is what subscribes a client to TanStack's focus and
29
+ * online managers. Those subscriptions refetch stale queries on window focus
30
+ * and on reconnect, resume a fetch that started offline or a retry that paused
31
+ * while the tab was hidden, and resume mutations sent offline.
32
+ * `QueryClientProvider` normally calls it, but every hook here binds to its
33
+ * reactor's own client rather than the context one, and the setup guide calls
34
+ * the provider optional. Without one, nothing mounted the client: a query that
35
+ * started offline stayed `paused` after the connection came back, a mutation
36
+ * sent offline stayed pending, and `refetchOnWindowFocus` and
37
+ * `refetchOnReconnect` never fired.
38
+ *
39
+ * `mount()` and `unmount()` are reference-counted, so this composes with a
40
+ * provider mounting the same client and with any number of hooks. It runs in
41
+ * an effect, like the provider's, so a server render never subscribes.
42
+ *
43
+ * A reactor stand-in without a `queryClient` (a test double, say) makes the
44
+ * TanStack hooks fall back to the context client, which its provider mounts,
45
+ * so there is nothing to do for one.
46
+ */
47
+ export function useMountQueryClient(
48
+ queryClient: QueryClient | undefined
49
+ ): void {
50
+ useEffect(() => {
51
+ if (!queryClient) return
52
+ queryClient.mount()
53
+ return () => queryClient.unmount()
54
+ }, [queryClient])
55
+ }
56
+
57
+ const isThenable = (value: unknown): value is PromiseLike<unknown> =>
58
+ typeof (value as { then?: unknown } | null | undefined)?.then === "function"
59
+
60
+ /**
61
+ * Keep `queryClient` mounted while a suspense hook waits on the promise it
62
+ * threw. Call it from a `catch` around the TanStack suspense hook, then
63
+ * rethrow. The `try` only lets the hook see the promise: it is rethrown
64
+ * untouched, so React discards the render as before and hook order is kept.
65
+ *
66
+ * A suspense hook throws its fetch promise before its component commits, so
67
+ * the effect in {@link useMountQueryClient} cannot run until that promise
68
+ * settles. A fetch that started offline, or whose retry is waiting for a hidden
69
+ * tab to come back, settles only once a mounted client hears the connection or
70
+ * the focus return. Without a provider nothing had mounted the client, so the
71
+ * Suspense fallback stayed up after reconnecting.
72
+ *
73
+ * The mount taken here is released when the promise settles, whether or not
74
+ * anything ever commits, so it stays balanced: each suspended render, a
75
+ * StrictMode double render or a retry included, takes and releases its own
76
+ * reference. Once the component commits, its effect holds the client like any
77
+ * other hook's. A tree discarded while suspended lets go once its fetch
78
+ * finishes, which it does on reconnect, as it would under a mounted provider.
79
+ * A server render never subscribes.
80
+ */
81
+ export function mountWhileSuspended(
82
+ queryClient: QueryClient | undefined,
83
+ thrown: unknown
84
+ ): void {
85
+ if (!queryClient || typeof window === "undefined" || !isThenable(thrown)) {
86
+ return
87
+ }
88
+ queryClient.mount()
89
+ const release = () => queryClient.unmount()
90
+ void thrown.then(release, release)
91
+ }
8
92
 
9
93
  /**
10
94
  * Internal query-key segment used to distinguish per-call factory args
@@ -12,10 +96,119 @@ import { generateKey } from "@ic-reactor/core"
12
96
  */
13
97
  export const FACTORY_KEY_ARGS_QUERY_KEY = "__ic_reactor_factory_key_args"
14
98
 
99
+ /**
100
+ * Internal query-key segment that ends the key of a query waiting on
101
+ * `skipToken`. Not part of the public API.
102
+ */
103
+ export const SKIPPED_QUERY_KEY = "__ic_reactor_skipped"
104
+
105
+ /**
106
+ * The key of a query whose args are `skipToken`: `prefix`, the method's key
107
+ * that every key its args give extends, and then a segment no call's key has.
108
+ *
109
+ * It used to be the bare prefix, which is also the key of a query of the same
110
+ * method made without args, as a method with no parameters is called. The
111
+ * skipped query then showed that query's data though its own args were not
112
+ * known. And TanStack Query refetches an entry with the options of whichever
113
+ * of its observers rendered last, so an invalidation, including the sweep a
114
+ * sign-in or sign-out runs, could find `skipToken` in place of the query
115
+ * function: the refetch failed with "Missing queryFn" and the other query
116
+ * kept the previous caller's answer.
117
+ *
118
+ * `kind` keeps a waiting list apart from a waiting query of the same method,
119
+ * since TanStack Query does not share an entry between `useQuery` and
120
+ * `useInfiniteQuery`.
121
+ */
122
+ export function skippedQueryKey(
123
+ prefix: QueryKey,
124
+ kind: "query" | "infinite"
125
+ ): QueryKey {
126
+ return [...prefix, { [SKIPPED_QUERY_KEY]: kind }]
127
+ }
128
+
129
+ /**
130
+ * The call config an infinite query's function fetches with: the caller's,
131
+ * aimed at the canister its query key names unless the caller named one.
132
+ *
133
+ * The key is built from the reactor's canister when the query is set up, but
134
+ * the query function reached `callMethod`, which reads `reactor.canisterId`
135
+ * again whenever it runs. After a `setCanisterId`, a retry or a refetch by an
136
+ * observer that had not re-rendered then fetched the new canister's pages and
137
+ * cached them under the old canister's key. `generateQueryKey` always roots a
138
+ * key at the canister it resolved, so the key says where its pages come from.
139
+ * `Reactor.getQueryOptions` pins its query function the same way.
140
+ */
141
+ export function callConfigForKey(
142
+ queryKey: QueryKey,
143
+ callConfig: CallConfig | undefined
144
+ ): CallConfig | undefined {
145
+ const keyedCanister = queryKey[0]
146
+ if (callConfig?.canisterId || typeof keyedCanister !== "string") {
147
+ return callConfig
148
+ }
149
+ return { ...callConfig, canisterId: keyedCanister }
150
+ }
151
+
15
152
  /** Convert a direct reactor result into a value TanStack Query can cache. */
16
153
  export const normalizeQueryData = <T>(value: T): ReactorQueryData<T> =>
17
154
  (value === undefined ? null : value) as ReactorQueryData<T>
18
155
 
156
+ /** Config options that decide how a query's function runs. */
157
+ const FETCH_OPTION_KEYS = [
158
+ "networkMode",
159
+ "retry",
160
+ "retryDelay",
161
+ "meta",
162
+ ] as const
163
+
164
+ type FetchOptionKey = (typeof FETCH_OPTION_KEYS)[number]
165
+
166
+ /**
167
+ * The part of a factory config that `fetch()` and `prefetch()` pass on.
168
+ *
169
+ * A factory's hook hands its whole config to TanStack Query, but the
170
+ * imperative path used to pass only the key and query function. So a config's
171
+ * `networkMode: "always"` let the hook fetch while `fetch()` stayed paused
172
+ * offline, its `retry` retried in the hook and not in a loader, and its `meta`
173
+ * never reached the QueryCache callbacks for a `fetch()` failure. These four
174
+ * say how the query function runs, so they now apply to both paths.
175
+ *
176
+ * Options that decide what the cache keeps (`gcTime`, `initialData`) or how an
177
+ * observer renders (`select`, `placeholderData`, `enabled`, …) stay with the
178
+ * hook. Unset options are left out rather than passed as `undefined`, which
179
+ * would override the QueryClient's own defaults.
180
+ */
181
+ export function pickFetchOptions<Config extends object>(
182
+ config: Config
183
+ ): Partial<Pick<Config, Extract<keyof Config, FetchOptionKey>>> {
184
+ const picked: Partial<Record<FetchOptionKey, unknown>> = {}
185
+ for (const key of FETCH_OPTION_KEYS) {
186
+ const value = (config as Partial<Record<FetchOptionKey, unknown>>)[key]
187
+ if (value !== undefined) picked[key] = value
188
+ }
189
+ return picked as Partial<Pick<Config, Extract<keyof Config, FetchOptionKey>>>
190
+ }
191
+
192
+ /**
193
+ * The `retry` entry of a query's options: the query's own `retry` when it sets
194
+ * one, otherwise the reactor's default for its method, and no entry when
195
+ * neither is set, so the QueryClient's defaults apply.
196
+ *
197
+ * The default is `Reactor.getQueryRetry`'s: `undefined` for a query method,
198
+ * and for an update method a retry of only the failures that prove the
199
+ * canister never ran the call, since each attempt runs the update again.
200
+ * Spread this after the caller's options. An update method's default then also
201
+ * replaces a `retry: undefined` spread in from them, which would otherwise
202
+ * select TanStack Query's own three retries.
203
+ */
204
+ export function retryOption<TRetry, TDefault>(
205
+ ownRetry: TRetry | undefined,
206
+ defaultRetry: TDefault | undefined
207
+ ): { retry?: TRetry | TDefault } {
208
+ const retry = ownRetry ?? defaultRetry
209
+ return retry === undefined ? {} : { retry }
210
+ }
211
+
19
212
  /**
20
213
  * Merge a base query key, optional per-call query key, and optional key-args
21
214
  * into a single query key array.
@@ -119,3 +312,194 @@ export function createBoundedCache<V>(limit: number = FACTORY_CACHE_LIMIT) {
119
312
  },
120
313
  }
121
314
  }
315
+
316
+ const isQueryKeySource = (value: object): value is QueryKeySource =>
317
+ typeof (value as Partial<QueryKeySource>).getQueryKey === "function"
318
+
319
+ /**
320
+ * Invalidate one `invalidateQueries` entry; see {@link invalidateTargets}.
321
+ */
322
+ function invalidateTarget<Service, Transform extends TransformKey>(
323
+ reactor: Reactor<Service, Transform>,
324
+ target: InvalidationTarget<Service, Transform> | null,
325
+ canisterId: CallConfig["canisterId"]
326
+ ): Promise<void> {
327
+ // React Query reads `{ queryKey: undefined }` as "match everything", so an
328
+ // absent entry would invalidate every query in the client, the app's
329
+ // unrelated non-canister ones included. `null` only comes from untyped code
330
+ // and would do the same.
331
+ if (target == null) return Promise.resolve()
332
+ if (Array.isArray(target)) {
333
+ return reactor.queryClient.invalidateQueries({ queryKey: target })
334
+ }
335
+ if (isQueryKeySource(target)) {
336
+ // A query object or factory invalidates on its own reactor's client, which
337
+ // is not this reactor's when each reactor has a QueryClient of its own.
338
+ return target.invalidate
339
+ ? target.invalidate()
340
+ : reactor.queryClient.invalidateQueries({
341
+ queryKey: target.getQueryKey(),
342
+ })
343
+ }
344
+ // A `{ functionName, args? }` descriptor. Its key is built now rather than
345
+ // when the mutation was set up, so it follows a `setCanisterId`.
346
+ const { functionName, args } = target as {
347
+ functionName: FunctionName<Service>
348
+ args?: ReactorArgs<Service, FunctionName<Service>, Transform>
349
+ }
350
+ // `args: []`, all that a method without parameters takes, names the same
351
+ // queries as no args. Keyed as given, it adds an args segment that a query
352
+ // made without args lacks, and would match none of those.
353
+ const hasArgs = (args as readonly unknown[] | undefined)?.length
354
+ return reactor.queryClient.invalidateQueries({
355
+ // At the canister the mutation was sent to, whose queries it changed.
356
+ // Only the canister is taken from its `callConfig`: an agent or effective
357
+ // target segment would narrow the prefix to the queries sent the same
358
+ // way, while the canister's state changed for every caller.
359
+ queryKey: reactor.generateQueryKey(
360
+ { functionName, args: hasArgs ? args : undefined },
361
+ canisterId ? { canisterId } : undefined
362
+ ),
363
+ })
364
+ }
365
+
366
+ /**
367
+ * Invalidate every entry of a mutation's `invalidateQueries` in parallel, on
368
+ * behalf of `reactor`, the mutation's own. It resolves once every active
369
+ * query they matched has refetched; a refetch that fails does not reject it,
370
+ * so it cannot turn an update that already ran into a failure.
371
+ *
372
+ * An entry is a query key, a query object or query factory (anything with a
373
+ * `getQueryKey()`), or a `{ functionName, args? }` descriptor, which is keyed
374
+ * by `reactor.generateQueryKey` at the canister the mutation was sent to:
375
+ * the one its `callConfig.canisterId` names, else the reactor's. `undefined`
376
+ * entries are skipped.
377
+ */
378
+ export async function invalidateTargets<
379
+ Service,
380
+ Transform extends TransformKey,
381
+ >(
382
+ reactor: Reactor<Service, Transform>,
383
+ targets: readonly InvalidationTarget<Service, Transform>[] | undefined,
384
+ callConfig?: CallConfig
385
+ ): Promise<void> {
386
+ if (!targets || targets.length === 0) return
387
+ await Promise.all(
388
+ targets.map((target) =>
389
+ invalidateTarget(reactor, target, callConfig?.canisterId)
390
+ )
391
+ )
392
+ }
393
+
394
+ /**
395
+ * Give an args-late query factory function its {@link QueryFactoryMethods}.
396
+ *
397
+ * `getQueryKey` builds the prefix every query of the factory shares, and is
398
+ * called each time so that it follows a `setCanisterId`.
399
+ */
400
+ export function withQueryFactoryMethods<Factory extends object>(
401
+ factory: Factory,
402
+ reactor: { readonly queryClient: QueryClient },
403
+ getQueryKey: () => QueryKey
404
+ ): Factory & QueryFactoryMethods {
405
+ const methods: QueryFactoryMethods = {
406
+ getQueryKey,
407
+ invalidate: () =>
408
+ reactor.queryClient.invalidateQueries({ queryKey: getQueryKey() }),
409
+ }
410
+ return Object.assign(factory, methods)
411
+ }
412
+
413
+ /** The rollback of an optimistic update that wrote nothing. */
414
+ const NOTHING_TO_ROLL_BACK: OptimisticRollback = { rollback: () => {} }
415
+
416
+ /** What {@link queryCacheControls} reads from a reactor. */
417
+ interface CacheOwner {
418
+ readonly queryClient: QueryClient
419
+ readonly clientManager: Pick<ClientManager, "identity">
420
+ }
421
+
422
+ /**
423
+ * The principal whose answers the reactor's cache holds: the one installed on
424
+ * the manager's agent, `undefined` before one is.
425
+ *
426
+ * Query keys carry no principal. `ClientManager.updateAgent` sweeps the cache
427
+ * instead when another principal signs in, removing inactive entries and
428
+ * refetching active ones, so a value read from the cache is this principal's
429
+ * only while it stays installed.
430
+ */
431
+ const cachedPrincipal = (reactor: CacheOwner): string | undefined =>
432
+ reactor.clientManager.identity?.getPrincipal().toText()
433
+
434
+ /**
435
+ * The {@link QueryCacheControls} of a query object: `cancel`, `reset` and
436
+ * `optimisticUpdate` on its own entry of the reactor's QueryClient.
437
+ *
438
+ * They match the key exactly. A query object's key is also the prefix of
439
+ * other entries (a query without args prefixes every args instance of its
440
+ * method), and cancelling or resetting those would reach queries this object
441
+ * does not own. `getQueryKey` is called each time, so the controls follow a
442
+ * `setCanisterId`.
443
+ */
444
+ export function queryCacheControls<TQueryFnData>(
445
+ reactor: CacheOwner,
446
+ getQueryKey: () => QueryKey
447
+ ): QueryCacheControls<TQueryFnData> {
448
+ return {
449
+ cancel: () =>
450
+ reactor.queryClient.cancelQueries({
451
+ queryKey: getQueryKey(),
452
+ exact: true,
453
+ }),
454
+
455
+ reset: () =>
456
+ reactor.queryClient.resetQueries({
457
+ queryKey: getQueryKey(),
458
+ exact: true,
459
+ }),
460
+
461
+ optimisticUpdate: async (updater) => {
462
+ const { queryClient } = reactor
463
+ const queryKey = getQueryKey()
464
+ // Nothing cached means nothing on screen to update. Cancelling the
465
+ // entry's first fetch would also leave it pending with no data.
466
+ if (queryClient.getQueryData(queryKey) === undefined) {
467
+ return NOTHING_TO_ROLL_BACK
468
+ }
469
+ const principal = cachedPrincipal(reactor)
470
+ // A fetch in flight would otherwise land after the write below with the
471
+ // canister's answer from before the mutation. Cancelling reverts the
472
+ // entry to what it held before that fetch, so it is read afterwards.
473
+ await queryClient.cancelQueries({ queryKey, exact: true })
474
+ // A sign-in or sign-out while that ran left the previous principal's
475
+ // value in the entry until the sweep's refetch lands. An update built
476
+ // on it would show that value to the principal signed in now.
477
+ if (cachedPrincipal(reactor) !== principal) return NOTHING_TO_ROLL_BACK
478
+ const snapshot = queryClient.getQueryState<TQueryFnData>(queryKey)
479
+ if (snapshot?.data === undefined) return NOTHING_TO_ROLL_BACK
480
+ const { data: previous, dataUpdatedAt, isInvalidated } = snapshot
481
+ queryClient.setQueryData<TQueryFnData>(queryKey, updater(previous))
482
+ return {
483
+ rollback: () => {
484
+ // After a switch to another principal, `previous` is the previous
485
+ // principal's value, which the sweep has removed or is refetching.
486
+ // Written back, it would be served to the one signed in now.
487
+ if (cachedPrincipal(reactor) !== principal) return
488
+ // With its own timestamp: written back as new, a value from before
489
+ // the mutation would look freshly fetched and skip the refetches
490
+ // its age calls for.
491
+ queryClient.setQueryData<TQueryFnData>(queryKey, previous, {
492
+ updatedAt: dataUpdatedAt,
493
+ })
494
+ // The write clears the invalidated mark too, and the fetch the
495
+ // update cancelled was often the refetch an invalidation started.
496
+ // Marked again, the value reads as outdated as it was, and a
497
+ // mounted query refetches it.
498
+ if (isInvalidated) {
499
+ void queryClient.invalidateQueries({ queryKey, exact: true })
500
+ }
501
+ },
502
+ }
503
+ },
504
+ }
505
+ }
package/src/validation.ts CHANGED
@@ -38,9 +38,20 @@ export interface MapValidationErrorsOptions {
38
38
  multiple?: boolean
39
39
  }
40
40
 
41
+ /**
42
+ * The field an issue belongs to: the first segment of its path, as a string.
43
+ * An issue with an empty path is about the whole argument (a zod object-level
44
+ * `.refine()`, or any issue of a primitive argument) and belongs to `""`.
45
+ */
46
+ function fieldNameOf(issue: ValidationIssue): string {
47
+ return String(issue.path[0] ?? "")
48
+ }
49
+
41
50
  /**
42
51
  * Maps validation error issues to a simple field -> message object.
43
- * Returns the first error message for each field path.
52
+ * Returns the first error message for each field path, or every message with
53
+ * `{ multiple: true }`. Issues about the whole argument (an empty path) are
54
+ * filed under `""`.
44
55
  *
45
56
  * @example
46
57
  * ```tsx
@@ -60,44 +71,56 @@ export interface MapValidationErrorsOptions {
60
71
  * })
61
72
  * ```
62
73
  */
63
- export function mapValidationErrors(error: ValidationError): FieldErrors
74
+ export function mapValidationErrors(
75
+ error: ValidationError,
76
+ options?: { multiple?: false }
77
+ ): FieldErrors
64
78
  export function mapValidationErrors(
65
79
  error: ValidationError,
66
80
  options: { multiple: true }
67
81
  ): FieldErrorsMultiple
68
82
  export function mapValidationErrors(
69
83
  error: ValidationError,
70
- options: { multiple: false }
71
- ): FieldErrors
84
+ options?: MapValidationErrorsOptions
85
+ ): FieldErrors | FieldErrorsMultiple
72
86
  export function mapValidationErrors(
73
87
  error: ValidationError,
74
88
  options?: MapValidationErrorsOptions
75
89
  ): FieldErrors | FieldErrorsMultiple {
90
+ // Collected in a Map, not a plain object: a field named like an
91
+ // Object.prototype member (`constructor`, `toString`, `__proto__`) read that
92
+ // member back as if the field were already filled, which dropped its issue,
93
+ // and threw on `.push` with `multiple`. `Object.fromEntries` then creates
94
+ // every field as an own property, `__proto__` included.
76
95
  if (options?.multiple) {
77
- const result: FieldErrorsMultiple = {}
96
+ const messages = new Map<string, string[]>()
78
97
  for (const issue of error.issues) {
79
- const fieldName = String(issue.path[0] ?? "")
80
- if (!result[fieldName]) {
81
- result[fieldName] = []
98
+ const fieldName = fieldNameOf(issue)
99
+ const fieldMessages = messages.get(fieldName)
100
+ if (fieldMessages) {
101
+ fieldMessages.push(issue.message)
102
+ } else {
103
+ messages.set(fieldName, [issue.message])
82
104
  }
83
- result[fieldName].push(issue.message)
84
105
  }
85
- return result
106
+ return Object.fromEntries(messages)
86
107
  }
87
108
 
88
- const result: FieldErrors = {}
109
+ const messages = new Map<string, string>()
89
110
  for (const issue of error.issues) {
90
- const fieldName = String(issue.path[0] ?? "")
91
- if (!result[fieldName]) {
92
- result[fieldName] = issue.message
111
+ const fieldName = fieldNameOf(issue)
112
+ if (!messages.get(fieldName)) {
113
+ messages.set(fieldName, issue.message)
93
114
  }
94
115
  }
95
- return result
116
+ return Object.fromEntries(messages)
96
117
  }
97
118
 
98
119
  /**
99
120
  * Gets error message for a specific field from a ValidationError.
100
- * Returns undefined if no error exists for that field.
121
+ * Returns undefined if no error exists for that field. Pass `""` for issues
122
+ * about the whole argument (an empty path), the key `mapValidationErrors`
123
+ * files them under.
101
124
  *
102
125
  * @example
103
126
  * ```tsx
@@ -111,13 +134,14 @@ export function getFieldError(
111
134
  error: ValidationError,
112
135
  fieldName: string
113
136
  ): string | undefined {
114
- const issue = error.issues.find((i) => String(i.path[0]) === fieldName)
137
+ const issue = error.issues.find((i) => fieldNameOf(i) === fieldName)
115
138
  return issue?.message
116
139
  }
117
140
 
118
141
  /**
119
142
  * Gets all error messages for a specific field from a ValidationError.
120
- * Returns empty array if no errors exist for that field.
143
+ * Returns empty array if no errors exist for that field. Pass `""` for issues
144
+ * about the whole argument (an empty path).
121
145
  *
122
146
  * @example
123
147
  * ```tsx
@@ -132,7 +156,7 @@ export function getFieldErrors(
132
156
  fieldName: string
133
157
  ): string[] {
134
158
  return error.issues
135
- .filter((i) => String(i.path[0]) === fieldName)
159
+ .filter((i) => fieldNameOf(i) === fieldName)
136
160
  .map((i) => i.message)
137
161
  }
138
162