UNPKG

19.3 kBPlain TextView Raw
1import type { Dispatch, AnyAction } from 'redux'
2import type {
3 PayloadAction,
4 ActionCreatorWithPreparedPayload,
5} from './createAction'
6import { createAction } from './createAction'
7import type { ThunkDispatch } from 'redux-thunk'
8import type { FallbackIfUnknown, IsAny, IsUnknown } from './tsHelpers'
9import { nanoid } from './nanoid'
10
11// @ts-ignore we need the import of these types due to a bundling issue.
12type _Keep = PayloadAction | ActionCreatorWithPreparedPayload<any, unknown>
13
14export type BaseThunkAPI<
15 S,
16 E,
17 D extends Dispatch = Dispatch,
18 RejectedValue = undefined,
19 RejectedMeta = unknown,
20 FulfilledMeta = unknown
21> = {
22 dispatch: D
23 getState: () => S
24 extra: E
25 requestId: string
26 signal: AbortSignal
27 rejectWithValue: IsUnknown<
28 RejectedMeta,
29 (value: RejectedValue) => RejectWithValue<RejectedValue, RejectedMeta>,
30 (
31 value: RejectedValue,
32 meta: RejectedMeta
33 ) => RejectWithValue<RejectedValue, RejectedMeta>
34 >
35 fulfillWithValue: IsUnknown<
36 FulfilledMeta,
37 <FulfilledValue>(
38 value: FulfilledValue
39 ) => FulfillWithMeta<FulfilledValue, FulfilledMeta>,
40 <FulfilledValue>(
41 value: FulfilledValue,
42 meta: FulfilledMeta
43 ) => FulfillWithMeta<FulfilledValue, FulfilledMeta>
44 >
45}
46
47/**
48 * @public
49 */
50export interface SerializedError {
51 name?: string
52 message?: string
53 stack?: string
54 code?: string
55}
56
57const commonProperties: Array<keyof SerializedError> = [
58 'name',
59 'message',
60 'stack',
61 'code',
62]
63
64class RejectWithValue<Payload, RejectedMeta> {
65 /*
66 type-only property to distinguish between RejectWithValue and FulfillWithMeta
67 does not exist at runtime
68 */
69 private readonly _type!: 'RejectWithValue'
70 constructor(
71 public readonly payload: Payload,
72 public readonly meta: RejectedMeta
73 ) {}
74}
75
76class FulfillWithMeta<Payload, FulfilledMeta> {
77 /*
78 type-only property to distinguish between RejectWithValue and FulfillWithMeta
79 does not exist at runtime
80 */
81 private readonly _type!: 'FulfillWithMeta'
82 constructor(
83 public readonly payload: Payload,
84 public readonly meta: FulfilledMeta
85 ) {}
86}
87
88/**
89 * Serializes an error into a plain object.
90 * Reworked from https://github.com/sindresorhus/serialize-error
91 *
92 * @public
93 */
94export const miniSerializeError = (value: any): SerializedError => {
95 if (typeof value === 'object' && value !== null) {
96 const simpleError: SerializedError = {}
97 for (const property of commonProperties) {
98 if (typeof value[property] === 'string') {
99 simpleError[property] = value[property]
100 }
101 }
102
103 return simpleError
104 }
105
106 return { message: String(value) }
107}
108
109type AsyncThunkConfig = {
110 state?: unknown
111 dispatch?: Dispatch
112 extra?: unknown
113 rejectValue?: unknown
114 serializedErrorType?: unknown
115 pendingMeta?: unknown
116 fulfilledMeta?: unknown
117 rejectedMeta?: unknown
118}
119
120type GetState<ThunkApiConfig> = ThunkApiConfig extends {
121 state: infer State
122}
123 ? State
124 : unknown
125type GetExtra<ThunkApiConfig> = ThunkApiConfig extends { extra: infer Extra }
126 ? Extra
127 : unknown
128type GetDispatch<ThunkApiConfig> = ThunkApiConfig extends {
129 dispatch: infer Dispatch
130}
131 ? FallbackIfUnknown<
132 Dispatch,
133 ThunkDispatch<
134 GetState<ThunkApiConfig>,
135 GetExtra<ThunkApiConfig>,
136 AnyAction
137 >
138 >
139 : ThunkDispatch<GetState<ThunkApiConfig>, GetExtra<ThunkApiConfig>, AnyAction>
140
141type GetThunkAPI<ThunkApiConfig> = BaseThunkAPI<
142 GetState<ThunkApiConfig>,
143 GetExtra<ThunkApiConfig>,
144 GetDispatch<ThunkApiConfig>,
145 GetRejectValue<ThunkApiConfig>,
146 GetRejectedMeta<ThunkApiConfig>,
147 GetFulfilledMeta<ThunkApiConfig>
148>
149
150type GetRejectValue<ThunkApiConfig> = ThunkApiConfig extends {
151 rejectValue: infer RejectValue
152}
153 ? RejectValue
154 : unknown
155
156type GetPendingMeta<ThunkApiConfig> = ThunkApiConfig extends {
157 pendingMeta: infer PendingMeta
158}
159 ? PendingMeta
160 : unknown
161
162type GetFulfilledMeta<ThunkApiConfig> = ThunkApiConfig extends {
163 fulfilledMeta: infer FulfilledMeta
164}
165 ? FulfilledMeta
166 : unknown
167
168type GetRejectedMeta<ThunkApiConfig> = ThunkApiConfig extends {
169 rejectedMeta: infer RejectedMeta
170}
171 ? RejectedMeta
172 : unknown
173
174type GetSerializedErrorType<ThunkApiConfig> = ThunkApiConfig extends {
175 serializedErrorType: infer GetSerializedErrorType
176}
177 ? GetSerializedErrorType
178 : SerializedError
179
180type MaybePromise<T> = T | Promise<T> | (T extends any ? Promise<T> : never)
181
182/**
183 * A type describing the return value of the `payloadCreator` argument to `createAsyncThunk`.
184 * Might be useful for wrapping `createAsyncThunk` in custom abstractions.
185 *
186 * @public
187 */
188export type AsyncThunkPayloadCreatorReturnValue<
189 Returned,
190 ThunkApiConfig extends AsyncThunkConfig
191> = MaybePromise<
192 | IsUnknown<
193 GetFulfilledMeta<ThunkApiConfig>,
194 Returned,
195 FulfillWithMeta<Returned, GetFulfilledMeta<ThunkApiConfig>>
196 >
197 | RejectWithValue<
198 GetRejectValue<ThunkApiConfig>,
199 GetRejectedMeta<ThunkApiConfig>
200 >
201>
202/**
203 * A type describing the `payloadCreator` argument to `createAsyncThunk`.
204 * Might be useful for wrapping `createAsyncThunk` in custom abstractions.
205 *
206 * @public
207 */
208export type AsyncThunkPayloadCreator<
209 Returned,
210 ThunkArg = void,
211 ThunkApiConfig extends AsyncThunkConfig = {}
212> = (
213 arg: ThunkArg,
214 thunkAPI: GetThunkAPI<ThunkApiConfig>
215) => AsyncThunkPayloadCreatorReturnValue<Returned, ThunkApiConfig>
216
217/**
218 * A ThunkAction created by `createAsyncThunk`.
219 * Dispatching it returns a Promise for either a
220 * fulfilled or rejected action.
221 * Also, the returned value contains an `abort()` method
222 * that allows the asyncAction to be cancelled from the outside.
223 *
224 * @public
225 */
226export type AsyncThunkAction<
227 Returned,
228 ThunkArg,
229 ThunkApiConfig extends AsyncThunkConfig
230> = (
231 dispatch: GetDispatch<ThunkApiConfig>,
232 getState: () => GetState<ThunkApiConfig>,
233 extra: GetExtra<ThunkApiConfig>
234) => Promise<
235 | ReturnType<AsyncThunkFulfilledActionCreator<Returned, ThunkArg>>
236 | ReturnType<AsyncThunkRejectedActionCreator<ThunkArg, ThunkApiConfig>>
237> & {
238 abort: (reason?: string) => void
239 requestId: string
240 arg: ThunkArg
241 unwrap: () => Promise<Returned>
242}
243
244type AsyncThunkActionCreator<
245 Returned,
246 ThunkArg,
247 ThunkApiConfig extends AsyncThunkConfig
248> = IsAny<
249 ThunkArg,
250 // any handling
251 (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>,
252 // unknown handling
253 unknown extends ThunkArg
254 ? (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument not specified or specified as void or undefined
255 : [ThunkArg] extends [void] | [undefined]
256 ? () => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument contains void
257 : [void] extends [ThunkArg] // make optional
258 ? (arg?: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument contains undefined
259 : [undefined] extends [ThunkArg]
260 ? WithStrictNullChecks<
261 // with strict nullChecks: make optional
262 (
263 arg?: ThunkArg
264 ) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>,
265 // without strict null checks this will match everything, so don't make it optional
266 (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
267 > // default case: normal argument
268 : (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
269>
270
271/**
272 * Options object for `createAsyncThunk`.
273 *
274 * @public
275 */
276export type AsyncThunkOptions<
277 ThunkArg = void,
278 ThunkApiConfig extends AsyncThunkConfig = {}
279> = {
280 /**
281 * A method to control whether the asyncThunk should be executed. Has access to the
282 * `arg`, `api.getState()` and `api.extra` arguments.
283 *
284 * @returns `false` if it should be skipped
285 */
286 condition?(
287 arg: ThunkArg,
288 api: Pick<GetThunkAPI<ThunkApiConfig>, 'getState' | 'extra'>
289 ): boolean | undefined
290 /**
291 * If `condition` returns `false`, the asyncThunk will be skipped.
292 * This option allows you to control whether a `rejected` action with `meta.condition == false`
293 * will be dispatched or not.
294 *
295 * @default `false`
296 */
297 dispatchConditionRejection?: boolean
298
299 serializeError?: (x: unknown) => GetSerializedErrorType<ThunkApiConfig>
300
301 /**
302 * A function to use when generating the `requestId` for the request sequence.
303 *
304 * @default `nanoid`
305 */
306 idGenerator?: () => string
307} & IsUnknown<
308 GetPendingMeta<ThunkApiConfig>,
309 {
310 /**
311 * A method to generate additional properties to be added to `meta` of the pending action.
312 *
313 * Using this optional overload will not modify the types correctly, this overload is only in place to support JavaScript users.
314 * Please use the `ThunkApiConfig` parameter `pendingMeta` to get access to a correctly typed overload
315 */
316 getPendingMeta?(
317 base: {
318 arg: ThunkArg
319 requestId: string
320 },
321 api: Pick<GetThunkAPI<ThunkApiConfig>, 'getState' | 'extra'>
322 ): GetPendingMeta<ThunkApiConfig>
323 },
324 {
325 /**
326 * A method to generate additional properties to be added to `meta` of the pending action.
327 */
328 getPendingMeta(
329 base: {
330 arg: ThunkArg
331 requestId: string
332 },
333 api: Pick<GetThunkAPI<ThunkApiConfig>, 'getState' | 'extra'>
334 ): GetPendingMeta<ThunkApiConfig>
335 }
336>
337
338export type AsyncThunkPendingActionCreator<
339 ThunkArg,
340 ThunkApiConfig = {}
341> = ActionCreatorWithPreparedPayload<
342 [string, ThunkArg, GetPendingMeta<ThunkApiConfig>?],
343 undefined,
344 string,
345 never,
346 {
347 arg: ThunkArg
348 requestId: string
349 requestStatus: 'pending'
350 } & GetPendingMeta<ThunkApiConfig>
351>
352
353export type AsyncThunkRejectedActionCreator<
354 ThunkArg,
355 ThunkApiConfig = {}
356> = ActionCreatorWithPreparedPayload<
357 [
358 Error | null,
359 string,
360 ThunkArg,
361 GetRejectValue<ThunkApiConfig>?,
362 GetRejectedMeta<ThunkApiConfig>?
363 ],
364 GetRejectValue<ThunkApiConfig> | undefined,
365 string,
366 GetSerializedErrorType<ThunkApiConfig>,
367 {
368 arg: ThunkArg
369 requestId: string
370 requestStatus: 'rejected'
371 aborted: boolean
372 condition: boolean
373 } & (
374 | ({ rejectedWithValue: false } & {
375 [K in keyof GetRejectedMeta<ThunkApiConfig>]?: undefined
376 })
377 | ({ rejectedWithValue: true } & GetRejectedMeta<ThunkApiConfig>)
378 )
379>
380
381export type AsyncThunkFulfilledActionCreator<
382 Returned,
383 ThunkArg,
384 ThunkApiConfig = {}
385> = ActionCreatorWithPreparedPayload<
386 [Returned, string, ThunkArg, GetFulfilledMeta<ThunkApiConfig>?],
387 Returned,
388 string,
389 never,
390 {
391 arg: ThunkArg
392 requestId: string
393 requestStatus: 'fulfilled'
394 } & GetFulfilledMeta<ThunkApiConfig>
395>
396
397/**
398 * A type describing the return value of `createAsyncThunk`.
399 * Might be useful for wrapping `createAsyncThunk` in custom abstractions.
400 *
401 * @public
402 */
403export type AsyncThunk<
404 Returned,
405 ThunkArg,
406 ThunkApiConfig extends AsyncThunkConfig
407> = AsyncThunkActionCreator<Returned, ThunkArg, ThunkApiConfig> & {
408 pending: AsyncThunkPendingActionCreator<ThunkArg, ThunkApiConfig>
409 rejected: AsyncThunkRejectedActionCreator<ThunkArg, ThunkApiConfig>
410 fulfilled: AsyncThunkFulfilledActionCreator<
411 Returned,
412 ThunkArg,
413 ThunkApiConfig
414 >
415 typePrefix: string
416}
417
418/**
419 *
420 * @param typePrefix
421 * @param payloadCreator
422 * @param options
423 *
424 * @public
425 */
426export function createAsyncThunk<
427 Returned,
428 ThunkArg = void,
429 ThunkApiConfig extends AsyncThunkConfig = {}
430>(
431 typePrefix: string,
432 payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>,
433 options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
434): AsyncThunk<Returned, ThunkArg, ThunkApiConfig> {
435 type RejectedValue = GetRejectValue<ThunkApiConfig>
436 type PendingMeta = GetPendingMeta<ThunkApiConfig>
437 type FulfilledMeta = GetFulfilledMeta<ThunkApiConfig>
438 type RejectedMeta = GetRejectedMeta<ThunkApiConfig>
439
440 const fulfilled: AsyncThunkFulfilledActionCreator<
441 Returned,
442 ThunkArg,
443 ThunkApiConfig
444 > = createAction(
445 typePrefix + '/fulfilled',
446 (
447 payload: Returned,
448 requestId: string,
449 arg: ThunkArg,
450 meta?: FulfilledMeta
451 ) => ({
452 payload,
453 meta: {
454 ...((meta as any) || {}),
455 arg,
456 requestId,
457 requestStatus: 'fulfilled' as const,
458 },
459 })
460 )
461
462 const pending: AsyncThunkPendingActionCreator<ThunkArg, ThunkApiConfig> =
463 createAction(
464 typePrefix + '/pending',
465 (requestId: string, arg: ThunkArg, meta?: PendingMeta) => ({
466 payload: undefined,
467 meta: {
468 ...((meta as any) || {}),
469 arg,
470 requestId,
471 requestStatus: 'pending' as const,
472 },
473 })
474 )
475
476 const rejected: AsyncThunkRejectedActionCreator<ThunkArg, ThunkApiConfig> =
477 createAction(
478 typePrefix + '/rejected',
479 (
480 error: Error | null,
481 requestId: string,
482 arg: ThunkArg,
483 payload?: RejectedValue,
484 meta?: RejectedMeta
485 ) => ({
486 payload,
487 error: ((options && options.serializeError) || miniSerializeError)(
488 error || 'Rejected'
489 ) as GetSerializedErrorType<ThunkApiConfig>,
490 meta: {
491 ...((meta as any) || {}),
492 arg,
493 requestId,
494 rejectedWithValue: !!payload,
495 requestStatus: 'rejected' as const,
496 aborted: error?.name === 'AbortError',
497 condition: error?.name === 'ConditionError',
498 },
499 })
500 )
501
502 let displayedWarning = false
503
504 const AC =
505 typeof AbortController !== 'undefined'
506 ? AbortController
507 : class implements AbortController {
508 signal: AbortSignal = {
509 aborted: false,
510 addEventListener() {},
511 dispatchEvent() {
512 return false
513 },
514 onabort() {},
515 removeEventListener() {},
516 }
517 abort() {
518 if (process.env.NODE_ENV !== 'production') {
519 if (!displayedWarning) {
520 displayedWarning = true
521 console.info(
522 `This platform does not implement AbortController.
523If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'.`
524 )
525 }
526 }
527 }
528 }
529
530 function actionCreator(
531 arg: ThunkArg
532 ): AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> {
533 return (dispatch, getState, extra) => {
534 const requestId = (options?.idGenerator ?? nanoid)()
535
536 const abortController = new AC()
537 let abortReason: string | undefined
538
539 const abortedPromise = new Promise<never>((_, reject) =>
540 abortController.signal.addEventListener('abort', () =>
541 reject({ name: 'AbortError', message: abortReason || 'Aborted' })
542 )
543 )
544
545 let started = false
546 function abort(reason?: string) {
547 if (started) {
548 abortReason = reason
549 abortController.abort()
550 }
551 }
552
553 const promise = (async function () {
554 let finalAction: ReturnType<typeof fulfilled | typeof rejected>
555 try {
556 if (
557 options &&
558 options.condition &&
559 options.condition(arg, { getState, extra }) === false
560 ) {
561 // eslint-disable-next-line no-throw-literal
562 throw {
563 name: 'ConditionError',
564 message: 'Aborted due to condition callback returning false.',
565 }
566 }
567 started = true
568 dispatch(
569 pending(
570 requestId,
571 arg,
572 options?.getPendingMeta?.({ requestId, arg }, { getState, extra })
573 )
574 )
575 finalAction = await Promise.race([
576 abortedPromise,
577 Promise.resolve(
578 payloadCreator(arg, {
579 dispatch,
580 getState,
581 extra,
582 requestId,
583 signal: abortController.signal,
584 rejectWithValue: ((
585 value: RejectedValue,
586 meta?: RejectedMeta
587 ) => {
588 return new RejectWithValue(value, meta)
589 }) as any,
590 fulfillWithValue: ((value: unknown, meta?: FulfilledMeta) => {
591 return new FulfillWithMeta(value, meta)
592 }) as any,
593 })
594 ).then((result) => {
595 if (result instanceof RejectWithValue) {
596 throw result
597 }
598 if (result instanceof FulfillWithMeta) {
599 return fulfilled(result.payload, requestId, arg, result.meta)
600 }
601 return fulfilled(result as any, requestId, arg)
602 }),
603 ])
604 } catch (err) {
605 finalAction =
606 err instanceof RejectWithValue
607 ? rejected(null, requestId, arg, err.payload, err.meta)
608 : rejected(err as any, requestId, arg)
609 }
610 // We dispatch the result action _after_ the catch, to avoid having any errors
611 // here get swallowed by the try/catch block,
612 // per https://twitter.com/dan_abramov/status/770914221638942720
613 // and https://github.com/reduxjs/redux-toolkit/blob/e85eb17b39a2118d859f7b7746e0f3fee523e089/docs/tutorials/advanced-tutorial.md#async-error-handling-logic-in-thunks
614
615 const skipDispatch =
616 options &&
617 !options.dispatchConditionRejection &&
618 rejected.match(finalAction) &&
619 (finalAction as any).meta.condition
620
621 if (!skipDispatch) {
622 dispatch(finalAction)
623 }
624 return finalAction
625 })()
626 return Object.assign(promise as Promise<any>, {
627 abort,
628 requestId,
629 arg,
630 unwrap() {
631 return promise.then<any>(unwrapResult)
632 },
633 })
634 }
635 }
636
637 return Object.assign(
638 actionCreator as AsyncThunkActionCreator<
639 Returned,
640 ThunkArg,
641 ThunkApiConfig
642 >,
643 {
644 pending,
645 rejected,
646 fulfilled,
647 typePrefix,
648 }
649 )
650}
651
652interface UnwrappableAction {
653 payload: any
654 meta?: any
655 error?: any
656}
657
658type UnwrappedActionPayload<T extends UnwrappableAction> = Exclude<
659 T,
660 { error: any }
661>['payload']
662
663/**
664 * @public
665 */
666export function unwrapResult<R extends UnwrappableAction>(
667 action: R
668): UnwrappedActionPayload<R> {
669 if (action.meta && action.meta.rejectedWithValue) {
670 throw action.payload
671 }
672 if (action.error) {
673 throw action.error
674 }
675 return action.payload
676}
677
678type WithStrictNullChecks<True, False> = undefined extends boolean
679 ? False
680 : True
681
\No newline at end of file