UNPKG

21.2 kBPlain TextView Raw
1import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs'
2import type { Api, ApiContext } from '../apiTypes'
3import type {
4 BaseQueryFn,
5 BaseQueryError,
6 QueryReturnValue,
7} from '../baseQueryTypes'
8import type { RootState, QueryKeys, QuerySubstateIdentifier } from './apiState'
9import { QueryStatus } from './apiState'
10import type {
11 StartQueryActionCreatorOptions,
12 QueryActionCreatorResult,
13} from './buildInitiate'
14import { forceQueryFnSymbol, isUpsertQuery } from './buildInitiate'
15import type {
16 AssertTagTypes,
17 EndpointDefinition,
18 EndpointDefinitions,
19 MutationDefinition,
20 QueryArgFrom,
21 QueryDefinition,
22 ResultTypeFrom,
23 FullTagDescription,
24} from '../endpointDefinitions'
25import { isQueryDefinition } from '../endpointDefinitions'
26import { calculateProvidedBy } from '../endpointDefinitions'
27import type {
28 AsyncThunkPayloadCreator,
29 Draft,
30 UnknownAction,
31} from '@reduxjs/toolkit'
32import {
33 isAllOf,
34 isFulfilled,
35 isPending,
36 isRejected,
37 isRejectedWithValue,
38 createAsyncThunk,
39 SHOULD_AUTOBATCH,
40} from './rtkImports'
41import type { Patch } from 'immer'
42import { isDraftable, produceWithPatches } from 'immer'
43import type { ThunkAction, ThunkDispatch, AsyncThunk } from '@reduxjs/toolkit'
44
45import { HandledError } from '../HandledError'
46
47import type { ApiEndpointQuery, PrefetchOptions } from './module'
48import type { UnwrapPromise } from '../tsHelpers'
49
50declare module './module' {
51 export interface ApiEndpointQuery<
52 Definition extends QueryDefinition<any, any, any, any, any>,
53 // eslint-disable-next-line @typescript-eslint/no-unused-vars
54 Definitions extends EndpointDefinitions,
55 > extends Matchers<QueryThunk, Definition> {}
56
57 export interface ApiEndpointMutation<
58 Definition extends MutationDefinition<any, any, any, any, any>,
59 // eslint-disable-next-line @typescript-eslint/no-unused-vars
60 Definitions extends EndpointDefinitions,
61 > extends Matchers<MutationThunk, Definition> {}
62}
63
64type EndpointThunk<
65 Thunk extends QueryThunk | MutationThunk,
66 Definition extends EndpointDefinition<any, any, any, any>,
67> =
68 Definition extends EndpointDefinition<
69 infer QueryArg,
70 infer BaseQueryFn,
71 any,
72 infer ResultType
73 >
74 ? Thunk extends AsyncThunk<unknown, infer ATArg, infer ATConfig>
75 ? AsyncThunk<
76 ResultType,
77 ATArg & { originalArgs: QueryArg },
78 ATConfig & { rejectValue: BaseQueryError<BaseQueryFn> }
79 >
80 : never
81 : never
82
83export type PendingAction<
84 Thunk extends QueryThunk | MutationThunk,
85 Definition extends EndpointDefinition<any, any, any, any>,
86> = ReturnType<EndpointThunk<Thunk, Definition>['pending']>
87
88export type FulfilledAction<
89 Thunk extends QueryThunk | MutationThunk,
90 Definition extends EndpointDefinition<any, any, any, any>,
91> = ReturnType<EndpointThunk<Thunk, Definition>['fulfilled']>
92
93export type RejectedAction<
94 Thunk extends QueryThunk | MutationThunk,
95 Definition extends EndpointDefinition<any, any, any, any>,
96> = ReturnType<EndpointThunk<Thunk, Definition>['rejected']>
97
98export type Matcher<M> = (value: any) => value is M
99
100export interface Matchers<
101 Thunk extends QueryThunk | MutationThunk,
102 Definition extends EndpointDefinition<any, any, any, any>,
103> {
104 matchPending: Matcher<PendingAction<Thunk, Definition>>
105 matchFulfilled: Matcher<FulfilledAction<Thunk, Definition>>
106 matchRejected: Matcher<RejectedAction<Thunk, Definition>>
107}
108
109export interface QueryThunkArg
110 extends QuerySubstateIdentifier,
111 StartQueryActionCreatorOptions {
112 type: 'query'
113 originalArgs: unknown
114 endpointName: string
115}
116
117export interface MutationThunkArg {
118 type: 'mutation'
119 originalArgs: unknown
120 endpointName: string
121 track?: boolean
122 fixedCacheKey?: string
123}
124
125export type ThunkResult = unknown
126
127export type ThunkApiMetaConfig = {
128 pendingMeta: {
129 startedTimeStamp: number
130 [SHOULD_AUTOBATCH]: true
131 }
132 fulfilledMeta: {
133 fulfilledTimeStamp: number
134 baseQueryMeta: unknown
135 [SHOULD_AUTOBATCH]: true
136 }
137 rejectedMeta: {
138 baseQueryMeta: unknown
139 [SHOULD_AUTOBATCH]: true
140 }
141}
142export type QueryThunk = AsyncThunk<
143 ThunkResult,
144 QueryThunkArg,
145 ThunkApiMetaConfig
146>
147export type MutationThunk = AsyncThunk<
148 ThunkResult,
149 MutationThunkArg,
150 ThunkApiMetaConfig
151>
152
153function defaultTransformResponse(baseQueryReturnValue: unknown) {
154 return baseQueryReturnValue
155}
156
157export type MaybeDrafted<T> = T | Draft<T>
158export type Recipe<T> = (data: MaybeDrafted<T>) => void | MaybeDrafted<T>
159export type UpsertRecipe<T> = (
160 data: MaybeDrafted<T> | undefined,
161) => void | MaybeDrafted<T>
162
163export type PatchQueryDataThunk<
164 Definitions extends EndpointDefinitions,
165 PartialState,
166> = <EndpointName extends QueryKeys<Definitions>>(
167 endpointName: EndpointName,
168 args: QueryArgFrom<Definitions[EndpointName]>,
169 patches: readonly Patch[],
170 updateProvided?: boolean,
171) => ThunkAction<void, PartialState, any, UnknownAction>
172
173export type UpdateQueryDataThunk<
174 Definitions extends EndpointDefinitions,
175 PartialState,
176> = <EndpointName extends QueryKeys<Definitions>>(
177 endpointName: EndpointName,
178 args: QueryArgFrom<Definitions[EndpointName]>,
179 updateRecipe: Recipe<ResultTypeFrom<Definitions[EndpointName]>>,
180 updateProvided?: boolean,
181) => ThunkAction<PatchCollection, PartialState, any, UnknownAction>
182
183export type UpsertQueryDataThunk<
184 Definitions extends EndpointDefinitions,
185 PartialState,
186> = <EndpointName extends QueryKeys<Definitions>>(
187 endpointName: EndpointName,
188 args: QueryArgFrom<Definitions[EndpointName]>,
189 value: ResultTypeFrom<Definitions[EndpointName]>,
190) => ThunkAction<
191 QueryActionCreatorResult<
192 Definitions[EndpointName] extends QueryDefinition<any, any, any, any>
193 ? Definitions[EndpointName]
194 : never
195 >,
196 PartialState,
197 any,
198 UnknownAction
199>
200
201/**
202 * An object returned from dispatching a `api.util.updateQueryData` call.
203 */
204export type PatchCollection = {
205 /**
206 * An `immer` Patch describing the cache update.
207 */
208 patches: Patch[]
209 /**
210 * An `immer` Patch to revert the cache update.
211 */
212 inversePatches: Patch[]
213 /**
214 * A function that will undo the cache update.
215 */
216 undo: () => void
217}
218
219export function buildThunks<
220 BaseQuery extends BaseQueryFn,
221 ReducerPath extends string,
222 Definitions extends EndpointDefinitions,
223>({
224 reducerPath,
225 baseQuery,
226 context: { endpointDefinitions },
227 serializeQueryArgs,
228 api,
229 assertTagType,
230}: {
231 baseQuery: BaseQuery
232 reducerPath: ReducerPath
233 context: ApiContext<Definitions>
234 serializeQueryArgs: InternalSerializeQueryArgs
235 api: Api<BaseQuery, Definitions, ReducerPath, any>
236 assertTagType: AssertTagTypes
237}) {
238 type State = RootState<any, string, ReducerPath>
239
240 const patchQueryData: PatchQueryDataThunk<EndpointDefinitions, State> =
241 (endpointName, args, patches, updateProvided) => (dispatch, getState) => {
242 const endpointDefinition = endpointDefinitions[endpointName]
243
244 const queryCacheKey = serializeQueryArgs({
245 queryArgs: args,
246 endpointDefinition,
247 endpointName,
248 })
249
250 dispatch(
251 api.internalActions.queryResultPatched({ queryCacheKey, patches }),
252 )
253
254 if (!updateProvided) {
255 return
256 }
257
258 const newValue = api.endpoints[endpointName].select(args)(
259 // Work around TS 4.1 mismatch
260 getState() as RootState<any, any, any>,
261 )
262
263 const providedTags = calculateProvidedBy(
264 endpointDefinition.providesTags,
265 newValue.data,
266 undefined,
267 args,
268 {},
269 assertTagType,
270 )
271
272 dispatch(
273 api.internalActions.updateProvidedBy({ queryCacheKey, providedTags }),
274 )
275 }
276
277 const updateQueryData: UpdateQueryDataThunk<EndpointDefinitions, State> =
278 (endpointName, args, updateRecipe, updateProvided = true) =>
279 (dispatch, getState) => {
280 const endpointDefinition = api.endpoints[endpointName]
281
282 const currentState = endpointDefinition.select(args)(
283 // Work around TS 4.1 mismatch
284 getState() as RootState<any, any, any>,
285 )
286
287 let ret: PatchCollection = {
288 patches: [],
289 inversePatches: [],
290 undo: () =>
291 dispatch(
292 api.util.patchQueryData(
293 endpointName,
294 args,
295 ret.inversePatches,
296 updateProvided,
297 ),
298 ),
299 }
300 if (currentState.status === QueryStatus.uninitialized) {
301 return ret
302 }
303 let newValue
304 if ('data' in currentState) {
305 if (isDraftable(currentState.data)) {
306 const [value, patches, inversePatches] = produceWithPatches(
307 currentState.data,
308 updateRecipe,
309 )
310 ret.patches.push(...patches)
311 ret.inversePatches.push(...inversePatches)
312 newValue = value
313 } else {
314 newValue = updateRecipe(currentState.data)
315 ret.patches.push({ op: 'replace', path: [], value: newValue })
316 ret.inversePatches.push({
317 op: 'replace',
318 path: [],
319 value: currentState.data,
320 })
321 }
322 }
323
324 if (ret.patches.length === 0) {
325 return ret
326 }
327
328 dispatch(
329 api.util.patchQueryData(
330 endpointName,
331 args,
332 ret.patches,
333 updateProvided,
334 ),
335 )
336
337 return ret
338 }
339
340 const upsertQueryData: UpsertQueryDataThunk<Definitions, State> =
341 (endpointName, args, value) => (dispatch) => {
342 return dispatch(
343 (
344 api.endpoints[endpointName] as ApiEndpointQuery<
345 QueryDefinition<any, any, any, any, any>,
346 Definitions
347 >
348 ).initiate(args, {
349 subscribe: false,
350 forceRefetch: true,
351 [forceQueryFnSymbol]: () => ({
352 data: value,
353 }),
354 }),
355 )
356 }
357
358 const executeEndpoint: AsyncThunkPayloadCreator<
359 ThunkResult,
360 QueryThunkArg | MutationThunkArg,
361 ThunkApiMetaConfig & { state: RootState<any, string, ReducerPath> }
362 > = async (
363 arg,
364 {
365 signal,
366 abort,
367 rejectWithValue,
368 fulfillWithValue,
369 dispatch,
370 getState,
371 extra,
372 },
373 ) => {
374 const endpointDefinition = endpointDefinitions[arg.endpointName]
375
376 try {
377 let transformResponse: (
378 baseQueryReturnValue: any,
379 meta: any,
380 arg: any,
381 ) => any = defaultTransformResponse
382 let result: QueryReturnValue
383 const baseQueryApi = {
384 signal,
385 abort,
386 dispatch,
387 getState,
388 extra,
389 endpoint: arg.endpointName,
390 type: arg.type,
391 forced:
392 arg.type === 'query' ? isForcedQuery(arg, getState()) : undefined,
393 }
394
395 const forceQueryFn =
396 arg.type === 'query' ? arg[forceQueryFnSymbol] : undefined
397 if (forceQueryFn) {
398 result = forceQueryFn()
399 } else if (endpointDefinition.query) {
400 result = await baseQuery(
401 endpointDefinition.query(arg.originalArgs),
402 baseQueryApi,
403 endpointDefinition.extraOptions as any,
404 )
405
406 if (endpointDefinition.transformResponse) {
407 transformResponse = endpointDefinition.transformResponse
408 }
409 } else {
410 result = await endpointDefinition.queryFn(
411 arg.originalArgs,
412 baseQueryApi,
413 endpointDefinition.extraOptions as any,
414 (arg) =>
415 baseQuery(
416 arg,
417 baseQueryApi,
418 endpointDefinition.extraOptions as any,
419 ),
420 )
421 }
422 if (
423 typeof process !== 'undefined' &&
424 process.env.NODE_ENV === 'development'
425 ) {
426 const what = endpointDefinition.query ? '`baseQuery`' : '`queryFn`'
427 let err: undefined | string
428 if (!result) {
429 err = `${what} did not return anything.`
430 } else if (typeof result !== 'object') {
431 err = `${what} did not return an object.`
432 } else if (result.error && result.data) {
433 err = `${what} returned an object containing both \`error\` and \`result\`.`
434 } else if (result.error === undefined && result.data === undefined) {
435 err = `${what} returned an object containing neither a valid \`error\` and \`result\`. At least one of them should not be \`undefined\``
436 } else {
437 for (const key of Object.keys(result)) {
438 if (key !== 'error' && key !== 'data' && key !== 'meta') {
439 err = `The object returned by ${what} has the unknown property ${key}.`
440 break
441 }
442 }
443 }
444 if (err) {
445 console.error(
446 `Error encountered handling the endpoint ${arg.endpointName}.
447 ${err}
448 It needs to return an object with either the shape \`{ data: <value> }\` or \`{ error: <value> }\` that may contain an optional \`meta\` property.
449 Object returned was:`,
450 result,
451 )
452 }
453 }
454
455 if (result.error) throw new HandledError(result.error, result.meta)
456
457 return fulfillWithValue(
458 await transformResponse(result.data, result.meta, arg.originalArgs),
459 {
460 fulfilledTimeStamp: Date.now(),
461 baseQueryMeta: result.meta,
462 [SHOULD_AUTOBATCH]: true,
463 },
464 )
465 } catch (error) {
466 let catchedError = error
467 if (catchedError instanceof HandledError) {
468 let transformErrorResponse: (
469 baseQueryReturnValue: any,
470 meta: any,
471 arg: any,
472 ) => any = defaultTransformResponse
473
474 if (
475 endpointDefinition.query &&
476 endpointDefinition.transformErrorResponse
477 ) {
478 transformErrorResponse = endpointDefinition.transformErrorResponse
479 }
480 try {
481 return rejectWithValue(
482 await transformErrorResponse(
483 catchedError.value,
484 catchedError.meta,
485 arg.originalArgs,
486 ),
487 { baseQueryMeta: catchedError.meta, [SHOULD_AUTOBATCH]: true },
488 )
489 } catch (e) {
490 catchedError = e
491 }
492 }
493 if (
494 typeof process !== 'undefined' &&
495 process.env.NODE_ENV !== 'production'
496 ) {
497 console.error(
498 `An unhandled error occurred processing a request for the endpoint "${arg.endpointName}".
499In the case of an unhandled error, no tags will be "provided" or "invalidated".`,
500 catchedError,
501 )
502 } else {
503 console.error(catchedError)
504 }
505 throw catchedError
506 }
507 }
508
509 function isForcedQuery(
510 arg: QueryThunkArg,
511 state: RootState<any, string, ReducerPath>,
512 ) {
513 const requestState = state[reducerPath]?.queries?.[arg.queryCacheKey]
514 const baseFetchOnMountOrArgChange =
515 state[reducerPath]?.config.refetchOnMountOrArgChange
516
517 const fulfilledVal = requestState?.fulfilledTimeStamp
518 const refetchVal =
519 arg.forceRefetch ?? (arg.subscribe && baseFetchOnMountOrArgChange)
520
521 if (refetchVal) {
522 // Return if its true or compare the dates because it must be a number
523 return (
524 refetchVal === true ||
525 (Number(new Date()) - Number(fulfilledVal)) / 1000 >= refetchVal
526 )
527 }
528 return false
529 }
530
531 const queryThunk = createAsyncThunk<
532 ThunkResult,
533 QueryThunkArg,
534 ThunkApiMetaConfig & { state: RootState<any, string, ReducerPath> }
535 >(`${reducerPath}/executeQuery`, executeEndpoint, {
536 getPendingMeta() {
537 return { startedTimeStamp: Date.now(), [SHOULD_AUTOBATCH]: true }
538 },
539 condition(queryThunkArgs, { getState }) {
540 const state = getState()
541
542 const requestState =
543 state[reducerPath]?.queries?.[queryThunkArgs.queryCacheKey]
544 const fulfilledVal = requestState?.fulfilledTimeStamp
545 const currentArg = queryThunkArgs.originalArgs
546 const previousArg = requestState?.originalArgs
547 const endpointDefinition =
548 endpointDefinitions[queryThunkArgs.endpointName]
549
550 // Order of these checks matters.
551 // In order for `upsertQueryData` to successfully run while an existing request is in flight,
552 /// we have to check for that first, otherwise `queryThunk` will bail out and not run at all.
553 if (isUpsertQuery(queryThunkArgs)) {
554 return true
555 }
556
557 // Don't retry a request that's currently in-flight
558 if (requestState?.status === 'pending') {
559 return false
560 }
561
562 // if this is forced, continue
563 if (isForcedQuery(queryThunkArgs, state)) {
564 return true
565 }
566
567 if (
568 isQueryDefinition(endpointDefinition) &&
569 endpointDefinition?.forceRefetch?.({
570 currentArg,
571 previousArg,
572 endpointState: requestState,
573 state,
574 })
575 ) {
576 return true
577 }
578
579 // Pull from the cache unless we explicitly force refetch or qualify based on time
580 if (fulfilledVal) {
581 // Value is cached and we didn't specify to refresh, skip it.
582 return false
583 }
584
585 return true
586 },
587 dispatchConditionRejection: true,
588 })
589
590 const mutationThunk = createAsyncThunk<
591 ThunkResult,
592 MutationThunkArg,
593 ThunkApiMetaConfig & { state: RootState<any, string, ReducerPath> }
594 >(`${reducerPath}/executeMutation`, executeEndpoint, {
595 getPendingMeta() {
596 return { startedTimeStamp: Date.now(), [SHOULD_AUTOBATCH]: true }
597 },
598 })
599
600 const hasTheForce = (options: any): options is { force: boolean } =>
601 'force' in options
602 const hasMaxAge = (
603 options: any,
604 ): options is { ifOlderThan: false | number } => 'ifOlderThan' in options
605
606 const prefetch =
607 <EndpointName extends QueryKeys<Definitions>>(
608 endpointName: EndpointName,
609 arg: any,
610 options: PrefetchOptions,
611 ): ThunkAction<void, any, any, UnknownAction> =>
612 (dispatch: ThunkDispatch<any, any, any>, getState: () => any) => {
613 const force = hasTheForce(options) && options.force
614 const maxAge = hasMaxAge(options) && options.ifOlderThan
615
616 const queryAction = (force: boolean = true) => {
617 const options = { forceRefetch: force, isPrefetch: true }
618 return (
619 api.endpoints[endpointName] as ApiEndpointQuery<any, any>
620 ).initiate(arg, options)
621 }
622 const latestStateValue = (
623 api.endpoints[endpointName] as ApiEndpointQuery<any, any>
624 ).select(arg)(getState())
625
626 if (force) {
627 dispatch(queryAction())
628 } else if (maxAge) {
629 const lastFulfilledTs = latestStateValue?.fulfilledTimeStamp
630 if (!lastFulfilledTs) {
631 dispatch(queryAction())
632 return
633 }
634 const shouldRetrigger =
635 (Number(new Date()) - Number(new Date(lastFulfilledTs))) / 1000 >=
636 maxAge
637 if (shouldRetrigger) {
638 dispatch(queryAction())
639 }
640 } else {
641 // If prefetching with no options, just let it try
642 dispatch(queryAction(false))
643 }
644 }
645
646 function matchesEndpoint(endpointName: string) {
647 return (action: any): action is UnknownAction =>
648 action?.meta?.arg?.endpointName === endpointName
649 }
650
651 function buildMatchThunkActions<
652 Thunk extends
653 | AsyncThunk<any, QueryThunkArg, ThunkApiMetaConfig>
654 | AsyncThunk<any, MutationThunkArg, ThunkApiMetaConfig>,
655 >(thunk: Thunk, endpointName: string) {
656 return {
657 matchPending: isAllOf(isPending(thunk), matchesEndpoint(endpointName)),
658 matchFulfilled: isAllOf(
659 isFulfilled(thunk),
660 matchesEndpoint(endpointName),
661 ),
662 matchRejected: isAllOf(isRejected(thunk), matchesEndpoint(endpointName)),
663 } as Matchers<Thunk, any>
664 }
665
666 return {
667 queryThunk,
668 mutationThunk,
669 prefetch,
670 updateQueryData,
671 upsertQueryData,
672 patchQueryData,
673 buildMatchThunkActions,
674 }
675}
676
677export function calculateProvidedByThunk(
678 action: UnwrapPromise<
679 ReturnType<ReturnType<QueryThunk>> | ReturnType<ReturnType<MutationThunk>>
680 >,
681 type: 'providesTags' | 'invalidatesTags',
682 endpointDefinitions: EndpointDefinitions,
683 assertTagType: AssertTagTypes,
684) {
685 return calculateProvidedBy(
686 endpointDefinitions[action.meta.arg.endpointName][type],
687 isFulfilled(action) ? action.payload : undefined,
688 isRejectedWithValue(action) ? action.payload : undefined,
689 action.meta.arg.originalArgs,
690 'baseQueryMeta' in action.meta ? action.meta.baseQueryMeta : undefined,
691 assertTagType,
692 )
693}