@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.
- package/README.md +410 -44
- package/dist/auth/auth-client-compat.d.ts +122 -0
- package/dist/auth/auth-client-compat.d.ts.map +1 -0
- package/dist/auth/auth-client-compat.js +162 -0
- package/dist/auth/auth-client-compat.js.map +1 -0
- package/dist/auth/authentication-manager.d.ts +287 -5
- package/dist/auth/authentication-manager.d.ts.map +1 -1
- package/dist/auth/authentication-manager.js +920 -150
- package/dist/auth/authentication-manager.js.map +1 -1
- package/dist/auth/createIdentityAttributeHooks.d.ts.map +1 -1
- package/dist/auth/createIdentityAttributeHooks.js +36 -20
- package/dist/auth/createIdentityAttributeHooks.js.map +1 -1
- package/dist/auth/identity-attributes-manager.d.ts +2 -1
- package/dist/auth/identity-attributes-manager.d.ts.map +1 -1
- package/dist/auth/identity-attributes-manager.js +90 -6
- package/dist/auth/identity-attributes-manager.js.map +1 -1
- package/dist/auth/identity-attributes.d.ts.map +1 -1
- package/dist/auth/identity-attributes.js +57 -0
- package/dist/auth/identity-attributes.js.map +1 -1
- package/dist/auth/local-ii-probe.d.ts +12 -1
- package/dist/auth/local-ii-probe.d.ts.map +1 -1
- package/dist/auth/local-ii-probe.js +22 -3
- package/dist/auth/local-ii-probe.js.map +1 -1
- package/dist/auth/types.d.ts +48 -5
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/createActorHooks.d.ts +9 -20
- package/dist/createActorHooks.d.ts.map +1 -1
- package/dist/createActorHooks.js.map +1 -1
- package/dist/createInfiniteQuery.d.ts +51 -10
- package/dist/createInfiniteQuery.d.ts.map +1 -1
- package/dist/createInfiniteQuery.js +39 -15
- package/dist/createInfiniteQuery.js.map +1 -1
- package/dist/createMutation.d.ts +4 -1
- package/dist/createMutation.d.ts.map +1 -1
- package/dist/createMutation.js +121 -84
- package/dist/createMutation.js.map +1 -1
- package/dist/createQuery.d.ts +35 -2
- package/dist/createQuery.d.ts.map +1 -1
- package/dist/createQuery.js +104 -17
- package/dist/createQuery.js.map +1 -1
- package/dist/createReactorProvider.d.ts +158 -0
- package/dist/createReactorProvider.d.ts.map +1 -0
- package/dist/createReactorProvider.js +256 -0
- package/dist/createReactorProvider.js.map +1 -0
- package/dist/createSuspenseInfiniteQuery.d.ts +16 -9
- package/dist/createSuspenseInfiniteQuery.d.ts.map +1 -1
- package/dist/createSuspenseInfiniteQuery.js +59 -27
- package/dist/createSuspenseInfiniteQuery.js.map +1 -1
- package/dist/createSuspenseQuery.d.ts +23 -2
- package/dist/createSuspenseQuery.d.ts.map +1 -1
- package/dist/createSuspenseQuery.js +68 -21
- package/dist/createSuspenseQuery.js.map +1 -1
- package/dist/defineDisplayReactor.d.ts +43 -0
- package/dist/defineDisplayReactor.d.ts.map +1 -0
- package/dist/defineDisplayReactor.js +42 -0
- package/dist/defineDisplayReactor.js.map +1 -0
- package/dist/defineReactor.d.ts +46 -72
- package/dist/defineReactor.d.ts.map +1 -1
- package/dist/defineReactor.js +11 -176
- package/dist/defineReactor.js.map +1 -1
- package/dist/defineReactorShared.d.ts +84 -0
- package/dist/defineReactorShared.d.ts.map +1 -0
- package/dist/defineReactorShared.js +139 -0
- package/dist/defineReactorShared.js.map +1 -0
- package/dist/hooks/createAuthHooks.d.ts +9 -2
- package/dist/hooks/createAuthHooks.d.ts.map +1 -1
- package/dist/hooks/createAuthHooks.js +184 -24
- package/dist/hooks/createAuthHooks.js.map +1 -1
- package/dist/hooks/useActorInfiniteQuery.d.ts +36 -8
- package/dist/hooks/useActorInfiniteQuery.d.ts.map +1 -1
- package/dist/hooks/useActorInfiniteQuery.js +54 -21
- package/dist/hooks/useActorInfiniteQuery.js.map +1 -1
- package/dist/hooks/useActorMethod.d.ts +37 -4
- package/dist/hooks/useActorMethod.d.ts.map +1 -1
- package/dist/hooks/useActorMethod.js +201 -57
- package/dist/hooks/useActorMethod.js.map +1 -1
- package/dist/hooks/useActorMutation.d.ts +15 -12
- package/dist/hooks/useActorMutation.d.ts.map +1 -1
- package/dist/hooks/useActorMutation.js +14 -13
- package/dist/hooks/useActorMutation.js.map +1 -1
- package/dist/hooks/useActorQuery.d.ts +17 -4
- package/dist/hooks/useActorQuery.d.ts.map +1 -1
- package/dist/hooks/useActorQuery.js +30 -9
- package/dist/hooks/useActorQuery.js.map +1 -1
- package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts +17 -5
- package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts.map +1 -1
- package/dist/hooks/useActorSuspenseInfiniteQuery.js +37 -17
- package/dist/hooks/useActorSuspenseInfiniteQuery.js.map +1 -1
- package/dist/hooks/useActorSuspenseQuery.d.ts +2 -2
- package/dist/hooks/useActorSuspenseQuery.d.ts.map +1 -1
- package/dist/hooks/useActorSuspenseQuery.js +20 -9
- package/dist/hooks/useActorSuspenseQuery.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/ownedAuthentication.d.ts +52 -0
- package/dist/ownedAuthentication.d.ts.map +1 -0
- package/dist/ownedAuthentication.js +49 -0
- package/dist/ownedAuthentication.js.map +1 -0
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +23 -0
- package/dist/server.js.map +1 -0
- package/dist/testing.d.ts +19 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +19 -0
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +428 -21
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/utils.d.ts +159 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +301 -1
- package/dist/utils.js.map +1 -1
- package/dist/validation.d.ts +12 -7
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +34 -15
- package/dist/validation.js.map +1 -1
- package/llms.txt +259 -33
- package/package.json +17 -5
- package/src/auth/auth-client-compat.ts +273 -0
- package/src/auth/authentication-manager.ts +918 -96
- package/src/auth/createIdentityAttributeHooks.ts +47 -21
- package/src/auth/identity-attributes-manager.ts +100 -5
- package/src/auth/identity-attributes.ts +75 -0
- package/src/auth/local-ii-probe.ts +29 -3
- package/src/auth/types.ts +49 -6
- package/src/createActorHooks.ts +50 -42
- package/src/createInfiniteQuery.ts +120 -28
- package/src/createMutation.ts +213 -132
- package/src/createQuery.ts +164 -32
- package/src/createReactorProvider.ts +365 -0
- package/src/createSuspenseInfiniteQuery.ts +93 -43
- package/src/createSuspenseQuery.ts +102 -32
- package/src/defineDisplayReactor.ts +62 -0
- package/src/defineReactor.ts +81 -263
- package/src/defineReactorShared.ts +268 -0
- package/src/hooks/createAuthHooks.ts +210 -28
- package/src/hooks/useActorInfiniteQuery.ts +156 -55
- package/src/hooks/useActorMethod.ts +295 -92
- package/src/hooks/useActorMutation.ts +42 -30
- package/src/hooks/useActorQuery.ts +43 -10
- package/src/hooks/useActorSuspenseInfiniteQuery.ts +110 -54
- package/src/hooks/useActorSuspenseQuery.ts +30 -15
- package/src/index.ts +8 -0
- package/src/ownedAuthentication.ts +81 -0
- package/src/server.ts +23 -0
- package/src/testing.ts +18 -0
- package/src/types.ts +492 -22
- package/src/utils.ts +387 -3
- 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
|
|
6
|
-
import type {
|
|
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(
|
|
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
|
|
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
|
|
96
|
+
const messages = new Map<string, string[]>()
|
|
78
97
|
for (const issue of error.issues) {
|
|
79
|
-
const fieldName =
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
106
|
+
return Object.fromEntries(messages)
|
|
86
107
|
}
|
|
87
108
|
|
|
88
|
-
const
|
|
109
|
+
const messages = new Map<string, string>()
|
|
89
110
|
for (const issue of error.issues) {
|
|
90
|
-
const fieldName =
|
|
91
|
-
if (!
|
|
92
|
-
|
|
111
|
+
const fieldName = fieldNameOf(issue)
|
|
112
|
+
if (!messages.get(fieldName)) {
|
|
113
|
+
messages.set(fieldName, issue.message)
|
|
93
114
|
}
|
|
94
115
|
}
|
|
95
|
-
return
|
|
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) =>
|
|
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) =>
|
|
159
|
+
.filter((i) => fieldNameOf(i) === fieldName)
|
|
136
160
|
.map((i) => i.message)
|
|
137
161
|
}
|
|
138
162
|
|