UNPKG

@reduxjs/toolkit

Version:

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

727 lines (683 loc) 22.4 kB
import type { PayloadAction } from '@reduxjs/toolkit' import { combineReducers, createAction, createSlice, isAnyOf, isFulfilled, isRejectedWithValue, createNextState, prepareAutoBatched, SHOULD_AUTOBATCH, nanoid, } from './rtkImports' import type { QuerySubstateIdentifier, QuerySubState, MutationSubstateIdentifier, MutationSubState, MutationState, QueryState, InvalidationState, Subscribers, QueryCacheKey, SubscriptionState, ConfigState, InfiniteQuerySubState, InfiniteQueryDirection, } from './apiState' import { QueryStatus } from './apiState' import type { AllQueryKeys, QueryArgFromAnyQueryDefinition, DataFromAnyQueryDefinition, InfiniteQueryThunk, MutationThunk, QueryThunk, QueryThunkArg, } from './buildThunks' import { calculateProvidedByThunk } from './buildThunks' import { isInfiniteQueryDefinition, type AssertTagTypes, type EndpointDefinitions, type FullTagDescription, type QueryDefinition, } from '../endpointDefinitions' import type { Patch } from 'immer' import { isDraft } from 'immer' import { applyPatches, original } from 'immer' import { onFocus, onFocusLost, onOffline, onOnline } from './setupListeners' import { isDocumentVisible, isOnline, copyWithStructuralSharing, } from '../utils' import type { ApiContext } from '../apiTypes' import { isUpsertQuery } from './buildInitiate' import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs' import type { UnwrapPromise } from '../tsHelpers' /** * A typesafe single entry to be upserted into the cache */ export type NormalizedQueryUpsertEntry< Definitions extends EndpointDefinitions, EndpointName extends AllQueryKeys<Definitions>, > = { endpointName: EndpointName arg: QueryArgFromAnyQueryDefinition<Definitions, EndpointName> value: DataFromAnyQueryDefinition<Definitions, EndpointName> } /** * The internal version that is not typesafe since we can't carry the generics through `createSlice` */ type NormalizedQueryUpsertEntryPayload = { endpointName: string arg: unknown value: unknown } export type ProcessedQueryUpsertEntry = { queryDescription: QueryThunkArg value: unknown } /** * A typesafe representation of a util action creator that accepts cache entry descriptions to upsert */ export type UpsertEntries<Definitions extends EndpointDefinitions> = (< EndpointNames extends Array<AllQueryKeys<Definitions>>, >( entries: [ ...{ [I in keyof EndpointNames]: NormalizedQueryUpsertEntry< Definitions, EndpointNames[I] > }, ], ) => PayloadAction<NormalizedQueryUpsertEntryPayload[]>) & { match: ( action: unknown, ) => action is PayloadAction<NormalizedQueryUpsertEntryPayload[]> } function updateQuerySubstateIfExists( state: QueryState<any>, queryCacheKey: QueryCacheKey, update: (substate: QuerySubState<any> | InfiniteQuerySubState<any>) => void, ) { const substate = state[queryCacheKey] if (substate) { update(substate) } } export function getMutationCacheKey( id: | MutationSubstateIdentifier | { requestId: string; arg: { fixedCacheKey?: string | undefined } }, ): string export function getMutationCacheKey(id: { fixedCacheKey?: string requestId?: string }): string | undefined export function getMutationCacheKey( id: | { fixedCacheKey?: string; requestId?: string } | MutationSubstateIdentifier | { requestId: string; arg: { fixedCacheKey?: string | undefined } }, ): string | undefined { return ('arg' in id ? id.arg.fixedCacheKey : id.fixedCacheKey) ?? id.requestId } function updateMutationSubstateIfExists( state: MutationState<any>, id: | MutationSubstateIdentifier | { requestId: string; arg: { fixedCacheKey?: string | undefined } }, update: (substate: MutationSubState<any>) => void, ) { const substate = state[getMutationCacheKey(id)] if (substate) { update(substate) } } const initialState = {} as any export function buildSlice({ reducerPath, queryThunk, mutationThunk, serializeQueryArgs, context: { endpointDefinitions: definitions, apiUid, extractRehydrationInfo, hasRehydrationInfo, }, assertTagType, config, }: { reducerPath: string queryThunk: QueryThunk infiniteQueryThunk: InfiniteQueryThunk<any> mutationThunk: MutationThunk serializeQueryArgs: InternalSerializeQueryArgs context: ApiContext<EndpointDefinitions> assertTagType: AssertTagTypes config: Omit< ConfigState<string>, 'online' | 'focused' | 'middlewareRegistered' > }) { const resetApiState = createAction(`${reducerPath}/resetApiState`) function writePendingCacheEntry( draft: QueryState<any>, arg: QueryThunkArg, upserting: boolean, meta: { arg: QueryThunkArg requestId: string // requestStatus: 'pending' } & { startedTimeStamp: number }, ) { draft[arg.queryCacheKey] ??= { status: QueryStatus.uninitialized, endpointName: arg.endpointName, } updateQuerySubstateIfExists(draft, arg.queryCacheKey, (substate) => { substate.status = QueryStatus.pending substate.requestId = upserting && substate.requestId ? // for `upsertQuery` **updates**, keep the current `requestId` substate.requestId : // for normal queries or `upsertQuery` **inserts** always update the `requestId` meta.requestId if (arg.originalArgs !== undefined) { substate.originalArgs = arg.originalArgs } substate.startedTimeStamp = meta.startedTimeStamp const endpointDefinition = definitions[meta.arg.endpointName] if (isInfiniteQueryDefinition(endpointDefinition) && 'direction' in arg) { ;(substate as InfiniteQuerySubState<any>).direction = arg.direction as InfiniteQueryDirection } }) } function writeFulfilledCacheEntry( draft: QueryState<any>, meta: { arg: QueryThunkArg; requestId: string } & { fulfilledTimeStamp: number baseQueryMeta: unknown }, payload: unknown, upserting: boolean, ) { updateQuerySubstateIfExists(draft, meta.arg.queryCacheKey, (substate) => { if (substate.requestId !== meta.requestId && !upserting) return const { merge } = definitions[meta.arg.endpointName] as QueryDefinition< any, any, any, any > substate.status = QueryStatus.fulfilled if (merge) { if (substate.data !== undefined) { const { fulfilledTimeStamp, arg, baseQueryMeta, requestId } = meta // There's existing cache data. Let the user merge it in themselves. // We're already inside an Immer-powered reducer, and the user could just mutate `substate.data` // themselves inside of `merge()`. But, they might also want to return a new value. // Try to let Immer figure that part out, save the result, and assign it to `substate.data`. let newData = createNextState(substate.data, (draftSubstateData) => { // As usual with Immer, you can mutate _or_ return inside here, but not both return merge(draftSubstateData, payload, { arg: arg.originalArgs, baseQueryMeta, fulfilledTimeStamp, requestId, }) }) substate.data = newData } else { // Presumably a fresh request. Just cache the response data. substate.data = payload } } else { // Assign or safely update the cache data. substate.data = (definitions[meta.arg.endpointName].structuralSharing ?? true) ? copyWithStructuralSharing( isDraft(substate.data) ? original(substate.data) : substate.data, payload, ) : payload } delete substate.error substate.fulfilledTimeStamp = meta.fulfilledTimeStamp }) } const querySlice = createSlice({ name: `${reducerPath}/queries`, initialState: initialState as QueryState<any>, reducers: { removeQueryResult: { reducer( draft, { payload: { queryCacheKey }, }: PayloadAction<QuerySubstateIdentifier>, ) { delete draft[queryCacheKey] }, prepare: prepareAutoBatched<QuerySubstateIdentifier>(), }, cacheEntriesUpserted: { reducer( draft, action: PayloadAction< ProcessedQueryUpsertEntry[], string, { RTK_autoBatch: boolean; requestId: string; timestamp: number } >, ) { for (const entry of action.payload) { const { queryDescription: arg, value } = entry writePendingCacheEntry(draft, arg, true, { arg, requestId: action.meta.requestId, startedTimeStamp: action.meta.timestamp, }) writeFulfilledCacheEntry( draft, { arg, requestId: action.meta.requestId, fulfilledTimeStamp: action.meta.timestamp, baseQueryMeta: {}, }, value, // We know we're upserting here true, ) } }, prepare: (payload: NormalizedQueryUpsertEntryPayload[]) => { const queryDescriptions: ProcessedQueryUpsertEntry[] = payload.map( (entry) => { const { endpointName, arg, value } = entry const endpointDefinition = definitions[endpointName] const queryDescription: QueryThunkArg = { type: 'query', endpointName: endpointName, originalArgs: entry.arg, queryCacheKey: serializeQueryArgs({ queryArgs: arg, endpointDefinition, endpointName, }), } return { queryDescription, value } }, ) const result = { payload: queryDescriptions, meta: { [SHOULD_AUTOBATCH]: true, requestId: nanoid(), timestamp: Date.now(), }, } return result }, }, queryResultPatched: { reducer( draft, { payload: { queryCacheKey, patches }, }: PayloadAction< QuerySubstateIdentifier & { patches: readonly Patch[] } >, ) { updateQuerySubstateIfExists(draft, queryCacheKey, (substate) => { substate.data = applyPatches(substate.data as any, patches.concat()) }) }, prepare: prepareAutoBatched< QuerySubstateIdentifier & { patches: readonly Patch[] } >(), }, }, extraReducers(builder) { builder .addCase(queryThunk.pending, (draft, { meta, meta: { arg } }) => { const upserting = isUpsertQuery(arg) writePendingCacheEntry(draft, arg, upserting, meta) }) .addCase(queryThunk.fulfilled, (draft, { meta, payload }) => { const upserting = isUpsertQuery(meta.arg) writeFulfilledCacheEntry(draft, meta, payload, upserting) }) .addCase( queryThunk.rejected, (draft, { meta: { condition, arg, requestId }, error, payload }) => { updateQuerySubstateIfExists( draft, arg.queryCacheKey, (substate) => { if (condition) { // request was aborted due to condition (another query already running) } else { // request failed if (substate.requestId !== requestId) return substate.status = QueryStatus.rejected substate.error = (payload ?? error) as any } }, ) }, ) .addMatcher(hasRehydrationInfo, (draft, action) => { const { queries } = extractRehydrationInfo(action)! for (const [key, entry] of Object.entries(queries)) { if ( // do not rehydrate entries that were currently in flight. entry?.status === QueryStatus.fulfilled || entry?.status === QueryStatus.rejected ) { draft[key] = entry } } }) }, }) const mutationSlice = createSlice({ name: `${reducerPath}/mutations`, initialState: initialState as MutationState<any>, reducers: { removeMutationResult: { reducer(draft, { payload }: PayloadAction<MutationSubstateIdentifier>) { const cacheKey = getMutationCacheKey(payload) if (cacheKey in draft) { delete draft[cacheKey] } }, prepare: prepareAutoBatched<MutationSubstateIdentifier>(), }, }, extraReducers(builder) { builder .addCase( mutationThunk.pending, (draft, { meta, meta: { requestId, arg, startedTimeStamp } }) => { if (!arg.track) return draft[getMutationCacheKey(meta)] = { requestId, status: QueryStatus.pending, endpointName: arg.endpointName, startedTimeStamp, } }, ) .addCase(mutationThunk.fulfilled, (draft, { payload, meta }) => { if (!meta.arg.track) return updateMutationSubstateIfExists(draft, meta, (substate) => { if (substate.requestId !== meta.requestId) return substate.status = QueryStatus.fulfilled substate.data = payload substate.fulfilledTimeStamp = meta.fulfilledTimeStamp }) }) .addCase(mutationThunk.rejected, (draft, { payload, error, meta }) => { if (!meta.arg.track) return updateMutationSubstateIfExists(draft, meta, (substate) => { if (substate.requestId !== meta.requestId) return substate.status = QueryStatus.rejected substate.error = (payload ?? error) as any }) }) .addMatcher(hasRehydrationInfo, (draft, action) => { const { mutations } = extractRehydrationInfo(action)! for (const [key, entry] of Object.entries(mutations)) { if ( // do not rehydrate entries that were currently in flight. (entry?.status === QueryStatus.fulfilled || entry?.status === QueryStatus.rejected) && // only rehydrate endpoints that were persisted using a `fixedCacheKey` key !== entry?.requestId ) { draft[key] = entry } } }) }, }) type CalculateProvidedByAction = UnwrapPromise< | ReturnType<ReturnType<QueryThunk>> | ReturnType<ReturnType<InfiniteQueryThunk<any>>> > const initialInvalidationState: InvalidationState<string> = { tags: {}, keys: {}, } const invalidationSlice = createSlice({ name: `${reducerPath}/invalidation`, initialState: initialInvalidationState, reducers: { updateProvidedBy: { reducer( draft, action: PayloadAction< Array<{ queryCacheKey: QueryCacheKey providedTags: readonly FullTagDescription<string>[] }> >, ) { for (const { queryCacheKey, providedTags } of action.payload) { removeCacheKeyFromTags(draft, queryCacheKey) for (const { type, id } of providedTags) { const subscribedQueries = ((draft.tags[type] ??= {})[ id || '__internal_without_id' ] ??= []) const alreadySubscribed = subscribedQueries.includes(queryCacheKey) if (!alreadySubscribed) { subscribedQueries.push(queryCacheKey) } } // Remove readonly from the providedTags array draft.keys[queryCacheKey] = providedTags as FullTagDescription<string>[] } }, prepare: prepareAutoBatched< Array<{ queryCacheKey: QueryCacheKey providedTags: readonly FullTagDescription<string>[] }> >(), }, }, extraReducers(builder) { builder .addCase( querySlice.actions.removeQueryResult, (draft, { payload: { queryCacheKey } }) => { removeCacheKeyFromTags(draft, queryCacheKey) }, ) .addMatcher(hasRehydrationInfo, (draft, action) => { const { provided } = extractRehydrationInfo(action)! for (const [type, incomingTags] of Object.entries(provided)) { for (const [id, cacheKeys] of Object.entries(incomingTags)) { const subscribedQueries = ((draft.tags[type] ??= {})[ id || '__internal_without_id' ] ??= []) for (const queryCacheKey of cacheKeys) { const alreadySubscribed = subscribedQueries.includes(queryCacheKey) if (!alreadySubscribed) { subscribedQueries.push(queryCacheKey) } } } } }) .addMatcher( isAnyOf(isFulfilled(queryThunk), isRejectedWithValue(queryThunk)), (draft, action) => { writeProvidedTagsForQueries(draft, [action]) }, ) .addMatcher( querySlice.actions.cacheEntriesUpserted.match, (draft, action) => { const mockActions: CalculateProvidedByAction[] = action.payload.map( ({ queryDescription, value }) => { return { type: 'UNKNOWN', payload: value, meta: { requestStatus: 'fulfilled', requestId: 'UNKNOWN', arg: queryDescription, }, } }, ) writeProvidedTagsForQueries(draft, mockActions) }, ) }, }) function removeCacheKeyFromTags( draft: InvalidationState<any>, queryCacheKey: QueryCacheKey, ) { const existingTags = draft.keys[queryCacheKey] ?? [] // Delete this cache key from any existing tags that may have provided it for (const tag of existingTags) { const tagType = tag.type const tagId = tag.id ?? '__internal_without_id' const tagSubscriptions = draft.tags[tagType]?.[tagId] if (tagSubscriptions) { draft.tags[tagType][tagId] = tagSubscriptions.filter( (qc) => qc !== queryCacheKey, ) } } delete draft.keys[queryCacheKey] } function writeProvidedTagsForQueries( draft: InvalidationState<string>, actions: CalculateProvidedByAction[], ) { const providedByEntries = actions.map((action) => { const providedTags = calculateProvidedByThunk( action, 'providesTags', definitions, assertTagType, ) const { queryCacheKey } = action.meta.arg return { queryCacheKey, providedTags } }) invalidationSlice.caseReducers.updateProvidedBy( draft, invalidationSlice.actions.updateProvidedBy(providedByEntries), ) } // Dummy slice to generate actions const subscriptionSlice = createSlice({ name: `${reducerPath}/subscriptions`, initialState: initialState as SubscriptionState, reducers: { updateSubscriptionOptions( d, a: PayloadAction< { endpointName: string requestId: string options: Subscribers[number] } & QuerySubstateIdentifier >, ) { // Dummy }, unsubscribeQueryResult( d, a: PayloadAction<{ requestId: string } & QuerySubstateIdentifier>, ) { // Dummy }, internal_getRTKQSubscriptions() {}, }, }) const internalSubscriptionsSlice = createSlice({ name: `${reducerPath}/internalSubscriptions`, initialState: initialState as SubscriptionState, reducers: { subscriptionsUpdated: { reducer(state, action: PayloadAction<Patch[]>) { return applyPatches(state, action.payload) }, prepare: prepareAutoBatched<Patch[]>(), }, }, }) const configSlice = createSlice({ name: `${reducerPath}/config`, initialState: { online: isOnline(), focused: isDocumentVisible(), middlewareRegistered: false, ...config, } as ConfigState<string>, reducers: { middlewareRegistered(state, { payload }: PayloadAction<string>) { state.middlewareRegistered = state.middlewareRegistered === 'conflict' || apiUid !== payload ? 'conflict' : true }, }, extraReducers: (builder) => { builder .addCase(onOnline, (state) => { state.online = true }) .addCase(onOffline, (state) => { state.online = false }) .addCase(onFocus, (state) => { state.focused = true }) .addCase(onFocusLost, (state) => { state.focused = false }) // update the state to be a new object to be picked up as a "state change" // by redux-persist's `autoMergeLevel2` .addMatcher(hasRehydrationInfo, (draft) => ({ ...draft })) }, }) const combinedReducer = combineReducers({ queries: querySlice.reducer, mutations: mutationSlice.reducer, provided: invalidationSlice.reducer, subscriptions: internalSubscriptionsSlice.reducer, config: configSlice.reducer, }) const reducer: typeof combinedReducer = (state, action) => combinedReducer(resetApiState.match(action) ? undefined : state, action) const actions = { ...configSlice.actions, ...querySlice.actions, ...subscriptionSlice.actions, ...internalSubscriptionsSlice.actions, ...mutationSlice.actions, ...invalidationSlice.actions, resetApiState, } return { reducer, actions } } export type SliceActions = ReturnType<typeof buildSlice>['actions']