UNPKG

@reduxjs/toolkit

Version:

The official, opinionated, batteries-included toolset for efficient Redux development

1,668 lines (1,411 loc) 50.3 kB
import { configureStore, createAction, createSlice, isAnyOf, } from '@reduxjs/toolkit' import type { AnyAction, PayloadAction, Action } from '@reduxjs/toolkit' import { createListenerMiddleware, createListenerEntry, addListener, removeListener, TaskAbortError, clearAllListeners, } from '../index' import type { ListenerEffect, ListenerEffectAPI, TypedAddListener, TypedStartListening, UnsubscribeListener, ListenerMiddleware, } from '../index' import type { AbortSignalWithReason, AddListenerOverloads, TypedRemoveListener, } from '../types' import { listenerCancelled, listenerCompleted } from '../exceptions' const middlewareApi = { getState: expect.any(Function), getOriginalState: expect.any(Function), condition: expect.any(Function), extra: undefined, take: expect.any(Function), signal: expect.any(Object), fork: expect.any(Function), delay: expect.any(Function), pause: expect.any(Function), dispatch: expect.any(Function), unsubscribe: expect.any(Function), subscribe: expect.any(Function), cancelActiveListeners: expect.any(Function), } const noop = () => {} // Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. export interface Deferred<T> extends Promise<T> { resolve(value?: T | PromiseLike<T>): void // deno-lint-ignore no-explicit-any reject(reason?: any): void } /** Creates a Promise with the `reject` and `resolve` functions * placed as methods on the promise object itself. It allows you to do: * * const p = deferred<number>(); * // ... * p.resolve(42); */ export function deferred<T>(): Deferred<T> { let methods const promise = new Promise<T>((resolve, reject): void => { methods = { resolve, reject } }) return Object.assign(promise, methods) as Deferred<T> } export declare type IsAny<T, True, False = never> = true | false extends ( T extends never ? true : false ) ? True : False export declare type IsUnknown<T, True, False = never> = unknown extends T ? IsAny<T, False, True> : False export function expectType<T>(t: T): T { return t } type Equals<T, U> = IsAny< T, never, IsAny<U, never, [T] extends [U] ? ([U] extends [T] ? any : never) : never> > export function expectExactType<T>(t: T) { return <U extends Equals<T, U>>(u: U) => {} } type EnsureUnknown<T extends any> = IsUnknown<T, any, never> export function expectUnknown<T extends EnsureUnknown<T>>(t: T) { return t } type EnsureAny<T extends any> = IsAny<T, any, never> export function expectExactAny<T extends EnsureAny<T>>(t: T) { return t } type IsNotAny<T> = IsAny<T, never, any> export function expectNotAny<T extends IsNotAny<T>>(t: T): T { return t } describe('createListenerMiddleware', () => { let store = configureStore({ reducer: () => 42, middleware: (gDM) => gDM().prepend(createListenerMiddleware().middleware), }) interface CounterState { value: number } const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 } as CounterState, reducers: { increment(state) { state.value += 1 }, decrement(state) { state.value -= 1 }, // Use the PayloadAction type to declare the contents of `action.payload` incrementByAmount: (state, action: PayloadAction<number>) => { state.value += action.payload }, }, }) const { increment, decrement, incrementByAmount } = counterSlice.actions function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } let reducer: jest.Mock let listenerMiddleware = createListenerMiddleware() let { middleware, startListening, stopListening, clearListeners } = listenerMiddleware let addTypedListenerAction = addListener as TypedAddListener<CounterState> let removeTypedListenerAction = removeListener as TypedRemoveListener<CounterState> const testAction1 = createAction<string>('testAction1') type TestAction1 = ReturnType<typeof testAction1> const testAction2 = createAction<string>('testAction2') type TestAction2 = ReturnType<typeof testAction2> const testAction3 = createAction<string>('testAction3') type TestAction3 = ReturnType<typeof testAction3> beforeAll(() => { jest.spyOn(console, 'error').mockImplementation(noop) }) beforeEach(() => { listenerMiddleware = createListenerMiddleware() middleware = listenerMiddleware.middleware startListening = listenerMiddleware.startListening stopListening = listenerMiddleware.stopListening clearListeners = listenerMiddleware.clearListeners reducer = jest.fn(() => ({})) store = configureStore({ reducer, middleware: (gDM) => gDM().prepend(middleware), }) }) describe('Middleware setup', () => { test('Allows passing an extra argument on middleware creation', () => { const originalExtra = 42 const listenerMiddleware = createListenerMiddleware({ extra: originalExtra, }) const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(listenerMiddleware.middleware), }) let foundExtra: number | null = null const typedAddListener = listenerMiddleware.startListening as TypedStartListening< CounterState, typeof store.dispatch, typeof originalExtra > typedAddListener({ matcher: (action: AnyAction): action is AnyAction => true, effect: (action, listenerApi) => { foundExtra = listenerApi.extra expectType<typeof originalExtra>(listenerApi.extra) }, }) store.dispatch(testAction1('a')) expect(foundExtra).toBe(originalExtra) }) test('Passes through if there are no listeners', () => { const originalAction = testAction1('a') const resultAction = store.dispatch(originalAction) expect(resultAction).toBe(originalAction) }) }) describe('Subscription and unsubscription', () => { test('directly subscribing', () => { const effect = jest.fn((_: TestAction1) => {}) startListening({ actionCreator: testAction1, effect: effect, }) store.dispatch(testAction1('a')) store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) expect(effect.mock.calls).toEqual([ [testAction1('a'), middlewareApi], [testAction1('c'), middlewareApi], ]) }) test('stopListening returns true if an entry has been unsubscribed, false otherwise', () => { const effect = jest.fn((_: TestAction1) => {}) startListening({ actionCreator: testAction1, effect, }) expect(stopListening({ actionCreator: testAction2, effect })).toBe(false) expect(stopListening({ actionCreator: testAction1, effect })).toBe(true) }) test('dispatch(removeListener({...})) returns true if an entry has been unsubscribed, false otherwise', () => { const effect = jest.fn((_: TestAction1) => {}) startListening({ actionCreator: testAction1, effect, }) expect( store.dispatch( removeTypedListenerAction({ actionCreator: testAction2, effect, }) ) ).toBe(false) expect( store.dispatch( removeTypedListenerAction({ actionCreator: testAction1, effect, }) ) ).toBe(true) }) test('can subscribe with a string action type', () => { const effect = jest.fn((_: AnyAction) => {}) store.dispatch( addListener({ type: testAction2.type, effect, }) ) store.dispatch(testAction2('b')) expect(effect.mock.calls).toEqual([[testAction2('b'), middlewareApi]]) store.dispatch(removeListener({ type: testAction2.type, effect })) store.dispatch(testAction2('b')) expect(effect.mock.calls).toEqual([[testAction2('b'), middlewareApi]]) }) test('can subscribe with a matcher function', () => { const effect = jest.fn((_: AnyAction) => {}) const isAction1Or2 = isAnyOf(testAction1, testAction2) const unsubscribe = startListening({ matcher: isAction1Or2, effect: effect, }) store.dispatch(testAction1('a')) store.dispatch(testAction2('b')) store.dispatch(testAction3('c')) expect(effect.mock.calls).toEqual([ [testAction1('a'), middlewareApi], [testAction2('b'), middlewareApi], ]) unsubscribe() store.dispatch(testAction2('b')) expect(effect.mock.calls).toEqual([ [testAction1('a'), middlewareApi], [testAction2('b'), middlewareApi], ]) }) test('Can subscribe with an action predicate function', () => { const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) let listener1Calls = 0 startListening({ predicate: (action, state) => { return (state as CounterState).value > 1 }, effect: () => { listener1Calls++ }, }) let listener2Calls = 0 startListening({ predicate: (action, state, prevState) => { return ( (state as CounterState).value > 1 && (prevState as CounterState).value % 2 === 0 ) }, effect: () => { listener2Calls++ }, }) store.dispatch(increment()) store.dispatch(increment()) store.dispatch(increment()) store.dispatch(increment()) expect(listener1Calls).toBe(3) expect(listener2Calls).toBe(1) }) test('subscribing with the same listener will not make it trigger twice (like EventTarget.addEventListener())', () => { const effect = jest.fn((_: TestAction1) => {}) startListening({ actionCreator: testAction1, effect, }) startListening({ actionCreator: testAction1, effect, }) store.dispatch(testAction1('a')) store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) expect(effect.mock.calls).toEqual([ [testAction1('a'), middlewareApi], [testAction1('c'), middlewareApi], ]) }) test('unsubscribing via callback', () => { const effect = jest.fn((_: TestAction1) => {}) const unsubscribe = startListening({ actionCreator: testAction1, effect, }) store.dispatch(testAction1('a')) unsubscribe() store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) }) test('directly unsubscribing', () => { const effect = jest.fn((_: TestAction1) => {}) startListening({ actionCreator: testAction1, effect, }) store.dispatch(testAction1('a')) stopListening({ actionCreator: testAction1, effect }) store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) }) test('unsubscribing without any subscriptions does not trigger an error', () => { stopListening({ matcher: testAction1.match, effect: noop }) }) test('subscribing via action', () => { const effect = jest.fn((_: TestAction1) => {}) store.dispatch( addListener({ actionCreator: testAction1, effect, }) ) store.dispatch(testAction1('a')) store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) expect(effect.mock.calls).toEqual([ [testAction1('a'), middlewareApi], [testAction1('c'), middlewareApi], ]) }) test('unsubscribing via callback from dispatch', () => { const effect = jest.fn((_: TestAction1) => {}) const unsubscribe = store.dispatch( addListener({ actionCreator: testAction1, effect, }) ) expectType<UnsubscribeListener>(unsubscribe) store.dispatch(testAction1('a')) unsubscribe() store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) }) test('unsubscribing via action', () => { const effect = jest.fn((_: TestAction1) => {}) startListening({ actionCreator: testAction1, effect, }) startListening({ actionCreator: testAction1, effect, }) store.dispatch(testAction1('a')) store.dispatch(removeListener({ actionCreator: testAction1, effect })) store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) }) test('can cancel an active listener when unsubscribing directly', async () => { let wasCancelled = false const unsubscribe = startListening({ actionCreator: testAction1, effect: async (action, listenerApi) => { try { await listenerApi.condition(testAction2.match) } catch (err) { if (err instanceof TaskAbortError) { wasCancelled = true } } }, }) store.dispatch(testAction1('a')) unsubscribe({ cancelActive: true }) expect(wasCancelled).toBe(false) await delay(10) expect(wasCancelled).toBe(true) }) test('can cancel an active listener when unsubscribing via stopListening', async () => { let wasCancelled = false const effect = async (action: any, listenerApi: any) => { try { await listenerApi.condition(testAction2.match) } catch (err) { if (err instanceof TaskAbortError) { wasCancelled = true } } } startListening({ actionCreator: testAction1, effect, }) store.dispatch(testAction1('a')) stopListening({ actionCreator: testAction1, effect, cancelActive: true }) expect(wasCancelled).toBe(false) await delay(10) expect(wasCancelled).toBe(true) }) test('can cancel an active listener when unsubscribing via removeListener', async () => { let wasCancelled = false const effect = async (action: any, listenerApi: any) => { try { await listenerApi.condition(testAction2.match) } catch (err) { if (err instanceof TaskAbortError) { wasCancelled = true } } } startListening({ actionCreator: testAction1, effect, }) store.dispatch(testAction1('a')) store.dispatch( removeListener({ actionCreator: testAction1, effect, cancelActive: true, }) ) expect(wasCancelled).toBe(false) await delay(10) expect(wasCancelled).toBe(true) }) const addListenerOptions: [ string, Omit< AddListenerOverloads< () => void, typeof store.getState, typeof store.dispatch >, 'effect' > ][] = [ ['predicate', { predicate: () => true }], ['actionCreator', { actionCreator: testAction1 }], ['matcher', { matcher: isAnyOf(testAction1, testAction2) }], ['type', { type: testAction1.type }], ] test.each(addListenerOptions)( 'add and remove listener with "%s" param correctly', (_, params) => { const effect: ListenerEffect< AnyAction, typeof store.getState, typeof store.dispatch > = jest.fn() startListening({ ...params, effect } as any) store.dispatch(testAction1('a')) expect(effect).toBeCalledTimes(1) stopListening({ ...params, effect } as any) store.dispatch(testAction1('b')) expect(effect).toBeCalledTimes(1) } ) const unforwardedActions: [string, AnyAction][] = [ [ 'addListener', addListener({ actionCreator: testAction1, effect: noop }), ], [ 'removeListener', removeListener({ actionCreator: testAction1, effect: noop }), ], ] test.each(unforwardedActions)( '"%s" is not forwarded to the reducer', (_, action) => { reducer.mockClear() store.dispatch(testAction1('a')) store.dispatch(action) store.dispatch(testAction2('b')) expect(reducer.mock.calls).toEqual([ [{}, testAction1('a')], [{}, testAction2('b')], ]) } ) test('listenerApi.signal has correct reason when listener is cancelled or completes', async () => { const notifyDeferred = createAction<Deferred<string>>('notify-deferred') startListening({ actionCreator: notifyDeferred, async effect({ payload }, { signal, cancelActiveListeners, delay }) { signal.addEventListener( 'abort', () => { payload.resolve((signal as AbortSignalWithReason<string>).reason) }, { once: true } ) cancelActiveListeners() delay(10) }, }) const deferredCancelledSignalReason = store.dispatch( notifyDeferred(deferred<string>()) ).payload const deferredCompletedSignalReason = store.dispatch( notifyDeferred(deferred<string>()) ).payload expect(await deferredCancelledSignalReason).toBe(listenerCancelled) expect(await deferredCompletedSignalReason).toBe(listenerCompleted) }) test('"can unsubscribe via middleware api', () => { const effect = jest.fn( (action: TestAction1, api: ListenerEffectAPI<any, any>) => { if (action.payload === 'b') { api.unsubscribe() } } ) startListening({ actionCreator: testAction1, effect, }) store.dispatch(testAction1('a')) store.dispatch(testAction1('b')) store.dispatch(testAction1('c')) expect(effect.mock.calls).toEqual([ [testAction1('a'), middlewareApi], [testAction1('b'), middlewareApi], ]) }) test('Can re-subscribe via middleware api', async () => { let numListenerRuns = 0 startListening({ actionCreator: testAction1, effect: async (action, listenerApi) => { numListenerRuns++ listenerApi.unsubscribe() await listenerApi.condition(testAction2.match) listenerApi.subscribe() }, }) store.dispatch(testAction1('a')) expect(numListenerRuns).toBe(1) store.dispatch(testAction1('a')) expect(numListenerRuns).toBe(1) store.dispatch(testAction2('b')) expect(numListenerRuns).toBe(1) await delay(5) store.dispatch(testAction1('b')) expect(numListenerRuns).toBe(2) }) }) describe('clear listeners', () => { test('dispatch(clearListenerAction()) cancels running listeners and removes all subscriptions', async () => { const listener1Test = deferred() let listener1Calls = 0 let listener2Calls = 0 let listener3Calls = 0 startListening({ actionCreator: testAction1, async effect(_, listenerApi) { listener1Calls++ listenerApi.signal.addEventListener( 'abort', () => listener1Test.resolve(listener1Calls), { once: true } ) await listenerApi.condition(() => true) listener1Test.reject(new Error('unreachable: listener1Test')) }, }) startListening({ actionCreator: clearAllListeners, effect() { listener2Calls++ }, }) startListening({ predicate: () => true, effect() { listener3Calls++ }, }) store.dispatch(testAction1('a')) store.dispatch(clearAllListeners()) store.dispatch(testAction1('b')) expect(await listener1Test).toBe(1) expect(listener1Calls).toBe(1) expect(listener3Calls).toBe(1) expect(listener2Calls).toBe(0) }) test('clear() cancels running listeners and removes all subscriptions', async () => { const listener1Test = deferred() let listener1Calls = 0 let listener2Calls = 0 startListening({ actionCreator: testAction1, async effect(_, listenerApi) { listener1Calls++ listenerApi.signal.addEventListener( 'abort', () => listener1Test.resolve(listener1Calls), { once: true } ) await listenerApi.condition(() => true) listener1Test.reject(new Error('unreachable: listener1Test')) }, }) startListening({ actionCreator: testAction2, effect() { listener2Calls++ }, }) store.dispatch(testAction1('a')) clearListeners() store.dispatch(testAction1('b')) store.dispatch(testAction2('c')) expect(listener2Calls).toBe(0) expect(await listener1Test).toBe(1) }) test('clear() cancels all running forked tasks', async () => { const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) startListening({ actionCreator: testAction1, async effect(_, { fork, dispatch }) { await fork(() => dispatch(incrementByAmount(3))).result dispatch(incrementByAmount(4)) }, }) expect(store.getState().value).toBe(0) store.dispatch(testAction1('a')) clearListeners() await Promise.resolve() // Forked tasks run on the next microtask. expect(store.getState().value).toBe(0) }) }) describe('Listener API', () => { test('Passes both getState and getOriginalState in the API', () => { const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) let listener1Calls = 0 startListening({ actionCreator: increment, effect: (action, listenerApi) => { const stateBefore = listenerApi.getOriginalState() as CounterState const currentState = listenerApi.getOriginalState() as CounterState listener1Calls++ // In the "before" phase, we pass the same state expect(currentState).toBe(stateBefore) }, }) let listener2Calls = 0 startListening({ actionCreator: increment, effect: (action, listenerApi) => { // TODO getState functions aren't typed right here const stateBefore = listenerApi.getOriginalState() as CounterState const currentState = listenerApi.getOriginalState() as CounterState listener2Calls++ // In the "after" phase, we pass the new state for `getState`, and still have original state too expect(currentState.value).toBe(stateBefore.value + 1) }, }) store.dispatch(increment()) expect(listener1Calls).toBe(1) expect(listener2Calls).toBe(1) }) test('getOriginalState can only be invoked synchronously', async () => { const onError = jest.fn() const listenerMiddleware = createListenerMiddleware<CounterState>({ onError, }) const { middleware, startListening } = listenerMiddleware const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) startListening({ actionCreator: increment, async effect(_, listenerApi) { const runIncrementBy = () => { listenerApi.dispatch( counterSlice.actions.incrementByAmount( listenerApi.getOriginalState().value + 2 ) ) } runIncrementBy() await Promise.resolve() runIncrementBy() }, }) expect(store.getState()).toEqual({ value: 0 }) store.dispatch(increment()) // state.value+=1 && trigger listener expect(onError).not.toHaveBeenCalled() expect(store.getState()).toEqual({ value: 3 }) await delay(0) expect(onError).toBeCalledWith( new Error( 'listenerMiddleware: getOriginalState can only be called synchronously' ), { raisedBy: 'effect' } ) expect(store.getState()).toEqual({ value: 3 }) }) test('by default, actions are forwarded to the store', () => { reducer.mockClear() const effect = jest.fn((_: TestAction1) => {}) startListening({ actionCreator: testAction1, effect, }) store.dispatch(testAction1('a')) expect(reducer.mock.calls).toEqual([[{}, testAction1('a')]]) }) test('listenerApi.delay does not trigger unhandledRejections for completed or cancelled listners', async () => { let deferredCompletedEvt = deferred() let deferredCancelledEvt = deferred() const godotPauseTrigger = deferred() // Unfortunately we cannot test declaratively unhandleRejections in jest: https://github.com/facebook/jest/issues/5620 // This test just fails if an `unhandledRejection` occurs. startListening({ actionCreator: increment, effect: async (_, listenerApi) => { listenerApi.unsubscribe() listenerApi.signal.addEventListener( 'abort', deferredCompletedEvt.resolve, { once: true } ) listenerApi.delay(100) // missing await }, }) startListening({ actionCreator: increment, effect: async (_, listenerApi) => { listenerApi.cancelActiveListeners() listenerApi.signal.addEventListener( 'abort', deferredCancelledEvt.resolve, { once: true } ) listenerApi.delay(100) // missing await listenerApi.pause(godotPauseTrigger) }, }) store.dispatch(increment()) store.dispatch(increment()) expect(await deferredCompletedEvt).toBeDefined() expect(await deferredCancelledEvt).toBeDefined() }) }) describe('Error handling', () => { test('Continues running other listeners if one of them raises an error', () => { const matcher = (action: any): action is any => true startListening({ matcher, effect: () => { throw new Error('Panic!') }, }) const effect = jest.fn(() => {}) startListening({ matcher, effect }) store.dispatch(testAction1('a')) expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) }) test('Continues running other listeners if a predicate raises an error', () => { const matcher = (action: any): action is any => true const firstListener = jest.fn(() => {}) const secondListener = jest.fn(() => {}) startListening({ // @ts-expect-error matcher: (arg: unknown): arg is unknown => { throw new Error('Predicate Panic!') }, effect: firstListener, }) startListening({ matcher, effect: secondListener }) store.dispatch(testAction1('a')) expect(firstListener).not.toHaveBeenCalled() expect(secondListener.mock.calls).toEqual([ [testAction1('a'), middlewareApi], ]) }) test('Notifies sync listener errors to `onError`, if provided', async () => { const onError = jest.fn() const listenerMiddleware = createListenerMiddleware({ onError, }) const { middleware, startListening } = listenerMiddleware reducer = jest.fn(() => ({})) store = configureStore({ reducer, middleware: (gDM) => gDM().prepend(middleware), }) const listenerError = new Error('Boom!') const matcher = (action: any): action is any => true startListening({ matcher, effect: () => { throw listenerError }, }) store.dispatch(testAction1('a')) await delay(100) expect(onError).toBeCalledWith(listenerError, { raisedBy: 'effect', }) }) test('Notifies async listeners errors to `onError`, if provided', async () => { const onError = jest.fn() const listenerMiddleware = createListenerMiddleware({ onError, }) const { middleware, startListening } = listenerMiddleware reducer = jest.fn(() => ({})) store = configureStore({ reducer, middleware: (gDM) => gDM().prepend(middleware), }) const listenerError = new Error('Boom!') const matcher = (action: any): action is any => true startListening({ matcher, effect: async () => { throw listenerError }, }) store.dispatch(testAction1('a')) await delay(100) expect(onError).toBeCalledWith(listenerError, { raisedBy: 'effect', }) }) }) describe('take and condition methods', () => { test('take resolves to the tuple [A, CurrentState, PreviousState] when the predicate matches the action', async () => { const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) const typedAddListener = startListening as TypedStartListening< CounterState, typeof store.dispatch > let result: [ReturnType<typeof increment>, CounterState, CounterState] | null = null typedAddListener({ predicate: incrementByAmount.match, async effect(_: AnyAction, listenerApi) { result = await listenerApi.take(increment.match) }, }) store.dispatch(incrementByAmount(1)) store.dispatch(increment()) await delay(10) expect(result).toEqual([increment(), { value: 2 }, { value: 1 }]) }) test('take resolves to null if the timeout expires', async () => { const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) let takeResult: any = undefined startListening({ predicate: incrementByAmount.match, effect: async (_, listenerApi) => { takeResult = await listenerApi.take(increment.match, 15) }, }) store.dispatch(incrementByAmount(1)) await delay(25) expect(takeResult).toBe(null) }) test("take resolves to [A, CurrentState, PreviousState] if the timeout is provided but doesn't expire", async () => { const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) let takeResult: any = undefined let stateBefore: any = undefined let stateCurrent: any = undefined startListening({ predicate: incrementByAmount.match, effect: async (_, listenerApi) => { stateBefore = listenerApi.getState() takeResult = await listenerApi.take(increment.match, 50) stateCurrent = listenerApi.getState() }, }) store.dispatch(incrementByAmount(1)) store.dispatch(increment()) await delay(25) expect(takeResult).toEqual([increment(), stateCurrent, stateBefore]) }) test("take resolves to `[A, CurrentState, PreviousState] | null` if a possibly undefined timeout parameter is provided", async () => { const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) type ExpectedTakeResultType = readonly [ReturnType<typeof increment>, CounterState, CounterState] | null let timeout: number | undefined = undefined let done = false const startAppListening = startListening as TypedStartListening<CounterState> startAppListening({ predicate: incrementByAmount.match, effect: async (_, listenerApi) => { const stateBefore = listenerApi.getState() let takeResult = await listenerApi.take(increment.match, timeout) const stateCurrent = listenerApi.getState() expect(takeResult).toEqual([increment(), stateCurrent, stateBefore]) timeout = 1 takeResult = await listenerApi.take(increment.match, timeout) expect(takeResult).toBeNull() expectType<ExpectedTakeResultType>(takeResult) done = true }, }) store.dispatch(incrementByAmount(1)) store.dispatch(increment()) await delay(25) expect(done).toBe(true); }) test('condition method resolves promise when the predicate succeeds', async () => { const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) let finalCount = 0 let listenerStarted = false startListening({ predicate: (action, _, previousState) => { return ( increment.match(action) && (previousState as CounterState).value === 0 ) }, effect: async (action, listenerApi) => { listenerStarted = true const result = await listenerApi.condition((action, currentState) => { return (currentState as CounterState).value === 3 }) expect(result).toBe(true) const latestState = listenerApi.getState() as CounterState finalCount = latestState.value }, }) store.dispatch(increment()) expect(listenerStarted).toBe(true) await delay(25) store.dispatch(increment()) store.dispatch(increment()) await delay(25) expect(finalCount).toBe(3) }) test('condition method resolves promise when there is a timeout', async () => { const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) let finalCount = 0 let listenerStarted = false startListening({ predicate: (action, currentState) => { return ( increment.match(action) && (currentState as CounterState).value === 1 ) }, effect: async (action, listenerApi) => { listenerStarted = true const result = await listenerApi.condition((action, currentState) => { return (currentState as CounterState).value === 3 }, 25) expect(result).toBe(false) const latestState = listenerApi.getState() as CounterState finalCount = latestState.value }, }) store.dispatch(increment()) expect(listenerStarted).toBe(true) store.dispatch(increment()) await delay(50) store.dispatch(increment()) expect(finalCount).toBe(2) }) test('take does not trigger unhandledRejections for completed or cancelled tasks', async () => { let deferredCompletedEvt = deferred() let deferredCancelledEvt = deferred() const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) const godotPauseTrigger = deferred() startListening({ predicate: () => true, effect: async (_, listenerApi) => { listenerApi.unsubscribe() // run once listenerApi.signal.addEventListener( 'abort', deferredCompletedEvt.resolve ) listenerApi.take(() => true) // missing await }, }) startListening({ predicate: () => true, effect: async (_, listenerApi) => { listenerApi.cancelActiveListeners() listenerApi.signal.addEventListener( 'abort', deferredCancelledEvt.resolve ) listenerApi.take(() => true) // missing await await listenerApi.pause(godotPauseTrigger) }, }) store.dispatch({ type: 'type' }) store.dispatch({ type: 'type' }) expect(await deferredCompletedEvt).toBeDefined() }) }) describe('Job API', () => { test('Allows canceling previous jobs', async () => { let jobsStarted = 0 let jobsContinued = 0 let jobsCanceled = 0 startListening({ actionCreator: increment, effect: async (action, listenerApi) => { jobsStarted++ if (jobsStarted < 3) { try { await listenerApi.condition(decrement.match) // Cancelation _should_ cause `condition()` to throw so we never // end up hitting this next line jobsContinued++ } catch (err) { if (err instanceof TaskAbortError) { jobsCanceled++ } } } else { listenerApi.cancelActiveListeners() } }, }) store.dispatch(increment()) store.dispatch(increment()) store.dispatch(increment()) await delay(10) expect(jobsStarted).toBe(3) expect(jobsContinued).toBe(0) expect(jobsCanceled).toBe(2) }) }) describe('Type tests', () => { const listenerMiddleware = createListenerMiddleware() const { middleware, startListening } = listenerMiddleware const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) test('State args default to unknown', () => { createListenerEntry({ predicate: ( action, currentState, previousState ): action is AnyAction => { expectUnknown(currentState) expectUnknown(previousState) return true }, effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectUnknown(listenerState) listenerApi.dispatch((dispatch, getState) => { const thunkState = getState() expectUnknown(thunkState) }) }, }) startListening({ predicate: ( action, currentState, previousState ): action is AnyAction => { expectUnknown(currentState) expectUnknown(previousState) return true }, effect: (action, listenerApi) => {}, }) startListening({ matcher: increment.match, effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectUnknown(listenerState) listenerApi.dispatch((dispatch, getState) => { const thunkState = getState() expectUnknown(thunkState) }) }, }) store.dispatch( addListener({ predicate: ( action, currentState, previousState ): action is AnyAction => { expectUnknown(currentState) expectUnknown(previousState) return true }, effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectUnknown(listenerState) listenerApi.dispatch((dispatch, getState) => { const thunkState = getState() expectUnknown(thunkState) }) }, }) ) store.dispatch( addListener({ matcher: increment.match, effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectUnknown(listenerState) // TODO Can't get the thunk dispatch types to carry through listenerApi.dispatch((dispatch, getState) => { const thunkState = getState() expectUnknown(thunkState) }) }, }) ) }) test('Action type is inferred from args', () => { startListening({ type: 'abcd', effect: (action, listenerApi) => { expectType<{ type: 'abcd' }>(action) }, }) startListening({ actionCreator: incrementByAmount, effect: (action, listenerApi) => { expectType<PayloadAction<number>>(action) }, }) startListening({ matcher: incrementByAmount.match, effect: (action, listenerApi) => { expectType<PayloadAction<number>>(action) }, }) startListening({ predicate: ( action, currentState, previousState ): action is PayloadAction<number> => { return typeof action.payload === 'boolean' }, effect: (action, listenerApi) => { // @ts-expect-error expectExactType<PayloadAction<number>>(action) }, }) startListening({ predicate: (action, currentState) => { return typeof action.payload === 'number' }, effect: (action, listenerApi) => { expectExactType<AnyAction>(action) }, }) store.dispatch( addListener({ type: 'abcd', effect: (action, listenerApi) => { expectType<{ type: 'abcd' }>(action) }, }) ) store.dispatch( addListener({ actionCreator: incrementByAmount, effect: (action, listenerApi) => { expectType<PayloadAction<number>>(action) }, }) ) store.dispatch( addListener({ matcher: incrementByAmount.match, effect: (action, listenerApi) => { expectType<PayloadAction<number>>(action) }, }) ) }) test('Can create a pre-typed middleware', () => { const typedMiddleware = createListenerMiddleware<CounterState>() typedMiddleware.startListening({ predicate: ( action, currentState, previousState ): action is AnyAction => { expectNotAny(currentState) expectNotAny(previousState) expectExactType<CounterState>(currentState) expectExactType<CounterState>(previousState) return true }, effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType<CounterState>(listenerState) listenerApi.dispatch((dispatch, getState) => { const thunkState = listenerApi.getState() expectExactType<CounterState>(thunkState) }) }, }) // Can pass a predicate function with fewer args typedMiddleware.startListening({ // TODO Why won't this infer the listener's `action` with implicit argument types? predicate: ( action: AnyAction, currentState: CounterState ): action is PayloadAction<number> => { expectNotAny(currentState) expectExactType<CounterState>(currentState) return true }, effect: (action, listenerApi) => { expectType<PayloadAction<number>>(action) const listenerState = listenerApi.getState() expectExactType<CounterState>(listenerState) listenerApi.dispatch((dispatch, getState) => { const thunkState = listenerApi.getState() expectExactType<CounterState>(thunkState) }) }, }) typedMiddleware.startListening({ actionCreator: incrementByAmount, effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType<CounterState>(listenerState) listenerApi.dispatch((dispatch, getState) => { const thunkState = listenerApi.getState() expectExactType<CounterState>(thunkState) }) }, }) store.dispatch( addTypedListenerAction({ predicate: ( action, currentState, previousState ): action is ReturnType<typeof incrementByAmount> => { expectNotAny(currentState) expectNotAny(previousState) expectExactType<CounterState>(currentState) expectExactType<CounterState>(previousState) return true }, effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType<CounterState>(listenerState) listenerApi.dispatch((dispatch, getState) => { const thunkState = listenerApi.getState() expectExactType<CounterState>(thunkState) }) }, }) ) store.dispatch( addTypedListenerAction({ predicate: ( action, currentState, previousState ): action is AnyAction => { expectNotAny(currentState) expectNotAny(previousState) expectExactType<CounterState>(currentState) expectExactType<CounterState>(previousState) return true }, effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType<CounterState>(listenerState) listenerApi.dispatch((dispatch, getState) => { const thunkState = listenerApi.getState() expectExactType<CounterState>(thunkState) }) }, }) ) }) test('Can create pre-typed versions of startListening and addListener', () => { const typedAddListener = startListening as TypedStartListening<CounterState> const typedAddListenerAction = addListener as TypedAddListener<CounterState> typedAddListener({ predicate: ( action, currentState, previousState ): action is AnyAction => { expectNotAny(currentState) expectNotAny(previousState) expectExactType<CounterState>(currentState) expectExactType<CounterState>(previousState) return true }, effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType<CounterState>(listenerState) // TODO Can't get the thunk dispatch types to carry through listenerApi.dispatch((dispatch, getState) => { const thunkState = listenerApi.getState() expectExactType<CounterState>(thunkState) }) }, }) typedAddListener({ matcher: incrementByAmount.match, effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType<CounterState>(listenerState) // TODO Can't get the thunk dispatch types to carry through listenerApi.dispatch((dispatch, getState) => { const thunkState = listenerApi.getState() expectExactType<CounterState>(thunkState) }) }, }) store.dispatch( typedAddListenerAction({ predicate: ( action, currentState, previousState ): action is AnyAction => { expectNotAny(currentState) expectNotAny(previousState) expectExactType<CounterState>(currentState) expectExactType<CounterState>(previousState) return true }, effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType<CounterState>(listenerState) listenerApi.dispatch((dispatch, getState) => { const thunkState = listenerApi.getState() expectExactType<CounterState>(thunkState) }) }, }) ) store.dispatch( typedAddListenerAction({ matcher: incrementByAmount.match, effect: (action, listenerApi) => {