1 | import type { Action, PayloadAction, UnknownAction } from '@reduxjs/toolkit'
|
2 | import {
|
3 | combineReducers,
|
4 | createAction,
|
5 | createSlice,
|
6 | isAnyOf,
|
7 | isFulfilled,
|
8 | isRejectedWithValue,
|
9 | createNextState,
|
10 | prepareAutoBatched,
|
11 | } from './rtkImports'
|
12 | import 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'
|
25 | import { QueryStatus } from './apiState'
|
26 | import type { MutationThunk, QueryThunk, RejectedAction } from './buildThunks'
|
27 | import { calculateProvidedByThunk } from './buildThunks'
|
28 | import type {
|
29 | AssertTagTypes,
|
30 | EndpointDefinitions,
|
31 | FullTagDescription,
|
32 | QueryDefinition,
|
33 | } from '../endpointDefinitions'
|
34 | import type { Patch } from 'immer'
|
35 | import { isDraft } from 'immer'
|
36 | import { applyPatches, original } from 'immer'
|
37 | import { onFocus, onFocusLost, onOffline, onOnline } from './setupListeners'
|
38 | import {
|
39 | isDocumentVisible,
|
40 | isOnline,
|
41 | copyWithStructuralSharing,
|
42 | } from '../utils'
|
43 | import type { ApiContext } from '../apiTypes'
|
44 | import { isUpsertQuery } from './buildInitiate'
|
45 |
|
46 | function 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 |
|
57 | export function getMutationCacheKey(
|
58 | id:
|
59 | | MutationSubstateIdentifier
|
60 | | { requestId: string; arg: { fixedCacheKey?: string | undefined } },
|
61 | ): string
|
62 | export function getMutationCacheKey(id: {
|
63 | fixedCacheKey?: string
|
64 | requestId?: string
|
65 | }): string | undefined
|
66 |
|
67 | export 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 |
|
76 | function 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 |
|
89 | const initialState = {} as any
|
90 |
|
91 | export 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 | ?
|
163 | substate.requestId
|
164 | :
|
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 |
|
192 |
|
193 |
|
194 |
|
195 | let newData = createNextState(
|
196 | substate.data,
|
197 | (draftSubstateData) => {
|
198 |
|
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 |
|
210 | substate.data = payload
|
211 | }
|
212 | } else {
|
213 |
|
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 |
|
239 | } else {
|
240 |
|
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 |
|
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 |
|
317 | (entry?.status === QueryStatus.fulfilled ||
|
318 | entry?.status === QueryStatus.rejected) &&
|
319 |
|
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 |
|
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 |
|
441 | },
|
442 | unsubscribeQueryResult(
|
443 | d,
|
444 | a: PayloadAction<{ requestId: string } & QuerySubstateIdentifier>,
|
445 | ) {
|
446 |
|
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 |
|
496 |
|
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 | }
|
524 | export type SliceActions = ReturnType<typeof buildSlice>['actions']
|