UNPKG

@reduxjs/toolkit

Version:

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

371 lines (342 loc) 10.9 kB
import type { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit' import type { BaseQueryFn, BaseQueryMeta, BaseQueryResult, } from '../../baseQueryTypes' import type { BaseEndpointDefinition } from '../../endpointDefinitions' import { DefinitionType, isAnyQueryDefinition } from '../../endpointDefinitions' import type { QueryCacheKey, RootState } from '../apiState' import type { MutationResultSelectorResult, QueryResultSelectorResult, } from '../buildSelectors' import { getMutationCacheKey } from '../buildSlice' import type { PatchCollection, Recipe } from '../buildThunks' import { isAsyncThunkAction, isFulfilled } from '../rtkImports' import type { ApiMiddlewareInternalHandler, InternalHandlerBuilder, PromiseWithKnownReason, SubMiddlewareApi, } from './types' export type ReferenceCacheLifecycle = never 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, BaseQueryResult<BaseQuery> > > /** * Updates the current cache entry value. * For documentation see `api.util.updateQueryData`. */ updateCachedData(updateRecipe: Recipe<ResultType>): PatchCollection } export type MutationBaseLifecycleApi< QueryArg, BaseQuery extends BaseQueryFn, ResultType, ReducerPath extends string = string, > = LifecycleApi<ReducerPath> & { /** * Gets the current value of this cache entry. */ getCacheEntry(): MutationResultSelectorResult< { type: DefinitionType.mutation } & BaseEndpointDefinition< QueryArg, BaseQuery, ResultType, BaseQueryResult<BaseQuery> > > } type LifecycleApi<ReducerPath extends string = string> = { /** * The dispatch method for the store */ dispatch: ThunkDispatch<any, any, UnknownAction> /** * 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 } type 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 type MutationCacheLifecycleApi< QueryArg, BaseQuery extends BaseQueryFn, ResultType, ReducerPath extends string = string, > = MutationBaseLifecycleApi<QueryArg, BaseQuery, ResultType, ReducerPath> & CacheLifecyclePromises<ResultType, BaseQueryMeta<BaseQuery>> export type CacheLifecycleQueryExtraOptions< ResultType, QueryArg, BaseQuery extends BaseQueryFn, ReducerPath extends string = string, > = { onCacheEntryAdded?( arg: QueryArg, api: QueryCacheLifecycleApi<QueryArg, BaseQuery, ResultType, ReducerPath>, ): Promise<void> | void } export type CacheLifecycleInfiniteQueryExtraOptions< ResultType, QueryArg, BaseQuery extends BaseQueryFn, ReducerPath extends string = string, > = CacheLifecycleQueryExtraOptions< ResultType, QueryArg, BaseQuery, ReducerPath > export type CacheLifecycleMutationExtraOptions< 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 buildCacheLifecycleHandler: InternalHandlerBuilder = ({ api, reducerPath, context, queryThunk, mutationThunk, internalState, selectors: { selectQueryEntry, selectApiState }, }) => { const isQueryThunk = isAsyncThunkAction(queryThunk) const isMutationThunk = isAsyncThunkAction(mutationThunk) const isFulfilledThunk = isFulfilled(queryThunk, mutationThunk) type CacheLifecycle = { valueResolved?(value: { data: unknown; meta: unknown }): unknown cacheEntryRemoved(): void } const lifecycleMap: Record<string, CacheLifecycle> = {} function resolveLifecycleEntry( cacheKey: string, data: unknown, meta: unknown, ) { const lifecycle = lifecycleMap[cacheKey] if (lifecycle?.valueResolved) { lifecycle.valueResolved({ data, meta, }) delete lifecycle.valueResolved } } function removeLifecycleEntry(cacheKey: string) { const lifecycle = lifecycleMap[cacheKey] if (lifecycle) { delete lifecycleMap[cacheKey] lifecycle.cacheEntryRemoved() } } const handler: ApiMiddlewareInternalHandler = ( action, mwApi, stateBefore, ) => { const cacheKey = getCacheKey(action) as QueryCacheKey function checkForNewCacheKey( endpointName: string, cacheKey: QueryCacheKey, requestId: string, originalArgs: unknown, ) { const oldEntry = selectQueryEntry(stateBefore, cacheKey) const newEntry = selectQueryEntry(mwApi.getState(), cacheKey) if (!oldEntry && newEntry) { handleNewKey(endpointName, originalArgs, cacheKey, mwApi, requestId) } } if (queryThunk.pending.match(action)) { checkForNewCacheKey( action.meta.arg.endpointName, cacheKey, action.meta.requestId, action.meta.arg.originalArgs, ) } else if (api.internalActions.cacheEntriesUpserted.match(action)) { for (const { queryDescription, value } of action.payload) { const { endpointName, originalArgs, queryCacheKey } = queryDescription checkForNewCacheKey( endpointName, queryCacheKey, action.meta.requestId, originalArgs, ) resolveLifecycleEntry(queryCacheKey, value, {}) } } 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 (isFulfilledThunk(action)) { resolveLifecycleEntry(cacheKey, action.payload, action.meta.baseQueryMeta) } else if ( api.internalActions.removeQueryResult.match(action) || api.internalActions.removeMutationResult.match(action) ) { removeLifecycleEntry(cacheKey) } else if (api.util.resetApiState.match(action)) { for (const cacheKey of Object.keys(lifecycleMap)) { removeLifecycleEntry(cacheKey) } } } function getCacheKey(action: any) { if (isQueryThunk(action)) return action.meta.arg.queryCacheKey if (isMutationThunk(action)) { return action.meta.arg.fixedCacheKey ?? action.meta.requestId } if (api.internalActions.removeQueryResult.match(action)) return action.payload.queryCacheKey if (api.internalActions.removeMutationResult.match(action)) return getMutationCacheKey(action.payload) 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 const 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( isAnyQueryDefinition(endpointDefinition) ? originalArgs : queryCacheKey, ) const extra = mwApi.dispatch((_, __, extra) => extra) const lifecycleApi = { ...mwApi, getCacheEntry: () => selector(mwApi.getState()), requestId, extra, updateCachedData: (isAnyQueryDefinition(endpointDefinition) ? (updateRecipe: Recipe<any>) => mwApi.dispatch( api.util.updateQueryData( endpointName as never, originalArgs as never, updateRecipe, ), ) : undefined) as any, cacheDataLoaded, cacheEntryRemoved, } const runningHandler = onCacheEntryAdded(originalArgs, lifecycleApi as any) // 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 }) } return handler }