UNPKG

@reduxjs/toolkit

Version:

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

332 lines (307 loc) 10.6 kB
import { isAsyncThunkAction, isFulfilled } from '@reduxjs/toolkit' import type { AnyAction } from 'redux' import type { ThunkDispatch } from 'redux-thunk' import type { BaseQueryFn, BaseQueryMeta } from '../../baseQueryTypes' import { DefinitionType } from '../../endpointDefinitions' import type { RootState } from '../apiState' import type { MutationResultSelectorResult, QueryResultSelectorResult, } from '../buildSelectors' import type { PatchCollection, Recipe } from '../buildThunks' import type { PromiseWithKnownReason, SubMiddlewareApi, SubMiddlewareBuilder, } from './types' export type ReferenceCacheLifecycle = never declare module '../../endpointDefinitions' { export interface QueryBaseLifecycleApi< QueryArg, BaseQuery extends BaseQueryFn, ResultType, ReducerPath extends string = string > extends LifecycleApi<ReducerPath> { /** * Gets the current value of this cache entry. */ getCacheEntry(): QueryResultSelectorResult< { type: DefinitionType.query } & BaseEndpointDefinition< QueryArg, BaseQuery, ResultType > > /** * Updates the current cache entry value. * For documentation see `api.util.updateQueryData`. */ updateCachedData(updateRecipe: Recipe<ResultType>): PatchCollection } export interface MutationBaseLifecycleApi< QueryArg, BaseQuery extends BaseQueryFn, ResultType, ReducerPath extends string = string > extends LifecycleApi<ReducerPath> { /** * Gets the current value of this cache entry. */ getCacheEntry(): MutationResultSelectorResult< { type: DefinitionType.mutation } & BaseEndpointDefinition< QueryArg, BaseQuery, ResultType > > } export interface LifecycleApi<ReducerPath extends string = string> { /** * The dispatch method for the store */ dispatch: ThunkDispatch<any, any, AnyAction> /** * A method to get the current state */ getState(): RootState<any, any, ReducerPath> /** * `extra` as provided as `thunk.extraArgument` to the `configureStore` `getDefaultMiddleware` option. */ extra: unknown /** * A unique ID generated for the mutation */ requestId: string } export interface CacheLifecyclePromises< ResultType = unknown, MetaType = unknown > { /** * Promise that will resolve with the first value for this cache key. * This allows you to `await` until an actual value is in cache. * * If the cache entry is removed from the cache before any value has ever * been resolved, this Promise will reject with * `new Error('Promise never resolved before cacheEntryRemoved.')` * to prevent memory leaks. * You can just re-throw that error (or not handle it at all) - * it will be caught outside of `cacheEntryAdded`. * * If you don't interact with this promise, it will not throw. */ cacheDataLoaded: PromiseWithKnownReason< { /** * The (transformed) query result. */ data: ResultType /** * The `meta` returned by the `baseQuery` */ meta: MetaType }, typeof neverResolvedError > /** * Promise that allows you to wait for the point in time when the cache entry * has been removed from the cache, by not being used/subscribed to any more * in the application for too long or by dispatching `api.util.resetApiState`. */ cacheEntryRemoved: Promise<void> } export interface QueryCacheLifecycleApi< QueryArg, BaseQuery extends BaseQueryFn, ResultType, ReducerPath extends string = string > extends QueryBaseLifecycleApi<QueryArg, BaseQuery, ResultType, ReducerPath>, CacheLifecyclePromises<ResultType, BaseQueryMeta<BaseQuery>> {} export interface MutationCacheLifecycleApi< QueryArg, BaseQuery extends BaseQueryFn, ResultType, ReducerPath extends string = string > extends MutationBaseLifecycleApi< QueryArg, BaseQuery, ResultType, ReducerPath >, CacheLifecyclePromises<ResultType, BaseQueryMeta<BaseQuery>> {} interface QueryExtraOptions< TagTypes extends string, ResultType, QueryArg, BaseQuery extends BaseQueryFn, ReducerPath extends string = string > { onCacheEntryAdded?( arg: QueryArg, api: QueryCacheLifecycleApi<QueryArg, BaseQuery, ResultType, ReducerPath> ): Promise<void> | void } interface MutationExtraOptions< TagTypes extends string, ResultType, QueryArg, BaseQuery extends BaseQueryFn, ReducerPath extends string = string > { onCacheEntryAdded?( arg: QueryArg, api: MutationCacheLifecycleApi< QueryArg, BaseQuery, ResultType, ReducerPath > ): Promise<void> | void } } const neverResolvedError = new Error( 'Promise never resolved before cacheEntryRemoved.' ) as Error & { message: 'Promise never resolved before cacheEntryRemoved.' } export const build: SubMiddlewareBuilder = ({ api, reducerPath, context, queryThunk, mutationThunk, }) => { const isQueryThunk = isAsyncThunkAction(queryThunk) const isMutationThunk = isAsyncThunkAction(mutationThunk) const isFullfilledThunk = isFulfilled(queryThunk, mutationThunk) return (mwApi) => { type CacheLifecycle = { valueResolved?(value: { data: unknown; meta: unknown }): unknown cacheEntryRemoved(): void } const lifecycleMap: Record<string, CacheLifecycle> = {} return (next) => (action): any => { const stateBefore = mwApi.getState() const result = next(action) const cacheKey = getCacheKey(action) if (queryThunk.pending.match(action)) { const oldState = stateBefore[reducerPath].queries[cacheKey] const state = mwApi.getState()[reducerPath].queries[cacheKey] if (!oldState && state) { handleNewKey( action.meta.arg.endpointName, action.meta.arg.originalArgs, cacheKey, mwApi, action.meta.requestId ) } } else if (mutationThunk.pending.match(action)) { const state = mwApi.getState()[reducerPath].mutations[cacheKey] if (state) { handleNewKey( action.meta.arg.endpointName, action.meta.arg.originalArgs, cacheKey, mwApi, action.meta.requestId ) } } else if (isFullfilledThunk(action)) { const lifecycle = lifecycleMap[cacheKey] if (lifecycle?.valueResolved) { lifecycle.valueResolved({ data: action.payload, meta: action.meta.baseQueryMeta, }) delete lifecycle.valueResolved } } else if ( api.internalActions.removeQueryResult.match(action) || api.internalActions.unsubscribeMutationResult.match(action) ) { const lifecycle = lifecycleMap[cacheKey] if (lifecycle) { delete lifecycleMap[cacheKey] lifecycle.cacheEntryRemoved() } } else if (api.util.resetApiState.match(action)) { for (const [cacheKey, lifecycle] of Object.entries(lifecycleMap)) { delete lifecycleMap[cacheKey] lifecycle.cacheEntryRemoved() } } return result } function getCacheKey(action: any) { if (isQueryThunk(action)) return action.meta.arg.queryCacheKey if (isMutationThunk(action)) return action.meta.requestId if (api.internalActions.removeQueryResult.match(action)) return action.payload.queryCacheKey if (api.internalActions.unsubscribeMutationResult.match(action)) return action.payload.requestId return '' } function handleNewKey( endpointName: string, originalArgs: any, queryCacheKey: string, mwApi: SubMiddlewareApi, requestId: string ) { const endpointDefinition = context.endpointDefinitions[endpointName] const onCacheEntryAdded = endpointDefinition?.onCacheEntryAdded if (!onCacheEntryAdded) return let lifecycle = {} as CacheLifecycle const cacheEntryRemoved = new Promise<void>((resolve) => { lifecycle.cacheEntryRemoved = resolve }) const cacheDataLoaded: PromiseWithKnownReason< { data: unknown; meta: unknown }, typeof neverResolvedError > = Promise.race([ new Promise<{ data: unknown; meta: unknown }>((resolve) => { lifecycle.valueResolved = resolve }), cacheEntryRemoved.then(() => { throw neverResolvedError }), ]) // prevent uncaught promise rejections from happening. // if the original promise is used in any way, that will create a new promise that will throw again cacheDataLoaded.catch(() => {}) lifecycleMap[queryCacheKey] = lifecycle const selector = (api.endpoints[endpointName] as any).select( endpointDefinition.type === DefinitionType.query ? originalArgs : queryCacheKey ) const extra = mwApi.dispatch((_, __, extra) => extra) const lifecycleApi = { ...mwApi, getCacheEntry: () => selector(mwApi.getState()), requestId, extra, updateCachedData: (endpointDefinition.type === DefinitionType.query ? (updateRecipe: Recipe<any>) => mwApi.dispatch( api.util.updateQueryData( endpointName as never, originalArgs, updateRecipe ) ) : undefined) as any, cacheDataLoaded, cacheEntryRemoved, } const runningHandler = onCacheEntryAdded(originalArgs, lifecycleApi) // if a `neverResolvedError` was thrown, but not handled in the running handler, do not let it leak out further Promise.resolve(runningHandler).catch((e) => { if (e === neverResolvedError) return throw e }) } } }