Skip to content

Commit 15ed8c3

Browse files
committed
feat(action-listener-middleware): add async error handling
Context: - #1648 (reply in thread) - #1648 - #547
1 parent 09939c1 commit 15ed8c3

File tree

4 files changed

+114
-11
lines changed

4 files changed

+114
-11
lines changed

packages/action-listener-middleware/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ Current options are:
102102

103103
- `extra`: an optional "extra argument" that will be injected into the `listenerApi` parameter of each listener. Equivalent to [the "extra argument" in the Redux Thunk middleware](https://redux.js.org/usage/writing-logic-thunks#injecting-config-values-into-thunks).
104104

105-
- `onError`: an optional error handler that gets called with synchronous errors raised by `listener` and `predicate`.
105+
- `onError`: an optional error handler that gets called with synchronous and async errors raised by `listener` and synchronous errors thrown by `predicate`.
106106

107107
### `listenerMiddleware.addListener(predicate, listener, options?) : Unsubscribe`
108108

packages/action-listener-middleware/src/index.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type {
2727
MiddlewarePhase,
2828
WithMiddlewareType,
2929
TakePattern,
30+
ListenerErrorInfo,
3031
} from './types'
3132

3233
export type {
@@ -48,7 +49,7 @@ function assertFunction(
4849
expected: string
4950
): asserts func is (...args: unknown[]) => unknown {
5051
if (typeof func !== 'function') {
51-
throw new TypeError(`${expected} in not a function`)
52+
throw new TypeError(`${expected} is not a function`)
5253
}
5354
}
5455

@@ -141,10 +142,11 @@ export const createListenerEntry: TypedCreateListenerEntry<unknown> = (
141142
*/
142143
const safelyNotifyError = (
143144
errorHandler: ListenerErrorHandler,
144-
errorToNotify: unknown
145+
errorToNotify: unknown,
146+
errorInfo: ListenerErrorInfo
145147
): void => {
146148
try {
147-
errorHandler(errorToNotify)
149+
errorHandler(errorToNotify, errorInfo)
148150
} catch (errorHandlerError) {
149151
// We cannot let an error raised here block the listener queue.
150152
// The error raised here will be picked up by `window.onerror`, `process.on('error')` etc...
@@ -333,8 +335,13 @@ export function createActionListenerMiddleware<
333335
try {
334336
runListener = entry.predicate(action, currentState, originalState)
335337
} catch (predicateError) {
336-
safelyNotifyError(onError, predicateError)
337338
runListener = false
339+
340+
safelyNotifyError(onError, predicateError, {
341+
async: false,
342+
raisedBy: 'predicate',
343+
phase: currentPhase,
344+
})
338345
}
339346
}
340347

@@ -343,7 +350,7 @@ export function createActionListenerMiddleware<
343350
}
344351

345352
try {
346-
entry.listener(action, {
353+
let promiseLikeOrUndefined = entry.listener(action, {
347354
...api,
348355
getOriginalState,
349356
condition,
@@ -355,8 +362,24 @@ export function createActionListenerMiddleware<
355362
listenerMap.set(entry.id, entry)
356363
},
357364
})
358-
} catch (listenerError) {
359-
safelyNotifyError(onError, listenerError)
365+
366+
if (promiseLikeOrUndefined) {
367+
Promise.resolve(promiseLikeOrUndefined).catch(
368+
(asyncListenerError) => {
369+
safelyNotifyError(onError, asyncListenerError, {
370+
async: true,
371+
raisedBy: 'listener',
372+
phase: currentPhase,
373+
})
374+
}
375+
)
376+
}
377+
} catch (syncListenerError) {
378+
safelyNotifyError(onError, syncListenerError, {
379+
async: false,
380+
raisedBy: 'listener',
381+
phase: currentPhase,
382+
})
360383
}
361384
}
362385
if (currentPhase === 'beforeReducer') {

packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,7 @@ describe('createActionListenerMiddleware', () => {
626626
])
627627
})
628628

629-
test('Notifies listener errors to `onError`, if provided', () => {
629+
test('Notifies sync listener errors to `onError`, if provided', () => {
630630
const onError = jest.fn()
631631
middleware = createActionListenerMiddleware({
632632
onError,
@@ -649,7 +649,43 @@ describe('createActionListenerMiddleware', () => {
649649
})
650650

651651
store.dispatch(testAction1('a'))
652-
expect(onError).toBeCalledWith(listenerError)
652+
expect(onError).toBeCalledWith(listenerError, {
653+
async: false,
654+
raisedBy: 'listener',
655+
phase: 'afterReducer',
656+
})
657+
})
658+
659+
test('Notifies async listeners errors to `onError`, if provided', async () => {
660+
const onError = jest.fn()
661+
middleware = createActionListenerMiddleware({
662+
onError,
663+
})
664+
reducer = jest.fn(() => ({}))
665+
store = configureStore({
666+
reducer,
667+
middleware: (gDM) => gDM().prepend(middleware),
668+
})
669+
670+
const listenerError = new Error('Boom!')
671+
const matcher = (action: any): action is any => true
672+
673+
middleware.addListener({
674+
matcher,
675+
listener: async () => {
676+
throw listenerError
677+
},
678+
})
679+
680+
store.dispatch(testAction1('a'))
681+
682+
await Promise.resolve()
683+
684+
expect(onError).toBeCalledWith(listenerError, {
685+
async: true,
686+
raisedBy: 'listener',
687+
phase: 'afterReducer',
688+
})
653689
})
654690

655691
test('take resolves to the tuple [A, CurrentState, PreviousState] when the predicate matches the action', (done) => {

packages/action-listener-middleware/src/types.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export type ActionListener<
8383
A extends AnyAction,
8484
S,
8585
D extends Dispatch<AnyAction>
86-
> = (action: A, api: ActionListenerMiddlewareAPI<S, D>) => void
86+
> = (action: A, api: ActionListenerMiddlewareAPI<S, D>) => void | Promise<void>
8787

8888
export interface ListenerErrorHandler {
8989
(error: unknown): void
@@ -317,3 +317,47 @@ export type ListenerPredicateGuardedActionType<T> = T extends ListenerPredicate<
317317
>
318318
? Action
319319
: never
320+
321+
export type SyncActionListener<
322+
A extends AnyAction,
323+
S,
324+
D extends Dispatch<AnyAction>
325+
> = (action: A, api: ActionListenerMiddlewareAPI<S, D>) => void
326+
327+
export type AsyncActionListener<
328+
A extends AnyAction,
329+
S,
330+
D extends Dispatch<AnyAction>
331+
> = (action: A, api: ActionListenerMiddlewareAPI<S, D>) => Promise<void>
332+
333+
/**
334+
* Additional infos regarding the error raised.
335+
*/
336+
export interface ListenerErrorInfo {
337+
async: boolean
338+
/**
339+
* Which function has generated the exception.
340+
*/
341+
raisedBy: 'listener' | 'predicate'
342+
/**
343+
* When the function that has raised the error has been called.
344+
*/
345+
phase: MiddlewarePhase
346+
}
347+
348+
/**
349+
* Gets notified with synchronous and asynchronous errors raised by `listeners` or `predicates`.
350+
* @param error The thrown error.
351+
* @param errorInfo Additional information regarding the thrown error.
352+
*/
353+
export interface ListenerErrorHandler {
354+
(error: unknown, errorInfo: ListenerErrorInfo): void
355+
}
356+
357+
export interface CreateListenerMiddlewareOptions<ExtraArgument = unknown> {
358+
extra?: ExtraArgument
359+
/**
360+
* Receives synchronous and asynchronous errors that are raised by `listener` and `listenerOption.predicate`.
361+
*/
362+
onError?: ListenerErrorHandler
363+
}

0 commit comments

Comments
 (0)