UNPKG

20.4 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 ): MaybePromise<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?: (arg: ThunkArg) => 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 */
426// separate signature without `AsyncThunkConfig` for better inference
427export function createAsyncThunk<Returned, ThunkArg = void>(
428 typePrefix: string,
429 payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, {}>,
430 options?: AsyncThunkOptions<ThunkArg, {}>
431): AsyncThunk<Returned, ThunkArg, {}>
432
433/**
434 *
435 * @param typePrefix
436 * @param payloadCreator
437 * @param options
438 *
439 * @public
440 */
441export function createAsyncThunk<
442 Returned,
443 ThunkArg,
444 ThunkApiConfig extends AsyncThunkConfig
445>(
446 typePrefix: string,
447 payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>,
448 options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
449): AsyncThunk<Returned, ThunkArg, ThunkApiConfig>
450
451export function createAsyncThunk<
452 Returned,
453 ThunkArg,
454 ThunkApiConfig extends AsyncThunkConfig
455>(
456 typePrefix: string,
457 payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>,
458 options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
459): AsyncThunk<Returned, ThunkArg, ThunkApiConfig> {
460 type RejectedValue = GetRejectValue<ThunkApiConfig>
461 type PendingMeta = GetPendingMeta<ThunkApiConfig>
462 type FulfilledMeta = GetFulfilledMeta<ThunkApiConfig>
463 type RejectedMeta = GetRejectedMeta<ThunkApiConfig>
464
465 const fulfilled: AsyncThunkFulfilledActionCreator<
466 Returned,
467 ThunkArg,
468 ThunkApiConfig
469 > = createAction(
470 typePrefix + '/fulfilled',
471 (
472 payload: Returned,
473 requestId: string,
474 arg: ThunkArg,
475 meta?: FulfilledMeta
476 ) => ({
477 payload,
478 meta: {
479 ...((meta as any) || {}),
480 arg,
481 requestId,
482 requestStatus: 'fulfilled' as const,
483 },
484 })
485 )
486
487 const pending: AsyncThunkPendingActionCreator<ThunkArg, ThunkApiConfig> =
488 createAction(
489 typePrefix + '/pending',
490 (requestId: string, arg: ThunkArg, meta?: PendingMeta) => ({
491 payload: undefined,
492 meta: {
493 ...((meta as any) || {}),
494 arg,
495 requestId,
496 requestStatus: 'pending' as const,
497 },
498 })
499 )
500
501 const rejected: AsyncThunkRejectedActionCreator<ThunkArg, ThunkApiConfig> =
502 createAction(
503 typePrefix + '/rejected',
504 (
505 error: Error | null,
506 requestId: string,
507 arg: ThunkArg,
508 payload?: RejectedValue,
509 meta?: RejectedMeta
510 ) => ({
511 payload,
512 error: ((options && options.serializeError) || miniSerializeError)(
513 error || 'Rejected'
514 ) as GetSerializedErrorType<ThunkApiConfig>,
515 meta: {
516 ...((meta as any) || {}),
517 arg,
518 requestId,
519 rejectedWithValue: !!payload,
520 requestStatus: 'rejected' as const,
521 aborted: error?.name === 'AbortError',
522 condition: error?.name === 'ConditionError',
523 },
524 })
525 )
526
527 let displayedWarning = false
528
529 const AC =
530 typeof AbortController !== 'undefined'
531 ? AbortController
532 : class implements AbortController {
533 signal: AbortSignal = {
534 aborted: false,
535 addEventListener() {},
536 dispatchEvent() {
537 return false
538 },
539 onabort() {},
540 removeEventListener() {},
541 }
542 abort() {
543 if (process.env.NODE_ENV !== 'production') {
544 if (!displayedWarning) {
545 displayedWarning = true
546 console.info(
547 `This platform does not implement AbortController.
548If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'.`
549 )
550 }
551 }
552 }
553 }
554
555 function actionCreator(
556 arg: ThunkArg
557 ): AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> {
558 return (dispatch, getState, extra) => {
559 const requestId = options?.idGenerator
560 ? options.idGenerator(arg)
561 : nanoid()
562
563 const abortController = new AC()
564 let abortReason: string | undefined
565
566 const abortedPromise = new Promise<never>((_, reject) =>
567 abortController.signal.addEventListener('abort', () =>
568 reject({ name: 'AbortError', message: abortReason || 'Aborted' })
569 )
570 )
571
572 let started = false
573 function abort(reason?: string) {
574 if (started) {
575 abortReason = reason
576 abortController.abort()
577 }
578 }
579
580 const promise = (async function () {
581 let finalAction: ReturnType<typeof fulfilled | typeof rejected>
582 try {
583 let conditionResult = options?.condition?.(arg, { getState, extra })
584 if (isThenable(conditionResult)) {
585 conditionResult = await conditionResult
586 }
587 if (conditionResult === false) {
588 // eslint-disable-next-line no-throw-literal
589 throw {
590 name: 'ConditionError',
591 message: 'Aborted due to condition callback returning false.',
592 }
593 }
594 started = true
595 dispatch(
596 pending(
597 requestId,
598 arg,
599 options?.getPendingMeta?.({ requestId, arg }, { getState, extra })
600 )
601 )
602 finalAction = await Promise.race([
603 abortedPromise,
604 Promise.resolve(
605 payloadCreator(arg, {
606 dispatch,
607 getState,
608 extra,
609 requestId,
610 signal: abortController.signal,
611 rejectWithValue: ((
612 value: RejectedValue,
613 meta?: RejectedMeta
614 ) => {
615 return new RejectWithValue(value, meta)
616 }) as any,
617 fulfillWithValue: ((value: unknown, meta?: FulfilledMeta) => {
618 return new FulfillWithMeta(value, meta)
619 }) as any,
620 })
621 ).then((result) => {
622 if (result instanceof RejectWithValue) {
623 throw result
624 }
625 if (result instanceof FulfillWithMeta) {
626 return fulfilled(result.payload, requestId, arg, result.meta)
627 }
628 return fulfilled(result as any, requestId, arg)
629 }),
630 ])
631 } catch (err) {
632 finalAction =
633 err instanceof RejectWithValue
634 ? rejected(null, requestId, arg, err.payload, err.meta)
635 : rejected(err as any, requestId, arg)
636 }
637 // We dispatch the result action _after_ the catch, to avoid having any errors
638 // here get swallowed by the try/catch block,
639 // per https://twitter.com/dan_abramov/status/770914221638942720
640 // and https://github.com/reduxjs/redux-toolkit/blob/e85eb17b39a2118d859f7b7746e0f3fee523e089/docs/tutorials/advanced-tutorial.md#async-error-handling-logic-in-thunks
641
642 const skipDispatch =
643 options &&
644 !options.dispatchConditionRejection &&
645 rejected.match(finalAction) &&
646 (finalAction as any).meta.condition
647
648 if (!skipDispatch) {
649 dispatch(finalAction)
650 }
651 return finalAction
652 })()
653 return Object.assign(promise as Promise<any>, {
654 abort,
655 requestId,
656 arg,
657 unwrap() {
658 return promise.then<any>(unwrapResult)
659 },
660 })
661 }
662 }
663
664 return Object.assign(
665 actionCreator as AsyncThunkActionCreator<
666 Returned,
667 ThunkArg,
668 ThunkApiConfig
669 >,
670 {
671 pending,
672 rejected,
673 fulfilled,
674 typePrefix,
675 }
676 )
677}
678
679interface UnwrappableAction {
680 payload: any
681 meta?: any
682 error?: any
683}
684
685type UnwrappedActionPayload<T extends UnwrappableAction> = Exclude<
686 T,
687 { error: any }
688>['payload']
689
690/**
691 * @public
692 */
693export function unwrapResult<R extends UnwrappableAction>(
694 action: R
695): UnwrappedActionPayload<R> {
696 if (action.meta && action.meta.rejectedWithValue) {
697 throw action.payload
698 }
699 if (action.error) {
700 throw action.error
701 }
702 return action.payload
703}
704
705type WithStrictNullChecks<True, False> = undefined extends boolean
706 ? False
707 : True
708
709function isThenable(value: any): value is PromiseLike<any> {
710 return (
711 value !== null &&
712 typeof value === 'object' &&
713 typeof value.then === 'function'
714 )
715}
716
\No newline at end of file