Skip to content

Commit 7513bfa

Browse files
committed
Rewrite polling handling for perf
# Conflicts: # packages/toolkit/src/query/core/buildMiddleware/polling.ts # packages/toolkit/src/query/core/buildMiddleware/types.ts # packages/toolkit/src/query/tests/polling.test.tsx
1 parent 89c7a67 commit 7513bfa

File tree

5 files changed

+138
-51
lines changed

5 files changed

+138
-51
lines changed

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

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,25 @@ export const buildPollingHandler: InternalHandlerBuilder = ({
2121
refetchQuery,
2222
internalState,
2323
}) => {
24-
const { currentPolls } = internalState
24+
const { currentPolls, currentSubscriptions } = internalState
2525

26-
const { currentSubscriptions } = internalState
26+
// Batching state for polling updates
27+
const pendingPollingUpdates = new Set<string>()
28+
let pollingUpdateTimer: ReturnType<typeof setTimeout> | null = null
2729

2830
const handler: ApiMiddlewareInternalHandler = (action, mwApi) => {
2931
if (
3032
api.internalActions.updateSubscriptionOptions.match(action) ||
3133
api.internalActions.unsubscribeQueryResult.match(action)
3234
) {
33-
updatePollingInterval(action.payload, mwApi)
35+
schedulePollingUpdate(action.payload.queryCacheKey, mwApi)
3436
}
3537

3638
if (
3739
queryThunk.pending.match(action) ||
3840
(queryThunk.rejected.match(action) && action.meta.condition)
3941
) {
40-
updatePollingInterval(action.meta.arg, mwApi)
42+
schedulePollingUpdate(action.meta.arg.queryCacheKey, mwApi)
4143
}
4244

4345
if (
@@ -49,6 +51,27 @@ export const buildPollingHandler: InternalHandlerBuilder = ({
4951

5052
if (api.util.resetApiState.match(action)) {
5153
clearPolls()
54+
// Clear any pending updates
55+
if (pollingUpdateTimer) {
56+
clearTimeout(pollingUpdateTimer)
57+
pollingUpdateTimer = null
58+
}
59+
pendingPollingUpdates.clear()
60+
}
61+
}
62+
63+
function schedulePollingUpdate(queryCacheKey: string, api: SubMiddlewareApi) {
64+
pendingPollingUpdates.add(queryCacheKey)
65+
66+
if (!pollingUpdateTimer) {
67+
pollingUpdateTimer = setTimeout(() => {
68+
// Process all pending updates in a single batch
69+
for (const key of pendingPollingUpdates) {
70+
updatePollingInterval({ queryCacheKey: key as any }, api)
71+
}
72+
pendingPollingUpdates.clear()
73+
pollingUpdateTimer = null
74+
}, 0)
5275
}
5376
}
5477

@@ -81,7 +104,7 @@ export const buildPollingHandler: InternalHandlerBuilder = ({
81104
findLowestPollingInterval(subscriptions)
82105
if (!Number.isFinite(lowestPollingInterval)) return
83106

84-
const currentPoll = currentPolls[queryCacheKey]
107+
const currentPoll = currentPolls.get(queryCacheKey)
85108

86109
if (currentPoll?.timeout) {
87110
clearTimeout(currentPoll.timeout)
@@ -90,7 +113,7 @@ export const buildPollingHandler: InternalHandlerBuilder = ({
90113

91114
const nextPollTimestamp = Date.now() + lowestPollingInterval
92115

93-
currentPolls[queryCacheKey] = {
116+
currentPolls.set(queryCacheKey, {
94117
nextPollTimestamp,
95118
pollingInterval: lowestPollingInterval,
96119
timeout: setTimeout(() => {
@@ -99,7 +122,7 @@ export const buildPollingHandler: InternalHandlerBuilder = ({
99122
}
100123
startNextPoll({ queryCacheKey }, api)
101124
}, lowestPollingInterval),
102-
}
125+
})
103126
}
104127

105128
function updatePollingInterval(
@@ -117,6 +140,7 @@ export const buildPollingHandler: InternalHandlerBuilder = ({
117140
const { lowestPollingInterval } = findLowestPollingInterval(subscriptions)
118141

119142
// HACK add extra data to track how many times this has been called in tests
143+
// yes we're mutating a nonexistent field on a Map here
120144
if (process.env.NODE_ENV === 'test') {
121145
const updateCounters = ((currentPolls as any).pollUpdateCounters ??= {})
122146
updateCounters[queryCacheKey] ??= 0
@@ -128,7 +152,8 @@ export const buildPollingHandler: InternalHandlerBuilder = ({
128152
return
129153
}
130154

131-
const currentPoll = currentPolls[queryCacheKey]
155+
const currentPoll = currentPolls.get(queryCacheKey)
156+
132157
const nextPollTimestamp = Date.now() + lowestPollingInterval
133158

134159
if (!currentPoll || nextPollTimestamp < currentPoll.nextPollTimestamp) {
@@ -137,15 +162,15 @@ export const buildPollingHandler: InternalHandlerBuilder = ({
137162
}
138163

139164
function cleanupPollForKey(key: string) {
140-
const existingPoll = currentPolls[key]
165+
const existingPoll = currentPolls.get(key)
141166
if (existingPoll?.timeout) {
142167
clearTimeout(existingPoll.timeout)
143168
}
144-
delete currentPolls[key]
169+
currentPolls.delete(key)
145170
}
146171

147172
function clearPolls() {
148-
for (const key of Object.keys(currentPolls)) {
173+
for (const key of currentPolls.keys()) {
149174
cleanupPollForKey(key)
150175
}
151176
}

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ import type { AllSelectors } from '../buildSelectors'
3737
export type QueryStateMeta<T> = Record<string, undefined | T>
3838
export type TimeoutId = ReturnType<typeof setTimeout>
3939

40+
type QueryPollState = {
41+
nextPollTimestamp: number
42+
timeout?: TimeoutId
43+
pollingInterval: number
44+
}
45+
4046
export interface InternalMiddlewareState {
4147
currentSubscriptions: SubscriptionInternalState
42-
currentPolls: QueryStateMeta<{
43-
nextPollTimestamp: number
44-
timeout?: TimeoutId
45-
pollingInterval: number
46-
}>
48+
currentPolls: Map<string, QueryPollState>
4749
runningQueries: Map<
4850
Dispatch,
4951
Record<

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ export const coreModule = ({
621621

622622
const internalState: InternalMiddlewareState = {
623623
currentSubscriptions: new Map(),
624-
currentPolls: {},
624+
currentPolls: new Map(),
625625
runningQueries: new Map(),
626626
runningMutations: new Map(),
627627
}

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

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe('Infinite queries', () => {
1616
name: string
1717
}
1818

19+
type HitCounter = { page: number; hitCounter: number }
1920
let counters: Record<string, number> = {}
2021
let queryCounter = 0
2122

@@ -88,39 +89,41 @@ describe('Infinite queries', () => {
8889
}),
8990
})
9091

91-
let hitCounter = 0
92-
93-
type HitCounter = { page: number; hitCounter: number }
92+
function createCountersApi() {
93+
let hitCounter = 0
9494

95-
const countersApi = createApi({
96-
baseQuery: fakeBaseQuery(),
97-
tagTypes: ['Counter'],
98-
endpoints: (build) => ({
99-
counters: build.infiniteQuery<HitCounter, string, number>({
100-
queryFn({ pageParam }) {
101-
hitCounter++
95+
const countersApi = createApi({
96+
baseQuery: fakeBaseQuery(),
97+
tagTypes: ['Counter'],
98+
endpoints: (build) => ({
99+
counters: build.infiniteQuery<HitCounter, string, number>({
100+
queryFn({ pageParam }) {
101+
hitCounter++
102102

103-
return { data: { page: pageParam, hitCounter } }
104-
},
105-
infiniteQueryOptions: {
106-
initialPageParam: 0,
107-
getNextPageParam: (
108-
lastPage,
109-
allPages,
110-
lastPageParam,
111-
allPageParams,
112-
) => lastPageParam + 1,
113-
},
114-
providesTags: ['Counter'],
115-
}),
116-
mutation: build.mutation<null, void>({
117-
queryFn: async () => {
118-
return { data: null }
119-
},
120-
invalidatesTags: ['Counter'],
103+
return { data: { page: pageParam, hitCounter } }
104+
},
105+
infiniteQueryOptions: {
106+
initialPageParam: 0,
107+
getNextPageParam: (
108+
lastPage,
109+
allPages,
110+
lastPageParam,
111+
allPageParams,
112+
) => lastPageParam + 1,
113+
},
114+
providesTags: ['Counter'],
115+
}),
116+
mutation: build.mutation<null, void>({
117+
queryFn: async () => {
118+
return { data: null }
119+
},
120+
invalidatesTags: ['Counter'],
121+
}),
121122
}),
122-
}),
123-
})
123+
})
124+
125+
return countersApi
126+
}
124127

125128
let storeRef = setupApiStore(
126129
pokemonApi,
@@ -155,7 +158,6 @@ describe('Infinite queries', () => {
155158

156159
counters = {}
157160

158-
hitCounter = 0
159161
queryCounter = 0
160162
})
161163

@@ -411,6 +413,8 @@ describe('Infinite queries', () => {
411413
}
412414
}
413415

416+
const countersApi = createCountersApi()
417+
414418
const storeRef = setupApiStore(
415419
countersApi,
416420
{ ...actionsReducer },
@@ -465,6 +469,8 @@ describe('Infinite queries', () => {
465469
}
466470
}
467471

472+
const countersApi = createCountersApi()
473+
468474
const storeRef = setupApiStore(
469475
countersApi,
470476
{ ...actionsReducer },
@@ -528,6 +534,7 @@ describe('Infinite queries', () => {
528534
})
529535

530536
test('Refetches on polling', async () => {
537+
const countersApi = createCountersApi()
531538
const checkResultData = (
532539
result: InfiniteQueryResult,
533540
expectedValues: HitCounter[],

packages/toolkit/src/query/tests/polling.test.tsx

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createApi } from '@reduxjs/toolkit/query'
2+
import type { QueryActionCreatorResult } from '@reduxjs/toolkit/query'
23
import { delay } from 'msw'
34
import { setupApiStore } from '../../tests/utils/helpers'
45
import type { SubscriptionSelectors } from '../core/buildMiddleware/types'
@@ -29,6 +30,11 @@ beforeEach(() => {
2930
;({ getSubscriptions } = storeRef.store.dispatch(
3031
api.internalActions.internal_getRTKQSubscriptions(),
3132
) as unknown as SubscriptionSelectors)
33+
34+
const currentPolls = storeRef.store.dispatch({
35+
type: `${api.reducerPath}/getPolling`,
36+
}) as any
37+
;(currentPolls as any).pollUpdateCounters = {}
3238
})
3339

3440
const getSubscribersForQueryCacheKey = (queryCacheKey: string) =>
@@ -67,13 +73,13 @@ describe('polling tests', () => {
6773

6874
await delay(1)
6975
expect(getSubs().size).toBe(1)
70-
expect(getSubs().get(requestId).pollingInterval).toBe(10)
76+
expect(getSubs()?.get(requestId)?.pollingInterval).toBe(10)
7177

7278
subscription.updateSubscriptionOptions({ pollingInterval: 20 })
7379

7480
await delay(1)
7581
expect(getSubs().size).toBe(1)
76-
expect(getSubs().get(requestId).pollingInterval).toBe(20)
82+
expect(getSubs()?.get(requestId)?.pollingInterval).toBe(20)
7783
})
7884

7985
it(`doesn't replace the interval when removing a shared query instance with a poll `, async () => {
@@ -155,7 +161,7 @@ describe('polling tests', () => {
155161
const callsWithoutSkip = mockBaseQuery.mock.calls.length
156162

157163
expect(callsWithSkip).toBe(1)
158-
expect(callsWithoutSkip).toBeGreaterThan(2)
164+
expect(callsWithoutSkip).toBeGreaterThanOrEqual(2)
159165

160166
storeRef.store.dispatch(api.util.resetApiState())
161167
})
@@ -230,4 +236,51 @@ describe('polling tests', () => {
230236
expect(getSubs().size).toBe(1)
231237
expect(getSubs().get(requestId)?.skipPollingIfUnfocused).toBe(true)
232238
})
239+
240+
it('should minimize polling recalculations when adding multiple subscribers', async () => {
241+
// Reset any existing state
242+
const storeRef = setupApiStore(api, undefined, {
243+
withoutTestLifecycles: true,
244+
})
245+
246+
const SUBSCRIBER_COUNT = 10
247+
const subscriptions: QueryActionCreatorResult<any>[] = []
248+
249+
// Add 10 subscribers to the same endpoint with polling enabled
250+
for (let i = 0; i < SUBSCRIBER_COUNT; i++) {
251+
const subscription = storeRef.store.dispatch(
252+
getPosts.initiate(1, {
253+
subscriptionOptions: { pollingInterval: 1000 },
254+
subscribe: true,
255+
}),
256+
)
257+
subscriptions.push(subscription)
258+
}
259+
260+
// Wait a bit for all subscriptions to be processed
261+
await Promise.all(subscriptions)
262+
263+
// Wait for the poll update timer
264+
await delay(25)
265+
266+
// Get the polling state using the secret "getPolling" action
267+
const currentPolls = storeRef.store.dispatch({
268+
type: `${api.reducerPath}/getPolling`,
269+
}) as any
270+
271+
// Get the query cache key for our endpoint
272+
const queryCacheKey = subscriptions[0].queryCacheKey
273+
274+
// Check the poll update counters
275+
const pollUpdateCounters = currentPolls.pollUpdateCounters || {}
276+
const updateCount = pollUpdateCounters[queryCacheKey] || 0
277+
278+
// With batching optimization, this should be much lower than SUBSCRIBER_COUNT
279+
// Ideally 1, but could be slightly higher due to timing
280+
expect(updateCount).toBeGreaterThanOrEqual(1)
281+
expect(updateCount).toBeLessThanOrEqual(2)
282+
283+
// Clean up subscriptions
284+
subscriptions.forEach((sub) => sub.unsubscribe())
285+
})
233286
})

0 commit comments

Comments
 (0)