@reduxjs/toolkit
Version:
The official, opinionated, batteries-included toolset for efficient Redux development
519 lines (472 loc) • 16.6 kB
text/typescript
import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs'
import type { Api, ApiContext } from '../apiTypes'
import type {
BaseQueryFn,
BaseQueryError,
QueryReturnValue,
} from '../baseQueryTypes'
import type { RootState, QueryKeys, QuerySubstateIdentifier } from './apiState'
import { QueryStatus } from './apiState'
import type { StartQueryActionCreatorOptions } from './buildInitiate'
import type {
AssertTagTypes,
EndpointDefinition,
EndpointDefinitions,
MutationDefinition,
QueryArgFrom,
QueryDefinition,
ResultTypeFrom,
} from '../endpointDefinitions'
import { calculateProvidedBy } from '../endpointDefinitions'
import type { AsyncThunkPayloadCreator, Draft } from '@reduxjs/toolkit'
import {
isAllOf,
isFulfilled,
isPending,
isRejected,
isRejectedWithValue,
} from '@reduxjs/toolkit'
import type { Patch } from 'immer'
import { isDraftable, produceWithPatches } from 'immer'
import type {
AnyAction,
ThunkAction,
ThunkDispatch,
AsyncThunk,
} from '@reduxjs/toolkit'
import { createAsyncThunk } from '@reduxjs/toolkit'
import { HandledError } from '../HandledError'
import type { ApiEndpointQuery, PrefetchOptions } from './module'
import type { UnwrapPromise } from '../tsHelpers'
declare module './module' {
export interface ApiEndpointQuery<
Definition extends QueryDefinition<any, any, any, any, any>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Definitions extends EndpointDefinitions
> extends Matchers<QueryThunk, Definition> {}
export interface ApiEndpointMutation<
Definition extends MutationDefinition<any, any, any, any, any>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Definitions extends EndpointDefinitions
> extends Matchers<MutationThunk, Definition> {}
}
type EndpointThunk<
Thunk extends QueryThunk | MutationThunk,
Definition extends EndpointDefinition<any, any, any, any>
> = Definition extends EndpointDefinition<
infer QueryArg,
infer BaseQueryFn,
any,
infer ResultType
>
? Thunk extends AsyncThunk<unknown, infer ATArg, infer ATConfig>
? AsyncThunk<
ResultType,
ATArg & { originalArgs: QueryArg },
ATConfig & { rejectValue: BaseQueryError<BaseQueryFn> }
>
: never
: never
export type PendingAction<
Thunk extends QueryThunk | MutationThunk,
Definition extends EndpointDefinition<any, any, any, any>
> = ReturnType<EndpointThunk<Thunk, Definition>['pending']>
export type FulfilledAction<
Thunk extends QueryThunk | MutationThunk,
Definition extends EndpointDefinition<any, any, any, any>
> = ReturnType<EndpointThunk<Thunk, Definition>['fulfilled']>
export type RejectedAction<
Thunk extends QueryThunk | MutationThunk,
Definition extends EndpointDefinition<any, any, any, any>
> = ReturnType<EndpointThunk<Thunk, Definition>['rejected']>
export type Matcher<M> = (value: any) => value is M
export interface Matchers<
Thunk extends QueryThunk | MutationThunk,
Definition extends EndpointDefinition<any, any, any, any>
> {
matchPending: Matcher<PendingAction<Thunk, Definition>>
matchFulfilled: Matcher<FulfilledAction<Thunk, Definition>>
matchRejected: Matcher<RejectedAction<Thunk, Definition>>
}
export interface QueryThunkArg
extends QuerySubstateIdentifier,
StartQueryActionCreatorOptions {
type: 'query'
originalArgs: unknown
endpointName: string
}
export interface MutationThunkArg {
type: 'mutation'
originalArgs: unknown
endpointName: string
track?: boolean
fixedCacheKey?: string
}
export type ThunkResult = unknown
export type ThunkApiMetaConfig = {
pendingMeta: { startedTimeStamp: number }
fulfilledMeta: {
fulfilledTimeStamp: number
baseQueryMeta: unknown
}
rejectedMeta: {
baseQueryMeta: unknown
}
}
export type QueryThunk = AsyncThunk<
ThunkResult,
QueryThunkArg,
ThunkApiMetaConfig
>
export type MutationThunk = AsyncThunk<
ThunkResult,
MutationThunkArg,
ThunkApiMetaConfig
>
function defaultTransformResponse(baseQueryReturnValue: unknown) {
return baseQueryReturnValue
}
export type MaybeDrafted<T> = T | Draft<T>
export type Recipe<T> = (data: MaybeDrafted<T>) => void | MaybeDrafted<T>
export type PatchQueryDataThunk<
Definitions extends EndpointDefinitions,
PartialState
> = <EndpointName extends QueryKeys<Definitions>>(
endpointName: EndpointName,
args: QueryArgFrom<Definitions[EndpointName]>,
patches: readonly Patch[]
) => ThunkAction<void, PartialState, any, AnyAction>
export type UpdateQueryDataThunk<
Definitions extends EndpointDefinitions,
PartialState
> = <EndpointName extends QueryKeys<Definitions>>(
endpointName: EndpointName,
args: QueryArgFrom<Definitions[EndpointName]>,
updateRecipe: Recipe<ResultTypeFrom<Definitions[EndpointName]>>
) => ThunkAction<PatchCollection, PartialState, any, AnyAction>
/**
* An object returned from dispatching a `api.util.updateQueryData` call.
*/
export type PatchCollection = {
/**
* An `immer` Patch describing the cache update.
*/
patches: Patch[]
/**
* An `immer` Patch to revert the cache update.
*/
inversePatches: Patch[]
/**
* A function that will undo the cache update.
*/
undo: () => void
}
export function buildThunks<
BaseQuery extends BaseQueryFn,
ReducerPath extends string,
Definitions extends EndpointDefinitions
>({
reducerPath,
baseQuery,
context: { endpointDefinitions },
serializeQueryArgs,
api,
}: {
baseQuery: BaseQuery
reducerPath: ReducerPath
context: ApiContext<Definitions>
serializeQueryArgs: InternalSerializeQueryArgs
api: Api<BaseQuery, Definitions, ReducerPath, any>
}) {
type State = RootState<any, string, ReducerPath>
const patchQueryData: PatchQueryDataThunk<EndpointDefinitions, State> =
(endpointName, args, patches) => (dispatch) => {
const endpointDefinition = endpointDefinitions[endpointName]
dispatch(
api.internalActions.queryResultPatched({
queryCacheKey: serializeQueryArgs({
queryArgs: args,
endpointDefinition,
endpointName,
}),
patches,
})
)
}
const updateQueryData: UpdateQueryDataThunk<EndpointDefinitions, State> =
(endpointName, args, updateRecipe) => (dispatch, getState) => {
const currentState = (
api.endpoints[endpointName] as ApiEndpointQuery<any, any>
).select(args)(getState())
let ret: PatchCollection = {
patches: [],
inversePatches: [],
undo: () =>
dispatch(
api.util.patchQueryData(endpointName, args, ret.inversePatches)
),
}
if (currentState.status === QueryStatus.uninitialized) {
return ret
}
if ('data' in currentState) {
if (isDraftable(currentState.data)) {
const [, patches, inversePatches] = produceWithPatches(
currentState.data,
updateRecipe
)
ret.patches.push(...patches)
ret.inversePatches.push(...inversePatches)
} else {
const value = updateRecipe(currentState.data)
ret.patches.push({ op: 'replace', path: [], value })
ret.inversePatches.push({
op: 'replace',
path: [],
value: currentState.data,
})
}
}
dispatch(api.util.patchQueryData(endpointName, args, ret.patches))
return ret
}
const executeEndpoint: AsyncThunkPayloadCreator<
ThunkResult,
QueryThunkArg | MutationThunkArg,
ThunkApiMetaConfig & { state: RootState<any, string, ReducerPath> }
> = async (
arg,
{ signal, rejectWithValue, fulfillWithValue, dispatch, getState, extra }
) => {
const endpointDefinition = endpointDefinitions[arg.endpointName]
try {
let transformResponse: (
baseQueryReturnValue: any,
meta: any,
arg: any
) => any = defaultTransformResponse
let result: QueryReturnValue
const baseQueryApi = {
signal,
dispatch,
getState,
extra,
endpoint: arg.endpointName,
type: arg.type,
forced:
arg.type === 'query' ? isForcedQuery(arg, getState()) : undefined,
}
if (endpointDefinition.query) {
result = await baseQuery(
endpointDefinition.query(arg.originalArgs),
baseQueryApi,
endpointDefinition.extraOptions as any
)
if (endpointDefinition.transformResponse) {
transformResponse = endpointDefinition.transformResponse
}
} else {
result = await endpointDefinition.queryFn(
arg.originalArgs,
baseQueryApi,
endpointDefinition.extraOptions as any,
(arg) =>
baseQuery(arg, baseQueryApi, endpointDefinition.extraOptions as any)
)
}
if (
typeof process !== 'undefined' &&
process.env.NODE_ENV === 'development'
) {
const what = endpointDefinition.query ? '`baseQuery`' : '`queryFn`'
let err: undefined | string
if (!result) {
err = `${what} did not return anything.`
} else if (typeof result !== 'object') {
err = `${what} did not return an object.`
} else if (result.error && result.data) {
err = `${what} returned an object containing both \`error\` and \`result\`.`
} else if (result.error === undefined && result.data === undefined) {
err = `${what} returned an object containing neither a valid \`error\` and \`result\`. At least one of them should not be \`undefined\``
} else {
for (const key of Object.keys(result)) {
if (key !== 'error' && key !== 'data' && key !== 'meta') {
err = `The object returned by ${what} has the unknown property ${key}.`
break
}
}
}
if (err) {
console.error(
`Error encountered handling the endpoint ${arg.endpointName}.
${err}
It needs to return an object with either the shape \`{ data: <value> }\` or \`{ error: <value> }\` that may contain an optional \`meta\` property.
Object returned was:`,
result
)
}
}
if (result.error) throw new HandledError(result.error, result.meta)
return fulfillWithValue(
await transformResponse(result.data, result.meta, arg.originalArgs),
{
fulfilledTimeStamp: Date.now(),
baseQueryMeta: result.meta,
}
)
} catch (error) {
if (error instanceof HandledError) {
return rejectWithValue(error.value, { baseQueryMeta: error.meta })
}
if (
typeof process !== 'undefined' &&
process.env.NODE_ENV === 'development'
) {
console.error(
`An unhandled error occured processing a request for the endpoint "${arg.endpointName}".
In the case of an unhandled error, no tags will be "provided" or "invalidated".`,
error
)
} else {
console.error(error)
}
throw error
}
}
function isForcedQuery(
arg: QueryThunkArg,
state: RootState<any, string, ReducerPath>
) {
const requestState = state[reducerPath]?.queries?.[arg.queryCacheKey]
const baseFetchOnMountOrArgChange =
state[reducerPath]?.config.refetchOnMountOrArgChange
const fulfilledVal = requestState?.fulfilledTimeStamp
const refetchVal =
arg.forceRefetch ?? (arg.subscribe && baseFetchOnMountOrArgChange)
if (refetchVal) {
// Return if its true or compare the dates because it must be a number
return (
refetchVal === true ||
(Number(new Date()) - Number(fulfilledVal)) / 1000 >= refetchVal
)
}
return false
}
const queryThunk = createAsyncThunk<
ThunkResult,
QueryThunkArg,
ThunkApiMetaConfig & { state: RootState<any, string, ReducerPath> }
>(`${reducerPath}/executeQuery`, executeEndpoint, {
getPendingMeta() {
return { startedTimeStamp: Date.now() }
},
condition(arg, { getState }) {
const state = getState()
const requestState = state[reducerPath]?.queries?.[arg.queryCacheKey]
const fulfilledVal = requestState?.fulfilledTimeStamp
// Don't retry a request that's currently in-flight
if (requestState?.status === 'pending') return false
// if this is forced, continue
if (isForcedQuery(arg, state)) return true
// Pull from the cache unless we explicitly force refetch or qualify based on time
if (fulfilledVal)
// Value is cached and we didn't specify to refresh, skip it.
return false
return true
},
dispatchConditionRejection: true,
})
const mutationThunk = createAsyncThunk<
ThunkResult,
MutationThunkArg,
ThunkApiMetaConfig & { state: RootState<any, string, ReducerPath> }
>(`${reducerPath}/executeMutation`, executeEndpoint, {
getPendingMeta() {
return { startedTimeStamp: Date.now() }
},
})
const hasTheForce = (options: any): options is { force: boolean } =>
'force' in options
const hasMaxAge = (
options: any
): options is { ifOlderThan: false | number } => 'ifOlderThan' in options
const prefetch =
<EndpointName extends QueryKeys<Definitions>>(
endpointName: EndpointName,
arg: any,
options: PrefetchOptions
): ThunkAction<void, any, any, AnyAction> =>
(dispatch: ThunkDispatch<any, any, any>, getState: () => any) => {
const force = hasTheForce(options) && options.force
const maxAge = hasMaxAge(options) && options.ifOlderThan
const queryAction = (force: boolean = true) =>
(api.endpoints[endpointName] as ApiEndpointQuery<any, any>).initiate(
arg,
{ forceRefetch: force }
)
const latestStateValue = (
api.endpoints[endpointName] as ApiEndpointQuery<any, any>
).select(arg)(getState())
if (force) {
dispatch(queryAction())
} else if (maxAge) {
const lastFulfilledTs = latestStateValue?.fulfilledTimeStamp
if (!lastFulfilledTs) {
dispatch(queryAction())
return
}
const shouldRetrigger =
(Number(new Date()) - Number(new Date(lastFulfilledTs))) / 1000 >=
maxAge
if (shouldRetrigger) {
dispatch(queryAction())
}
} else {
// If prefetching with no options, just let it try
dispatch(queryAction(false))
}
}
function matchesEndpoint(endpointName: string) {
return (action: any): action is AnyAction =>
action?.meta?.arg?.endpointName === endpointName
}
function buildMatchThunkActions<
Thunk extends
| AsyncThunk<any, QueryThunkArg, ThunkApiMetaConfig>
| AsyncThunk<any, MutationThunkArg, ThunkApiMetaConfig>
>(thunk: Thunk, endpointName: string) {
return {
matchPending: isAllOf(isPending(thunk), matchesEndpoint(endpointName)),
matchFulfilled: isAllOf(
isFulfilled(thunk),
matchesEndpoint(endpointName)
),
matchRejected: isAllOf(isRejected(thunk), matchesEndpoint(endpointName)),
} as Matchers<Thunk, any>
}
return {
queryThunk,
mutationThunk,
prefetch,
updateQueryData,
patchQueryData,
buildMatchThunkActions,
}
}
export function calculateProvidedByThunk(
action: UnwrapPromise<
ReturnType<ReturnType<QueryThunk>> | ReturnType<ReturnType<MutationThunk>>
>,
type: 'providesTags' | 'invalidatesTags',
endpointDefinitions: EndpointDefinitions,
assertTagType: AssertTagTypes
) {
return calculateProvidedBy(
endpointDefinitions[action.meta.arg.endpointName][type],
isFulfilled(action) ? action.payload : undefined,
isRejectedWithValue(action) ? action.payload : undefined,
action.meta.arg.originalArgs,
'baseQueryMeta' in action.meta ? action.meta.baseQueryMeta : undefined,
assertTagType
)
}