Skip to content

Commit 3ca3c88

Browse files
authored
Merge pull request #2599 from reduxjs/feature/batch-rtkq-rejections
2 parents a4a0c2d + ba9ed75 commit 3ca3c88

11 files changed

+281
-60
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/cacheCollection.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ import type {
1111

1212
export type ReferenceCacheCollection = never
1313

14+
function isObjectEmpty(obj: Record<any, any>) {
15+
// Apparently a for..in loop is faster than `Object.keys()` here:
16+
// https://stackoverflow.com/a/59787784/62937
17+
for (let k in obj) {
18+
// If there is at least one key, it's not empty
19+
return false
20+
}
21+
return true
22+
}
23+
1424
declare module '../../endpointDefinitions' {
1525
interface QueryExtraOptions<
1626
TagTypes extends string,
@@ -38,6 +48,15 @@ export const THIRTY_TWO_BIT_MAX_TIMER_SECONDS = 2_147_483_647 / 1_000 - 1
3848
export const build: SubMiddlewareBuilder = ({ reducerPath, api, context }) => {
3949
const { removeQueryResult, unsubscribeQueryResult } = api.internalActions
4050

51+
function anySubscriptionsRemainingForKey(
52+
queryCacheKey: string,
53+
api: SubMiddlewareApi
54+
) {
55+
const subscriptions =
56+
api.getState()[reducerPath].subscriptions[queryCacheKey]
57+
return !!subscriptions && !isObjectEmpty(subscriptions)
58+
}
59+
4160
return (mwApi) => {
4261
const currentRemovalTimeouts: QueryStateMeta<TimeoutId> = {}
4362

@@ -94,6 +113,11 @@ export const build: SubMiddlewareBuilder = ({ reducerPath, api, context }) => {
94113
] as QueryDefinition<any, any, any, any>
95114
const keepUnusedDataFor =
96115
endpointDefinition?.keepUnusedDataFor ?? config.keepUnusedDataFor
116+
117+
if (keepUnusedDataFor === Infinity) {
118+
// Hey, user said keep this forever!
119+
return
120+
}
97121
// Prevent `setTimeout` timers from overflowing a 32-bit internal int, by
98122
// clamping the max value to be at most 1000ms less than the 32-bit max.
99123
// Look, a 24.8-day keepalive ought to be enough for anybody, right? :)
@@ -103,18 +127,18 @@ export const build: SubMiddlewareBuilder = ({ reducerPath, api, context }) => {
103127
Math.min(keepUnusedDataFor, THIRTY_TWO_BIT_MAX_TIMER_SECONDS)
104128
)
105129

106-
const currentTimeout = currentRemovalTimeouts[queryCacheKey]
107-
if (currentTimeout) {
108-
clearTimeout(currentTimeout)
109-
}
110-
currentRemovalTimeouts[queryCacheKey] = setTimeout(() => {
111-
const subscriptions =
112-
api.getState()[reducerPath].subscriptions[queryCacheKey]
113-
if (!subscriptions || Object.keys(subscriptions).length === 0) {
114-
api.dispatch(removeQueryResult({ queryCacheKey }))
130+
if (!anySubscriptionsRemainingForKey(queryCacheKey, api)) {
131+
const currentTimeout = currentRemovalTimeouts[queryCacheKey]
132+
if (currentTimeout) {
133+
clearTimeout(currentTimeout)
115134
}
116-
delete currentRemovalTimeouts![queryCacheKey]
117-
}, finalKeepUnusedDataFor * 1000)
135+
currentRemovalTimeouts[queryCacheKey] = setTimeout(() => {
136+
if (!anySubscriptionsRemainingForKey(queryCacheKey, api)) {
137+
api.dispatch(removeQueryResult({ queryCacheKey }))
138+
}
139+
delete currentRemovalTimeouts![queryCacheKey]
140+
}, finalKeepUnusedDataFor * 1000)
141+
}
118142
}
119143
}
120144
}

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/cacheCollection.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ describe(`query: await cleanup, keepUnusedDataFor set`, () => {
101101
query: () => '/success',
102102
keepUnusedDataFor: 0,
103103
}),
104+
query4: build.query<unknown, string>({
105+
query: () => '/success',
106+
keepUnusedDataFor: Infinity,
107+
}),
104108
}),
105109
keepUnusedDataFor: 29,
106110
})
@@ -126,9 +130,18 @@ describe(`query: await cleanup, keepUnusedDataFor set`, () => {
126130
expect(onCleanup).not.toHaveBeenCalled()
127131
store.dispatch(api.endpoints.query3.initiate('arg')).unsubscribe()
128132
expect(onCleanup).not.toHaveBeenCalled()
129-
jest.advanceTimersByTime(1), await waitMs()
133+
jest.advanceTimersByTime(1)
134+
await waitMs()
130135
expect(onCleanup).toHaveBeenCalled()
131136
})
137+
138+
test('endpoint keepUnusedDataFor: Infinity', async () => {
139+
expect(onCleanup).not.toHaveBeenCalled()
140+
store.dispatch(api.endpoints.query4.initiate('arg')).unsubscribe()
141+
expect(onCleanup).not.toHaveBeenCalled()
142+
jest.advanceTimersByTime(THIRTY_TWO_BIT_MAX_INT)
143+
expect(onCleanup).not.toHaveBeenCalled()
144+
})
132145
})
133146

134147
function storeForApi<

0 commit comments

Comments
 (0)