Skip to content

Commit 5e17bd8

Browse files
committed
Rewrite subscription handling to batch actions
When a component mounts and tries to subscribe, it dispatches a thunk. The first thunk dispatched for a given cache entry makes the actual request. Later thunks for the same cache entry will see that the cache entry exists, return `false` from `condition()`, and bail out. That dispatches a `rejected` action, and the reducer logic listened for that to add the additional subscription entry. However, this meant that adding N subscriptions to a cache entry would dispatch N + 1: `pending` + `fulfilled` from the first thunk, and N-1 `rejected` actions from all others. We've had reports that this led to slow perf when rendering large lists of components with query hooks inside. I've rewritten the logic to catch all `query/rejected` subscription actions within an event loop tick, and dispatch them as a single batched action at the end of the tick with `queueMicrotask`. This drastically minimizes the number of dispatched actions - for a list of 1000 items, it went from 1002 actions to 4, and 6.5s to 1s.
1 parent a4a0c2d commit 5e17bd8

File tree

8 files changed

+223
-42
lines changed

8 files changed

+223
-42
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { QueryThunk, RejectedAction } from '../buildThunks'
2+
import type { SubMiddlewareBuilder } from './types'
3+
4+
// Copied from https://github.com/feross/queue-microtask
5+
let promise: Promise<any>
6+
const queueMicrotaskShim =
7+
typeof queueMicrotask === 'function'
8+
? queueMicrotask.bind(typeof window !== 'undefined' ? window : global)
9+
: // reuse resolved promise, and allocate it lazily
10+
(cb: () => void) =>
11+
(promise || (promise = Promise.resolve())).then(cb).catch((err: any) =>
12+
setTimeout(() => {
13+
throw err
14+
}, 0)
15+
)
16+
17+
export const build: SubMiddlewareBuilder = ({
18+
api,
19+
context: { apiUid },
20+
queryThunk,
21+
reducerPath,
22+
}) => {
23+
return (mwApi) => {
24+
let abortedQueryActionsQueue: RejectedAction<QueryThunk, any>[] = []
25+
let dispatchQueued = false
26+
27+
return (next) => (action) => {
28+
if (queryThunk.rejected.match(action)) {
29+
const { condition, arg } = action.meta
30+
31+
if (condition && arg.subscribe) {
32+
// request was aborted due to condition (another query already running)
33+
// _Don't_ dispatch right away - queue it for a debounced grouped dispatch
34+
abortedQueryActionsQueue.push(action)
35+
36+
if (!dispatchQueued) {
37+
queueMicrotaskShim(() => {
38+
mwApi.dispatch(
39+
api.internalActions.subscriptionRequestsRejected(
40+
abortedQueryActionsQueue
41+
)
42+
)
43+
abortedQueryActionsQueue = []
44+
})
45+
dispatchQueued = true
46+
}
47+
// _Don't_ let the action reach the reducers now!
48+
return
49+
}
50+
}
51+
52+
const result = next(action)
53+
54+
return result
55+
}
56+
}
57+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { build as buildWindowEventHandling } from './windowEventHandling'
1717
import { build as buildCacheLifecycle } from './cacheLifecycle'
1818
import { build as buildQueryLifecycle } from './queryLifecycle'
1919
import { build as buildDevMiddleware } from './devMiddleware'
20+
import { build as buildBatchActions } from './batchActions'
2021

2122
export function buildMiddleware<
2223
Definitions extends EndpointDefinitions,
@@ -38,6 +39,7 @@ export function buildMiddleware<
3839
buildWindowEventHandling,
3940
buildCacheLifecycle,
4041
buildQueryLifecycle,
42+
buildBatchActions,
4143
].map((build) =>
4244
build({
4345
...(input as any as BuildMiddlewareInput<

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

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import type {
2323
ConfigState,
2424
} from './apiState'
2525
import { QueryStatus } from './apiState'
26-
import type { MutationThunk, QueryThunk } from './buildThunks'
26+
import type { MutationThunk, QueryThunk, RejectedAction } from './buildThunks'
2727
import { calculateProvidedByThunk } from './buildThunks'
2828
import type {
2929
AssertTagTypes,
@@ -387,6 +387,26 @@ export function buildSlice({
387387
delete draft[queryCacheKey]![requestId]
388388
}
389389
},
390+
subscriptionRequestsRejected(
391+
draft,
392+
action: PayloadAction<RejectedAction<QueryThunk, any>[]>
393+
) {
394+
// We need to process "rejected" actions caused by a component trying to start a subscription
395+
// after there's already a cache entry. Since many components may mount at once and all want
396+
// the same data, we use a middleware that intercepts those actions batches these together
397+
// into a single larger action , and we'll process all of them at once.
398+
for (let rejectedAction of action.payload) {
399+
const {
400+
meta: { condition, arg, requestId },
401+
} = rejectedAction
402+
// request was aborted due to condition (another query already running)
403+
if (condition && arg.subscribe) {
404+
const substate = (draft[arg.queryCacheKey] ??= {})
405+
substate[requestId] =
406+
arg.subscriptionOptions ?? substate[requestId] ?? {}
407+
}
408+
}
409+
},
390410
},
391411
extraReducers: (builder) => {
392412
builder
@@ -403,17 +423,6 @@ export function buildSlice({
403423
arg.subscriptionOptions ?? substate[requestId] ?? {}
404424
}
405425
})
406-
.addCase(
407-
queryThunk.rejected,
408-
(draft, { meta: { condition, arg, requestId }, error, payload }) => {
409-
// request was aborted due to condition (another query already running)
410-
if (condition && arg.subscribe) {
411-
const substate = (draft[arg.queryCacheKey] ??= {})
412-
substate[requestId] =
413-
arg.subscriptionOptions ?? substate[requestId] ?? {}
414-
}
415-
}
416-
)
417426
// update the state to be a new object to be picked up as a "state change"
418427
// by redux-persist's `autoMergeLevel2`
419428
.addMatcher(hasRehydrationInfo, (draft) => ({ ...draft }))

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -696,16 +696,25 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
696696
pollingInterval,
697697
})
698698

699+
const lastRenderHadSubscription = useRef(false)
700+
699701
const promiseRef = useRef<QueryActionCreatorResult<any>>()
700702

701703
let { queryCacheKey, requestId } = promiseRef.current || {}
702-
const subscriptionRemoved = useSelector(
704+
const currentRenderHasSubscription = useSelector(
703705
(state: RootState<Definitions, string, string>) =>
704706
!!queryCacheKey &&
705707
!!requestId &&
706708
!state[api.reducerPath].subscriptions[queryCacheKey]?.[requestId]
707709
)
708710

711+
const subscriptionRemoved =
712+
!currentRenderHasSubscription && lastRenderHadSubscription.current
713+
714+
usePossiblyImmediateEffect(() => {
715+
lastRenderHadSubscription.current = currentRenderHasSubscription
716+
})
717+
709718
usePossiblyImmediateEffect((): void | undefined => {
710719
promiseRef.current = undefined
711720
}, [subscriptionRemoved])
@@ -736,6 +745,7 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
736745
forceRefetch: refetchOnMountOrArgChange,
737746
})
738747
)
748+
739749
promiseRef.current = promise
740750
} else if (stableSubscriptionOptions !== lastSubscriptionOptions) {
741751
lastPromise.updateSubscriptionOptions(stableSubscriptionOptions)
@@ -923,8 +933,9 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
923933
...options,
924934
})
925935

926-
const { data, status, isLoading, isSuccess, isError, error } = queryStateResults;
927-
useDebugValue({ data, status, isLoading, isSuccess, isError, error });
936+
const { data, status, isLoading, isSuccess, isError, error } =
937+
queryStateResults
938+
useDebugValue({ data, status, isLoading, isSuccess, isError, error })
928939

929940
return useMemo(
930941
() => ({ ...queryStateResults, ...querySubscriptionResults }),
@@ -993,8 +1004,24 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
9931004
})
9941005
}, [dispatch, fixedCacheKey, promise, requestId])
9951006

996-
const { endpointName, data, status, isLoading, isSuccess, isError, error } = currentState;
997-
useDebugValue({ endpointName, data, status, isLoading, isSuccess, isError, error });
1007+
const {
1008+
endpointName,
1009+
data,
1010+
status,
1011+
isLoading,
1012+
isSuccess,
1013+
isError,
1014+
error,
1015+
} = currentState
1016+
useDebugValue({
1017+
endpointName,
1018+
data,
1019+
status,
1020+
isLoading,
1021+
isSuccess,
1022+
isError,
1023+
error,
1024+
})
9981025

9991026
const finalState = useMemo(
10001027
() => ({ ...currentState, originalArgs, reset }),

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,9 +1162,12 @@ describe('hooks tests', () => {
11621162
})
11631163

11641164
test('useMutation return value contains originalArgs', async () => {
1165-
const { result } = renderHook(() => api.endpoints.updateUser.useMutation(), {
1166-
wrapper: storeRef.wrapper,
1167-
})
1165+
const { result } = renderHook(
1166+
() => api.endpoints.updateUser.useMutation(),
1167+
{
1168+
wrapper: storeRef.wrapper,
1169+
}
1170+
)
11681171
const arg = { name: 'Foo' }
11691172

11701173
const firstRenderResult = result.current
@@ -1955,13 +1958,13 @@ describe('hooks with createApi defaults set', () => {
19551958

19561959
const addBtn = screen.getByTestId('addPost')
19571960

1958-
await waitFor(() => expect(getRenderCount()).toBe(3))
1961+
await waitFor(() => expect(getRenderCount()).toBe(4))
19591962

19601963
fireEvent.click(addBtn)
1961-
await waitFor(() => expect(getRenderCount()).toBe(5))
1964+
await waitFor(() => expect(getRenderCount()).toBe(6))
19621965
fireEvent.click(addBtn)
19631966
fireEvent.click(addBtn)
1964-
await waitFor(() => expect(getRenderCount()).toBe(7))
1967+
await waitFor(() => expect(getRenderCount()).toBe(8))
19651968
})
19661969

19671970
test('useQuery with selectFromResult option serves a deeply memoized value and does not rerender unnecessarily', async () => {

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

Lines changed: 92 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
import React from 'react'
44

5+
import { createListenerMiddleware } from '@reduxjs/toolkit'
56
import { createApi, QueryStatus } from '@reduxjs/toolkit/query/react'
6-
import { render, waitFor } from '@testing-library/react'
7+
import { render, waitFor, act, screen } from '@testing-library/react'
78
import { setupApiStore } from './helpers'
89

910
const api = createApi({
10-
baseQuery: () => ({ data: null }),
11+
baseQuery: () => ({ data: 42 }),
1112
endpoints: (build) => ({
1213
a: build.query<unknown, void>({ query: () => '' }),
1314
b: build.query<unknown, void>({ query: () => '' }),
@@ -19,18 +20,21 @@ let getSubStateA = () => storeRef.store.getState().api.queries['a(undefined)']
1920
let getSubStateB = () => storeRef.store.getState().api.queries['b(undefined)']
2021

2122
function UsingA() {
22-
api.endpoints.a.useQuery()
23-
return <></>
23+
const { data } = api.endpoints.a.useQuery()
24+
25+
return <>Result: {data} </>
2426
}
2527

2628
function UsingB() {
2729
api.endpoints.b.useQuery()
30+
2831
return <></>
2932
}
3033

3134
function UsingAB() {
3235
api.endpoints.a.useQuery()
3336
api.endpoints.b.useQuery()
37+
3438
return <></>
3539
}
3640

@@ -90,11 +94,15 @@ test('data stays in store when component stays rendered while data for another c
9094

9195
const statusA = getSubStateA()
9296

93-
rerender(
94-
<>
95-
<UsingA />
96-
</>
97-
)
97+
await act(async () => {
98+
rerender(
99+
<>
100+
<UsingA />
101+
</>
102+
)
103+
104+
jest.advanceTimersByTime(10)
105+
})
98106

99107
jest.advanceTimersByTime(120000)
100108

@@ -108,8 +116,8 @@ test('data stays in store when one component requiring the data stays in the sto
108116

109117
const { rerender } = render(
110118
<>
111-
<UsingA />
112-
<UsingAB />
119+
<UsingA key="a" />
120+
<UsingAB key="ab" />
113121
</>,
114122
{ wrapper: storeRef.wrapper }
115123
)
@@ -121,14 +129,81 @@ test('data stays in store when one component requiring the data stays in the sto
121129
const statusA = getSubStateA()
122130
const statusB = getSubStateB()
123131

124-
rerender(
125-
<>
126-
<UsingAB />
127-
</>
128-
)
132+
await act(async () => {
133+
rerender(
134+
<>
135+
<UsingAB key="ab" />
136+
</>
137+
)
138+
jest.advanceTimersByTime(10)
139+
jest.runAllTimers()
140+
})
129141

130-
jest.advanceTimersByTime(120000)
142+
await act(async () => {
143+
jest.advanceTimersByTime(120000)
144+
jest.runAllTimers()
145+
})
131146

132147
expect(getSubStateA()).toEqual(statusA)
133148
expect(getSubStateB()).toEqual(statusB)
134149
})
150+
151+
test('Minimizes the number of subscription dispatches when multiple components ask for the same data', async () => {
152+
const listenerMiddleware = createListenerMiddleware()
153+
const storeRef = setupApiStore(api, undefined, {
154+
middleware: [listenerMiddleware.middleware],
155+
withoutTestLifecycles: true,
156+
})
157+
158+
let getSubscriptionsA = () =>
159+
storeRef.store.getState().api.subscriptions['a(undefined)']
160+
161+
let actionTypes: string[] = []
162+
163+
listenerMiddleware.startListening({
164+
predicate: () => true,
165+
effect: (action) => {
166+
actionTypes.push(action.type)
167+
},
168+
})
169+
170+
const NUM_LIST_ITEMS = 1000
171+
172+
function ParentComponent() {
173+
const listItems = Array.from({ length: NUM_LIST_ITEMS }).map((_, i) => (
174+
<UsingA key={i} />
175+
))
176+
177+
return <>{listItems}</>
178+
}
179+
180+
const start = Date.now()
181+
182+
render(<ParentComponent />, {
183+
wrapper: storeRef.wrapper,
184+
})
185+
186+
jest.advanceTimersByTime(10)
187+
188+
await waitFor(() => {
189+
return screen.getAllByText(/42/).length > 0
190+
})
191+
192+
const end = Date.now()
193+
194+
const timeElapsed = end - start
195+
196+
const subscriptions = getSubscriptionsA()
197+
198+
expect(Object.keys(subscriptions!).length).toBe(NUM_LIST_ITEMS)
199+
// Expected: [
200+
// 'api/config/middlewareRegistered',
201+
// 'api/executeQuery/pending',
202+
// 'api/subscriptions/subscriptionRequestsRejected',
203+
// 'api/executeQuery/fulfilled'
204+
// ]
205+
expect(actionTypes.length).toBe(4)
206+
// Could be flaky in CI, but we'll see.
207+
// Currently seeing 1000ms in local dev, 6300 without the batching fixes
208+
expect(timeElapsed).toBeLessThan(2500)
209+
}, 25000)

0 commit comments

Comments
 (0)