Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Delayed tag invalidations #3116

Merged
1 change: 1 addition & 0 deletions packages/toolkit/src/query/apiTypes.ts
Expand Up @@ -45,6 +45,7 @@ export type Module<Name extends ModuleName> = {
| 'refetchOnMountOrArgChange'
| 'refetchOnFocus'
| 'refetchOnReconnect'
| 'invalidationBehavior'
| 'tagTypes'
>,
context: ApiContext<Definitions>
Expand Down
1 change: 1 addition & 0 deletions packages/toolkit/src/query/core/apiState.ts
Expand Up @@ -254,6 +254,7 @@ export type ConfigState<ReducerPath> = RefetchConfigOptions & {

export type ModifiableConfigState = {
keepUnusedDataFor: number
invalidationBehavior: 'delayed' | 'immediately'
} & RefetchConfigOptions

export type MutationState<D extends EndpointDefinitions> = {
Expand Down
@@ -1,8 +1,16 @@
import { isAnyOf, isFulfilled, isRejectedWithValue } from '../rtkImports'
import {
isAnyOf,
isFulfilled,
isRejected,
isRejectedWithValue,
} from '../rtkImports'

import type { FullTagDescription } from '../../endpointDefinitions'
import type {
EndpointDefinitions,
FullTagDescription,
} from '../../endpointDefinitions'
import { calculateProvidedBy } from '../../endpointDefinitions'
import type { QueryCacheKey } from '../apiState'
import type { CombinedState, QueryCacheKey } from '../apiState'
import { QueryStatus } from '../apiState'
import { calculateProvidedByThunk } from '../buildThunks'
import type {
Expand All @@ -18,6 +26,7 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
context,
context: { endpointDefinitions },
mutationThunk,
queryThunk,
api,
assertTagType,
refetchQuery,
Expand All @@ -29,6 +38,13 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
isRejectedWithValue(mutationThunk)
)

const isQueryEnd = isAnyOf(
isFulfilled(mutationThunk, queryThunk),
isRejected(mutationThunk, queryThunk)
)

let pendingTagInvalidations: FullTagDescription<string>[] = []

const handler: ApiMiddlewareInternalHandler = (action, mwApi) => {
if (isThunkActionWithTags(action)) {
invalidateTags(
Expand All @@ -38,12 +54,11 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
endpointDefinitions,
assertTagType
),
mwApi,
internalState
mwApi
)
}

if (api.util.invalidateTags.match(action)) {
} else if (isQueryEnd(action)) {
invalidateTags([], mwApi)
} else if (api.util.invalidateTags.match(action)) {
invalidateTags(
calculateProvidedBy(
action.payload,
Expand All @@ -53,21 +68,38 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
undefined,
assertTagType
),
mwApi,
internalState
mwApi
)
}
}

function hasPendingRequests(state: CombinedState<EndpointDefinitions, string, string>) {
for (const key in state.queries) {
if (state.queries[key]?.status === QueryStatus.pending) return true;
}
for (const key in state.mutations) {
if (state.mutations[key]?.status === QueryStatus.pending) return true;
}
return false;
}

function invalidateTags(
tags: readonly FullTagDescription<string>[],
mwApi: SubMiddlewareApi,
internalState: InternalMiddlewareState
newTags: readonly FullTagDescription<string>[],
mwApi: SubMiddlewareApi
) {
const rootState = mwApi.getState()

const state = rootState[reducerPath]

pendingTagInvalidations.push(...newTags)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✋ This looks like we could end up with the same tags showing up in pendingTagInvalidations multiple times, which I think also means we'd end up running some extra logic when we try to invalidate. Can pendingTagInvalidations be a Set instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, since tags are objects and not strings in general. More specifically, their type is

{
    type: TagType;
    id?: string | number | undefined;
}

The set of invalidated endpoints is already deduplicated in selectInvalidatedBy. Now, we could try de-duplicating here again by converting these tag objects into canonical string forms, but I think this is creating more overhead than it alleviates except in extreme cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, and while its true that tags can end up in pendingTagInvalidations multiple times, the amount of processing required is no different than if they were invalidated immediately. So this is not a performance regression. (It's not like this code will show up in the profiler anyway.)

if (state.config.invalidationBehavior === 'delayed' && hasPendingRequests(state)) {
return
}

const tags = pendingTagInvalidations
pendingTagInvalidations = []
if (tags.length === 0) return

const toInvalidate = api.util.selectInvalidatedBy(rootState, tags)

context.batch(() => {
Expand Down
2 changes: 2 additions & 0 deletions packages/toolkit/src/query/core/module.ts
Expand Up @@ -452,6 +452,7 @@ export const coreModule = (): Module<CoreModule> => ({
refetchOnMountOrArgChange,
refetchOnFocus,
refetchOnReconnect,
invalidationBehavior,
},
context
) {
Expand Down Expand Up @@ -514,6 +515,7 @@ export const coreModule = (): Module<CoreModule> => ({
refetchOnMountOrArgChange,
keepUnusedDataFor,
reducerPath,
invalidationBehavior,
},
})

Expand Down
11 changes: 11 additions & 0 deletions packages/toolkit/src/query/createApi.ts
Expand Up @@ -151,6 +151,16 @@ export interface CreateApiOptions<
* Note: requires [`setupListeners`](./setupListeners) to have been called.
*/
refetchOnReconnect?: boolean
/**
* Defaults to `'immediately'`. This setting allows you to control when tags are invalidated after a mutation.
*
* - `'immediately'`: Queries are invalidated instantly after the mutation finished, even if they are running.
* If the query provides tags that were invalidated while it ran, it won't be re-fetched.
* - `'delayed'`: Invalidation only happens after all queries and mutations are settled.
* This ensures that queries are always invalidated correctly and automatically "batches" invalidations of concurrent mutations.
* Note that if you constantly have some queries (or mutations) running, this can delay tag invalidations indefinitely.
*/
invalidationBehavior?: 'delayed' | 'immediately'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ so this is configured at the full API level, not just per-endpoint?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes.

I think this is a sane solution to a rare-ish case. For a use-case where one would note the delay, you would need to have an application where you have very long-running queries constantly, and mutations firing at the same time that they run. This tastes unhealthy for performance to begin with and I would strongly suggest the developer to fix the queries, but if that's not possible there's this escape hatch.

Or maybe I'm wrong. Do you have a particular use case in mind where a per-endpoint setting would have a strong advantage?

/**
* A function that is passed every dispatched action. If this returns something other than `undefined`,
* that return value will be used to rehydrate fulfilled & errored queries.
Expand Down Expand Up @@ -255,6 +265,7 @@ export function buildCreateApi<Modules extends [Module<any>, ...Module<any>[]]>(
refetchOnMountOrArgChange: false,
refetchOnFocus: false,
refetchOnReconnect: false,
invalidationBehavior: 'delayed',
GeorchW marked this conversation as resolved.
Show resolved Hide resolved
...options,
extractRehydrationInfo,
serializeQueryArgs(queryArgsApi) {
Expand Down
1 change: 1 addition & 0 deletions packages/toolkit/src/query/tests/buildSlice.test.ts
Expand Up @@ -51,6 +51,7 @@ describe('buildSlice', () => {
api: {
config: {
focused: true,
invalidationBehavior: 'delayed',
keepUnusedDataFor: 60,
middlewareRegistered: true,
online: true,
Expand Down
109 changes: 109 additions & 0 deletions packages/toolkit/src/query/tests/raceConditions.test.ts
@@ -0,0 +1,109 @@
import { createApi, QueryStatus } from '@reduxjs/toolkit/query'
import { getLog } from 'console-testing-library'
import { actionsReducer, setupApiStore, waitMs } from './helpers'

// We need to be able to control when which query resolves to simulate race
// conditions properly, that's the purpose of this factory.
const createPromiseFactory = () => {
const resolveQueue: (() => void)[] = []
const createPromise = () =>
new Promise<void>((resolve) => {
resolveQueue.push(resolve)
})
const resolveOldest = () => {
resolveQueue.shift()?.()
}
return { createPromise, resolveOldest }
}

const getEatenBananaPromises = createPromiseFactory()
const eatBananaPromises = createPromiseFactory()

let eatenBananas = 0
const api = createApi({
invalidationBehavior: 'delayed',
baseQuery: () => undefined as any,
tagTypes: ['Banana'],
endpoints: (build) => ({
// Eat a banana.
eatBanana: build.mutation<unknown, void>({
queryFn: async () => {
await eatBananaPromises.createPromise()
eatenBananas += 1
return { data: null, meta: {} }
},
invalidatesTags: ['Banana'],
}),

// Get the number of eaten bananas.
getEatenBananas: build.query<number, void>({
queryFn: async (arg, arg1, arg2, arg3) => {
const result = eatenBananas
await getEatenBananaPromises.createPromise()
return { data: result }
},
providesTags: ['Banana'],
}),
}),
})
const { getEatenBananas, eatBanana } = api.endpoints

const storeRef = setupApiStore(api, {
...actionsReducer,
})

it('invalidates a query after a corresponding mutation', async () => {
eatenBananas = 0

const query = storeRef.store.dispatch(getEatenBananas.initiate())
const getQueryState = () =>
storeRef.store.getState().api.queries[query.queryCacheKey]
getEatenBananaPromises.resolveOldest()
await waitMs(2)

expect(getQueryState()?.data).toBe(0)
expect(getQueryState()?.status).toBe(QueryStatus.fulfilled)

const mutation = storeRef.store.dispatch(eatBanana.initiate())
const getMutationState = () =>
storeRef.store.getState().api.mutations[mutation.requestId]
eatBananaPromises.resolveOldest()
await waitMs(2)

expect(getMutationState()?.status).toBe(QueryStatus.fulfilled)
expect(getQueryState()?.data).toBe(0)
expect(getQueryState()?.status).toBe(QueryStatus.pending)

getEatenBananaPromises.resolveOldest()
await waitMs(2)

expect(getQueryState()?.data).toBe(1)
expect(getQueryState()?.status).toBe(QueryStatus.fulfilled)
})

it('invalidates a query whose corresponding mutation finished while the query was in flight', async () => {
eatenBananas = 0

const query = storeRef.store.dispatch(getEatenBananas.initiate())
const getQueryState = () =>
storeRef.store.getState().api.queries[query.queryCacheKey]

const mutation = storeRef.store.dispatch(eatBanana.initiate())
const getMutationState = () =>
storeRef.store.getState().api.mutations[mutation.requestId]
eatBananaPromises.resolveOldest()
await waitMs(2)
expect(getMutationState()?.status).toBe(QueryStatus.fulfilled)

getEatenBananaPromises.resolveOldest()
await waitMs(2)
expect(getQueryState()?.data).toBe(0)
expect(getQueryState()?.status).toBe(QueryStatus.pending)

// should already be refetching
getEatenBananaPromises.resolveOldest()
await waitMs(2)

expect(getQueryState()?.status).toBe(QueryStatus.fulfilled)
expect(getQueryState()?.data).toBe(1)
})
1 change: 1 addition & 0 deletions packages/toolkit/src/tests/combineSlices.test.ts
Expand Up @@ -33,6 +33,7 @@ const api = {
subscriptions: {},
config: {
reducerPath: 'api',
invalidationBehavior: 'delayed',
online: false,
focused: false,
keepUnusedDataFor: 60,
Expand Down