UNPKG

50.1 kBPlain TextView Raw
1import {
2 configureStore,
3 createAction,
4 createSlice,
5 Dispatch,
6 isAnyOf,
7} from '@reduxjs/toolkit'
8
9import type { AnyAction, PayloadAction, Action } from '@reduxjs/toolkit'
10
11import {
12 createListenerMiddleware,
13 createListenerEntry,
14 addListener,
15 removeListener,
16 TaskAbortError,
17 clearAllListeners,
18} from '../index'
19
20import type {
21 ListenerEffect,
22 ListenerEffectAPI,
23 TypedAddListener,
24 TypedStartListening,
25 UnsubscribeListener,
26 ListenerMiddleware,
27} from '../index'
28import type {
29 AbortSignalWithReason,
30 AddListenerOverloads,
31 TypedRemoveListener,
32} from '../types'
33import { listenerCancelled, listenerCompleted } from '../exceptions'
34
35const middlewareApi = {
36 getState: expect.any(Function),
37 getOriginalState: expect.any(Function),
38 condition: expect.any(Function),
39 extra: undefined,
40 take: expect.any(Function),
41 signal: expect.any(Object),
42 fork: expect.any(Function),
43 delay: expect.any(Function),
44 pause: expect.any(Function),
45 dispatch: expect.any(Function),
46 unsubscribe: expect.any(Function),
47 subscribe: expect.any(Function),
48 cancelActiveListeners: expect.any(Function),
49}
50
51const noop = () => {}
52
53// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license.
54export interface Deferred<T> extends Promise<T> {
55 resolve(value?: T | PromiseLike<T>): void
56 // deno-lint-ignore no-explicit-any
57 reject(reason?: any): void
58}
59
60/** Creates a Promise with the `reject` and `resolve` functions
61 * placed as methods on the promise object itself. It allows you to do:
62 *
63 * const p = deferred<number>();
64 * // ...
65 * p.resolve(42);
66 */
67export function deferred<T>(): Deferred<T> {
68 let methods
69 const promise = new Promise<T>((resolve, reject): void => {
70 methods = { resolve, reject }
71 })
72 return Object.assign(promise, methods) as Deferred<T>
73}
74
75export declare type IsAny<T, True, False = never> = true | false extends (
76 T extends never ? true : false
77)
78 ? True
79 : False
80
81export declare type IsUnknown<T, True, False = never> = unknown extends T
82 ? IsAny<T, False, True>
83 : False
84
85export function expectType<T>(t: T): T {
86 return t
87}
88
89type Equals<T, U> = IsAny<
90 T,
91 never,
92 IsAny<U, never, [T] extends [U] ? ([U] extends [T] ? any : never) : never>
93>
94export function expectExactType<T>(t: T) {
95 return <U extends Equals<T, U>>(u: U) => {}
96}
97
98type EnsureUnknown<T extends any> = IsUnknown<T, any, never>
99export function expectUnknown<T extends EnsureUnknown<T>>(t: T) {
100 return t
101}
102
103type EnsureAny<T extends any> = IsAny<T, any, never>
104export function expectExactAny<T extends EnsureAny<T>>(t: T) {
105 return t
106}
107
108type IsNotAny<T> = IsAny<T, never, any>
109export function expectNotAny<T extends IsNotAny<T>>(t: T): T {
110 return t
111}
112
113describe('createListenerMiddleware', () => {
114 let store = configureStore({
115 reducer: () => 42,
116 middleware: (gDM) => gDM().prepend(createListenerMiddleware().middleware),
117 })
118
119 interface CounterState {
120 value: number
121 }
122
123 const counterSlice = createSlice({
124 name: 'counter',
125 initialState: { value: 0 } as CounterState,
126 reducers: {
127 increment(state) {
128 state.value += 1
129 },
130 decrement(state) {
131 state.value -= 1
132 },
133 // Use the PayloadAction type to declare the contents of `action.payload`
134 incrementByAmount: (state, action: PayloadAction<number>) => {
135 state.value += action.payload
136 },
137 },
138 })
139 const { increment, decrement, incrementByAmount } = counterSlice.actions
140
141 function delay(ms: number) {
142 return new Promise((resolve) => setTimeout(resolve, ms))
143 }
144
145 let reducer: jest.Mock
146 let listenerMiddleware = createListenerMiddleware()
147 let { middleware, startListening, stopListening, clearListeners } =
148 listenerMiddleware
149 let addTypedListenerAction = addListener as TypedAddListener<CounterState>
150 let removeTypedListenerAction =
151 removeListener as TypedRemoveListener<CounterState>
152
153 const testAction1 = createAction<string>('testAction1')
154 type TestAction1 = ReturnType<typeof testAction1>
155 const testAction2 = createAction<string>('testAction2')
156 type TestAction2 = ReturnType<typeof testAction2>
157 const testAction3 = createAction<string>('testAction3')
158 type TestAction3 = ReturnType<typeof testAction3>
159
160 beforeAll(() => {
161 jest.spyOn(console, 'error').mockImplementation(noop)
162 })
163
164 beforeEach(() => {
165 listenerMiddleware = createListenerMiddleware()
166 middleware = listenerMiddleware.middleware
167 startListening = listenerMiddleware.startListening
168 stopListening = listenerMiddleware.stopListening
169 clearListeners = listenerMiddleware.clearListeners
170 reducer = jest.fn(() => ({}))
171 store = configureStore({
172 reducer,
173 middleware: (gDM) => gDM().prepend(middleware),
174 })
175 })
176
177 describe('Middleware setup', () => {
178 test('Allows passing an extra argument on middleware creation', () => {
179 const originalExtra = 42
180 const listenerMiddleware = createListenerMiddleware({
181 extra: originalExtra,
182 })
183 const store = configureStore({
184 reducer: counterSlice.reducer,
185 middleware: (gDM) => gDM().prepend(listenerMiddleware.middleware),
186 })
187
188 let foundExtra = null
189
190 const typedAddListener =
191 listenerMiddleware.startListening as TypedStartListening<
192 CounterState,
193 typeof store.dispatch,
194 typeof originalExtra
195 >
196
197 typedAddListener({
198 matcher: (action: AnyAction): action is AnyAction => true,
199 effect: (action, listenerApi) => {
200 foundExtra = listenerApi.extra
201 expectType<typeof originalExtra>(listenerApi.extra)
202 },
203 })
204
205 store.dispatch(testAction1('a'))
206 expect(foundExtra).toBe(originalExtra)
207 })
208
209 test('Passes through if there are no listeners', () => {
210 const originalAction = testAction1('a')
211 const resultAction = store.dispatch(originalAction)
212 expect(resultAction).toBe(originalAction)
213 })
214 })
215
216 describe('Subscription and unsubscription', () => {
217 test('directly subscribing', () => {
218 const effect = jest.fn((_: TestAction1) => {})
219
220 startListening({
221 actionCreator: testAction1,
222 effect: effect,
223 })
224
225 store.dispatch(testAction1('a'))
226 store.dispatch(testAction2('b'))
227 store.dispatch(testAction1('c'))
228
229 expect(effect.mock.calls).toEqual([
230 [testAction1('a'), middlewareApi],
231 [testAction1('c'), middlewareApi],
232 ])
233 })
234
235 test('stopListening returns true if an entry has been unsubscribed, false otherwise', () => {
236 const effect = jest.fn((_: TestAction1) => {})
237
238 startListening({
239 actionCreator: testAction1,
240 effect,
241 })
242
243 expect(stopListening({ actionCreator: testAction2, effect })).toBe(false)
244 expect(stopListening({ actionCreator: testAction1, effect })).toBe(true)
245 })
246
247 test('dispatch(removeListener({...})) returns true if an entry has been unsubscribed, false otherwise', () => {
248 const effect = jest.fn((_: TestAction1) => {})
249
250 startListening({
251 actionCreator: testAction1,
252 effect,
253 })
254
255 expect(
256 store.dispatch(
257 removeTypedListenerAction({
258 actionCreator: testAction2,
259 effect,
260 })
261 )
262 ).toBe(false)
263 expect(
264 store.dispatch(
265 removeTypedListenerAction({
266 actionCreator: testAction1,
267 effect,
268 })
269 )
270 ).toBe(true)
271 })
272
273 test('can subscribe with a string action type', () => {
274 const effect = jest.fn((_: AnyAction) => {})
275
276 store.dispatch(
277 addListener({
278 type: testAction2.type,
279 effect,
280 })
281 )
282
283 store.dispatch(testAction2('b'))
284 expect(effect.mock.calls).toEqual([[testAction2('b'), middlewareApi]])
285
286 store.dispatch(removeListener({ type: testAction2.type, effect }))
287
288 store.dispatch(testAction2('b'))
289 expect(effect.mock.calls).toEqual([[testAction2('b'), middlewareApi]])
290 })
291
292 test('can subscribe with a matcher function', () => {
293 const effect = jest.fn((_: AnyAction) => {})
294
295 const isAction1Or2 = isAnyOf(testAction1, testAction2)
296
297 const unsubscribe = startListening({
298 matcher: isAction1Or2,
299 effect: effect,
300 })
301
302 store.dispatch(testAction1('a'))
303 store.dispatch(testAction2('b'))
304 store.dispatch(testAction3('c'))
305 expect(effect.mock.calls).toEqual([
306 [testAction1('a'), middlewareApi],
307 [testAction2('b'), middlewareApi],
308 ])
309
310 unsubscribe()
311
312 store.dispatch(testAction2('b'))
313 expect(effect.mock.calls).toEqual([
314 [testAction1('a'), middlewareApi],
315 [testAction2('b'), middlewareApi],
316 ])
317 })
318
319 test('Can subscribe with an action predicate function', () => {
320 const store = configureStore({
321 reducer: counterSlice.reducer,
322 middleware: (gDM) => gDM().prepend(middleware),
323 })
324
325 let listener1Calls = 0
326
327 startListening({
328 predicate: (action, state) => {
329 return (state as CounterState).value > 1
330 },
331 effect: () => {
332 listener1Calls++
333 },
334 })
335
336 let listener2Calls = 0
337
338 startListening({
339 predicate: (action, state, prevState) => {
340 return (
341 (state as CounterState).value > 1 &&
342 (prevState as CounterState).value % 2 === 0
343 )
344 },
345 effect: () => {
346 listener2Calls++
347 },
348 })
349
350 store.dispatch(increment())
351 store.dispatch(increment())
352 store.dispatch(increment())
353 store.dispatch(increment())
354
355 expect(listener1Calls).toBe(3)
356 expect(listener2Calls).toBe(1)
357 })
358
359 test('subscribing with the same listener will not make it trigger twice (like EventTarget.addEventListener())', () => {
360 const effect = jest.fn((_: TestAction1) => {})
361
362 startListening({
363 actionCreator: testAction1,
364 effect,
365 })
366 startListening({
367 actionCreator: testAction1,
368 effect,
369 })
370
371 store.dispatch(testAction1('a'))
372 store.dispatch(testAction2('b'))
373 store.dispatch(testAction1('c'))
374
375 expect(effect.mock.calls).toEqual([
376 [testAction1('a'), middlewareApi],
377 [testAction1('c'), middlewareApi],
378 ])
379 })
380
381 test('unsubscribing via callback', () => {
382 const effect = jest.fn((_: TestAction1) => {})
383
384 const unsubscribe = startListening({
385 actionCreator: testAction1,
386 effect,
387 })
388
389 store.dispatch(testAction1('a'))
390 unsubscribe()
391 store.dispatch(testAction2('b'))
392 store.dispatch(testAction1('c'))
393
394 expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]])
395 })
396
397 test('directly unsubscribing', () => {
398 const effect = jest.fn((_: TestAction1) => {})
399
400 startListening({
401 actionCreator: testAction1,
402 effect,
403 })
404
405 store.dispatch(testAction1('a'))
406
407 stopListening({ actionCreator: testAction1, effect })
408 store.dispatch(testAction2('b'))
409 store.dispatch(testAction1('c'))
410
411 expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]])
412 })
413
414 test('unsubscribing without any subscriptions does not trigger an error', () => {
415 stopListening({ matcher: testAction1.match, effect: noop })
416 })
417
418 test('subscribing via action', () => {
419 const effect = jest.fn((_: TestAction1) => {})
420
421 store.dispatch(
422 addListener({
423 actionCreator: testAction1,
424 effect,
425 })
426 )
427
428 store.dispatch(testAction1('a'))
429 store.dispatch(testAction2('b'))
430 store.dispatch(testAction1('c'))
431
432 expect(effect.mock.calls).toEqual([
433 [testAction1('a'), middlewareApi],
434 [testAction1('c'), middlewareApi],
435 ])
436 })
437
438 test('unsubscribing via callback from dispatch', () => {
439 const effect = jest.fn((_: TestAction1) => {})
440
441 const unsubscribe = store.dispatch(
442 addListener({
443 actionCreator: testAction1,
444 effect,
445 })
446 )
447
448 expectType<UnsubscribeListener>(unsubscribe)
449
450 store.dispatch(testAction1('a'))
451
452 unsubscribe()
453 store.dispatch(testAction2('b'))
454 store.dispatch(testAction1('c'))
455
456 expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]])
457 })
458
459 test('unsubscribing via action', () => {
460 const effect = jest.fn((_: TestAction1) => {})
461
462 startListening({
463 actionCreator: testAction1,
464 effect,
465 })
466
467 startListening({
468 actionCreator: testAction1,
469 effect,
470 })
471
472 store.dispatch(testAction1('a'))
473
474 store.dispatch(removeListener({ actionCreator: testAction1, effect }))
475 store.dispatch(testAction2('b'))
476 store.dispatch(testAction1('c'))
477
478 expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]])
479 })
480
481 test('can cancel an active listener when unsubscribing directly', async () => {
482 let wasCancelled = false
483 const unsubscribe = startListening({
484 actionCreator: testAction1,
485 effect: async (action, listenerApi) => {
486 try {
487 await listenerApi.condition(testAction2.match)
488 } catch (err) {
489 if (err instanceof TaskAbortError) {
490 wasCancelled = true
491 }
492 }
493 },
494 })
495
496 store.dispatch(testAction1('a'))
497 unsubscribe({ cancelActive: true })
498 expect(wasCancelled).toBe(false)
499 await delay(10)
500 expect(wasCancelled).toBe(true)
501 })
502
503 test('can cancel an active listener when unsubscribing via stopListening', async () => {
504 let wasCancelled = false
505 const effect = async (action: any, listenerApi: any) => {
506 try {
507 await listenerApi.condition(testAction2.match)
508 } catch (err) {
509 if (err instanceof TaskAbortError) {
510 wasCancelled = true
511 }
512 }
513 }
514 startListening({
515 actionCreator: testAction1,
516 effect,
517 })
518
519 store.dispatch(testAction1('a'))
520 stopListening({ actionCreator: testAction1, effect, cancelActive: true })
521 expect(wasCancelled).toBe(false)
522 await delay(10)
523 expect(wasCancelled).toBe(true)
524 })
525
526 test('can cancel an active listener when unsubscribing via removeListener', async () => {
527 let wasCancelled = false
528 const effect = async (action: any, listenerApi: any) => {
529 try {
530 await listenerApi.condition(testAction2.match)
531 } catch (err) {
532 if (err instanceof TaskAbortError) {
533 wasCancelled = true
534 }
535 }
536 }
537 startListening({
538 actionCreator: testAction1,
539 effect,
540 })
541
542 store.dispatch(testAction1('a'))
543 store.dispatch(
544 removeListener({
545 actionCreator: testAction1,
546 effect,
547 cancelActive: true,
548 })
549 )
550 expect(wasCancelled).toBe(false)
551 await delay(10)
552 expect(wasCancelled).toBe(true)
553 })
554
555 const addListenerOptions: [
556 string,
557 Omit<
558 AddListenerOverloads<
559 () => void,
560 typeof store.getState,
561 typeof store.dispatch
562 >,
563 'effect'
564 >
565 ][] = [
566 ['predicate', { predicate: () => true }],
567 ['actionCreator', { actionCreator: testAction1 }],
568 ['matcher', { matcher: isAnyOf(testAction1, testAction2) }],
569 ['type', { type: testAction1.type }],
570 ]
571
572 test.each(addListenerOptions)(
573 'add and remove listener with "%s" param correctly',
574 (_, params) => {
575 const effect: ListenerEffect<
576 AnyAction,
577 typeof store.getState,
578 typeof store.dispatch
579 > = jest.fn()
580
581 startListening({ ...params, effect } as any)
582
583 store.dispatch(testAction1('a'))
584 expect(effect).toBeCalledTimes(1)
585
586 stopListening({ ...params, effect } as any)
587
588 store.dispatch(testAction1('b'))
589 expect(effect).toBeCalledTimes(1)
590 }
591 )
592
593 const unforwardedActions: [string, AnyAction][] = [
594 [
595 'addListener',
596 addListener({ actionCreator: testAction1, effect: noop }),
597 ],
598 [
599 'removeListener',
600 removeListener({ actionCreator: testAction1, effect: noop }),
601 ],
602 ]
603 test.each(unforwardedActions)(
604 '"%s" is not forwarded to the reducer',
605 (_, action) => {
606 reducer.mockClear()
607
608 store.dispatch(testAction1('a'))
609 store.dispatch(action)
610 store.dispatch(testAction2('b'))
611
612 expect(reducer.mock.calls).toEqual([
613 [{}, testAction1('a')],
614 [{}, testAction2('b')],
615 ])
616 }
617 )
618
619 test('listenerApi.signal has correct reason when listener is cancelled or completes', async () => {
620 const notifyDeferred = createAction<Deferred<string>>('notify-deferred')
621
622 startListening({
623 actionCreator: notifyDeferred,
624 async effect({ payload }, { signal, cancelActiveListeners, delay }) {
625 signal.addEventListener(
626 'abort',
627 () => {
628 payload.resolve((signal as AbortSignalWithReason<string>).reason)
629 },
630 { once: true }
631 )
632
633 cancelActiveListeners()
634 delay(10)
635 },
636 })
637
638 const deferredCancelledSignalReason = store.dispatch(
639 notifyDeferred(deferred<string>())
640 ).payload
641 const deferredCompletedSignalReason = store.dispatch(
642 notifyDeferred(deferred<string>())
643 ).payload
644
645 expect(await deferredCancelledSignalReason).toBe(listenerCancelled)
646 expect(await deferredCompletedSignalReason).toBe(listenerCompleted)
647 })
648
649 test('"can unsubscribe via middleware api', () => {
650 const effect = jest.fn(
651 (action: TestAction1, api: ListenerEffectAPI<any, any>) => {
652 if (action.payload === 'b') {
653 api.unsubscribe()
654 }
655 }
656 )
657
658 startListening({
659 actionCreator: testAction1,
660 effect,
661 })
662
663 store.dispatch(testAction1('a'))
664 store.dispatch(testAction1('b'))
665 store.dispatch(testAction1('c'))
666
667 expect(effect.mock.calls).toEqual([
668 [testAction1('a'), middlewareApi],
669 [testAction1('b'), middlewareApi],
670 ])
671 })
672
673 test('Can re-subscribe via middleware api', async () => {
674 let numListenerRuns = 0
675 startListening({
676 actionCreator: testAction1,
677 effect: async (action, listenerApi) => {
678 numListenerRuns++
679
680 listenerApi.unsubscribe()
681
682 await listenerApi.condition(testAction2.match)
683
684 listenerApi.subscribe()
685 },
686 })
687
688 store.dispatch(testAction1('a'))
689 expect(numListenerRuns).toBe(1)
690
691 store.dispatch(testAction1('a'))
692 expect(numListenerRuns).toBe(1)
693
694 store.dispatch(testAction2('b'))
695 expect(numListenerRuns).toBe(1)
696
697 await delay(5)
698
699 store.dispatch(testAction1('b'))
700 expect(numListenerRuns).toBe(2)
701 })
702 })
703
704 describe('clear listeners', () => {
705 test('dispatch(clearListenerAction()) cancels running listeners and removes all subscriptions', async () => {
706 const listener1Test = deferred()
707 let listener1Calls = 0
708 let listener2Calls = 0
709 let listener3Calls = 0
710
711 startListening({
712 actionCreator: testAction1,
713 async effect(_, listenerApi) {
714 listener1Calls++
715 listenerApi.signal.addEventListener(
716 'abort',
717 () => listener1Test.resolve(listener1Calls),
718 { once: true }
719 )
720 await listenerApi.condition(() => true)
721 listener1Test.reject(new Error('unreachable: listener1Test'))
722 },
723 })
724
725 startListening({
726 actionCreator: clearAllListeners,
727 effect() {
728 listener2Calls++
729 },
730 })
731
732 startListening({
733 predicate: () => true,
734 effect() {
735 listener3Calls++
736 },
737 })
738
739 store.dispatch(testAction1('a'))
740 store.dispatch(clearAllListeners())
741 store.dispatch(testAction1('b'))
742 expect(await listener1Test).toBe(1)
743 expect(listener1Calls).toBe(1)
744 expect(listener3Calls).toBe(1)
745 expect(listener2Calls).toBe(0)
746 })
747
748 test('clear() cancels running listeners and removes all subscriptions', async () => {
749 const listener1Test = deferred()
750
751 let listener1Calls = 0
752 let listener2Calls = 0
753
754 startListening({
755 actionCreator: testAction1,
756 async effect(_, listenerApi) {
757 listener1Calls++
758 listenerApi.signal.addEventListener(
759 'abort',
760 () => listener1Test.resolve(listener1Calls),
761 { once: true }
762 )
763 await listenerApi.condition(() => true)
764 listener1Test.reject(new Error('unreachable: listener1Test'))
765 },
766 })
767
768 startListening({
769 actionCreator: testAction2,
770 effect() {
771 listener2Calls++
772 },
773 })
774
775 store.dispatch(testAction1('a'))
776
777 clearListeners()
778 store.dispatch(testAction1('b'))
779 store.dispatch(testAction2('c'))
780
781 expect(listener2Calls).toBe(0)
782 expect(await listener1Test).toBe(1)
783 })
784
785 test('clear() cancels all running forked tasks', async () => {
786 const store = configureStore({
787 reducer: counterSlice.reducer,
788 middleware: (gDM) => gDM().prepend(middleware),
789 })
790
791 startListening({
792 actionCreator: testAction1,
793 async effect(_, { fork, dispatch }) {
794 await fork(() => dispatch(incrementByAmount(3))).result
795 dispatch(incrementByAmount(4))
796 },
797 })
798
799 expect(store.getState().value).toBe(0)
800 store.dispatch(testAction1('a'))
801
802 clearListeners()
803
804 await Promise.resolve() // Forked tasks run on the next microtask.
805
806 expect(store.getState().value).toBe(0)
807 })
808 })
809
810 describe('Listener API', () => {
811 test('Passes both getState and getOriginalState in the API', () => {
812 const store = configureStore({
813 reducer: counterSlice.reducer,
814 middleware: (gDM) => gDM().prepend(middleware),
815 })
816
817 let listener1Calls = 0
818 startListening({
819 actionCreator: increment,
820 effect: (action, listenerApi) => {
821 const stateBefore = listenerApi.getOriginalState() as CounterState
822 const currentState = listenerApi.getOriginalState() as CounterState
823
824 listener1Calls++
825 // In the "before" phase, we pass the same state
826 expect(currentState).toBe(stateBefore)
827 },
828 })
829
830 let listener2Calls = 0
831 startListening({
832 actionCreator: increment,
833 effect: (action, listenerApi) => {
834 // TODO getState functions aren't typed right here
835 const stateBefore = listenerApi.getOriginalState() as CounterState
836 const currentState = listenerApi.getOriginalState() as CounterState
837
838 listener2Calls++
839 // In the "after" phase, we pass the new state for `getState`, and still have original state too
840 expect(currentState.value).toBe(stateBefore.value + 1)
841 },
842 })
843
844 store.dispatch(increment())
845
846 expect(listener1Calls).toBe(1)
847 expect(listener2Calls).toBe(1)
848 })
849
850 test('getOriginalState can only be invoked synchronously', async () => {
851 const onError = jest.fn()
852
853 const listenerMiddleware = createListenerMiddleware<CounterState>({
854 onError,
855 })
856 const { middleware, startListening } = listenerMiddleware
857 const store = configureStore({
858 reducer: counterSlice.reducer,
859 middleware: (gDM) => gDM().prepend(middleware),
860 })
861
862 startListening({
863 actionCreator: increment,
864 async effect(_, listenerApi) {
865 const runIncrementBy = () => {
866 listenerApi.dispatch(
867 counterSlice.actions.incrementByAmount(
868 listenerApi.getOriginalState().value + 2
869 )
870 )
871 }
872
873 runIncrementBy()
874
875 await Promise.resolve()
876
877 runIncrementBy()
878 },
879 })
880
881 expect(store.getState()).toEqual({ value: 0 })
882
883 store.dispatch(increment()) // state.value+=1 && trigger listener
884 expect(onError).not.toHaveBeenCalled()
885 expect(store.getState()).toEqual({ value: 3 })
886
887 await delay(0)
888
889 expect(onError).toBeCalledWith(
890 new Error(
891 'listenerMiddleware: getOriginalState can only be called synchronously'
892 ),
893 { raisedBy: 'effect' }
894 )
895 expect(store.getState()).toEqual({ value: 3 })
896 })
897
898 test('by default, actions are forwarded to the store', () => {
899 reducer.mockClear()
900
901 const effect = jest.fn((_: TestAction1) => {})
902
903 startListening({
904 actionCreator: testAction1,
905 effect,
906 })
907
908 store.dispatch(testAction1('a'))
909
910 expect(reducer.mock.calls).toEqual([[{}, testAction1('a')]])
911 })
912
913 test('listenerApi.delay does not trigger unhandledRejections for completed or cancelled listners', async () => {
914 let deferredCompletedEvt = deferred()
915 let deferredCancelledEvt = deferred()
916 const godotPauseTrigger = deferred()
917
918 // Unfortunately we cannot test declaratively unhandleRejections in jest: https://github.com/facebook/jest/issues/5620
919 // This test just fails if an `unhandledRejection` occurs.
920 startListening({
921 actionCreator: increment,
922 effect: async (_, listenerApi) => {
923 listenerApi.unsubscribe()
924 listenerApi.signal.addEventListener(
925 'abort',
926 deferredCompletedEvt.resolve,
927 { once: true }
928 )
929 listenerApi.delay(100) // missing await
930 },
931 })
932
933 startListening({
934 actionCreator: increment,
935 effect: async (_, listenerApi) => {
936 listenerApi.cancelActiveListeners()
937 listenerApi.signal.addEventListener(
938 'abort',
939 deferredCancelledEvt.resolve,
940 { once: true }
941 )
942 listenerApi.delay(100) // missing await
943 listenerApi.pause(godotPauseTrigger)
944 },
945 })
946
947 store.dispatch(increment())
948 store.dispatch(increment())
949
950 expect(await deferredCompletedEvt).toBeDefined()
951 expect(await deferredCancelledEvt).toBeDefined()
952 })
953 })
954
955 describe('Error handling', () => {
956 test('Continues running other listeners if one of them raises an error', () => {
957 const matcher = (action: any): action is any => true
958
959 startListening({
960 matcher,
961 effect: () => {
962 throw new Error('Panic!')
963 },
964 })
965
966 const effect = jest.fn(() => {})
967 startListening({ matcher, effect })
968
969 store.dispatch(testAction1('a'))
970 expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]])
971 })
972
973 test('Continues running other listeners if a predicate raises an error', () => {
974 const matcher = (action: any): action is any => true
975 const firstListener = jest.fn(() => {})
976 const secondListener = jest.fn(() => {})
977
978 startListening({
979 // @ts-expect-error
980 matcher: (arg: unknown): arg is unknown => {
981 throw new Error('Predicate Panic!')
982 },
983 effect: firstListener,
984 })
985
986 startListening({ matcher, effect: secondListener })
987
988 store.dispatch(testAction1('a'))
989 expect(firstListener).not.toHaveBeenCalled()
990 expect(secondListener.mock.calls).toEqual([
991 [testAction1('a'), middlewareApi],
992 ])
993 })
994
995 test('Notifies sync listener errors to `onError`, if provided', async () => {
996 const onError = jest.fn()
997 const listenerMiddleware = createListenerMiddleware({
998 onError,
999 })
1000 const { middleware, startListening } = listenerMiddleware
1001 reducer = jest.fn(() => ({}))
1002 store = configureStore({
1003 reducer,
1004 middleware: (gDM) => gDM().prepend(middleware),
1005 })
1006
1007 const listenerError = new Error('Boom!')
1008
1009 const matcher = (action: any): action is any => true
1010
1011 startListening({
1012 matcher,
1013 effect: () => {
1014 throw listenerError
1015 },
1016 })
1017
1018 store.dispatch(testAction1('a'))
1019 await delay(100)
1020
1021 expect(onError).toBeCalledWith(listenerError, {
1022 raisedBy: 'effect',
1023 })
1024 })
1025
1026 test('Notifies async listeners errors to `onError`, if provided', async () => {
1027 const onError = jest.fn()
1028 const listenerMiddleware = createListenerMiddleware({
1029 onError,
1030 })
1031 const { middleware, startListening } = listenerMiddleware
1032 reducer = jest.fn(() => ({}))
1033 store = configureStore({
1034 reducer,
1035 middleware: (gDM) => gDM().prepend(middleware),
1036 })
1037
1038 const listenerError = new Error('Boom!')
1039 const matcher = (action: any): action is any => true
1040
1041 startListening({
1042 matcher,
1043 effect: async () => {
1044 throw listenerError
1045 },
1046 })
1047
1048 store.dispatch(testAction1('a'))
1049
1050 await delay(100)
1051
1052 expect(onError).toBeCalledWith(listenerError, {
1053 raisedBy: 'effect',
1054 })
1055 })
1056 })
1057
1058 describe('take and condition methods', () => {
1059 test('take resolves to the tuple [A, CurrentState, PreviousState] when the predicate matches the action', async () => {
1060 const store = configureStore({
1061 reducer: counterSlice.reducer,
1062 middleware: (gDM) => gDM().prepend(middleware),
1063 })
1064
1065 let result = null
1066
1067 startListening({
1068 predicate: incrementByAmount.match,
1069 effect: async (_, listenerApi) => {
1070 result = await listenerApi.take(increment.match)
1071 },
1072 })
1073 store.dispatch(incrementByAmount(1))
1074 store.dispatch(increment())
1075
1076 await delay(10)
1077
1078 expect(result).toEqual([increment(), { value: 2 }, { value: 1 }])
1079 })
1080
1081 test('take resolves to null if the timeout expires', async () => {
1082 const store = configureStore({
1083 reducer: counterSlice.reducer,
1084 middleware: (gDM) => gDM().prepend(middleware),
1085 })
1086
1087 let takeResult: any = undefined
1088
1089 startListening({
1090 predicate: incrementByAmount.match,
1091 effect: async (_, listenerApi) => {
1092 takeResult = await listenerApi.take(increment.match, 15)
1093 },
1094 })
1095 store.dispatch(incrementByAmount(1))
1096 await delay(25)
1097
1098 expect(takeResult).toBe(null)
1099 })
1100
1101 test("take resolves to [A, CurrentState, PreviousState] if the timeout is provided but doesn't expire", async () => {
1102 const store = configureStore({
1103 reducer: counterSlice.reducer,
1104 middleware: (gDM) => gDM().prepend(middleware),
1105 })
1106 let takeResult: any = undefined
1107 let stateBefore: any = undefined
1108 let stateCurrent: any = undefined
1109
1110 startListening({
1111 predicate: incrementByAmount.match,
1112 effect: async (_, listenerApi) => {
1113 stateBefore = listenerApi.getState()
1114 takeResult = await listenerApi.take(increment.match, 50)
1115 stateCurrent = listenerApi.getState()
1116 },
1117 })
1118 store.dispatch(incrementByAmount(1))
1119 store.dispatch(increment())
1120
1121 await delay(25)
1122 expect(takeResult).toEqual([increment(), stateCurrent, stateBefore])
1123 })
1124
1125 test("take resolves to `[A, CurrentState, PreviousState] | null` if a possibly undefined timeout parameter is provided", async () => {
1126 const store = configureStore({
1127 reducer: counterSlice.reducer,
1128 middleware: (gDM) => gDM().prepend(middleware),
1129 })
1130
1131 type ExpectedTakeResultType = readonly [ReturnType<typeof increment>, CounterState, CounterState] | null
1132
1133 let timeout: number | undefined = undefined
1134 let done = false
1135
1136 const startAppListening = startListening as TypedStartListening<CounterState>
1137 startAppListening({
1138 predicate: incrementByAmount.match,
1139 effect: async (_, listenerApi) => {
1140 const stateBefore = listenerApi.getState()
1141
1142 let takeResult = await listenerApi.take(increment.match, timeout)
1143 const stateCurrent = listenerApi.getState()
1144 expect(takeResult).toEqual([increment(), stateCurrent, stateBefore])
1145
1146 timeout = 1
1147 takeResult = await listenerApi.take(increment.match, timeout)
1148 expect(takeResult).toBeNull()
1149
1150 expectType<ExpectedTakeResultType>(takeResult)
1151
1152 done = true
1153 },
1154 })
1155 store.dispatch(incrementByAmount(1))
1156 store.dispatch(increment())
1157
1158 await delay(25)
1159 expect(done).toBe(true);
1160 })
1161
1162 test('condition method resolves promise when the predicate succeeds', async () => {
1163 const store = configureStore({
1164 reducer: counterSlice.reducer,
1165 middleware: (gDM) => gDM().prepend(middleware),
1166 })
1167
1168 let finalCount = 0
1169 let listenerStarted = false
1170
1171 startListening({
1172 predicate: (action, _, previousState) => {
1173 return (
1174 increment.match(action) &&
1175 (previousState as CounterState).value === 0
1176 )
1177 },
1178 effect: async (action, listenerApi) => {
1179 listenerStarted = true
1180 const result = await listenerApi.condition((action, currentState) => {
1181 return (currentState as CounterState).value === 3
1182 })
1183
1184 expect(result).toBe(true)
1185 const latestState = listenerApi.getState() as CounterState
1186 finalCount = latestState.value
1187 },
1188 })
1189
1190 store.dispatch(increment())
1191
1192 expect(listenerStarted).toBe(true)
1193 await delay(25)
1194 store.dispatch(increment())
1195 store.dispatch(increment())
1196
1197 await delay(25)
1198
1199 expect(finalCount).toBe(3)
1200 })
1201
1202 test('condition method resolves promise when there is a timeout', async () => {
1203 const store = configureStore({
1204 reducer: counterSlice.reducer,
1205 middleware: (gDM) => gDM().prepend(middleware),
1206 })
1207
1208 let finalCount = 0
1209 let listenerStarted = false
1210
1211 startListening({
1212 predicate: (action, currentState) => {
1213 return (
1214 increment.match(action) &&
1215 (currentState as CounterState).value === 1
1216 )
1217 },
1218 effect: async (action, listenerApi) => {
1219 listenerStarted = true
1220 const result = await listenerApi.condition((action, currentState) => {
1221 return (currentState as CounterState).value === 3
1222 }, 25)
1223
1224 expect(result).toBe(false)
1225 const latestState = listenerApi.getState() as CounterState
1226 finalCount = latestState.value
1227 },
1228 })
1229
1230 store.dispatch(increment())
1231 expect(listenerStarted).toBe(true)
1232
1233 store.dispatch(increment())
1234
1235 await delay(50)
1236 store.dispatch(increment())
1237
1238 expect(finalCount).toBe(2)
1239 })
1240
1241 test('take does not trigger unhandledRejections for completed or cancelled tasks', async () => {
1242 let deferredCompletedEvt = deferred()
1243 let deferredCancelledEvt = deferred()
1244 const store = configureStore({
1245 reducer: counterSlice.reducer,
1246 middleware: (gDM) => gDM().prepend(middleware),
1247 })
1248 const godotPauseTrigger = deferred()
1249
1250 startListening({
1251 predicate: () => true,
1252 effect: async (_, listenerApi) => {
1253 listenerApi.unsubscribe() // run once
1254 listenerApi.signal.addEventListener(
1255 'abort',
1256 deferredCompletedEvt.resolve
1257 )
1258 listenerApi.take(() => true) // missing await
1259 },
1260 })
1261
1262 startListening({
1263 predicate: () => true,
1264 effect: async (_, listenerApi) => {
1265 listenerApi.cancelActiveListeners()
1266 listenerApi.signal.addEventListener(
1267 'abort',
1268 deferredCancelledEvt.resolve
1269 )
1270 listenerApi.take(() => true) // missing await
1271 await listenerApi.pause(godotPauseTrigger)
1272 },
1273 })
1274
1275 store.dispatch({ type: 'type' })
1276 store.dispatch({ type: 'type' })
1277 expect(await deferredCompletedEvt).toBeDefined()
1278 })
1279 })
1280
1281 describe('Job API', () => {
1282 test('Allows canceling previous jobs', async () => {
1283 let jobsStarted = 0
1284 let jobsContinued = 0
1285 let jobsCanceled = 0
1286
1287 startListening({
1288 actionCreator: increment,
1289 effect: async (action, listenerApi) => {
1290 jobsStarted++
1291
1292 if (jobsStarted < 3) {
1293 try {
1294 await listenerApi.condition(decrement.match)
1295 // Cancelation _should_ cause `condition()` to throw so we never
1296 // end up hitting this next line
1297 jobsContinued++
1298 } catch (err) {
1299 if (err instanceof TaskAbortError) {
1300 jobsCanceled++
1301 }
1302 }
1303 } else {
1304 listenerApi.cancelActiveListeners()
1305 }
1306 },
1307 })
1308
1309 store.dispatch(increment())
1310 store.dispatch(increment())
1311 store.dispatch(increment())
1312
1313 await delay(10)
1314 expect(jobsStarted).toBe(3)
1315 expect(jobsContinued).toBe(0)
1316 expect(jobsCanceled).toBe(2)
1317 })
1318 })
1319
1320 describe('Type tests', () => {
1321 const listenerMiddleware = createListenerMiddleware()
1322 const { middleware, startListening } = listenerMiddleware
1323 const store = configureStore({
1324 reducer: counterSlice.reducer,
1325 middleware: (gDM) => gDM().prepend(middleware),
1326 })
1327
1328 test('State args default to unknown', () => {
1329 createListenerEntry({
1330 predicate: (
1331 action,
1332 currentState,
1333 previousState
1334 ): action is AnyAction => {
1335 expectUnknown(currentState)
1336 expectUnknown(previousState)
1337 return true
1338 },
1339 effect: (action, listenerApi) => {
1340 const listenerState = listenerApi.getState()
1341 expectUnknown(listenerState)
1342 listenerApi.dispatch((dispatch, getState) => {
1343 const thunkState = getState()
1344 expectUnknown(thunkState)
1345 })
1346 },
1347 })
1348
1349 startListening({
1350 predicate: (
1351 action,
1352 currentState,
1353 previousState
1354 ): action is AnyAction => {
1355 expectUnknown(currentState)
1356 expectUnknown(previousState)
1357 return true
1358 },
1359 effect: (action, listenerApi) => {},
1360 })
1361
1362 startListening({
1363 matcher: increment.match,
1364 effect: (action, listenerApi) => {
1365 const listenerState = listenerApi.getState()
1366 expectUnknown(listenerState)
1367 listenerApi.dispatch((dispatch, getState) => {
1368 const thunkState = getState()
1369 expectUnknown(thunkState)
1370 })
1371 },
1372 })
1373
1374 store.dispatch(
1375 addListener({
1376 predicate: (
1377 action,
1378 currentState,
1379 previousState
1380 ): action is AnyAction => {
1381 expectUnknown(currentState)
1382 expectUnknown(previousState)
1383 return true
1384 },
1385 effect: (action, listenerApi) => {
1386 const listenerState = listenerApi.getState()
1387 expectUnknown(listenerState)
1388 listenerApi.dispatch((dispatch, getState) => {
1389 const thunkState = getState()
1390 expectUnknown(thunkState)
1391 })
1392 },
1393 })
1394 )
1395
1396 store.dispatch(
1397 addListener({
1398 matcher: increment.match,
1399 effect: (action, listenerApi) => {
1400 const listenerState = listenerApi.getState()
1401 expectUnknown(listenerState)
1402 // TODO Can't get the thunk dispatch types to carry through
1403 listenerApi.dispatch((dispatch, getState) => {
1404 const thunkState = getState()
1405 expectUnknown(thunkState)
1406 })
1407 },
1408 })
1409 )
1410 })
1411
1412 test('Action type is inferred from args', () => {
1413 startListening({
1414 type: 'abcd',
1415 effect: (action, listenerApi) => {
1416 expectType<{ type: 'abcd' }>(action)
1417 },
1418 })
1419
1420 startListening({
1421 actionCreator: incrementByAmount,
1422 effect: (action, listenerApi) => {
1423 expectType<PayloadAction<number>>(action)
1424 },
1425 })
1426
1427 startListening({
1428 matcher: incrementByAmount.match,
1429 effect: (action, listenerApi) => {
1430 expectType<PayloadAction<number>>(action)
1431 },
1432 })
1433
1434 startListening({
1435 predicate: (
1436 action,
1437 currentState,
1438 previousState
1439 ): action is PayloadAction<number> => {
1440 return typeof action.payload === 'boolean'
1441 },
1442 effect: (action, listenerApi) => {
1443 // @ts-expect-error
1444 expectExactType<PayloadAction<number>>(action)
1445 },
1446 })
1447
1448 startListening({
1449 predicate: (action, currentState) => {
1450 return typeof action.payload === 'number'
1451 },
1452 effect: (action, listenerApi) => {
1453 expectExactType<AnyAction>(action)
1454 },
1455 })
1456
1457 store.dispatch(
1458 addListener({
1459 type: 'abcd',
1460 effect: (action, listenerApi) => {
1461 expectType<{ type: 'abcd' }>(action)
1462 },
1463 })
1464 )
1465
1466 store.dispatch(
1467 addListener({
1468 actionCreator: incrementByAmount,
1469 effect: (action, listenerApi) => {
1470 expectType<PayloadAction<number>>(action)
1471 },
1472 })
1473 )
1474
1475 store.dispatch(
1476 addListener({
1477 matcher: incrementByAmount.match,
1478 effect: (action, listenerApi) => {
1479 expectType<PayloadAction<number>>(action)
1480 },
1481 })
1482 )
1483 })
1484
1485 test('Can create a pre-typed middleware', () => {
1486 const typedMiddleware = createListenerMiddleware<CounterState>()
1487
1488 typedMiddleware.startListening({
1489 predicate: (
1490 action,
1491 currentState,
1492 previousState
1493 ): action is AnyAction => {
1494 expectNotAny(currentState)
1495 expectNotAny(previousState)
1496 expectExactType<CounterState>(currentState)
1497 expectExactType<CounterState>(previousState)
1498 return true
1499 },
1500 effect: (action, listenerApi) => {
1501 const listenerState = listenerApi.getState()
1502 expectExactType<CounterState>(listenerState)
1503 listenerApi.dispatch((dispatch, getState) => {
1504 const thunkState = listenerApi.getState()
1505 expectExactType<CounterState>(thunkState)
1506 })
1507 },
1508 })
1509
1510 // Can pass a predicate function with fewer args
1511 typedMiddleware.startListening({
1512 // TODO Why won't this infer the listener's `action` with implicit argument types?
1513 predicate: (
1514 action: AnyAction,
1515 currentState: CounterState
1516 ): action is PayloadAction<number> => {
1517 expectNotAny(currentState)
1518 expectExactType<CounterState>(currentState)
1519 return true
1520 },
1521 effect: (action, listenerApi) => {
1522 expectType<PayloadAction<number>>(action)
1523
1524 const listenerState = listenerApi.getState()
1525 expectExactType<CounterState>(listenerState)
1526 listenerApi.dispatch((dispatch, getState) => {
1527 const thunkState = listenerApi.getState()
1528 expectExactType<CounterState>(thunkState)
1529 })
1530 },
1531 })
1532
1533 typedMiddleware.startListening({
1534 actionCreator: incrementByAmount,
1535 effect: (action, listenerApi) => {
1536 const listenerState = listenerApi.getState()
1537 expectExactType<CounterState>(listenerState)
1538 listenerApi.dispatch((dispatch, getState) => {
1539 const thunkState = listenerApi.getState()
1540 expectExactType<CounterState>(thunkState)
1541 })
1542 },
1543 })
1544
1545 store.dispatch(
1546 addTypedListenerAction({
1547 predicate: (
1548 action,
1549 currentState,
1550 previousState
1551 ): action is ReturnType<typeof incrementByAmount> => {
1552 expectNotAny(currentState)
1553 expectNotAny(previousState)
1554 expectExactType<CounterState>(currentState)
1555 expectExactType<CounterState>(previousState)
1556 return true
1557 },
1558 effect: (action, listenerApi) => {
1559 const listenerState = listenerApi.getState()
1560 expectExactType<CounterState>(listenerState)
1561 listenerApi.dispatch((dispatch, getState) => {
1562 const thunkState = listenerApi.getState()
1563 expectExactType<CounterState>(thunkState)
1564 })
1565 },
1566 })
1567 )
1568
1569 store.dispatch(
1570 addTypedListenerAction({
1571 predicate: (
1572 action,
1573 currentState,
1574 previousState
1575 ): action is AnyAction => {
1576 expectNotAny(currentState)
1577 expectNotAny(previousState)
1578 expectExactType<CounterState>(currentState)
1579 expectExactType<CounterState>(previousState)
1580 return true
1581 },
1582 effect: (action, listenerApi) => {
1583 const listenerState = listenerApi.getState()
1584 expectExactType<CounterState>(listenerState)
1585 listenerApi.dispatch((dispatch, getState) => {
1586 const thunkState = listenerApi.getState()
1587 expectExactType<CounterState>(thunkState)
1588 })
1589 },
1590 })
1591 )
1592 })
1593
1594 test('Can create pre-typed versions of startListening and addListener', () => {
1595 const typedAddListener =
1596 startListening as TypedStartListening<CounterState>
1597 const typedAddListenerAction =
1598 addListener as TypedAddListener<CounterState>
1599
1600 typedAddListener({
1601 predicate: (
1602 action,
1603 currentState,
1604 previousState
1605 ): action is AnyAction => {
1606 expectNotAny(currentState)
1607 expectNotAny(previousState)
1608 expectExactType<CounterState>(currentState)
1609 expectExactType<CounterState>(previousState)
1610 return true
1611 },
1612 effect: (action, listenerApi) => {
1613 const listenerState = listenerApi.getState()
1614 expectExactType<CounterState>(listenerState)
1615 // TODO Can't get the thunk dispatch types to carry through
1616 listenerApi.dispatch((dispatch, getState) => {
1617 const thunkState = listenerApi.getState()
1618 expectExactType<CounterState>(thunkState)
1619 })
1620 },
1621 })
1622
1623 typedAddListener({
1624 matcher: incrementByAmount.match,
1625 effect: (action, listenerApi) => {
1626 const listenerState = listenerApi.getState()
1627 expectExactType<CounterState>(listenerState)
1628 // TODO Can't get the thunk dispatch types to carry through
1629 listenerApi.dispatch((dispatch, getState) => {
1630 const thunkState = listenerApi.getState()
1631 expectExactType<CounterState>(thunkState)
1632 })
1633 },
1634 })
1635
1636 store.dispatch(
1637 typedAddListenerAction({
1638 predicate: (
1639 action,
1640 currentState,
1641 previousState
1642 ): action is AnyAction => {
1643 expectNotAny(currentState)
1644 expectNotAny(previousState)
1645 expectExactType<CounterState>(currentState)
1646 expectExactType<CounterState>(previousState)
1647 return true
1648 },
1649 effect: (action, listenerApi) => {
1650 const listenerState = listenerApi.getState()
1651 expectExactType<CounterState>(listenerState)
1652 listenerApi.dispatch((dispatch, getState) => {
1653 const thunkState = listenerApi.getState()
1654 expectExactType<CounterState>(thunkState)
1655 })
1656 },
1657 })
1658 )
1659
1660 store.dispatch(
1661 typedAddListenerAction({
1662 matcher: incrementByAmount.match,
1663 effect: (action, listenerApi) => {
1664 const listenerState = listenerApi.getState()
1665 expectExactType<CounterState>(listenerState)
1666 listenerApi.dispatch((dispatch, getState) => {
1667 const thunkState = listenerApi.getState()
1668 expectExactType<CounterState>(thunkState)
1669 })
1670 },
1671 })
1672 )
1673 })
1674 })
1675})