UNPKG

@reduxjs/toolkit

Version:

The official, opinionated, batteries-included toolset for efficient Redux development

463 lines (420 loc) 14.5 kB
import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs' import type { Api, ApiContext } from '../apiTypes' import type { BaseQueryFn, BaseQueryError, QueryReturnValue, } from '../baseQueryTypes' import { BaseQueryArg } from '../baseQueryTypes' import type { RootState, QueryKeys, QuerySubstateIdentifier } from './apiState' import { QueryStatus, CombinedState } from './apiState' import type { StartQueryActionCreatorOptions } from './buildInitiate' import type { AssertTagTypes, EndpointDefinition, EndpointDefinitions, MutationDefinition, QueryArgFrom, QueryDefinition, ResultTypeFrom, } from '../endpointDefinitions' import { calculateProvidedBy, FullTagDescription } 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 { originalArgs: unknown endpointName: string } export interface MutationThunkArg { originalArgs: unknown endpointName: string track?: boolean } 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) => any = defaultTransformResponse let result: QueryReturnValue const baseQueryApi = { signal, dispatch, getState, extra, } 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 (result.error) throw new HandledError(result.error, result.meta) return fulfillWithValue( await transformResponse(result.data, result.meta), { 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 } } 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()[reducerPath] const requestState = state?.queries?.[arg.queryCacheKey] const baseFetchOnMountOrArgChange = state.config.refetchOnMountOrArgChange const fulfilledVal = requestState?.fulfilledTimeStamp const refetchVal = arg.forceRefetch ?? (arg.subscribe && baseFetchOnMountOrArgChange) // Don't retry a request that's currently in-flight if (requestState?.status === 'pending') return false // Pull from the cache unless we explicitly force refetch or qualify based on time if (fulfilledVal) { 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 ) } // 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, assertTagType ) }