UNPKG

@reduxjs/toolkit

Version:

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

536 lines (510 loc) 18 kB
import type { AnyAction, PayloadAction } from '@reduxjs/toolkit' import { combineReducers, createAction, createSlice, isAnyOf, isFulfilled, isRejectedWithValue, createNextState, prepareAutoBatched, } from '@reduxjs/toolkit' import type { CombinedState as CombinedQueryState, QuerySubstateIdentifier, QuerySubState, MutationSubstateIdentifier, MutationSubState, MutationState, QueryState, InvalidationState, Subscribers, QueryCacheKey, SubscriptionState, ConfigState, } from './apiState' import { QueryStatus } from './apiState' import type { MutationThunk, QueryThunk, RejectedAction } from './buildThunks' import { calculateProvidedByThunk } from './buildThunks' import type { AssertTagTypes, EndpointDefinitions, FullTagDescription, 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' function updateQuerySubstateIfExists( state: QueryState<any>, queryCacheKey: QueryCacheKey, update: (substate: QuerySubState<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, context: { endpointDefinitions: definitions, apiUid, extractRehydrationInfo, hasRehydrationInfo, }, assertTagType, config, }: { reducerPath: string queryThunk: QueryThunk mutationThunk: MutationThunk context: ApiContext<EndpointDefinitions> assertTagType: AssertTagTypes config: Omit< ConfigState<string>, 'online' | 'focused' | 'middlewareRegistered' > }) { const resetApiState = createAction(`${reducerPath}/resetApiState`) 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>(), }, 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) if (arg.subscribe || upserting) { // only initialize substate if we want to subscribe to it 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 }) }) .addCase(queryThunk.fulfilled, (draft, { meta, payload }) => { updateQuerySubstateIfExists( draft, meta.arg.queryCacheKey, (substate) => { if ( substate.requestId !== meta.requestId && !isUpsertQuery(meta.arg) ) 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 } ) }) .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 } } }) }, }) const invalidationSlice = createSlice({ name: `${reducerPath}/invalidation`, initialState: initialState as InvalidationState<string>, reducers: { updateProvidedBy: { reducer( draft, action: PayloadAction<{ queryCacheKey: QueryCacheKey providedTags: readonly FullTagDescription<string>[] }> ) { const { queryCacheKey, providedTags } = action.payload for (const tagTypeSubscriptions of Object.values(draft)) { for (const idSubscriptions of Object.values(tagTypeSubscriptions)) { const foundAt = idSubscriptions.indexOf(queryCacheKey) if (foundAt !== -1) { idSubscriptions.splice(foundAt, 1) } } } for (const { type, id } of providedTags) { const subscribedQueries = ((draft[type] ??= {})[ id || '__internal_without_id' ] ??= []) const alreadySubscribed = subscribedQueries.includes(queryCacheKey) if (!alreadySubscribed) { subscribedQueries.push(queryCacheKey) } } }, prepare: prepareAutoBatched<{ queryCacheKey: QueryCacheKey providedTags: readonly FullTagDescription<string>[] }>(), }, }, extraReducers(builder) { builder .addCase( querySlice.actions.removeQueryResult, (draft, { payload: { queryCacheKey } }) => { for (const tagTypeSubscriptions of Object.values(draft)) { for (const idSubscriptions of Object.values( tagTypeSubscriptions )) { const foundAt = idSubscriptions.indexOf(queryCacheKey) if (foundAt !== -1) { idSubscriptions.splice(foundAt, 1) } } } } ) .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[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) => { const providedTags = calculateProvidedByThunk( action, 'providesTags', definitions, assertTagType ) const { queryCacheKey } = action.meta.arg invalidationSlice.caseReducers.updateProvidedBy( draft, invalidationSlice.actions.updateProvidedBy({ queryCacheKey, providedTags, }) ) } ) }, }) // 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_probeSubscription( d, a: PayloadAction<{ queryCacheKey: string; requestId: string }> ) { // dummy }, }, }) 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< CombinedQueryState<any, string, string> >({ 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, /** @deprecated has been renamed to `removeMutationResult` */ unsubscribeMutationResult: mutationSlice.actions.removeMutationResult, resetApiState, } return { reducer, actions } } export type SliceActions = ReturnType<typeof buildSlice>['actions']