@reduxjs/toolkit
Version:
The official, opinionated, batteries-included toolset for efficient Redux development
1,375 lines (1,140 loc) • 39 kB
text/typescript
import {
listenerCancelled,
listenerCompleted,
} from '@internal/listenerMiddleware/exceptions'
import type {
AbortSignalWithReason,
AddListenerOverloads,
} from '@internal/listenerMiddleware/types'
import { noop } from '@internal/listenerMiddleware/utils'
import type {
Action,
ListenerEffect,
ListenerEffectAPI,
PayloadAction,
TypedRemoveListener,
TypedStartListening,
UnknownAction,
} from '@reduxjs/toolkit'
import {
TaskAbortError,
addListener,
clearAllListeners,
configureStore,
createAction,
createListenerMiddleware,
createSlice,
isAnyOf,
removeListener,
} from '@reduxjs/toolkit'
import type { Mock } from 'vitest'
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),
cancel: expect.any(Function),
throwIfCancelled: expect.any(Function),
}
// 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>
}
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: Mock
let listenerMiddleware = createListenerMiddleware()
let { middleware, startListening, stopListening, clearListeners } =
listenerMiddleware
const 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')
vi.spyOn(console, 'error').mockImplementation(noop)
beforeEach(() => {
listenerMiddleware = createListenerMiddleware()
middleware = listenerMiddleware.middleware
startListening = listenerMiddleware.startListening
stopListening = listenerMiddleware.stopListening
clearListeners = listenerMiddleware.clearListeners
reducer = vi.fn(() => ({}))
store = configureStore({
reducer,
middleware: (gDM) => gDM().prepend(middleware),
})
})
afterEach(() => {
vi.clearAllMocks()
})
afterAll(() => {
vi.restoreAllMocks()
})
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): action is Action => true,
effect: (action, listenerApi) => {
foundExtra = 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 = vi.fn((_: TestAction1) => {})
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('stopListening returns true if an entry has been unsubscribed, false otherwise', () => {
const effect = vi.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 = vi.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 = vi.fn((_: UnknownAction) => {})
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 = vi.fn((_: UnknownAction) => {})
const isAction1Or2 = isAnyOf(testAction1, testAction2)
const unsubscribe = startListening({
matcher: isAction1Or2,
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 = vi.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('subscribing with the same effect but different predicate is allowed', () => {
const effect = vi.fn((_: TestAction1 | TestAction2) => {})
startListening({
actionCreator: testAction1,
effect,
})
startListening({
actionCreator: testAction2,
effect,
})
store.dispatch(testAction1('a'))
store.dispatch(testAction2('b'))
expect(effect.mock.calls).toEqual([
[testAction1('a'), middlewareApi],
[testAction2('b'), middlewareApi],
])
})
test('unsubscribing via callback', () => {
const effect = vi.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 = vi.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 = vi.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 = vi.fn((_: TestAction1) => {})
const unsubscribe = store.dispatch(
addListener({
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('unsubscribing via action', () => {
const effect = vi.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' | 'withTypes'
>,
][] = [
['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<
UnknownAction,
typeof store.getState,
typeof store.dispatch
> = vi.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, UnknownAction][] = [
[
'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 self-cancel via middleware api', async () => {
const notifyDeferred = createAction<Deferred<string>>('notify-deferred')
startListening({
actionCreator: notifyDeferred,
effect: async ({ payload }, { signal, cancel, delay }) => {
signal.addEventListener(
'abort',
() => {
payload.resolve((signal as AbortSignalWithReason<string>).reason)
},
{ once: true },
)
cancel()
},
})
const deferredCancelledSignalReason = store.dispatch(
notifyDeferred(deferred<string>()),
).payload
expect(await deferredCancelledSignalReason).toBe(listenerCancelled)
})
test('Can easily check if the listener has been cancelled', async () => {
const pauseDeferred = deferred<void>()
let listenerCancelled = false
let listenerStarted = false
let listenerCompleted = false
let cancelListener: () => void = () => {}
let error: TaskAbortError | undefined = undefined
startListening({
actionCreator: testAction1,
effect: async ({ payload }, { throwIfCancelled, cancel }) => {
cancelListener = cancel
try {
listenerStarted = true
throwIfCancelled()
await pauseDeferred
throwIfCancelled()
listenerCompleted = true
} catch (err) {
if (err instanceof TaskAbortError) {
listenerCancelled = true
error = err
}
}
},
})
store.dispatch(testAction1('a'))
expect(listenerStarted).toBe(true)
expect(listenerCompleted).toBe(false)
expect(listenerCancelled).toBe(false)
// Cancel it while the listener is paused at a non-cancel-aware promise
cancelListener()
pauseDeferred.resolve()
await delay(10)
expect(listenerCompleted).toBe(false)
expect(listenerCancelled).toBe(true)
expect((error as any)?.message).toBe(
'task cancelled (reason: listener-cancelled)',
)
})
test('can unsubscribe via middleware api', () => {
const effect = vi.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 = vi.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 = vi.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 () => {
const deferredCompletedEvt = deferred()
const 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 = vi.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 = vi.fn(() => {})
const secondListener = vi.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 = vi.fn()
const listenerMiddleware = createListenerMiddleware({
onError,
})
const { middleware, startListening } = listenerMiddleware
reducer = vi.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 = vi.fn()
const listenerMiddleware = createListenerMiddleware({
onError,
})
const { middleware, startListening } = listenerMiddleware
reducer = vi.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(_: UnknownAction, 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),
})
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()
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 () => {
const deferredCompletedEvt = deferred()
const 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)
})
})
})