Skip to content

Commit d2bbb8d

Browse files
Rewrite subscription handling and polling calculations for better perf (#5064)
* Add test for subscription serialization * Rewrite internal subscription handle for better perf * Hack in a way to expose polling update counters in tests * Rewrite polling handling for perf * Use lazy getOrInsert defaults --------- Co-authored-by: codesandbox-bot <codesandbot@codesandbox.io>
1 parent 3c6de47 commit d2bbb8d

16 files changed

+442
-204
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export type SubscriptionOptions = {
148148
*/
149149
refetchOnFocus?: boolean
150150
}
151+
export type SubscribersInternal = Map<string, SubscriptionOptions>
151152
export type Subscribers = { [requestId: string]: SubscriptionOptions }
152153
export type QueryKeys<Definitions extends EndpointDefinitions> = {
153154
[K in keyof Definitions]: Definitions[K] extends QueryDefinition<
@@ -327,6 +328,8 @@ export type QueryState<D extends EndpointDefinitions> = {
327328
| undefined
328329
}
329330

331+
export type SubscriptionInternalState = Map<string, SubscribersInternal>
332+
330333
export type SubscriptionState = {
331334
[queryCacheKey: string]: Subscribers | undefined
332335
}

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

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import type {
4242
ThunkApiMetaConfig,
4343
} from './buildThunks'
4444
import type { ApiEndpointQuery } from './module'
45+
import type { InternalMiddlewareState } from './buildMiddleware/types'
4546

4647
export type BuildInitiateApiEndpointQuery<
4748
Definition extends QueryDefinition<any, any, any, any, any>,
@@ -270,27 +271,17 @@ export function buildInitiate({
270271
mutationThunk,
271272
api,
272273
context,
274+
internalState,
273275
}: {
274276
serializeQueryArgs: InternalSerializeQueryArgs
275277
queryThunk: QueryThunk
276278
infiniteQueryThunk: InfiniteQueryThunk<any>
277279
mutationThunk: MutationThunk
278280
api: Api<any, EndpointDefinitions, any, any>
279281
context: ApiContext<EndpointDefinitions>
282+
internalState: InternalMiddlewareState
280283
}) {
281-
const runningQueries: Map<
282-
Dispatch,
283-
Record<
284-
string,
285-
| QueryActionCreatorResult<any>
286-
| InfiniteQueryActionCreatorResult<any>
287-
| undefined
288-
>
289-
> = new Map()
290-
const runningMutations: Map<
291-
Dispatch,
292-
Record<string, MutationActionCreatorResult<any> | undefined>
293-
> = new Map()
284+
const { runningQueries, runningMutations } = internalState
294285

295286
const {
296287
unsubscribeQueryResult,

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

Lines changed: 63 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import type { InternalHandlerBuilder, SubscriptionSelectors } from './types'
2-
import type { SubscriptionState } from '../apiState'
2+
import type { SubscriptionInternalState, SubscriptionState } from '../apiState'
33
import { produceWithPatches } from 'immer'
44
import type { Action } from '@reduxjs/toolkit'
5-
import { countObjectKeys } from '../../utils/countObjectKeys'
5+
import { getOrInsertComputed, createNewMap } from '../../utils/getOrInsert'
66

77
export const buildBatchedActionsHandler: InternalHandlerBuilder<
88
[actionShouldContinue: boolean, returnValue: SubscriptionSelectors | boolean]
9-
> = ({ api, queryThunk, internalState }) => {
9+
> = ({ api, queryThunk, internalState, mwApi }) => {
1010
const subscriptionsPrefix = `${api.reducerPath}/subscriptions`
1111

1212
let previousSubscriptions: SubscriptionState =
@@ -20,58 +20,63 @@ export const buildBatchedActionsHandler: InternalHandlerBuilder<
2020
// Actually intentionally mutate the subscriptions state used in the middleware
2121
// This is done to speed up perf when loading many components
2222
const actuallyMutateSubscriptions = (
23-
mutableState: SubscriptionState,
23+
currentSubscriptions: SubscriptionInternalState,
2424
action: Action,
2525
) => {
2626
if (updateSubscriptionOptions.match(action)) {
2727
const { queryCacheKey, requestId, options } = action.payload
2828

29-
if (mutableState?.[queryCacheKey]?.[requestId]) {
30-
mutableState[queryCacheKey]![requestId] = options
29+
const sub = currentSubscriptions.get(queryCacheKey)
30+
if (sub?.has(requestId)) {
31+
sub.set(requestId, options)
3132
}
3233
return true
3334
}
3435
if (unsubscribeQueryResult.match(action)) {
3536
const { queryCacheKey, requestId } = action.payload
36-
if (mutableState[queryCacheKey]) {
37-
delete mutableState[queryCacheKey]![requestId]
37+
const sub = currentSubscriptions.get(queryCacheKey)
38+
if (sub) {
39+
sub.delete(requestId)
3840
}
3941
return true
4042
}
4143
if (api.internalActions.removeQueryResult.match(action)) {
42-
delete mutableState[action.payload.queryCacheKey]
44+
currentSubscriptions.delete(action.payload.queryCacheKey)
4345
return true
4446
}
4547
if (queryThunk.pending.match(action)) {
4648
const {
4749
meta: { arg, requestId },
4850
} = action
49-
const substate = (mutableState[arg.queryCacheKey] ??= {})
50-
substate[`${requestId}_running`] = {}
51+
const substate = getOrInsertComputed(
52+
currentSubscriptions,
53+
arg.queryCacheKey,
54+
createNewMap,
55+
)
5156
if (arg.subscribe) {
52-
substate[requestId] =
53-
arg.subscriptionOptions ?? substate[requestId] ?? {}
57+
substate.set(
58+
requestId,
59+
arg.subscriptionOptions ?? substate.get(requestId) ?? {},
60+
)
5461
}
5562
return true
5663
}
5764
let mutated = false
58-
if (
59-
queryThunk.fulfilled.match(action) ||
60-
queryThunk.rejected.match(action)
61-
) {
62-
const state = mutableState[action.meta.arg.queryCacheKey] || {}
63-
const key = `${action.meta.requestId}_running`
64-
mutated ||= !!state[key]
65-
delete state[key]
66-
}
65+
6766
if (queryThunk.rejected.match(action)) {
6867
const {
6968
meta: { condition, arg, requestId },
7069
} = action
7170
if (condition && arg.subscribe) {
72-
const substate = (mutableState[arg.queryCacheKey] ??= {})
73-
substate[requestId] =
74-
arg.subscriptionOptions ?? substate[requestId] ?? {}
71+
const substate = getOrInsertComputed(
72+
currentSubscriptions,
73+
arg.queryCacheKey,
74+
createNewMap,
75+
)
76+
substate.set(
77+
requestId,
78+
arg.subscriptionOptions ?? substate.get(requestId) ?? {},
79+
)
7580

7681
mutated = true
7782
}
@@ -83,12 +88,12 @@ export const buildBatchedActionsHandler: InternalHandlerBuilder<
8388
const getSubscriptions = () => internalState.currentSubscriptions
8489
const getSubscriptionCount = (queryCacheKey: string) => {
8590
const subscriptions = getSubscriptions()
86-
const subscriptionsForQueryArg = subscriptions[queryCacheKey] ?? {}
87-
return countObjectKeys(subscriptionsForQueryArg)
91+
const subscriptionsForQueryArg = subscriptions.get(queryCacheKey)
92+
return subscriptionsForQueryArg?.size ?? 0
8893
}
8994
const isRequestSubscribed = (queryCacheKey: string, requestId: string) => {
9095
const subscriptions = getSubscriptions()
91-
return !!subscriptions?.[queryCacheKey]?.[requestId]
96+
return !!subscriptions?.get(queryCacheKey)?.get(requestId)
9297
}
9398

9499
const subscriptionSelectors: SubscriptionSelectors = {
@@ -97,6 +102,21 @@ export const buildBatchedActionsHandler: InternalHandlerBuilder<
97102
isRequestSubscribed,
98103
}
99104

105+
function serializeSubscriptions(
106+
currentSubscriptions: SubscriptionInternalState,
107+
): SubscriptionState {
108+
// We now use nested Maps for subscriptions, instead of
109+
// plain Records. Stringify this accordingly so we can
110+
// convert it to the shape we need for the store.
111+
return JSON.parse(
112+
JSON.stringify(
113+
Object.fromEntries(
114+
[...currentSubscriptions].map(([k, v]) => [k, Object.fromEntries(v)]),
115+
),
116+
),
117+
)
118+
}
119+
100120
return (
101121
action,
102122
mwApi,
@@ -106,13 +126,14 @@ export const buildBatchedActionsHandler: InternalHandlerBuilder<
106126
] => {
107127
if (!previousSubscriptions) {
108128
// Initialize it the first time this handler runs
109-
previousSubscriptions = JSON.parse(
110-
JSON.stringify(internalState.currentSubscriptions),
129+
previousSubscriptions = serializeSubscriptions(
130+
internalState.currentSubscriptions,
111131
)
112132
}
113133

114134
if (api.util.resetApiState.match(action)) {
115-
previousSubscriptions = internalState.currentSubscriptions = {}
135+
previousSubscriptions = {}
136+
internalState.currentSubscriptions.clear()
116137
updateSyncTimer = null
117138
return [true, false]
118139
}
@@ -133,6 +154,15 @@ export const buildBatchedActionsHandler: InternalHandlerBuilder<
133154

134155
let actionShouldContinue = true
135156

157+
// HACK Sneak the test-only polling state back out
158+
if (
159+
process.env.NODE_ENV === 'test' &&
160+
typeof action.type === 'string' &&
161+
action.type === `${api.reducerPath}/getPolling`
162+
) {
163+
return [false, internalState.currentPolls] as any
164+
}
165+
136166
if (didMutate) {
137167
if (!updateSyncTimer) {
138168
// We only use the subscription state for the Redux DevTools at this point,
@@ -142,8 +172,8 @@ export const buildBatchedActionsHandler: InternalHandlerBuilder<
142172
// In 1.9, it was updated in a microtask, but now we do it at most every 500ms.
143173
updateSyncTimer = setTimeout(() => {
144174
// Deep clone the current subscription data
145-
const newSubscriptions: SubscriptionState = JSON.parse(
146-
JSON.stringify(internalState.currentSubscriptions),
175+
const newSubscriptions: SubscriptionState = serializeSubscriptions(
176+
internalState.currentSubscriptions,
147177
)
148178
// Figure out a smaller diff between original and current
149179
const [, patches] = produceWithPatches(

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

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,13 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({
3535
internalState,
3636
selectors: { selectQueryEntry, selectConfig },
3737
getRunningQueryThunk,
38+
mwApi,
3839
}) => {
3940
const { removeQueryResult, unsubscribeQueryResult, cacheEntriesUpserted } =
4041
api.internalActions
4142

43+
const runningQueries = internalState.runningQueries.get(mwApi.dispatch)!
44+
4245
const canTriggerUnsubscribe = isAnyOf(
4346
unsubscribeQueryResult.match,
4447
queryThunk.fulfilled,
@@ -47,19 +50,14 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({
4750
)
4851

4952
function anySubscriptionsRemainingForKey(queryCacheKey: string) {
50-
const subscriptions = internalState.currentSubscriptions[queryCacheKey]
53+
const subscriptions = internalState.currentSubscriptions.get(queryCacheKey)
5154
if (!subscriptions) {
5255
return false
5356
}
5457

55-
// Check if there are any keys that are NOT _running subscriptions
56-
for (const key in subscriptions) {
57-
if (!key.endsWith('_running')) {
58-
return true
59-
}
60-
}
61-
// Only _running subscriptions remain (or empty)
62-
return false
58+
const hasSubscriptions = subscriptions.size > 0
59+
const isRunning = runningQueries?.[queryCacheKey] !== undefined
60+
return hasSubscriptions || isRunning
6361
}
6462

6563
const currentRemovalTimeouts: QueryStateMeta<TimeoutId> = {}

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function buildMiddleware<
4545
ReducerPath extends string,
4646
TagTypes extends string,
4747
>(input: BuildMiddlewareInput<Definitions, ReducerPath, TagTypes>) {
48-
const { reducerPath, queryThunk, api, context } = input
48+
const { reducerPath, queryThunk, api, context, internalState } = input
4949
const { apiUid } = context
5050

5151
const actions = {
@@ -73,10 +73,6 @@ export function buildMiddleware<
7373
> = (mwApi) => {
7474
let initialized = false
7575

76-
const internalState: InternalMiddlewareState = {
77-
currentSubscriptions: {},
78-
}
79-
8076
const builderArgs = {
8177
...(input as any as BuildMiddlewareInput<
8278
EndpointDefinitions,
@@ -86,6 +82,7 @@ export function buildMiddleware<
8682
internalState,
8783
refetchQuery,
8884
isThisApiSliceAction,
85+
mwApi,
8986
}
9087

9188
const handlers = handlerBuilders.map((build) => build(builderArgs))

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,8 @@ import type {
1717
SubMiddlewareApi,
1818
InternalHandlerBuilder,
1919
ApiMiddlewareInternalHandler,
20-
InternalMiddlewareState,
2120
} from './types'
22-
import { countObjectKeys } from '../../utils/countObjectKeys'
21+
import { getOrInsertComputed, createNewMap } from '../../utils/getOrInsert'
2322

2423
export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
2524
reducerPath,
@@ -111,11 +110,14 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
111110
const valuesArray = Array.from(toInvalidate.values())
112111
for (const { queryCacheKey } of valuesArray) {
113112
const querySubState = state.queries[queryCacheKey]
114-
const subscriptionSubState =
115-
internalState.currentSubscriptions[queryCacheKey] ?? {}
113+
const subscriptionSubState = getOrInsertComputed(
114+
internalState.currentSubscriptions,
115+
queryCacheKey,
116+
createNewMap,
117+
)
116118

117119
if (querySubState) {
118-
if (countObjectKeys(subscriptionSubState) === 0) {
120+
if (subscriptionSubState.size === 0) {
119121
mwApi.dispatch(
120122
removeQueryResult({
121123
queryCacheKey: queryCacheKey as QueryCacheKey,

0 commit comments

Comments
 (0)