Skip to content

Commit f9e0531

Browse files
committed
Rewrite RTKQ internal subscription lookups and subscription syncing
- Changed `subscriptionUpdated` from a microtask to a 500ms throttle - Exposed the RTKQ internal middleware state to be returned via an internal action, so that hooks can read that state directly without needing to call `dispatch()` on every render. - Reworked tests to completely ignore `subscriptionsUpdated` in any action sequence checks due to timing changes and irrelevance - Fixed case where `invalidateTags` was still reading from store state
1 parent d207ced commit f9e0531

15 files changed

+178
-171
lines changed

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

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -265,19 +265,16 @@ export function buildInitiate({
265265
function middlewareWarning(dispatch: Dispatch) {
266266
if (process.env.NODE_ENV !== 'production') {
267267
if ((middlewareWarning as any).triggered) return
268-
const registered:
269-
| ReturnType<typeof api.internalActions.internal_probeSubscription>
270-
| boolean = dispatch(
271-
api.internalActions.internal_probeSubscription({
272-
queryCacheKey: 'DOES_NOT_EXIST',
273-
requestId: 'DUMMY_REQUEST_ID',
274-
})
275-
)
268+
const returnedValue = dispatch(api.internalActions.getRTKQInternalState())
276269

277270
;(middlewareWarning as any).triggered = true
278271

279-
// The RTKQ middleware _should_ always return a boolean for `probeSubscription`
280-
if (typeof registered !== 'boolean') {
272+
// The RTKQ middleware should return the internal state object,
273+
// but it should _not_ be the action object.
274+
if (
275+
typeof returnedValue !== 'object' ||
276+
typeof returnedValue?.type === 'string'
277+
) {
281278
// Otherwise, must not have been added
282279
throw new Error(
283280
`Warning: Middleware for RTK-Query API at reducerPath "${api.reducerPath}" has not been added to the store.

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

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1-
import type { InternalHandlerBuilder } from './types'
1+
import type { InternalHandlerBuilder, InternalMiddlewareState } from './types'
22
import type { SubscriptionState } from '../apiState'
33
import { produceWithPatches } from 'immer'
44
import type { Action } from '@reduxjs/toolkit'
55

66
export const buildBatchedActionsHandler: InternalHandlerBuilder<
7-
[actionShouldContinue: boolean, subscriptionExists: boolean]
7+
[
8+
actionShouldContinue: boolean,
9+
returnValue: InternalMiddlewareState | boolean
10+
]
811
> = ({ api, queryThunk, internalState }) => {
912
const subscriptionsPrefix = `${api.reducerPath}/subscriptions`
1013

1114
let previousSubscriptions: SubscriptionState =
1215
null as unknown as SubscriptionState
1316

14-
let dispatchQueued = false
17+
let updateSyncTimer: ReturnType<typeof window.setTimeout> | null = null
1518

1619
const { updateSubscriptionOptions, unsubscribeQueryResult } =
1720
api.internalActions
@@ -82,7 +85,10 @@ export const buildBatchedActionsHandler: InternalHandlerBuilder<
8285
return (
8386
action,
8487
mwApi
85-
): [actionShouldContinue: boolean, hasSubscription: boolean] => {
88+
): [
89+
actionShouldContinue: boolean,
90+
result: InternalMiddlewareState | boolean
91+
] => {
8692
if (!previousSubscriptions) {
8793
// Initialize it the first time this handler runs
8894
previousSubscriptions = JSON.parse(
@@ -92,16 +98,16 @@ export const buildBatchedActionsHandler: InternalHandlerBuilder<
9298

9399
if (api.util.resetApiState.match(action)) {
94100
previousSubscriptions = internalState.currentSubscriptions = {}
101+
updateSyncTimer = null
95102
return [true, false]
96103
}
97104

98105
// Intercept requests by hooks to see if they're subscribed
99-
// Necessary because we delay updating store state to the end of the tick
100-
if (api.internalActions.internal_probeSubscription.match(action)) {
101-
const { queryCacheKey, requestId } = action.payload
102-
const hasSubscription =
103-
!!internalState.currentSubscriptions[queryCacheKey]?.[requestId]
104-
return [false, hasSubscription]
106+
// We return the internal state reference so that hooks
107+
// can do their own checks to see if they're still active.
108+
// It's stupid and hacky, but it does cut down on some dispatch calls.
109+
if (api.internalActions.getRTKQInternalState.match(action)) {
110+
return [false, internalState]
105111
}
106112

107113
// Update subscription data based on this action
@@ -110,9 +116,16 @@ export const buildBatchedActionsHandler: InternalHandlerBuilder<
110116
action
111117
)
112118

119+
let actionShouldContinue = true
120+
113121
if (didMutate) {
114-
if (!dispatchQueued) {
115-
queueMicrotask(() => {
122+
if (!updateSyncTimer) {
123+
// We only use the subscription state for the Redux DevTools at this point,
124+
// as the real data is kept here in the middleware.
125+
// Given that, we can throttle synchronizing this state significantly to
126+
// save on overall perf.
127+
// In 1.9, it was updated in a microtask, but now we do it at most every 500ms.
128+
updateSyncTimer = setTimeout(() => {
116129
// Deep clone the current subscription data
117130
const newSubscriptions: SubscriptionState = JSON.parse(
118131
JSON.stringify(internalState.currentSubscriptions)
@@ -127,25 +140,23 @@ export const buildBatchedActionsHandler: InternalHandlerBuilder<
127140
mwApi.next(api.internalActions.subscriptionsUpdated(patches))
128141
// Save the cloned state for later reference
129142
previousSubscriptions = newSubscriptions
130-
dispatchQueued = false
131-
})
132-
dispatchQueued = true
143+
updateSyncTimer = null
144+
}, 500)
133145
}
134146

135147
const isSubscriptionSliceAction =
136148
typeof action.type == 'string' &&
137149
!!action.type.startsWith(subscriptionsPrefix)
150+
138151
const isAdditionalSubscriptionAction =
139152
queryThunk.rejected.match(action) &&
140153
action.meta.condition &&
141154
!!action.meta.arg.subscribe
142155

143-
const actionShouldContinue =
156+
actionShouldContinue =
144157
!isSubscriptionSliceAction && !isAdditionalSubscriptionAction
145-
146-
return [actionShouldContinue, false]
147158
}
148159

149-
return [true, false]
160+
return [actionShouldContinue, false]
150161
}
151162
}

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export function buildMiddleware<
7171
>),
7272
internalState,
7373
refetchQuery,
74+
isThisApiSliceAction,
7475
}
7576

7677
const handlers = handlerBuilders.map((build) => build(builderArgs))
@@ -93,18 +94,15 @@ export function buildMiddleware<
9394

9495
const stateBefore = mwApi.getState()
9596

96-
const [actionShouldContinue, hasSubscription] = batchedActionsHandler(
97-
action,
98-
mwApiWithNext,
99-
stateBefore
100-
)
97+
const [actionShouldContinue, internalProbeResult] =
98+
batchedActionsHandler(action, mwApiWithNext, stateBefore)
10199

102100
let res: any
103101

104102
if (actionShouldContinue) {
105103
res = next(action)
106104
} else {
107-
res = hasSubscription
105+
res = internalProbeResult
108106
}
109107

110108
if (!!mwApi.getState()[reducerPath]) {

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
SubMiddlewareApi,
1010
InternalHandlerBuilder,
1111
ApiMiddlewareInternalHandler,
12+
InternalMiddlewareState,
1213
} from './types'
1314

1415
export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
@@ -19,6 +20,7 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
1920
api,
2021
assertTagType,
2122
refetchQuery,
23+
internalState,
2224
}) => {
2325
const { removeQueryResult } = api.internalActions
2426
const isThunkActionWithTags = isAnyOf(
@@ -35,7 +37,8 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
3537
endpointDefinitions,
3638
assertTagType
3739
),
38-
mwApi
40+
mwApi,
41+
internalState
3942
)
4043
}
4144

@@ -49,16 +52,19 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
4952
undefined,
5053
assertTagType
5154
),
52-
mwApi
55+
mwApi,
56+
internalState
5357
)
5458
}
5559
}
5660

5761
function invalidateTags(
5862
tags: readonly FullTagDescription<string>[],
59-
mwApi: SubMiddlewareApi
63+
mwApi: SubMiddlewareApi,
64+
internalState: InternalMiddlewareState
6065
) {
6166
const rootState = mwApi.getState()
67+
6268
const state = rootState[reducerPath]
6369

6470
const toInvalidate = api.util.selectInvalidatedBy(rootState, tags)
@@ -67,7 +73,8 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
6773
const valuesArray = Array.from(toInvalidate.values())
6874
for (const { queryCacheKey } of valuesArray) {
6975
const querySubState = state.queries[queryCacheKey]
70-
const subscriptionSubState = state.subscriptions[queryCacheKey] ?? {}
76+
const subscriptionSubState =
77+
internalState.currentSubscriptions[queryCacheKey] ?? {}
7178

7279
if (querySubState) {
7380
if (Object.keys(subscriptionSubState).length === 0) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export interface BuildSubMiddlewareInput
6161
queryCacheKey: string,
6262
override?: Partial<QueryThunkArg>
6363
): AsyncThunkAction<ThunkResult, QueryThunkArg, {}>
64+
isThisApiSliceAction: (action: Action) => boolean
6465
}
6566

6667
export type SubMiddlewareBuilder = (

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { PayloadAction, UnknownAction } from '@reduxjs/toolkit'
1+
import type { Action, PayloadAction, UnknownAction } from '@reduxjs/toolkit'
22
import {
33
combineReducers,
44
createAction,
@@ -443,12 +443,7 @@ export function buildSlice({
443443
) {
444444
// Dummy
445445
},
446-
internal_probeSubscription(
447-
d,
448-
a: PayloadAction<{ queryCacheKey: string; requestId: string }>
449-
) {
450-
// dummy
451-
},
446+
getRTKQInternalState() {},
452447
},
453448
})
454449

packages/toolkit/src/query/react/buildHooks.ts

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { UNINITIALIZED_VALUE } from './constants'
5353
import { useShallowStableValue } from './useShallowStableValue'
5454
import type { BaseQueryFn } from '../baseQueryTypes'
5555
import { defaultSerializeQueryArgs } from '../defaultSerializeQueryArgs'
56+
import { InternalMiddlewareState } from '../core/buildMiddleware/types'
5657

5758
// Copy-pasted from React-Redux
5859
export const useIsomorphicLayoutEffect =
@@ -681,6 +682,27 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
681682
Definitions
682683
>
683684
const dispatch = useDispatch<ThunkDispatch<any, any, UnknownAction>>()
685+
const internalStateRef = useRef<InternalMiddlewareState | null>(null)
686+
if (!internalStateRef.current) {
687+
const returnedValue = dispatch(
688+
api.internalActions.getRTKQInternalState()
689+
)
690+
691+
if (process.env.NODE_ENV !== 'production') {
692+
if (
693+
typeof returnedValue !== 'object' ||
694+
typeof returnedValue?.type === 'string'
695+
) {
696+
throw new Error(
697+
`Warning: Middleware for RTK-Query API at reducerPath "${api.reducerPath}" has not been added to the store.
698+
You must add the middleware for RTK-Query to function correctly!`
699+
)
700+
}
701+
}
702+
703+
internalStateRef.current =
704+
returnedValue as unknown as InternalMiddlewareState
705+
}
684706
const stableArg = useStableQueryArgs(
685707
skip ? skipToken : arg,
686708
// Even if the user provided a per-endpoint `serializeQueryArgs` with
@@ -704,28 +726,15 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
704726

705727
let { queryCacheKey, requestId } = promiseRef.current || {}
706728

707-
// HACK Because the latest state is in the middleware, we actually
708-
// dispatch an action that will be intercepted and returned.
729+
// HACK We've saved the middleware internal state into a ref,
730+
// and that state object gets directly mutated. But, we've _got_ a reference
731+
// to it locally, so we can just read the data directly here in the hook.
709732
let currentRenderHasSubscription = false
710733
if (queryCacheKey && requestId) {
711-
// This _should_ return a boolean, even if the types don't line up
712-
const returnedValue = dispatch(
713-
api.internalActions.internal_probeSubscription({
714-
queryCacheKey,
715-
requestId,
716-
})
717-
)
718-
719-
if (process.env.NODE_ENV !== 'production') {
720-
if (typeof returnedValue !== 'boolean') {
721-
throw new Error(
722-
`Warning: Middleware for RTK-Query API at reducerPath "${api.reducerPath}" has not been added to the store.
723-
You must add the middleware for RTK-Query to function correctly!`
724-
)
725-
}
726-
}
727-
728-
currentRenderHasSubscription = !!returnedValue
734+
currentRenderHasSubscription =
735+
!!internalStateRef.current?.currentSubscriptions?.[queryCacheKey]?.[
736+
requestId
737+
]
729738
}
730739

731740
const subscriptionRemoved =

0 commit comments

Comments
 (0)