1 | import {
|
2 | configureStore,
|
3 | createAction,
|
4 | createSlice,
|
5 | Dispatch,
|
6 | isAnyOf,
|
7 | } from '@reduxjs/toolkit'
|
8 |
|
9 | import type { AnyAction, PayloadAction, Action } from '@reduxjs/toolkit'
|
10 |
|
11 | import {
|
12 | createListenerMiddleware,
|
13 | createListenerEntry,
|
14 | addListener,
|
15 | removeListener,
|
16 | TaskAbortError,
|
17 | clearAllListeners,
|
18 | } from '../index'
|
19 |
|
20 | import type {
|
21 | ListenerEffect,
|
22 | ListenerEffectAPI,
|
23 | TypedAddListener,
|
24 | TypedStartListening,
|
25 | UnsubscribeListener,
|
26 | ListenerMiddleware,
|
27 | } from '../index'
|
28 | import type {
|
29 | AbortSignalWithReason,
|
30 | AddListenerOverloads,
|
31 | TypedRemoveListener,
|
32 | } from '../types'
|
33 | import { listenerCancelled, listenerCompleted } from '../exceptions'
|
34 |
|
35 | const 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 |
|
51 | const noop = () => {}
|
52 |
|
53 |
|
54 | export interface Deferred<T> extends Promise<T> {
|
55 | resolve(value?: T | PromiseLike<T>): void
|
56 |
|
57 | reject(reason?: any): void
|
58 | }
|
59 |
|
60 |
|
61 |
|
62 |
|
63 |
|
64 |
|
65 |
|
66 |
|
67 | export 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 |
|
75 | export declare type IsAny<T, True, False = never> = true | false extends (
|
76 | T extends never ? true : false
|
77 | )
|
78 | ? True
|
79 | : False
|
80 |
|
81 | export declare type IsUnknown<T, True, False = never> = unknown extends T
|
82 | ? IsAny<T, False, True>
|
83 | : False
|
84 |
|
85 | export function expectType<T>(t: T): T {
|
86 | return t
|
87 | }
|
88 |
|
89 | type Equals<T, U> = IsAny<
|
90 | T,
|
91 | never,
|
92 | IsAny<U, never, [T] extends [U] ? ([U] extends [T] ? any : never) : never>
|
93 | >
|
94 | export function expectExactType<T>(t: T) {
|
95 | return <U extends Equals<T, U>>(u: U) => {}
|
96 | }
|
97 |
|
98 | type EnsureUnknown<T extends any> = IsUnknown<T, any, never>
|
99 | export function expectUnknown<T extends EnsureUnknown<T>>(t: T) {
|
100 | return t
|
101 | }
|
102 |
|
103 | type EnsureAny<T extends any> = IsAny<T, any, never>
|
104 | export function expectExactAny<T extends EnsureAny<T>>(t: T) {
|
105 | return t
|
106 | }
|
107 |
|
108 | type IsNotAny<T> = IsAny<T, never, any>
|
109 | export function expectNotAny<T extends IsNotAny<T>>(t: T): T {
|
110 | return t
|
111 | }
|
112 |
|
113 | describe('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 |
|
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()
|
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 |
|
826 | expect(currentState).toBe(stateBefore)
|
827 | },
|
828 | })
|
829 |
|
830 | let listener2Calls = 0
|
831 | startListening({
|
832 | actionCreator: increment,
|
833 | effect: (action, listenerApi) => {
|
834 |
|
835 | const stateBefore = listenerApi.getOriginalState() as CounterState
|
836 | const currentState = listenerApi.getOriginalState() as CounterState
|
837 |
|
838 | listener2Calls++
|
839 |
|
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())
|
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 |
|
919 |
|
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)
|
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)
|
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 |
|
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()
|
1254 | listenerApi.signal.addEventListener(
|
1255 | 'abort',
|
1256 | deferredCompletedEvt.resolve
|
1257 | )
|
1258 | listenerApi.take(() => true)
|
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)
|
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 |
|
1296 |
|
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 |
|
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 |
|
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 |
|
1511 | typedMiddleware.startListening({
|
1512 |
|
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 |
|
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 |
|
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 | })
|