Repository navigation
Expand file tree
/
Copy pathhydration.ts
More file actions
436 lines (404 loc) 路 15.8 KB
/
Copy pathhydration.ts
File metadata and controls
436 lines (404 loc) 路 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
import { noop } from './utils'
import type {
DefaultError,
MutationKey,
MutationMeta,
MutationOptions,
MutationScope,
QueryKey,
QueryMeta,
QueryOptions,
} from './types'
import type { QueryClient } from './queryClient'
import type { Query, QueryState } from './query'
import type { Mutation, MutationState } from './mutation'
// TYPES
type TransformerFn = (data: any) => any
/**
* Reads the value of a promise synchronously if it has already resolved, e.g. a thenable that
* resolves within its `then` call.
* @param promise - The promise to read.
* @returns An object with the resolved `data`, or `undefined` if the promise hasn't resolved
* synchronously (or resolved with `undefined`).
*/
function tryResolveSync(promise: PromiseLike<unknown>) {
let data: unknown
const thenResult = promise.then((result) => {
data = result
return result
}, noop) as Promise<unknown> | undefined
// .catch can be unavailable on certain kinds of thenable's
// oxlint-disable-next-line typescript/no-unnecessary-condition
thenResult?.catch?.(noop)
if (data !== undefined) {
return { data }
}
return undefined
}
/**
* Options for `dehydrate`, controlling which queries/mutations are included in the resulting `DehydratedState` and
* how their data/errors are transformed before being serialized (e.g. for embedding in server-rendered markup).
*/
export interface DehydrateOptions {
/** Transforms a query's `data` before it is dehydrated. Useful for non-JSON-serializable data. */
serializeData?: TransformerFn
/** Predicate to decide whether a given `Mutation` should be dehydrated. Defaults to `defaultShouldDehydrateMutation`. */
shouldDehydrateMutation?: (mutation: Mutation) => boolean
/** Predicate to decide whether a given `Query` should be dehydrated. Defaults to `defaultShouldDehydrateQuery`. */
shouldDehydrateQuery?: (query: Query) => boolean
/**
* Predicate to decide whether a query's error should be redacted before dehydration. Errors are redacted
* (replaced with a generic `Error('redacted')`) unless this function is provided and returns `false` for the
* given error, in which case the original error is kept.
*/
shouldRedactErrors?: (error: unknown) => boolean
}
/**
* Options for `hydrate`, controlling the default options applied to queries/mutations restored from a
* `DehydratedState`, and how to reverse any transformation applied by `DehydrateOptions.serializeData`.
*/
export interface HydrateOptions {
/** Options applied to the queries and mutations restored from the dehydrated state. */
defaultOptions?: {
/** Transforms a query's `data` after it is read from the dehydrated state, reversing `serializeData`. */
deserializeData?: TransformerFn
/** Default options merged into every query restored from the dehydrated state. */
queries?: QueryOptions
/** Default options merged into every mutation restored from the dehydrated state. */
mutations?: MutationOptions<unknown, DefaultError, unknown, unknown>
}
}
interface DehydratedMutation {
mutationKey?: MutationKey
state: MutationState
meta?: MutationMeta
scope?: MutationScope
}
interface DehydratedQuery {
queryHash: string
queryKey: QueryKey
state: QueryState
dehydratedAt: number
promise?: Promise<unknown>
meta?: QueryMeta
queryType?: 'infinite'
}
/**
* A serializable snapshot of a `QueryClient`'s cache, as produced by `dehydrate` and consumed by `hydrate`. Typically
* transported from server to client (e.g. embedded in server-rendered markup) to seed the client's cache with data
* that has already been fetched, avoiding a redundant fetch on the client.
*/
export interface DehydratedState {
/**
* The dehydrated mutations, by default only the paused ones.
*/
mutations: Array<DehydratedMutation>
/**
* The dehydrated queries, by default only the successful ones.
*/
queries: Array<DehydratedQuery>
}
// FUNCTIONS
/**
* Dehydrates a single `Mutation` into a serializable snapshot of its key, state, scope, and meta.
* @param mutation - The mutation to dehydrate.
* @returns The dehydrated mutation.
*/
function dehydrateMutation(mutation: Mutation): DehydratedMutation {
return {
mutationKey: mutation.options.mutationKey,
state: mutation.state,
...(mutation.options.scope && { scope: mutation.options.scope }),
...(mutation.meta && { meta: mutation.meta }),
}
}
/**
* Prepares the in-flight promise of a pending query for dehydration: applies `serializeData` to its
* result, and redacts its rejection error unless `shouldRedactErrors` returns `false` for it.
* @param query - The pending query whose promise is dehydrated.
* @param serializeData - Optional transform applied to the resolved data.
* @param shouldRedactErrors - Optional predicate; if it returns `false` for the rejection error,
* that
* error is kept as-is instead of being redacted.
* @returns The transformed promise, or `undefined` if the query has no in-flight promise.
*/
function dehydratePromise(
query: Query,
serializeData?: TransformerFn,
shouldRedactErrors?: (error: unknown) => boolean,
): Promise<unknown> | undefined {
const promise = query.promise?.then(serializeData).catch((error) => {
if (shouldRedactErrors?.(error) === false) {
// Reject original error if it should not be redacted
return Promise.reject(error)
}
// If not in production, log original error before rejecting redacted error
if (process.env.NODE_ENV !== 'production') {
console.error(
`A query that was dehydrated as pending ended up rejecting. [${query.queryHash}]: ${error}; The error will be redacted in production builds`,
)
}
return Promise.reject(new Error('redacted'))
})
// Avoid unhandled promise rejections
// We need the promise we dehydrate to reject to get the correct result into
// the query cache, but we also want to avoid unhandled promise rejections
// in whatever environment the prefetches are happening in.
promise?.catch(noop)
return promise
}
/**
* Dehydrates a single `Query` into a serializable `DehydratedQuery` snapshot. Note that most query config (e.g.
* `queryFn`, `staleTime`) is not dehydrated but instead meant to be configured again when consuming the
* de/rehydrated data, typically with `useQuery` on the client. If the query is still `pending`, its in-flight
* promise is dehydrated too so it can be resumed on the other side instead of re-fetched.
* @param query - The query to dehydrate.
* @param serializeData - Optional transform applied to `query.state.data` before it is included in the snapshot.
* @param shouldRedactErrors - Optional predicate; if it returns `false` for the promise's rejection error, that
* error is kept as-is instead of being redacted.
* @returns The dehydrated query.
*/
export function dehydrateQuery(
query: Query,
serializeData?: TransformerFn,
shouldRedactErrors?: (error: unknown) => boolean,
): DehydratedQuery {
return {
dehydratedAt: Date.now(),
state: {
...query.state,
...(query.state.data !== undefined && {
data: serializeData
? serializeData(query.state.data)
: query.state.data,
}),
},
queryKey: query.queryKey,
queryHash: query.queryHash,
...(query.state.status === 'pending' && {
promise: dehydratePromise(query, serializeData, shouldRedactErrors),
}),
...(query.meta && { meta: query.meta }),
...(query.queryType && { queryType: query.queryType }),
}
}
/**
* The default `shouldDehydrateMutation` predicate used by `dehydrate`. Only dehydrates mutations that are
* currently paused (e.g. paused by `networkMode` while offline).
* @param mutation - The mutation to check.
* @returns `true` if the mutation is paused.
*/
export function defaultShouldDehydrateMutation(mutation: Mutation) {
return mutation.state.isPaused
}
/**
* The default `shouldDehydrateQuery` predicate used by `dehydrate`. Only dehydrates queries whose status is
* `'success'`.
* @param query - The query to check.
* @returns `true` if the query's status is `'success'`.
*/
export function defaultShouldDehydrateQuery(query: Query) {
return query.state.status === 'success'
}
/**
* Dehydrates a `QueryClient`'s cache (queries and mutations) into a plain, serializable `DehydratedState`,
* typically to embed in server-rendered markup and later restore into a client-side `QueryClient` via `hydrate`.
* Which queries/mutations are included, and how their data/errors are transformed, is controlled by `options`,
* falling back to the client's `dehydrate` default options, and finally to `defaultShouldDehydrateQuery` /
* `defaultShouldDehydrateMutation`.
* @param client - The client whose cache is dehydrated.
* @param options - Controls which queries and mutations are included and how their data and errors
* are transformed. Each option falls back to the client's `defaultOptions.dehydrate`.
* @returns The dehydrated state, with the included `queries` and `mutations`.
* @example
* ```ts
* const queryClient = new QueryClient()
*
* await queryClient.prefetchQuery({
* queryKey: ['posts'],
* queryFn: getPosts,
* })
*
* const dehydratedState = dehydrate(queryClient)
* ```
*/
export function dehydrate(
client: QueryClient,
options: DehydrateOptions = {},
): DehydratedState {
const filterMutation =
options.shouldDehydrateMutation ??
client.getDefaultOptions().dehydrate?.shouldDehydrateMutation ??
defaultShouldDehydrateMutation
const mutations = client
.getMutationCache()
.getAll()
.flatMap((mutation) =>
filterMutation(mutation) ? [dehydrateMutation(mutation)] : [],
)
const filterQuery =
options.shouldDehydrateQuery ??
client.getDefaultOptions().dehydrate?.shouldDehydrateQuery ??
defaultShouldDehydrateQuery
const shouldRedactErrors =
options.shouldRedactErrors ??
client.getDefaultOptions().dehydrate?.shouldRedactErrors
const serializeData =
options.serializeData ?? client.getDefaultOptions().dehydrate?.serializeData
const queries = client
.getQueryCache()
.getAll()
.flatMap((query) =>
filterQuery(query)
? [dehydrateQuery(query, serializeData, shouldRedactErrors)]
: [],
)
return { mutations, queries }
}
/**
* Restores a `DehydratedState` (as produced by `dehydrate`) into a `QueryClient`'s cache, typically to seed the
* client with data already fetched on the server. `mutations` and `queries` are each optional on `dehydratedState`.
* Queries not yet in the cache are built from the dehydrated snapshot; queries that already exist are only updated
* when the dehydrated data is newer than what's already cached. Newly built queries have their `fetchStatus` reset
* to `'idle'` so they don't hydrate stuck in a fetching state. If a dehydrated query still had an in-flight
* promise, it is resumed via `query.fetch()` (reusing that promise as `initialPromise`) rather than re-invoking
* `queryFn`.
* @param client - The client whose cache is restored into.
* @param dehydratedState - The dehydrated state, e.g. produced by `dehydrate` on the server.
* @param options - `defaultOptions` merged into every restored query and mutation (on top of the
* client's `defaultOptions.hydrate`), and `deserializeData` to reverse `serializeData`.
* @example
* ```ts
* // dehydratedState was produced by `dehydrate` on the server
* // and sent to the client, e.g. embedded in server-rendered markup.
* const queryClient = new QueryClient()
*
* hydrate(queryClient, dehydratedState)
* ```
*/
export function hydrate(
client: QueryClient,
dehydratedState: Partial<DehydratedState>,
options?: HydrateOptions,
): void {
const mutationCache = client.getMutationCache()
const queryCache = client.getQueryCache()
const deserializeData =
options?.defaultOptions?.deserializeData ??
client.getDefaultOptions().hydrate?.deserializeData
dehydratedState.mutations?.forEach(({ state, ...mutationOptions }) => {
mutationCache.build(
client,
{
...client.getDefaultOptions().hydrate?.mutations,
...options?.defaultOptions?.mutations,
...mutationOptions,
},
state,
)
})
dehydratedState.queries?.forEach(
({
queryKey,
state,
queryHash,
meta,
promise,
dehydratedAt,
queryType,
}) => {
const syncData = promise ? tryResolveSync(promise) : undefined
const rawData = state.data === undefined ? syncData?.data : state.data
const data =
rawData === undefined
? rawData
: deserializeData
? deserializeData(rawData)
: rawData
let query = queryCache.get(queryHash)
const existingQueryIsPending = query?.state.status === 'pending'
const existingQueryIsFetching = query?.state.fetchStatus === 'fetching'
// Do not hydrate if an existing query exists with newer data
if (query) {
const hasNewerSyncData =
syncData && dehydratedAt > query.state.dataUpdatedAt
if (
state.dataUpdatedAt > query.state.dataUpdatedAt ||
hasNewerSyncData
) {
// Omit fetchStatus from dehydrated state so that query stays in its current fetchStatus
const { fetchStatus: _ignored, ...serializedState } = state
query.setState({
...serializedState,
data,
// If the query was pending at the moment of dehydration, but resolved to have data
// before hydration, we can assume the query should be hydrated as successful.
//
// Since you can opt into dehydrating failed queries, and those can have data from
// previous successful fetches, we make sure we only do this for pending queries.
...(state.status === 'pending' &&
data !== undefined && {
status: 'success' as const,
dataUpdatedAt: dehydratedAt,
// Preserve existing fetchStatus if the existing query is actively fetching.
...(!existingQueryIsFetching && {
fetchStatus: 'idle' as const,
}),
}),
})
}
} else {
// Restore query
query = queryCache.build(
client,
{
...client.getDefaultOptions().hydrate?.queries,
...options?.defaultOptions?.queries,
queryKey,
queryHash,
meta,
_type: queryType,
},
// Reset fetch status to idle to avoid
// query being stuck in fetching state upon hydration
{
...state,
data,
fetchStatus: 'idle',
// Like above, if the query was pending at the moment of dehydration but has data,
// we can assume it should be hydrated as successful.
status:
state.status === 'pending' && data !== undefined
? 'success'
: state.status,
...(state.status === 'pending' &&
data !== undefined && {
dataUpdatedAt: dehydratedAt,
}),
},
)
}
if (
promise &&
// If the data was synchronously available, there is no need to set up
// a retryer and thus no reason to call fetch
!syncData &&
!existingQueryIsPending &&
!existingQueryIsFetching &&
// Only hydrate if dehydration is newer than any existing data,
// this is always true for new queries
dehydratedAt > query.state.dataUpdatedAt
) {
// This doesn't actually fetch - it just creates a retryer
// which will re-use the passed `initialPromise`
query
.fetch(undefined, {
// RSC transformed promises are not thenable
initialPromise: Promise.resolve(promise).then(deserializeData),
})
// Avoid unhandled promise rejections
.catch(noop)
}
},
)
}