1 | import { Dispatch, AnyAction } from 'redux'
|
2 | import {
|
3 | createAction,
|
4 | PayloadAction,
|
5 | ActionCreatorWithPreparedPayload
|
6 | } from './createAction'
|
7 | import { ThunkDispatch } from 'redux-thunk'
|
8 | import { FallbackIfUnknown, IsAny } from './tsHelpers'
|
9 | import { nanoid } from './nanoid'
|
10 |
|
11 |
|
12 | type _Keep = PayloadAction | ActionCreatorWithPreparedPayload<any, unknown>
|
13 |
|
14 | export 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 |
|
30 |
|
31 | export interface SerializedError {
|
32 | name?: string
|
33 | message?: string
|
34 | stack?: string
|
35 | code?: string
|
36 | }
|
37 |
|
38 | const commonProperties: Array<keyof SerializedError> = [
|
39 | 'name',
|
40 | 'message',
|
41 | 'stack',
|
42 | 'code'
|
43 | ]
|
44 |
|
45 | class RejectWithValue<RejectValue> {
|
46 | public name = 'RejectWithValue'
|
47 | public message = 'Rejected'
|
48 | constructor(public readonly payload: RejectValue) {}
|
49 | }
|
50 |
|
51 |
|
52 | export 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 |
|
67 | type AsyncThunkConfig = {
|
68 | state?: unknown
|
69 | dispatch?: Dispatch
|
70 | extra?: unknown
|
71 | rejectValue?: unknown
|
72 | serializedErrorType?: unknown
|
73 | }
|
74 |
|
75 | type GetState<ThunkApiConfig> = ThunkApiConfig extends {
|
76 | state: infer State
|
77 | }
|
78 | ? State
|
79 | : unknown
|
80 | type GetExtra<ThunkApiConfig> = ThunkApiConfig extends { extra: infer Extra }
|
81 | ? Extra
|
82 | : unknown
|
83 | type 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 |
|
96 | type GetThunkAPI<ThunkApiConfig> = BaseThunkAPI<
|
97 | GetState<ThunkApiConfig>,
|
98 | GetExtra<ThunkApiConfig>,
|
99 | GetDispatch<ThunkApiConfig>,
|
100 | GetRejectValue<ThunkApiConfig>
|
101 | >
|
102 |
|
103 | type GetRejectValue<ThunkApiConfig> = ThunkApiConfig extends {
|
104 | rejectValue: infer RejectValue
|
105 | }
|
106 | ? RejectValue
|
107 | : unknown
|
108 |
|
109 | type GetSerializedErrorType<ThunkApiConfig> = ThunkApiConfig extends {
|
110 | serializedErrorType: infer GetSerializedErrorType
|
111 | }
|
112 | ? GetSerializedErrorType
|
113 | : SerializedError
|
114 |
|
115 |
|
116 |
|
117 |
|
118 |
|
119 |
|
120 |
|
121 | export type AsyncThunkPayloadCreatorReturnValue<
|
122 | Returned,
|
123 | ThunkApiConfig extends AsyncThunkConfig
|
124 | > =
|
125 | | Promise<Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>>
|
126 | | Returned
|
127 | | RejectWithValue<GetRejectValue<ThunkApiConfig>>
|
128 |
|
129 |
|
130 |
|
131 |
|
132 |
|
133 |
|
134 | export 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 |
|
145 |
|
146 |
|
147 |
|
148 |
|
149 |
|
150 |
|
151 |
|
152 | export 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 |
|
169 | type AsyncThunkActionCreator<
|
170 | Returned,
|
171 | ThunkArg,
|
172 | ThunkApiConfig extends AsyncThunkConfig
|
173 | > = IsAny<
|
174 | ThunkArg,
|
175 |
|
176 | (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>,
|
177 |
|
178 | unknown extends ThunkArg
|
179 | ? (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
|
180 | : [ThunkArg] extends [void] | [undefined]
|
181 | ? () => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
|
182 | : [void] extends [ThunkArg]
|
183 | ? (arg?: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
|
184 | : [undefined] extends [ThunkArg]
|
185 | ? WithStrictNullChecks<
|
186 |
|
187 | (
|
188 | arg?: ThunkArg
|
189 | ) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>,
|
190 |
|
191 | (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
|
192 | >
|
193 | : (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
|
194 | >
|
195 |
|
196 | interface AsyncThunkOptions<
|
197 | ThunkArg = void,
|
198 | ThunkApiConfig extends AsyncThunkConfig = {}
|
199 | > {
|
200 | |
201 |
|
202 |
|
203 |
|
204 |
|
205 |
|
206 | condition?(
|
207 | arg: ThunkArg,
|
208 | api: Pick<GetThunkAPI<ThunkApiConfig>, 'getState' | 'extra'>
|
209 | ): boolean | undefined
|
210 | |
211 |
|
212 |
|
213 |
|
214 |
|
215 |
|
216 |
|
217 | dispatchConditionRejection?: boolean
|
218 |
|
219 | serializeError?: (x: unknown) => GetSerializedErrorType<ThunkApiConfig>
|
220 | }
|
221 |
|
222 | export 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 |
|
236 | export 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 |
|
254 | export 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 |
|
271 |
|
272 |
|
273 |
|
274 |
|
275 | export 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 |
|
289 |
|
290 |
|
291 |
|
292 |
|
293 |
|
294 | export 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.
|
378 | If 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 |
|
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 |
|
448 |
|
449 |
|
450 |
|
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 |
|
482 | interface UnwrappableAction {
|
483 | payload: any
|
484 | meta?: any
|
485 | error?: any
|
486 | }
|
487 |
|
488 | type UnwrappedActionPayload<T extends UnwrappableAction> = Exclude<
|
489 | T,
|
490 | { error: any }
|
491 | >['payload']
|
492 |
|
493 | /**
|
494 | * @public
|
495 | */
|
496 | export 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 |
|
508 | type WithStrictNullChecks<True, False> = undefined extends boolean
|
509 | ? False
|
510 | : True
|
511 |
|
\ | No newline at end of file |