UNPKG

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