source: node_modules/@reduxjs/toolkit/src/tests/createAsyncThunk.test.ts

Last change on this file was a762898, checked in by istevanoska <ilinastevanoska@…>, 5 months ago

Added visualizations

  • Property mode set to 100644
File size: 31.5 KB
RevLine 
[a762898]1import { noop } from '@internal/listenerMiddleware/utils'
2import { delay, promiseWithResolvers } from '@internal/utils'
3import type { CreateAsyncThunkFunction, UnknownAction } from '@reduxjs/toolkit'
4import {
5 configureStore,
6 createAsyncThunk,
7 createReducer,
8 miniSerializeError,
9 unwrapResult,
10} from '@reduxjs/toolkit'
11
12declare global {
13 interface Window {
14 AbortController: AbortController
15 }
16}
17
18describe('createAsyncThunk', () => {
19 it('creates the action types', () => {
20 const thunkActionCreator = createAsyncThunk('testType', async () => 42)
21
22 expect(thunkActionCreator.fulfilled.type).toBe('testType/fulfilled')
23 expect(thunkActionCreator.pending.type).toBe('testType/pending')
24 expect(thunkActionCreator.rejected.type).toBe('testType/rejected')
25 })
26
27 it('exposes the typePrefix it was created with', () => {
28 const thunkActionCreator = createAsyncThunk('testType', async () => 42)
29
30 expect(thunkActionCreator.typePrefix).toBe('testType')
31 })
32
33 it('includes a settled matcher', () => {
34 const thunkActionCreator = createAsyncThunk('testType', async () => 42)
35 expect(thunkActionCreator.settled).toEqual(expect.any(Function))
36 expect(thunkActionCreator.settled(thunkActionCreator.pending(''))).toBe(
37 false,
38 )
39 expect(
40 thunkActionCreator.settled(thunkActionCreator.rejected(null, '')),
41 ).toBe(true)
42 expect(
43 thunkActionCreator.settled(thunkActionCreator.fulfilled(42, '')),
44 ).toBe(true)
45 })
46
47 it('works without passing arguments to the payload creator', async () => {
48 const thunkActionCreator = createAsyncThunk('testType', async () => 42)
49
50 let timesReducerCalled = 0
51
52 const reducer = () => {
53 timesReducerCalled++
54 }
55
56 const store = configureStore({
57 reducer,
58 })
59
60 // reset from however many times the store called it
61 timesReducerCalled = 0
62
63 await store.dispatch(thunkActionCreator())
64
65 expect(timesReducerCalled).toBe(2)
66 })
67
68 it('accepts arguments and dispatches the actions on resolve', async () => {
69 const dispatch = vi.fn()
70
71 let passedArg: any
72
73 const result = 42
74 const args = 123
75 let generatedRequestId = ''
76
77 const thunkActionCreator = createAsyncThunk(
78 'testType',
79 async (arg: number, { requestId }) => {
80 passedArg = arg
81 generatedRequestId = requestId
82 return result
83 },
84 )
85
86 const thunkFunction = thunkActionCreator(args)
87
88 const thunkPromise = thunkFunction(dispatch, () => {}, undefined)
89
90 expect(thunkPromise.requestId).toBe(generatedRequestId)
91 expect(thunkPromise.arg).toBe(args)
92
93 await thunkPromise
94
95 expect(passedArg).toBe(args)
96
97 expect(dispatch).toHaveBeenNthCalledWith(
98 1,
99 thunkActionCreator.pending(generatedRequestId, args),
100 )
101
102 expect(dispatch).toHaveBeenNthCalledWith(
103 2,
104 thunkActionCreator.fulfilled(result, generatedRequestId, args),
105 )
106 })
107
108 it('accepts arguments and dispatches the actions on reject', async () => {
109 const dispatch = vi.fn()
110
111 const args = 123
112 let generatedRequestId = ''
113
114 const error = new Error('Panic!')
115
116 const thunkActionCreator = createAsyncThunk(
117 'testType',
118 async (args: number, { requestId }) => {
119 generatedRequestId = requestId
120 throw error
121 },
122 )
123
124 const thunkFunction = thunkActionCreator(args)
125
126 try {
127 await thunkFunction(dispatch, () => {}, undefined)
128 } catch (e) {}
129
130 expect(dispatch).toHaveBeenNthCalledWith(
131 1,
132 thunkActionCreator.pending(generatedRequestId, args),
133 )
134
135 expect(dispatch).toHaveBeenCalledTimes(2)
136
137 // Have to check the bits of the action separately since the error was processed
138 const errorAction = dispatch.mock.calls[1][0]
139 expect(errorAction.error).toEqual(miniSerializeError(error))
140 expect(errorAction.meta.requestId).toBe(generatedRequestId)
141 expect(errorAction.meta.arg).toBe(args)
142 })
143
144 it('dispatches an empty error when throwing a random object without serializedError properties', async () => {
145 const dispatch = vi.fn()
146
147 const args = 123
148 let generatedRequestId = ''
149
150 const errorObject = { wny: 'dothis' }
151
152 const thunkActionCreator = createAsyncThunk(
153 'testType',
154 async (args: number, { requestId }) => {
155 generatedRequestId = requestId
156 throw errorObject
157 },
158 )
159
160 const thunkFunction = thunkActionCreator(args)
161
162 try {
163 await thunkFunction(dispatch, () => {}, undefined)
164 } catch (e) {}
165
166 expect(dispatch).toHaveBeenNthCalledWith(
167 1,
168 thunkActionCreator.pending(generatedRequestId, args),
169 )
170
171 expect(dispatch).toHaveBeenCalledTimes(2)
172
173 const errorAction = dispatch.mock.calls[1][0]
174 expect(errorAction.error).toEqual({})
175 expect(errorAction.meta.requestId).toBe(generatedRequestId)
176 expect(errorAction.meta.arg).toBe(args)
177 })
178
179 it('dispatches an action with a formatted error when throwing an object with known error keys', async () => {
180 const dispatch = vi.fn()
181
182 const args = 123
183 let generatedRequestId = ''
184
185 const errorObject = {
186 name: 'Custom thrown error',
187 message: 'This is not necessary',
188 code: '400',
189 }
190
191 const thunkActionCreator = createAsyncThunk(
192 'testType',
193 async (args: number, { requestId }) => {
194 generatedRequestId = requestId
195 throw errorObject
196 },
197 )
198
199 const thunkFunction = thunkActionCreator(args)
200
201 try {
202 await thunkFunction(dispatch, () => {}, undefined)
203 } catch (e) {}
204
205 expect(dispatch).toHaveBeenNthCalledWith(
206 1,
207 thunkActionCreator.pending(generatedRequestId, args),
208 )
209
210 expect(dispatch).toHaveBeenCalledTimes(2)
211
212 // Have to check the bits of the action separately since the error was processed
213 const errorAction = dispatch.mock.calls[1][0]
214 expect(errorAction.error).toEqual(miniSerializeError(errorObject))
215 expect(Object.keys(errorAction.error)).not.toContain('stack')
216 expect(errorAction.meta.requestId).toBe(generatedRequestId)
217 expect(errorAction.meta.arg).toBe(args)
218 })
219
220 it('dispatches a rejected action with a customized payload when a user returns rejectWithValue()', async () => {
221 const dispatch = vi.fn()
222
223 const args = 123
224 let generatedRequestId = ''
225
226 const errorPayload = {
227 errorMessage:
228 'I am a fake server-provided 400 payload with validation details',
229 errors: [
230 { field_one: 'Must be a string' },
231 { field_two: 'Must be a number' },
232 ],
233 }
234
235 const thunkActionCreator = createAsyncThunk(
236 'testType',
237 async (args: number, { requestId, rejectWithValue }) => {
238 generatedRequestId = requestId
239
240 return rejectWithValue(errorPayload)
241 },
242 )
243
244 const thunkFunction = thunkActionCreator(args)
245
246 try {
247 await thunkFunction(dispatch, () => {}, undefined)
248 } catch (e) {}
249
250 expect(dispatch).toHaveBeenNthCalledWith(
251 1,
252 thunkActionCreator.pending(generatedRequestId, args),
253 )
254
255 expect(dispatch).toHaveBeenCalledTimes(2)
256
257 // Have to check the bits of the action separately since the error was processed
258 const errorAction = dispatch.mock.calls[1][0]
259
260 expect(errorAction.error.message).toEqual('Rejected')
261 expect(errorAction.payload).toBe(errorPayload)
262 expect(errorAction.meta.arg).toBe(args)
263 })
264
265 it('dispatches a rejected action with a customized payload when a user throws rejectWithValue()', async () => {
266 const dispatch = vi.fn()
267
268 const args = 123
269 let generatedRequestId = ''
270
271 const errorPayload = {
272 errorMessage:
273 'I am a fake server-provided 400 payload with validation details',
274 errors: [
275 { field_one: 'Must be a string' },
276 { field_two: 'Must be a number' },
277 ],
278 }
279
280 const thunkActionCreator = createAsyncThunk(
281 'testType',
282 async (args: number, { requestId, rejectWithValue }) => {
283 generatedRequestId = requestId
284
285 throw rejectWithValue(errorPayload)
286 },
287 )
288
289 const thunkFunction = thunkActionCreator(args)
290
291 try {
292 await thunkFunction(dispatch, () => {}, undefined)
293 } catch (e) {}
294
295 expect(dispatch).toHaveBeenNthCalledWith(
296 1,
297 thunkActionCreator.pending(generatedRequestId, args),
298 )
299
300 expect(dispatch).toHaveBeenCalledTimes(2)
301
302 // Have to check the bits of the action separately since the error was processed
303 const errorAction = dispatch.mock.calls[1][0]
304
305 expect(errorAction.error.message).toEqual('Rejected')
306 expect(errorAction.payload).toBe(errorPayload)
307 expect(errorAction.meta.arg).toBe(args)
308 })
309
310 it('dispatches a rejected action with a miniSerializeError when rejectWithValue conditions are not satisfied', async () => {
311 const dispatch = vi.fn()
312
313 const args = 123
314 let generatedRequestId = ''
315
316 const error = new Error('Panic!')
317
318 const errorPayload = {
319 errorMessage:
320 'I am a fake server-provided 400 payload with validation details',
321 errors: [
322 { field_one: 'Must be a string' },
323 { field_two: 'Must be a number' },
324 ],
325 }
326
327 const thunkActionCreator = createAsyncThunk(
328 'testType',
329 async (args: number, { requestId, rejectWithValue }) => {
330 generatedRequestId = requestId
331
332 try {
333 throw error
334 } catch (err) {
335 if (!(err as any).response) {
336 throw err
337 }
338 return rejectWithValue(errorPayload)
339 }
340 },
341 )
342
343 const thunkFunction = thunkActionCreator(args)
344
345 try {
346 await thunkFunction(dispatch, () => {}, undefined)
347 } catch (e) {}
348
349 expect(dispatch).toHaveBeenNthCalledWith(
350 1,
351 thunkActionCreator.pending(generatedRequestId, args),
352 )
353
354 expect(dispatch).toHaveBeenCalledTimes(2)
355
356 // Have to check the bits of the action separately since the error was processed
357 const errorAction = dispatch.mock.calls[1][0]
358 expect(errorAction.error).toEqual(miniSerializeError(error))
359 expect(errorAction.payload).toEqual(undefined)
360 expect(errorAction.meta.requestId).toBe(generatedRequestId)
361 expect(errorAction.meta.arg).toBe(args)
362 })
363})
364
365describe('createAsyncThunk with abortController', () => {
366 const asyncThunk = createAsyncThunk(
367 'test',
368 function abortablePayloadCreator(_: any, { signal }) {
369 return new Promise((resolve, reject) => {
370 if (signal.aborted) {
371 reject(
372 new DOMException(
373 'This should never be reached as it should already be handled.',
374 'AbortError',
375 ),
376 )
377 }
378 signal.addEventListener('abort', () => {
379 reject(new DOMException('Was aborted while running', 'AbortError'))
380 })
381 setTimeout(resolve, 100)
382 })
383 },
384 )
385
386 let store = configureStore({
387 reducer(store: UnknownAction[] = []) {
388 return store
389 },
390 })
391
392 beforeEach(() => {
393 store = configureStore({
394 reducer(store: UnknownAction[] = [], action) {
395 return [...store, action]
396 },
397 })
398 })
399
400 test('normal usage', async () => {
401 await store.dispatch(asyncThunk({}))
402 expect(store.getState()).toEqual([
403 expect.any(Object),
404 expect.objectContaining({ type: 'test/pending' }),
405 expect.objectContaining({ type: 'test/fulfilled' }),
406 ])
407 })
408
409 test('abort after dispatch', async () => {
410 const promise = store.dispatch(asyncThunk({}))
411 promise.abort('AbortReason')
412 const result = await promise
413 const expectedAbortedAction = {
414 type: 'test/rejected',
415 error: {
416 message: 'AbortReason',
417 name: 'AbortError',
418 },
419 meta: { aborted: true, requestId: promise.requestId },
420 }
421
422 // abortedAction with reason is dispatched after test/pending is dispatched
423 expect(store.getState()).toMatchObject([
424 {},
425 { type: 'test/pending' },
426 expectedAbortedAction,
427 ])
428
429 // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
430 expect(result).toMatchObject(expectedAbortedAction)
431
432 // calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
433 expect(() => unwrapResult(result)).toThrowError(
434 expect.objectContaining(expectedAbortedAction.error),
435 )
436 })
437
438 test('even when the payloadCreator does not directly support the signal, no further actions are dispatched', async () => {
439 const unawareAsyncThunk = createAsyncThunk('unaware', async () => {
440 await new Promise((resolve) => setTimeout(resolve, 100))
441 return 'finished'
442 })
443
444 const promise = store.dispatch(unawareAsyncThunk())
445 promise.abort('AbortReason')
446 const result = await promise
447
448 const expectedAbortedAction = {
449 type: 'unaware/rejected',
450 error: {
451 message: 'AbortReason',
452 name: 'AbortError',
453 },
454 }
455
456 // abortedAction with reason is dispatched after test/pending is dispatched
457 expect(store.getState()).toEqual([
458 expect.any(Object),
459 expect.objectContaining({ type: 'unaware/pending' }),
460 expect.objectContaining(expectedAbortedAction),
461 ])
462
463 // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
464 expect(result).toMatchObject(expectedAbortedAction)
465
466 // calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
467 expect(() => unwrapResult(result)).toThrowError(
468 expect.objectContaining(expectedAbortedAction.error),
469 )
470 })
471
472 test('dispatch(asyncThunk) returns on abort and does not wait for the promiseProvider to finish', async () => {
473 let running = false
474 const longRunningAsyncThunk = createAsyncThunk('longRunning', async () => {
475 running = true
476 await new Promise((resolve) => setTimeout(resolve, 30000))
477 running = false
478 })
479
480 const promise = store.dispatch(longRunningAsyncThunk())
481 expect(running).toBeTruthy()
482 promise.abort()
483 const result = await promise
484 expect(running).toBeTruthy()
485 expect(result).toMatchObject({
486 type: 'longRunning/rejected',
487 error: { message: 'Aborted', name: 'AbortError' },
488 meta: { aborted: true },
489 })
490 })
491
492 describe('behavior with missing AbortController', () => {
493 let keepAbortController: (typeof window)['AbortController']
494 let freshlyLoadedModule: typeof import('../createAsyncThunk')
495
496 beforeEach(async () => {
497 keepAbortController = window.AbortController
498 delete (window as any).AbortController
499 vi.resetModules()
500 freshlyLoadedModule = await import('../createAsyncThunk')
501 vi.stubEnv('NODE_ENV', 'development')
502 })
503
504 afterEach(() => {
505 vi.unstubAllEnvs()
506 vi.clearAllMocks()
507 vi.stubGlobal('AbortController', keepAbortController)
508 vi.resetModules()
509 })
510
511 test('calling a thunk made with createAsyncThunk should fail if no global abortController is not available', async () => {
512 const longRunningAsyncThunk = freshlyLoadedModule.createAsyncThunk(
513 'longRunning',
514 async () => {
515 await new Promise((resolve) => setTimeout(resolve, 30000))
516 },
517 )
518
519 expect(longRunningAsyncThunk()).toThrow('AbortController is not defined')
520 })
521 })
522})
523
524test('non-serializable arguments are ignored by serializableStateInvariantMiddleware', async () => {
525 const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(noop)
526 const nonSerializableValue = new Map()
527 const asyncThunk = createAsyncThunk('test', (arg: Map<any, any>) => {})
528
529 configureStore({
530 reducer: () => 0,
531 }).dispatch(asyncThunk(nonSerializableValue))
532
533 expect(consoleErrorSpy).not.toHaveBeenCalled()
534
535 consoleErrorSpy.mockRestore()
536})
537
538describe('conditional skipping of asyncThunks', () => {
539 const arg = {}
540 const getState = vi.fn(() => ({}))
541 const dispatch = vi.fn((x: any) => x)
542 const payloadCreator = vi.fn((x: typeof arg) => 10)
543 const condition = vi.fn(() => false)
544 const extra = {}
545
546 beforeEach(() => {
547 getState.mockClear()
548 dispatch.mockClear()
549 payloadCreator.mockClear()
550 condition.mockClear()
551 })
552
553 test('returning false from condition skips payloadCreator and returns a rejected action', async () => {
554 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
555 const result = await asyncThunk(arg)(dispatch, getState, extra)
556
557 expect(condition).toHaveBeenCalled()
558 expect(payloadCreator).not.toHaveBeenCalled()
559 expect(asyncThunk.rejected.match(result)).toBe(true)
560 expect((result as any).meta.condition).toBe(true)
561 })
562
563 test('return falsy from condition does not skip payload creator', async () => {
564 // Override TS's expectation that this is a boolean
565 condition.mockReturnValueOnce(undefined as unknown as boolean)
566 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
567 const result = await asyncThunk(arg)(dispatch, getState, extra)
568
569 expect(condition).toHaveBeenCalled()
570 expect(payloadCreator).toHaveBeenCalled()
571 expect(asyncThunk.fulfilled.match(result)).toBe(true)
572 expect(result.payload).toBe(10)
573 })
574
575 test('returning true from condition executes payloadCreator', async () => {
576 condition.mockReturnValueOnce(true)
577 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
578 const result = await asyncThunk(arg)(dispatch, getState, extra)
579
580 expect(condition).toHaveBeenCalled()
581 expect(payloadCreator).toHaveBeenCalled()
582 expect(asyncThunk.fulfilled.match(result)).toBe(true)
583 expect(result.payload).toBe(10)
584 })
585
586 test('condition is called with arg, getState and extra', async () => {
587 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
588 await asyncThunk(arg)(dispatch, getState, extra)
589
590 expect(condition).toHaveBeenCalledOnce()
591 expect(condition).toHaveBeenLastCalledWith(
592 arg,
593 expect.objectContaining({ getState, extra }),
594 )
595 })
596
597 test('pending is dispatched synchronously if condition is synchronous', async () => {
598 const condition = () => true
599 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
600 const thunkCallPromise = asyncThunk(arg)(dispatch, getState, extra)
601 expect(dispatch).toHaveBeenCalledOnce()
602 await thunkCallPromise
603 expect(dispatch).toHaveBeenCalledTimes(2)
604 })
605
606 test('async condition', async () => {
607 const condition = () => Promise.resolve(false)
608 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
609 await asyncThunk(arg)(dispatch, getState, extra)
610 expect(dispatch).not.toHaveBeenCalled()
611 })
612
613 test('async condition with rejected promise', async () => {
614 const condition = () => Promise.reject()
615 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
616 await asyncThunk(arg)(dispatch, getState, extra)
617 expect(dispatch).toHaveBeenCalledOnce()
618 expect(dispatch).toHaveBeenLastCalledWith(
619 expect.objectContaining({ type: 'test/rejected' }),
620 )
621 })
622
623 test('async condition with AbortController signal first', async () => {
624 const condition = async () => {
625 await delay(25)
626 return true
627 }
628 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
629
630 try {
631 const thunkPromise = asyncThunk(arg)(dispatch, getState, extra)
632 thunkPromise.abort()
633 await thunkPromise
634 } catch (err) {}
635 expect(dispatch).not.toHaveBeenCalled()
636 })
637
638 test('rejected action is not dispatched by default', async () => {
639 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
640 await asyncThunk(arg)(dispatch, getState, extra)
641
642 expect(dispatch).not.toHaveBeenCalled()
643 })
644
645 test('does not fail when attempting to abort a canceled promise', async () => {
646 const asyncPayloadCreator = vi.fn(async (x: typeof arg) => {
647 await delay(200)
648 return 10
649 })
650
651 const asyncThunk = createAsyncThunk('test', asyncPayloadCreator, {
652 condition,
653 })
654 const promise = asyncThunk(arg)(dispatch, getState, extra)
655 promise.abort(
656 `If the promise was 1. somehow canceled, 2. in a 'started' state and 3. we attempted to abort, this would crash the tests`,
657 )
658 })
659
660 test('rejected action can be dispatched via option', async () => {
661 const asyncThunk = createAsyncThunk('test', payloadCreator, {
662 condition,
663 dispatchConditionRejection: true,
664 })
665 await asyncThunk(arg)(dispatch, getState, extra)
666
667 expect(dispatch).toHaveBeenCalledOnce()
668 expect(dispatch).toHaveBeenLastCalledWith(
669 expect.objectContaining({
670 error: {
671 message: 'Aborted due to condition callback returning false.',
672 name: 'ConditionError',
673 },
674 meta: {
675 aborted: false,
676 arg,
677 rejectedWithValue: false,
678 condition: true,
679 requestId: expect.stringContaining(''),
680 requestStatus: 'rejected',
681 },
682 payload: undefined,
683 type: 'test/rejected',
684 }),
685 )
686 })
687})
688
689test('serializeError implementation', async () => {
690 function serializeError() {
691 return 'serialized!'
692 }
693 const errorObject = 'something else!'
694
695 const store = configureStore({
696 reducer: (state = [], action) => [...state, action],
697 })
698
699 const asyncThunk = createAsyncThunk<
700 unknown,
701 void,
702 { serializedErrorType: string }
703 >('test', () => Promise.reject(errorObject), { serializeError })
704 const rejected = await store.dispatch(asyncThunk())
705 if (!asyncThunk.rejected.match(rejected)) {
706 throw new Error()
707 }
708
709 const expectation = {
710 type: 'test/rejected',
711 payload: undefined,
712 error: 'serialized!',
713 meta: expect.any(Object),
714 }
715 expect(rejected).toEqual(expectation)
716 expect(store.getState()[2]).toEqual(expectation)
717 expect(rejected.error).not.toEqual(miniSerializeError(errorObject))
718})
719
720describe('unwrapResult', () => {
721 const getState = vi.fn(() => ({}))
722 const dispatch = vi.fn((x: any) => x)
723 const extra = {}
724 test('fulfilled case', async () => {
725 const asyncThunk = createAsyncThunk('test', () => {
726 return 'fulfilled!' as const
727 })
728
729 const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
730 unwrapResult,
731 )
732
733 await expect(unwrapPromise).resolves.toBe('fulfilled!')
734
735 const unwrapPromise2 = asyncThunk()(dispatch, getState, extra)
736 const res = await unwrapPromise2.unwrap()
737 expect(res).toBe('fulfilled!')
738 })
739 test('error case', async () => {
740 const error = new Error('Panic!')
741 const asyncThunk = createAsyncThunk('test', () => {
742 throw error
743 })
744
745 const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
746 unwrapResult,
747 )
748
749 await expect(unwrapPromise).rejects.toEqual(miniSerializeError(error))
750
751 const unwrapPromise2 = asyncThunk()(dispatch, getState, extra)
752 await expect(unwrapPromise2.unwrap()).rejects.toEqual(
753 miniSerializeError(error),
754 )
755 })
756 test('rejectWithValue case', async () => {
757 const asyncThunk = createAsyncThunk('test', (_, { rejectWithValue }) => {
758 return rejectWithValue('rejectWithValue!')
759 })
760
761 const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
762 unwrapResult,
763 )
764
765 await expect(unwrapPromise).rejects.toBe('rejectWithValue!')
766
767 const unwrapPromise2 = asyncThunk()(dispatch, getState, extra)
768 await expect(unwrapPromise2.unwrap()).rejects.toBe('rejectWithValue!')
769 })
770})
771
772describe('idGenerator option', () => {
773 const getState = () => ({})
774 const dispatch = (x: any) => x
775 const extra = {}
776
777 test('idGenerator implementation - can customizes how request IDs are generated', async () => {
778 function makeFakeIdGenerator() {
779 let id = 0
780 return vi.fn(() => {
781 id++
782 return `fake-random-id-${id}`
783 })
784 }
785
786 let generatedRequestId = ''
787
788 const idGenerator = makeFakeIdGenerator()
789 const asyncThunk = createAsyncThunk(
790 'test',
791 async (args: void, { requestId }) => {
792 generatedRequestId = requestId
793 },
794 { idGenerator },
795 )
796
797 // dispatching the thunks should be using the custom id generator
798 const promise0 = asyncThunk()(dispatch, getState, extra)
799 expect(generatedRequestId).toEqual('fake-random-id-1')
800 expect(promise0.requestId).toEqual('fake-random-id-1')
801 expect((await promise0).meta.requestId).toEqual('fake-random-id-1')
802
803 const promise1 = asyncThunk()(dispatch, getState, extra)
804 expect(generatedRequestId).toEqual('fake-random-id-2')
805 expect(promise1.requestId).toEqual('fake-random-id-2')
806 expect((await promise1).meta.requestId).toEqual('fake-random-id-2')
807
808 const promise2 = asyncThunk()(dispatch, getState, extra)
809 expect(generatedRequestId).toEqual('fake-random-id-3')
810 expect(promise2.requestId).toEqual('fake-random-id-3')
811 expect((await promise2).meta.requestId).toEqual('fake-random-id-3')
812
813 generatedRequestId = ''
814 const defaultAsyncThunk = createAsyncThunk(
815 'test',
816 async (args: void, { requestId }) => {
817 generatedRequestId = requestId
818 },
819 )
820 // dispatching the default options thunk should still generate an id,
821 // but not using the custom id generator
822 const promise3 = defaultAsyncThunk()(dispatch, getState, extra)
823 expect(generatedRequestId).toEqual(promise3.requestId)
824 expect(promise3.requestId).not.toEqual('')
825 expect(promise3.requestId).not.toEqual(
826 expect.stringContaining('fake-random-id'),
827 )
828 expect((await promise3).meta.requestId).not.toEqual(
829 expect.stringContaining('fake-fandom-id'),
830 )
831 })
832
833 test('idGenerator should be called with thunkArg', async () => {
834 const customIdGenerator = vi.fn((seed) => `fake-unique-random-id-${seed}`)
835 let generatedRequestId = ''
836 const asyncThunk = createAsyncThunk(
837 'test',
838 async (args: any, { requestId }) => {
839 generatedRequestId = requestId
840 },
841 { idGenerator: customIdGenerator },
842 )
843
844 const thunkArg = 1
845 const expected = 'fake-unique-random-id-1'
846 const asyncThunkPromise = asyncThunk(thunkArg)(dispatch, getState, extra)
847
848 expect(customIdGenerator).toHaveBeenCalledWith(thunkArg)
849 expect(asyncThunkPromise.requestId).toEqual(expected)
850 expect((await asyncThunkPromise).meta.requestId).toEqual(expected)
851 })
852})
853
854test('`condition` will see state changes from a synchronously invoked asyncThunk', () => {
855 type State = ReturnType<typeof store.getState>
856 const onStart = vi.fn()
857 const asyncThunk = createAsyncThunk<
858 void,
859 { force?: boolean },
860 { state: State }
861 >('test', onStart, {
862 condition({ force }, { getState }) {
863 return force || !getState().started
864 },
865 })
866 const store = configureStore({
867 reducer: createReducer({ started: false }, (builder) => {
868 builder.addCase(asyncThunk.pending, (state) => {
869 state.started = true
870 })
871 }),
872 })
873
874 store.dispatch(asyncThunk({ force: false }))
875 expect(onStart).toHaveBeenCalledOnce()
876 store.dispatch(asyncThunk({ force: false }))
877 expect(onStart).toHaveBeenCalledOnce()
878 store.dispatch(asyncThunk({ force: true }))
879 expect(onStart).toHaveBeenCalledTimes(2)
880})
881
882const getNewStore = () =>
883 configureStore({
884 reducer(actions: UnknownAction[] = [], action) {
885 return [...actions, action]
886 },
887 })
888
889describe('meta', () => {
890 let store = getNewStore()
891
892 beforeEach(() => {
893 store = getNewStore()
894 })
895
896 test('pendingMeta', () => {
897 const pendingThunk = createAsyncThunk('test', (arg: string) => {}, {
898 getPendingMeta({ arg, requestId }) {
899 expect(arg).toBe('testArg')
900 expect(requestId).toEqual(expect.any(String))
901 return { extraProp: 'foo' }
902 },
903 })
904 const ret = store.dispatch(pendingThunk('testArg'))
905 expect(store.getState()[1]).toEqual({
906 meta: {
907 arg: 'testArg',
908 extraProp: 'foo',
909 requestId: ret.requestId,
910 requestStatus: 'pending',
911 },
912 payload: undefined,
913 type: 'test/pending',
914 })
915 })
916
917 test('fulfilledMeta', async () => {
918 const fulfilledThunk = createAsyncThunk<
919 string,
920 string,
921 { fulfilledMeta: { extraProp: string } }
922 >('test', (arg: string, { fulfillWithValue }) => {
923 return fulfillWithValue('hooray!', { extraProp: 'bar' })
924 })
925 const ret = store.dispatch(fulfilledThunk('testArg'))
926 expect(await ret).toEqual({
927 meta: {
928 arg: 'testArg',
929 extraProp: 'bar',
930 requestId: ret.requestId,
931 requestStatus: 'fulfilled',
932 },
933 payload: 'hooray!',
934 type: 'test/fulfilled',
935 })
936 })
937
938 test('rejectedMeta', async () => {
939 const fulfilledThunk = createAsyncThunk<
940 string,
941 string,
942 { rejectedMeta: { extraProp: string } }
943 >('test', (arg: string, { rejectWithValue }) => {
944 return rejectWithValue('damn!', { extraProp: 'baz' })
945 })
946 const promise = store.dispatch(fulfilledThunk('testArg'))
947 const ret = await promise
948 expect(ret).toEqual({
949 meta: {
950 arg: 'testArg',
951 extraProp: 'baz',
952 requestId: promise.requestId,
953 requestStatus: 'rejected',
954 rejectedWithValue: true,
955 aborted: false,
956 condition: false,
957 },
958 error: { message: 'Rejected' },
959 payload: 'damn!',
960 type: 'test/rejected',
961 })
962
963 if (ret.meta.requestStatus === 'rejected' && ret.meta.rejectedWithValue) {
964 } else {
965 // could be caused by a `throw`, `abort()` or `condition` - no `rejectedMeta` in that case
966 // @ts-expect-error
967 ret.meta.extraProp
968 }
969 })
970
971 test('typed createAsyncThunk.withTypes', () => {
972 const typedCAT = createAsyncThunk.withTypes<{
973 state: { s: string }
974 rejectValue: string
975 extra: { s: string; n: number }
976 }>()
977 const thunk = typedCAT('a', () => 'b')
978 const expectFunction = expect.any(Function)
979 expect(thunk.fulfilled).toEqual(expectFunction)
980 expect(thunk.pending).toEqual(expectFunction)
981 expect(thunk.rejected).toEqual(expectFunction)
982 expect(thunk.settled).toEqual(expectFunction)
983 expect(thunk.fulfilled.type).toBe('a/fulfilled')
984 })
985 test('createAsyncThunkWrapper using CreateAsyncThunkFunction', async () => {
986 const customSerializeError = () => 'serialized!'
987 const createAppAsyncThunk: CreateAsyncThunkFunction<{
988 serializedErrorType: ReturnType<typeof customSerializeError>
989 }> = (prefix: string, payloadCreator: any, options: any) =>
990 createAsyncThunk(prefix, payloadCreator, {
991 ...options,
992 serializeError: customSerializeError,
993 }) as any
994
995 const asyncThunk = createAppAsyncThunk('test', async () => {
996 throw new Error('Panic!')
997 })
998
999 const promise = store.dispatch(asyncThunk())
1000 const result = await promise
1001 if (!asyncThunk.rejected.match(result)) {
1002 throw new Error('should have thrown')
1003 }
1004 expect(result.error).toEqual('serialized!')
1005 })
1006})
1007
1008describe('dispatch config', () => {
1009 let store = getNewStore()
1010
1011 beforeEach(() => {
1012 store = getNewStore()
1013 })
1014 test('accepts external signal', async () => {
1015 const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => {
1016 signal.throwIfAborted()
1017 const { promise, reject } = promiseWithResolvers<never>()
1018 signal.addEventListener('abort', () => reject(signal.reason))
1019 return promise
1020 })
1021
1022 const abortController = new AbortController()
1023 const promise = store.dispatch(
1024 asyncThunk(undefined, { signal: abortController.signal }),
1025 )
1026 abortController.abort()
1027 await expect(promise.unwrap()).rejects.toThrow(
1028 'External signal was aborted',
1029 )
1030 })
1031 test('handles already aborted external signal', async () => {
1032 const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => {
1033 signal.throwIfAborted()
1034 const { promise, reject } = promiseWithResolvers<never>()
1035 signal.addEventListener('abort', () => reject(signal.reason))
1036 return promise
1037 })
1038
1039 const signal = AbortSignal.abort()
1040 const promise = store.dispatch(asyncThunk(undefined, { signal }))
1041 await expect(promise.unwrap()).rejects.toThrow(
1042 'Aborted due to condition callback returning false.',
1043 )
1044 })
1045})
Note: See TracBrowser for help on using the repository browser.