@reduxjs/toolkit
Version:
The official, opinionated, batteries-included toolset for efficient Redux development
332 lines (307 loc) • 10.6 kB
text/typescript
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
})
}
}
}