UNPKG

14.3 kBPlain TextView Raw
1import { Dispatch, AnyAction } from 'redux'
2import {
3 createAction,
4 PayloadAction,
5 ActionCreatorWithPreparedPayload
6} from './createAction'
7import { ThunkDispatch } from 'redux-thunk'
8import { FallbackIfUnknown, IsAny } 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> = {
20 dispatch: D
21 getState: () => S
22 extra: E
23 requestId: string
24 signal: AbortSignal
25 rejectWithValue(value: RejectedValue): RejectWithValue<RejectedValue>
26}
27
28/**
29 * @public
30 */
31export interface SerializedError {
32 name?: string
33 message?: string
34 stack?: string
35 code?: string
36}
37
38const commonProperties: Array<keyof SerializedError> = [
39 'name',
40 'message',
41 'stack',
42 'code'
43]
44
45class RejectWithValue<RejectValue> {
46 public name = 'RejectWithValue'
47 public message = 'Rejected'
48 constructor(public readonly payload: RejectValue) {}
49}
50
51// Reworked from https://github.com/sindresorhus/serialize-error
52export const miniSerializeError = (value: any): SerializedError => {
53 if (typeof value === 'object' && value !== null) {
54 const simpleError: SerializedError = {}
55 for (const property of commonProperties) {
56 if (typeof value[property] === 'string') {
57 simpleError[property] = value[property]
58 }
59 }
60
61 return simpleError
62 }
63
64 return { message: String(value) }
65}
66
67type AsyncThunkConfig = {
68 state?: unknown
69 dispatch?: Dispatch
70 extra?: unknown
71 rejectValue?: unknown
72 serializedErrorType?: unknown
73}
74
75type GetState<ThunkApiConfig> = ThunkApiConfig extends {
76 state: infer State
77}
78 ? State
79 : unknown
80type GetExtra<ThunkApiConfig> = ThunkApiConfig extends { extra: infer Extra }
81 ? Extra
82 : unknown
83type GetDispatch<ThunkApiConfig> = ThunkApiConfig extends {
84 dispatch: infer Dispatch
85}
86 ? FallbackIfUnknown<
87 Dispatch,
88 ThunkDispatch<
89 GetState<ThunkApiConfig>,
90 GetExtra<ThunkApiConfig>,
91 AnyAction
92 >
93 >
94 : ThunkDispatch<GetState<ThunkApiConfig>, GetExtra<ThunkApiConfig>, AnyAction>
95
96type GetThunkAPI<ThunkApiConfig> = BaseThunkAPI<
97 GetState<ThunkApiConfig>,
98 GetExtra<ThunkApiConfig>,
99 GetDispatch<ThunkApiConfig>,
100 GetRejectValue<ThunkApiConfig>
101>
102
103type GetRejectValue<ThunkApiConfig> = ThunkApiConfig extends {
104 rejectValue: infer RejectValue
105}
106 ? RejectValue
107 : unknown
108
109type GetSerializedErrorType<ThunkApiConfig> = ThunkApiConfig extends {
110 serializedErrorType: infer GetSerializedErrorType
111}
112 ? GetSerializedErrorType
113 : SerializedError
114
115/**
116 * A type describing the return value of the `payloadCreator` argument to `createAsyncThunk`.
117 * Might be useful for wrapping `createAsyncThunk` in custom abstractions.
118 *
119 * @public
120 */
121export type AsyncThunkPayloadCreatorReturnValue<
122 Returned,
123 ThunkApiConfig extends AsyncThunkConfig
124> =
125 | Promise<Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>>
126 | Returned
127 | RejectWithValue<GetRejectValue<ThunkApiConfig>>
128/**
129 * A type describing the `payloadCreator` argument to `createAsyncThunk`.
130 * Might be useful for wrapping `createAsyncThunk` in custom abstractions.
131 *
132 * @public
133 */
134export type AsyncThunkPayloadCreator<
135 Returned,
136 ThunkArg = void,
137 ThunkApiConfig extends AsyncThunkConfig = {}
138> = (
139 arg: ThunkArg,
140 thunkAPI: GetThunkAPI<ThunkApiConfig>
141) => AsyncThunkPayloadCreatorReturnValue<Returned, ThunkApiConfig>
142
143/**
144 * A ThunkAction created by `createAsyncThunk`.
145 * Dispatching it returns a Promise for either a
146 * fulfilled or rejected action.
147 * Also, the returned value contains a `abort()` method
148 * that allows the asyncAction to be cancelled from the outside.
149 *
150 * @public
151 */
152export type AsyncThunkAction<
153 Returned,
154 ThunkArg,
155 ThunkApiConfig extends AsyncThunkConfig
156> = (
157 dispatch: GetDispatch<ThunkApiConfig>,
158 getState: () => GetState<ThunkApiConfig>,
159 extra: GetExtra<ThunkApiConfig>
160) => Promise<
161 | ReturnType<AsyncThunkFulfilledActionCreator<Returned, ThunkArg>>
162 | ReturnType<AsyncThunkRejectedActionCreator<ThunkArg, ThunkApiConfig>>
163> & {
164 abort(reason?: string): void
165 requestId: string
166 arg: ThunkArg
167}
168
169type AsyncThunkActionCreator<
170 Returned,
171 ThunkArg,
172 ThunkApiConfig extends AsyncThunkConfig
173> = IsAny<
174 ThunkArg,
175 // any handling
176 (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>,
177 // unknown handling
178 unknown extends ThunkArg
179 ? (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument not specified or specified as void or undefined
180 : [ThunkArg] extends [void] | [undefined]
181 ? () => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument contains void
182 : [void] extends [ThunkArg] // make optional
183 ? (arg?: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument contains undefined
184 : [undefined] extends [ThunkArg]
185 ? WithStrictNullChecks<
186 // with strict nullChecks: make optional
187 (
188 arg?: ThunkArg
189 ) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>,
190 // without strict null checks this will match everything, so don't make it optional
191 (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
192 > // default case: normal argument
193 : (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
194>
195
196interface AsyncThunkOptions<
197 ThunkArg = void,
198 ThunkApiConfig extends AsyncThunkConfig = {}
199> {
200 /**
201 * A method to control whether the asyncThunk should be executed. Has access to the
202 * `arg`, `api.getState()` and `api.extra` arguments.
203 *
204 * @returns `false` if it should be skipped
205 */
206 condition?(
207 arg: ThunkArg,
208 api: Pick<GetThunkAPI<ThunkApiConfig>, 'getState' | 'extra'>
209 ): boolean | undefined
210 /**
211 * If `condition` returns `false`, the asyncThunk will be skipped.
212 * This option allows you to control whether a `rejected` action with `meta.condition == false`
213 * will be dispatched or not.
214 *
215 * @default `false`
216 */
217 dispatchConditionRejection?: boolean
218
219 serializeError?: (x: unknown) => GetSerializedErrorType<ThunkApiConfig>
220}
221
222export type AsyncThunkPendingActionCreator<
223 ThunkArg
224> = ActionCreatorWithPreparedPayload<
225 [string, ThunkArg],
226 undefined,
227 string,
228 never,
229 {
230 arg: ThunkArg
231 requestId: string
232 requestStatus: 'pending'
233 }
234>
235
236export type AsyncThunkRejectedActionCreator<
237 ThunkArg,
238 ThunkApiConfig
239> = ActionCreatorWithPreparedPayload<
240 [Error | null, string, ThunkArg],
241 GetRejectValue<ThunkApiConfig> | undefined,
242 string,
243 GetSerializedErrorType<ThunkApiConfig>,
244 {
245 arg: ThunkArg
246 requestId: string
247 rejectedWithValue: boolean
248 requestStatus: 'rejected'
249 aborted: boolean
250 condition: boolean
251 }
252>
253
254export type AsyncThunkFulfilledActionCreator<
255 Returned,
256 ThunkArg
257> = ActionCreatorWithPreparedPayload<
258 [Returned, string, ThunkArg],
259 Returned,
260 string,
261 never,
262 {
263 arg: ThunkArg
264 requestId: string
265 requestStatus: 'fulfilled'
266 }
267>
268
269/**
270 * A type describing the return value of `createAsyncThunk`.
271 * Might be useful for wrapping `createAsyncThunk` in custom abstractions.
272 *
273 * @public
274 */
275export type AsyncThunk<
276 Returned,
277 ThunkArg,
278 ThunkApiConfig extends AsyncThunkConfig
279> = AsyncThunkActionCreator<Returned, ThunkArg, ThunkApiConfig> & {
280 pending: AsyncThunkPendingActionCreator<ThunkArg>
281 rejected: AsyncThunkRejectedActionCreator<ThunkArg, ThunkApiConfig>
282 fulfilled: AsyncThunkFulfilledActionCreator<Returned, ThunkArg>
283 typePrefix: string
284}
285
286/**
287 *
288 * @param typePrefix
289 * @param payloadCreator
290 * @param options
291 *
292 * @public
293 */
294export function createAsyncThunk<
295 Returned,
296 ThunkArg = void,
297 ThunkApiConfig extends AsyncThunkConfig = {}
298>(
299 typePrefix: string,
300 payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>,
301 options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
302): AsyncThunk<Returned, ThunkArg, ThunkApiConfig> {
303 type RejectedValue = GetRejectValue<ThunkApiConfig>
304
305 const fulfilled = createAction(
306 typePrefix + '/fulfilled',
307 (result: Returned, requestId: string, arg: ThunkArg) => {
308 return {
309 payload: result,
310 meta: {
311 arg,
312 requestId,
313 requestStatus: 'fulfilled' as const
314 }
315 }
316 }
317 )
318
319 const pending = createAction(
320 typePrefix + '/pending',
321 (requestId: string, arg: ThunkArg) => {
322 return {
323 payload: undefined,
324 meta: {
325 arg,
326 requestId,
327 requestStatus: 'pending' as const
328 }
329 }
330 }
331 )
332
333 const rejected = createAction(
334 typePrefix + '/rejected',
335 (error: Error | null, requestId: string, arg: ThunkArg) => {
336 const rejectedWithValue = error instanceof RejectWithValue
337 const aborted = !!error && error.name === 'AbortError'
338 const condition = !!error && error.name === 'ConditionError'
339
340 return {
341 payload: error instanceof RejectWithValue ? error.payload : undefined,
342 error: ((options && options.serializeError) || miniSerializeError)(
343 error || 'Rejected'
344 ) as GetSerializedErrorType<ThunkApiConfig>,
345 meta: {
346 arg,
347 requestId,
348 rejectedWithValue,
349 requestStatus: 'rejected' as const,
350 aborted,
351 condition
352 }
353 }
354 }
355 )
356
357 let displayedWarning = false
358
359 const AC =
360 typeof AbortController !== 'undefined'
361 ? AbortController
362 : class implements AbortController {
363 signal: AbortSignal = {
364 aborted: false,
365 addEventListener() {},
366 dispatchEvent() {
367 return false
368 },
369 onabort() {},
370 removeEventListener() {}
371 }
372 abort() {
373 if (process.env.NODE_ENV !== 'production') {
374 if (!displayedWarning) {
375 displayedWarning = true
376 console.info(
377 `This platform does not implement AbortController.
378If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'.`
379 )
380 }
381 }
382 }
383 }
384
385 function actionCreator(
386 arg: ThunkArg
387 ): AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> {
388 return (dispatch, getState, extra) => {
389 const requestId = nanoid()
390
391 const abortController = new AC()
392 let abortReason: string | undefined
393
394 const abortedPromise = new Promise<never>((_, reject) =>
395 abortController.signal.addEventListener('abort', () =>
396 reject({ name: 'AbortError', message: abortReason || 'Aborted' })
397 )
398 )
399
400 let started = false
401 function abort(reason?: string) {
402 if (started) {
403 abortReason = reason
404 abortController.abort()
405 }
406 }
407
408 const promise = (async function() {
409 let finalAction: ReturnType<typeof fulfilled | typeof rejected>
410 try {
411 if (
412 options &&
413 options.condition &&
414 options.condition(arg, { getState, extra }) === false
415 ) {
416 // eslint-disable-next-line no-throw-literal
417 throw {
418 name: 'ConditionError',
419 message: 'Aborted due to condition callback returning false.'
420 }
421 }
422 started = true
423 dispatch(pending(requestId, arg))
424 finalAction = await Promise.race([
425 abortedPromise,
426 Promise.resolve(
427 payloadCreator(arg, {
428 dispatch,
429 getState,
430 extra,
431 requestId,
432 signal: abortController.signal,
433 rejectWithValue(value: RejectedValue) {
434 return new RejectWithValue(value)
435 }
436 })
437 ).then(result => {
438 if (result instanceof RejectWithValue) {
439 return rejected(result, requestId, arg)
440 }
441 return fulfilled(result, requestId, arg)
442 })
443 ])
444 } catch (err) {
445 finalAction = rejected(err, requestId, arg)
446 }
447 // We dispatch the result action _after_ the catch, to avoid having any errors
448 // here get swallowed by the try/catch block,
449 // per https://twitter.com/dan_abramov/status/770914221638942720
450 // and https://redux-toolkit.js.org/tutorials/advanced-tutorial#async-error-handling-logic-in-thunks
451
452 const skipDispatch =
453 options &&
454 !options.dispatchConditionRejection &&
455 rejected.match(finalAction) &&
456 finalAction.meta.condition
457
458 if (!skipDispatch) {
459 dispatch(finalAction)
460 }
461 return finalAction
462 })()
463 return Object.assign(promise, { abort, requestId, arg })
464 }
465 }
466
467 return Object.assign(
468 actionCreator as AsyncThunkActionCreator<
469 Returned,
470 ThunkArg,
471 ThunkApiConfig
472 >,
473 {
474 pending,
475 rejected,
476 fulfilled,
477 typePrefix
478 }
479 )
480}
481
482interface UnwrappableAction {
483 payload: any
484 meta?: any
485 error?: any
486}
487
488type UnwrappedActionPayload<T extends UnwrappableAction> = Exclude<
489 T,
490 { error: any }
491>['payload']
492
493/**
494 * @public
495 */
496export function unwrapResult<R extends UnwrappableAction>(
497 action: R
498): UnwrappedActionPayload<R> {
499 if (action.meta && action.meta.rejectedWithValue) {
500 throw action.payload
501 }
502 if (action.error) {
503 throw action.error
504 }
505 return action.payload
506}
507
508type WithStrictNullChecks<True, False> = undefined extends boolean
509 ? False
510 : True
511
\No newline at end of file