Skip to content

Commit fa62548

Browse files
authored
Merge pull request #3116 from GeorchW/delayed-tag-invalidations
2 parents 1bf2a99 + 712f90e commit fa62548

File tree

8 files changed

+172
-14
lines changed

8 files changed

+172
-14
lines changed

packages/toolkit/src/query/apiTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export type Module<Name extends ModuleName> = {
4545
| 'refetchOnMountOrArgChange'
4646
| 'refetchOnFocus'
4747
| 'refetchOnReconnect'
48+
| 'invalidationBehavior'
4849
| 'tagTypes'
4950
>,
5051
context: ApiContext<Definitions>

packages/toolkit/src/query/core/apiState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ export type ConfigState<ReducerPath> = RefetchConfigOptions & {
254254

255255
export type ModifiableConfigState = {
256256
keepUnusedDataFor: number
257+
invalidationBehavior: 'delayed' | 'immediately'
257258
} & RefetchConfigOptions
258259

259260
export type MutationState<D extends EndpointDefinitions> = {

packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
import { isAnyOf, isFulfilled, isRejectedWithValue } from '../rtkImports'
1+
import {
2+
isAnyOf,
3+
isFulfilled,
4+
isRejected,
5+
isRejectedWithValue,
6+
} from '../rtkImports'
27

3-
import type { FullTagDescription } from '../../endpointDefinitions'
8+
import type {
9+
EndpointDefinitions,
10+
FullTagDescription,
11+
} from '../../endpointDefinitions'
412
import { calculateProvidedBy } from '../../endpointDefinitions'
5-
import type { QueryCacheKey } from '../apiState'
13+
import type { CombinedState, QueryCacheKey } from '../apiState'
614
import { QueryStatus } from '../apiState'
715
import { calculateProvidedByThunk } from '../buildThunks'
816
import type {
@@ -18,6 +26,7 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
1826
context,
1927
context: { endpointDefinitions },
2028
mutationThunk,
29+
queryThunk,
2130
api,
2231
assertTagType,
2332
refetchQuery,
@@ -29,6 +38,13 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
2938
isRejectedWithValue(mutationThunk)
3039
)
3140

41+
const isQueryEnd = isAnyOf(
42+
isFulfilled(mutationThunk, queryThunk),
43+
isRejected(mutationThunk, queryThunk)
44+
)
45+
46+
let pendingTagInvalidations: FullTagDescription<string>[] = []
47+
3248
const handler: ApiMiddlewareInternalHandler = (action, mwApi) => {
3349
if (isThunkActionWithTags(action)) {
3450
invalidateTags(
@@ -38,12 +54,11 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
3854
endpointDefinitions,
3955
assertTagType
4056
),
41-
mwApi,
42-
internalState
57+
mwApi
4358
)
44-
}
45-
46-
if (api.util.invalidateTags.match(action)) {
59+
} else if (isQueryEnd(action)) {
60+
invalidateTags([], mwApi)
61+
} else if (api.util.invalidateTags.match(action)) {
4762
invalidateTags(
4863
calculateProvidedBy(
4964
action.payload,
@@ -53,21 +68,38 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
5368
undefined,
5469
assertTagType
5570
),
56-
mwApi,
57-
internalState
71+
mwApi
5872
)
5973
}
6074
}
6175

76+
function hasPendingRequests(state: CombinedState<EndpointDefinitions, string, string>) {
77+
for (const key in state.queries) {
78+
if (state.queries[key]?.status === QueryStatus.pending) return true;
79+
}
80+
for (const key in state.mutations) {
81+
if (state.mutations[key]?.status === QueryStatus.pending) return true;
82+
}
83+
return false;
84+
}
85+
6286
function invalidateTags(
63-
tags: readonly FullTagDescription<string>[],
64-
mwApi: SubMiddlewareApi,
65-
internalState: InternalMiddlewareState
87+
newTags: readonly FullTagDescription<string>[],
88+
mwApi: SubMiddlewareApi
6689
) {
6790
const rootState = mwApi.getState()
68-
6991
const state = rootState[reducerPath]
7092

93+
pendingTagInvalidations.push(...newTags)
94+
95+
if (state.config.invalidationBehavior === 'delayed' && hasPendingRequests(state)) {
96+
return
97+
}
98+
99+
const tags = pendingTagInvalidations
100+
pendingTagInvalidations = []
101+
if (tags.length === 0) return
102+
71103
const toInvalidate = api.util.selectInvalidatedBy(rootState, tags)
72104

73105
context.batch(() => {

packages/toolkit/src/query/core/module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ export const coreModule = (): Module<CoreModule> => ({
452452
refetchOnMountOrArgChange,
453453
refetchOnFocus,
454454
refetchOnReconnect,
455+
invalidationBehavior,
455456
},
456457
context
457458
) {
@@ -514,6 +515,7 @@ export const coreModule = (): Module<CoreModule> => ({
514515
refetchOnMountOrArgChange,
515516
keepUnusedDataFor,
516517
reducerPath,
518+
invalidationBehavior,
517519
},
518520
})
519521

packages/toolkit/src/query/createApi.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,16 @@ export interface CreateApiOptions<
151151
* Note: requires [`setupListeners`](./setupListeners) to have been called.
152152
*/
153153
refetchOnReconnect?: boolean
154+
/**
155+
* Defaults to `'immediately'`. This setting allows you to control when tags are invalidated after a mutation.
156+
*
157+
* - `'immediately'`: Queries are invalidated instantly after the mutation finished, even if they are running.
158+
* If the query provides tags that were invalidated while it ran, it won't be re-fetched.
159+
* - `'delayed'`: Invalidation only happens after all queries and mutations are settled.
160+
* This ensures that queries are always invalidated correctly and automatically "batches" invalidations of concurrent mutations.
161+
* Note that if you constantly have some queries (or mutations) running, this can delay tag invalidations indefinitely.
162+
*/
163+
invalidationBehavior?: 'delayed' | 'immediately'
154164
/**
155165
* A function that is passed every dispatched action. If this returns something other than `undefined`,
156166
* that return value will be used to rehydrate fulfilled & errored queries.
@@ -255,6 +265,7 @@ export function buildCreateApi<Modules extends [Module<any>, ...Module<any>[]]>(
255265
refetchOnMountOrArgChange: false,
256266
refetchOnFocus: false,
257267
refetchOnReconnect: false,
268+
invalidationBehavior: 'delayed',
258269
...options,
259270
extractRehydrationInfo,
260271
serializeQueryArgs(queryArgsApi) {

packages/toolkit/src/query/tests/buildSlice.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ describe('buildSlice', () => {
5151
api: {
5252
config: {
5353
focused: true,
54+
invalidationBehavior: 'delayed',
5455
keepUnusedDataFor: 60,
5556
middlewareRegistered: true,
5657
online: true,
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { createApi, QueryStatus } from '@reduxjs/toolkit/query'
2+
import { getLog } from 'console-testing-library'
3+
import { actionsReducer, setupApiStore, waitMs } from './helpers'
4+
5+
// We need to be able to control when which query resolves to simulate race
6+
// conditions properly, that's the purpose of this factory.
7+
const createPromiseFactory = () => {
8+
const resolveQueue: (() => void)[] = []
9+
const createPromise = () =>
10+
new Promise<void>((resolve) => {
11+
resolveQueue.push(resolve)
12+
})
13+
const resolveOldest = () => {
14+
resolveQueue.shift()?.()
15+
}
16+
return { createPromise, resolveOldest }
17+
}
18+
19+
const getEatenBananaPromises = createPromiseFactory()
20+
const eatBananaPromises = createPromiseFactory()
21+
22+
let eatenBananas = 0
23+
const api = createApi({
24+
invalidationBehavior: 'delayed',
25+
baseQuery: () => undefined as any,
26+
tagTypes: ['Banana'],
27+
endpoints: (build) => ({
28+
// Eat a banana.
29+
eatBanana: build.mutation<unknown, void>({
30+
queryFn: async () => {
31+
await eatBananaPromises.createPromise()
32+
eatenBananas += 1
33+
return { data: null, meta: {} }
34+
},
35+
invalidatesTags: ['Banana'],
36+
}),
37+
38+
// Get the number of eaten bananas.
39+
getEatenBananas: build.query<number, void>({
40+
queryFn: async (arg, arg1, arg2, arg3) => {
41+
const result = eatenBananas
42+
await getEatenBananaPromises.createPromise()
43+
return { data: result }
44+
},
45+
providesTags: ['Banana'],
46+
}),
47+
}),
48+
})
49+
const { getEatenBananas, eatBanana } = api.endpoints
50+
51+
const storeRef = setupApiStore(api, {
52+
...actionsReducer,
53+
})
54+
55+
it('invalidates a query after a corresponding mutation', async () => {
56+
eatenBananas = 0
57+
58+
const query = storeRef.store.dispatch(getEatenBananas.initiate())
59+
const getQueryState = () =>
60+
storeRef.store.getState().api.queries[query.queryCacheKey]
61+
getEatenBananaPromises.resolveOldest()
62+
await waitMs(2)
63+
64+
expect(getQueryState()?.data).toBe(0)
65+
expect(getQueryState()?.status).toBe(QueryStatus.fulfilled)
66+
67+
const mutation = storeRef.store.dispatch(eatBanana.initiate())
68+
const getMutationState = () =>
69+
storeRef.store.getState().api.mutations[mutation.requestId]
70+
eatBananaPromises.resolveOldest()
71+
await waitMs(2)
72+
73+
expect(getMutationState()?.status).toBe(QueryStatus.fulfilled)
74+
expect(getQueryState()?.data).toBe(0)
75+
expect(getQueryState()?.status).toBe(QueryStatus.pending)
76+
77+
getEatenBananaPromises.resolveOldest()
78+
await waitMs(2)
79+
80+
expect(getQueryState()?.data).toBe(1)
81+
expect(getQueryState()?.status).toBe(QueryStatus.fulfilled)
82+
})
83+
84+
it('invalidates a query whose corresponding mutation finished while the query was in flight', async () => {
85+
eatenBananas = 0
86+
87+
const query = storeRef.store.dispatch(getEatenBananas.initiate())
88+
const getQueryState = () =>
89+
storeRef.store.getState().api.queries[query.queryCacheKey]
90+
91+
const mutation = storeRef.store.dispatch(eatBanana.initiate())
92+
const getMutationState = () =>
93+
storeRef.store.getState().api.mutations[mutation.requestId]
94+
eatBananaPromises.resolveOldest()
95+
await waitMs(2)
96+
expect(getMutationState()?.status).toBe(QueryStatus.fulfilled)
97+
98+
getEatenBananaPromises.resolveOldest()
99+
await waitMs(2)
100+
expect(getQueryState()?.data).toBe(0)
101+
expect(getQueryState()?.status).toBe(QueryStatus.pending)
102+
103+
// should already be refetching
104+
getEatenBananaPromises.resolveOldest()
105+
await waitMs(2)
106+
107+
expect(getQueryState()?.status).toBe(QueryStatus.fulfilled)
108+
expect(getQueryState()?.data).toBe(1)
109+
})

packages/toolkit/src/tests/combineSlices.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const api = {
3333
subscriptions: {},
3434
config: {
3535
reducerPath: 'api',
36+
invalidationBehavior: 'delayed',
3637
online: false,
3738
focused: false,
3839
keepUnusedDataFor: 60,

0 commit comments

Comments
 (0)