UNPKG

17.6 kBPlain TextView Raw
1import type { Action, PayloadAction, UnknownAction } from '@reduxjs/toolkit'
2import {
3 combineReducers,
4 createAction,
5 createSlice,
6 isAnyOf,
7 isFulfilled,
8 isRejectedWithValue,
9 createNextState,
10 prepareAutoBatched,
11} from './rtkImports'
12import type {
13 QuerySubstateIdentifier,
14 QuerySubState,
15 MutationSubstateIdentifier,
16 MutationSubState,
17 MutationState,
18 QueryState,
19 InvalidationState,
20 Subscribers,
21 QueryCacheKey,
22 SubscriptionState,
23 ConfigState,
24} from './apiState'
25import { QueryStatus } from './apiState'
26import type { MutationThunk, QueryThunk, RejectedAction } from './buildThunks'
27import { calculateProvidedByThunk } from './buildThunks'
28import type {
29 AssertTagTypes,
30 EndpointDefinitions,
31 FullTagDescription,
32 QueryDefinition,
33} from '../endpointDefinitions'
34import type { Patch } from 'immer'
35import { isDraft } from 'immer'
36import { applyPatches, original } from 'immer'
37import { onFocus, onFocusLost, onOffline, onOnline } from './setupListeners'
38import {
39 isDocumentVisible,
40 isOnline,
41 copyWithStructuralSharing,
42} from '../utils'
43import type { ApiContext } from '../apiTypes'
44import { isUpsertQuery } from './buildInitiate'
45
46function updateQuerySubstateIfExists(
47 state: QueryState<any>,
48 queryCacheKey: QueryCacheKey,
49 update: (substate: QuerySubState<any>) => void,
50) {
51 const substate = state[queryCacheKey]
52 if (substate) {
53 update(substate)
54 }
55}
56
57export function getMutationCacheKey(
58 id:
59 | MutationSubstateIdentifier
60 | { requestId: string; arg: { fixedCacheKey?: string | undefined } },
61): string
62export function getMutationCacheKey(id: {
63 fixedCacheKey?: string
64 requestId?: string
65}): string | undefined
66
67export function getMutationCacheKey(
68 id:
69 | { fixedCacheKey?: string; requestId?: string }
70 | MutationSubstateIdentifier
71 | { requestId: string; arg: { fixedCacheKey?: string | undefined } },
72): string | undefined {
73 return ('arg' in id ? id.arg.fixedCacheKey : id.fixedCacheKey) ?? id.requestId
74}
75
76function updateMutationSubstateIfExists(
77 state: MutationState<any>,
78 id:
79 | MutationSubstateIdentifier
80 | { requestId: string; arg: { fixedCacheKey?: string | undefined } },
81 update: (substate: MutationSubState<any>) => void,
82) {
83 const substate = state[getMutationCacheKey(id)]
84 if (substate) {
85 update(substate)
86 }
87}
88
89const initialState = {} as any
90
91export function buildSlice({
92 reducerPath,
93 queryThunk,
94 mutationThunk,
95 context: {
96 endpointDefinitions: definitions,
97 apiUid,
98 extractRehydrationInfo,
99 hasRehydrationInfo,
100 },
101 assertTagType,
102 config,
103}: {
104 reducerPath: string
105 queryThunk: QueryThunk
106 mutationThunk: MutationThunk
107 context: ApiContext<EndpointDefinitions>
108 assertTagType: AssertTagTypes
109 config: Omit<
110 ConfigState<string>,
111 'online' | 'focused' | 'middlewareRegistered'
112 >
113}) {
114 const resetApiState = createAction(`${reducerPath}/resetApiState`)
115 const querySlice = createSlice({
116 name: `${reducerPath}/queries`,
117 initialState: initialState as QueryState<any>,
118 reducers: {
119 removeQueryResult: {
120 reducer(
121 draft,
122 {
123 payload: { queryCacheKey },
124 }: PayloadAction<QuerySubstateIdentifier>,
125 ) {
126 delete draft[queryCacheKey]
127 },
128 prepare: prepareAutoBatched<QuerySubstateIdentifier>(),
129 },
130 queryResultPatched: {
131 reducer(
132 draft,
133 {
134 payload: { queryCacheKey, patches },
135 }: PayloadAction<
136 QuerySubstateIdentifier & { patches: readonly Patch[] }
137 >,
138 ) {
139 updateQuerySubstateIfExists(draft, queryCacheKey, (substate) => {
140 substate.data = applyPatches(substate.data as any, patches.concat())
141 })
142 },
143 prepare: prepareAutoBatched<
144 QuerySubstateIdentifier & { patches: readonly Patch[] }
145 >(),
146 },
147 },
148 extraReducers(builder) {
149 builder
150 .addCase(queryThunk.pending, (draft, { meta, meta: { arg } }) => {
151 const upserting = isUpsertQuery(arg)
152 draft[arg.queryCacheKey] ??= {
153 status: QueryStatus.uninitialized,
154 endpointName: arg.endpointName,
155 }
156
157 updateQuerySubstateIfExists(draft, arg.queryCacheKey, (substate) => {
158 substate.status = QueryStatus.pending
159
160 substate.requestId =
161 upserting && substate.requestId
162 ? // for `upsertQuery` **updates**, keep the current `requestId`
163 substate.requestId
164 : // for normal queries or `upsertQuery` **inserts** always update the `requestId`
165 meta.requestId
166 if (arg.originalArgs !== undefined) {
167 substate.originalArgs = arg.originalArgs
168 }
169 substate.startedTimeStamp = meta.startedTimeStamp
170 })
171 })
172 .addCase(queryThunk.fulfilled, (draft, { meta, payload }) => {
173 updateQuerySubstateIfExists(
174 draft,
175 meta.arg.queryCacheKey,
176 (substate) => {
177 if (
178 substate.requestId !== meta.requestId &&
179 !isUpsertQuery(meta.arg)
180 )
181 return
182 const { merge } = definitions[
183 meta.arg.endpointName
184 ] as QueryDefinition<any, any, any, any>
185 substate.status = QueryStatus.fulfilled
186
187 if (merge) {
188 if (substate.data !== undefined) {
189 const { fulfilledTimeStamp, arg, baseQueryMeta, requestId } =
190 meta
191 // There's existing cache data. Let the user merge it in themselves.
192 // We're already inside an Immer-powered reducer, and the user could just mutate `substate.data`
193 // themselves inside of `merge()`. But, they might also want to return a new value.
194 // Try to let Immer figure that part out, save the result, and assign it to `substate.data`.
195 let newData = createNextState(
196 substate.data,
197 (draftSubstateData) => {
198 // As usual with Immer, you can mutate _or_ return inside here, but not both
199 return merge(draftSubstateData, payload, {
200 arg: arg.originalArgs,
201 baseQueryMeta,
202 fulfilledTimeStamp,
203 requestId,
204 })
205 },
206 )
207 substate.data = newData
208 } else {
209 // Presumably a fresh request. Just cache the response data.
210 substate.data = payload
211 }
212 } else {
213 // Assign or safely update the cache data.
214 substate.data =
215 definitions[meta.arg.endpointName].structuralSharing ?? true
216 ? copyWithStructuralSharing(
217 isDraft(substate.data)
218 ? original(substate.data)
219 : substate.data,
220 payload,
221 )
222 : payload
223 }
224
225 delete substate.error
226 substate.fulfilledTimeStamp = meta.fulfilledTimeStamp
227 },
228 )
229 })
230 .addCase(
231 queryThunk.rejected,
232 (draft, { meta: { condition, arg, requestId }, error, payload }) => {
233 updateQuerySubstateIfExists(
234 draft,
235 arg.queryCacheKey,
236 (substate) => {
237 if (condition) {
238 // request was aborted due to condition (another query already running)
239 } else {
240 // request failed
241 if (substate.requestId !== requestId) return
242 substate.status = QueryStatus.rejected
243 substate.error = (payload ?? error) as any
244 }
245 },
246 )
247 },
248 )
249 .addMatcher(hasRehydrationInfo, (draft, action) => {
250 const { queries } = extractRehydrationInfo(action)!
251 for (const [key, entry] of Object.entries(queries)) {
252 if (
253 // do not rehydrate entries that were currently in flight.
254 entry?.status === QueryStatus.fulfilled ||
255 entry?.status === QueryStatus.rejected
256 ) {
257 draft[key] = entry
258 }
259 }
260 })
261 },
262 })
263 const mutationSlice = createSlice({
264 name: `${reducerPath}/mutations`,
265 initialState: initialState as MutationState<any>,
266 reducers: {
267 removeMutationResult: {
268 reducer(draft, { payload }: PayloadAction<MutationSubstateIdentifier>) {
269 const cacheKey = getMutationCacheKey(payload)
270 if (cacheKey in draft) {
271 delete draft[cacheKey]
272 }
273 },
274 prepare: prepareAutoBatched<MutationSubstateIdentifier>(),
275 },
276 },
277 extraReducers(builder) {
278 builder
279 .addCase(
280 mutationThunk.pending,
281 (draft, { meta, meta: { requestId, arg, startedTimeStamp } }) => {
282 if (!arg.track) return
283
284 draft[getMutationCacheKey(meta)] = {
285 requestId,
286 status: QueryStatus.pending,
287 endpointName: arg.endpointName,
288 startedTimeStamp,
289 }
290 },
291 )
292 .addCase(mutationThunk.fulfilled, (draft, { payload, meta }) => {
293 if (!meta.arg.track) return
294
295 updateMutationSubstateIfExists(draft, meta, (substate) => {
296 if (substate.requestId !== meta.requestId) return
297 substate.status = QueryStatus.fulfilled
298 substate.data = payload
299 substate.fulfilledTimeStamp = meta.fulfilledTimeStamp
300 })
301 })
302 .addCase(mutationThunk.rejected, (draft, { payload, error, meta }) => {
303 if (!meta.arg.track) return
304
305 updateMutationSubstateIfExists(draft, meta, (substate) => {
306 if (substate.requestId !== meta.requestId) return
307
308 substate.status = QueryStatus.rejected
309 substate.error = (payload ?? error) as any
310 })
311 })
312 .addMatcher(hasRehydrationInfo, (draft, action) => {
313 const { mutations } = extractRehydrationInfo(action)!
314 for (const [key, entry] of Object.entries(mutations)) {
315 if (
316 // do not rehydrate entries that were currently in flight.
317 (entry?.status === QueryStatus.fulfilled ||
318 entry?.status === QueryStatus.rejected) &&
319 // only rehydrate endpoints that were persisted using a `fixedCacheKey`
320 key !== entry?.requestId
321 ) {
322 draft[key] = entry
323 }
324 }
325 })
326 },
327 })
328
329 const invalidationSlice = createSlice({
330 name: `${reducerPath}/invalidation`,
331 initialState: initialState as InvalidationState<string>,
332 reducers: {
333 updateProvidedBy: {
334 reducer(
335 draft,
336 action: PayloadAction<{
337 queryCacheKey: QueryCacheKey
338 providedTags: readonly FullTagDescription<string>[]
339 }>,
340 ) {
341 const { queryCacheKey, providedTags } = action.payload
342
343 for (const tagTypeSubscriptions of Object.values(draft)) {
344 for (const idSubscriptions of Object.values(tagTypeSubscriptions)) {
345 const foundAt = idSubscriptions.indexOf(queryCacheKey)
346 if (foundAt !== -1) {
347 idSubscriptions.splice(foundAt, 1)
348 }
349 }
350 }
351
352 for (const { type, id } of providedTags) {
353 const subscribedQueries = ((draft[type] ??= {})[
354 id || '__internal_without_id'
355 ] ??= [])
356 const alreadySubscribed = subscribedQueries.includes(queryCacheKey)
357 if (!alreadySubscribed) {
358 subscribedQueries.push(queryCacheKey)
359 }
360 }
361 },
362 prepare: prepareAutoBatched<{
363 queryCacheKey: QueryCacheKey
364 providedTags: readonly FullTagDescription<string>[]
365 }>(),
366 },
367 },
368 extraReducers(builder) {
369 builder
370 .addCase(
371 querySlice.actions.removeQueryResult,
372 (draft, { payload: { queryCacheKey } }) => {
373 for (const tagTypeSubscriptions of Object.values(draft)) {
374 for (const idSubscriptions of Object.values(
375 tagTypeSubscriptions,
376 )) {
377 const foundAt = idSubscriptions.indexOf(queryCacheKey)
378 if (foundAt !== -1) {
379 idSubscriptions.splice(foundAt, 1)
380 }
381 }
382 }
383 },
384 )
385 .addMatcher(hasRehydrationInfo, (draft, action) => {
386 const { provided } = extractRehydrationInfo(action)!
387 for (const [type, incomingTags] of Object.entries(provided)) {
388 for (const [id, cacheKeys] of Object.entries(incomingTags)) {
389 const subscribedQueries = ((draft[type] ??= {})[
390 id || '__internal_without_id'
391 ] ??= [])
392 for (const queryCacheKey of cacheKeys) {
393 const alreadySubscribed =
394 subscribedQueries.includes(queryCacheKey)
395 if (!alreadySubscribed) {
396 subscribedQueries.push(queryCacheKey)
397 }
398 }
399 }
400 }
401 })
402 .addMatcher(
403 isAnyOf(isFulfilled(queryThunk), isRejectedWithValue(queryThunk)),
404 (draft, action) => {
405 const providedTags = calculateProvidedByThunk(
406 action,
407 'providesTags',
408 definitions,
409 assertTagType,
410 )
411 const { queryCacheKey } = action.meta.arg
412
413 invalidationSlice.caseReducers.updateProvidedBy(
414 draft,
415 invalidationSlice.actions.updateProvidedBy({
416 queryCacheKey,
417 providedTags,
418 }),
419 )
420 },
421 )
422 },
423 })
424
425 // Dummy slice to generate actions
426 const subscriptionSlice = createSlice({
427 name: `${reducerPath}/subscriptions`,
428 initialState: initialState as SubscriptionState,
429 reducers: {
430 updateSubscriptionOptions(
431 d,
432 a: PayloadAction<
433 {
434 endpointName: string
435 requestId: string
436 options: Subscribers[number]
437 } & QuerySubstateIdentifier
438 >,
439 ) {
440 // Dummy
441 },
442 unsubscribeQueryResult(
443 d,
444 a: PayloadAction<{ requestId: string } & QuerySubstateIdentifier>,
445 ) {
446 // Dummy
447 },
448 internal_getRTKQSubscriptions() {},
449 },
450 })
451
452 const internalSubscriptionsSlice = createSlice({
453 name: `${reducerPath}/internalSubscriptions`,
454 initialState: initialState as SubscriptionState,
455 reducers: {
456 subscriptionsUpdated: {
457 reducer(state, action: PayloadAction<Patch[]>) {
458 return applyPatches(state, action.payload)
459 },
460 prepare: prepareAutoBatched<Patch[]>(),
461 },
462 },
463 })
464
465 const configSlice = createSlice({
466 name: `${reducerPath}/config`,
467 initialState: {
468 online: isOnline(),
469 focused: isDocumentVisible(),
470 middlewareRegistered: false,
471 ...config,
472 } as ConfigState<string>,
473 reducers: {
474 middlewareRegistered(state, { payload }: PayloadAction<string>) {
475 state.middlewareRegistered =
476 state.middlewareRegistered === 'conflict' || apiUid !== payload
477 ? 'conflict'
478 : true
479 },
480 },
481 extraReducers: (builder) => {
482 builder
483 .addCase(onOnline, (state) => {
484 state.online = true
485 })
486 .addCase(onOffline, (state) => {
487 state.online = false
488 })
489 .addCase(onFocus, (state) => {
490 state.focused = true
491 })
492 .addCase(onFocusLost, (state) => {
493 state.focused = false
494 })
495 // update the state to be a new object to be picked up as a "state change"
496 // by redux-persist's `autoMergeLevel2`
497 .addMatcher(hasRehydrationInfo, (draft) => ({ ...draft }))
498 },
499 })
500
501 const combinedReducer = combineReducers({
502 queries: querySlice.reducer,
503 mutations: mutationSlice.reducer,
504 provided: invalidationSlice.reducer,
505 subscriptions: internalSubscriptionsSlice.reducer,
506 config: configSlice.reducer,
507 })
508
509 const reducer: typeof combinedReducer = (state, action) =>
510 combinedReducer(resetApiState.match(action) ? undefined : state, action)
511
512 const actions = {
513 ...configSlice.actions,
514 ...querySlice.actions,
515 ...subscriptionSlice.actions,
516 ...internalSubscriptionsSlice.actions,
517 ...mutationSlice.actions,
518 ...invalidationSlice.actions,
519 resetApiState,
520 }
521
522 return { reducer, actions }
523}
524export type SliceActions = ReturnType<typeof buildSlice>['actions']