Skip to content

Commit f040ad6

Browse files
committed
refactor(action-listener-middleware): add async error handling
Context: - reduxjs#1648 (reply in thread) - reduxjs#1648 - reduxjs#547
1 parent 24bbf9f commit f040ad6

File tree

3 files changed

+101
-12
lines changed

3 files changed

+101
-12
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: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,17 +98,49 @@ export interface ActionListenerMiddlewareAPI<S, D extends Dispatch<AnyAction>>
9898
extra: unknown
9999
}
100100

101+
export type SyncActionListener<
102+
A extends AnyAction,
103+
S,
104+
D extends Dispatch<AnyAction>
105+
> = (action: A, api: ActionListenerMiddlewareAPI<S, D>) => void
106+
107+
export type AsyncActionListener<
108+
A extends AnyAction,
109+
S,
110+
D extends Dispatch<AnyAction>
111+
> = (action: A, api: ActionListenerMiddlewareAPI<S, D>) => Promise<void>
112+
101113
/**
102114
* @alpha
103115
*/
104116
export type ActionListener<
105117
A extends AnyAction,
106118
S,
107119
D extends Dispatch<AnyAction>
108-
> = (action: A, api: ActionListenerMiddlewareAPI<S, D>) => void
120+
> = SyncActionListener<A, S, D> | AsyncActionListener<A, S, D>
109121

122+
/**
123+
* Additional information regarding the error.
124+
*/
125+
export interface ListenerErrorInfo {
126+
async: boolean
127+
/**
128+
* Which function has generated the exception.
129+
*/
130+
raisedBy: 'listener' | 'predicate'
131+
/**
132+
* When the function that has raised the error has been called.
133+
*/
134+
phase: MiddlewarePhase
135+
}
136+
137+
/**
138+
* Gets notified with synchronous and asynchronous errors raised by `listeners` or `predicates`.
139+
* @param error The thrown error.
140+
* @param errorInfo Additional information regarding the thrown error.
141+
*/
110142
export interface ListenerErrorHandler {
111-
(error: unknown): void
143+
(error: unknown, errorInfo: ListenerErrorInfo): void
112144
}
113145

114146
export interface ActionListenerOptions {
@@ -123,7 +155,7 @@ export interface ActionListenerOptions {
123155
export interface CreateListenerMiddlewareOptions<ExtraArgument = unknown> {
124156
extra?: ExtraArgument
125157
/**
126-
* Receives synchronous errors that are raised by `listener` and `listenerOption.predicate`.
158+
* Receives synchronous and asynchronous errors that are raised by `listener` and `listenerOption.predicate`.
127159
*/
128160
onError?: ListenerErrorHandler
129161
}
@@ -310,10 +342,11 @@ export type ActionListenerMiddleware<
310342
*/
311343
const safelyNotifyError = (
312344
errorHandler: ListenerErrorHandler,
313-
errorToNotify: unknown
345+
errorToNotify: unknown,
346+
errorInfo: ListenerErrorInfo
314347
): void => {
315348
try {
316-
errorHandler(errorToNotify)
349+
errorHandler(errorToNotify, errorInfo)
317350
} catch (errorHandlerError) {
318351
// We cannot let an error raised here block the listener queue.
319352
// The error raised here will be picked up by `window.onerror`, `process.on('error')` etc...
@@ -456,7 +489,11 @@ export function createActionListenerMiddleware<
456489
try {
457490
runListener = entry.predicate(action, currentState, originalState)
458491
} catch (predicateError) {
459-
safelyNotifyError(onError, predicateError)
492+
safelyNotifyError(onError, predicateError, {
493+
async: false,
494+
raisedBy: 'predicate',
495+
phase: currentPhase,
496+
})
460497
runListener = false
461498
}
462499
}
@@ -466,7 +503,7 @@ export function createActionListenerMiddleware<
466503
}
467504

468505
try {
469-
entry.listener(action, {
506+
let promiseLikeOrUndefined = entry.listener(action, {
470507
...api,
471508
getOriginalState,
472509
// eslint-disable-next-line @typescript-eslint/no-use-before-define
@@ -478,8 +515,24 @@ export function createActionListenerMiddleware<
478515
listenerMap.set(entry.id, entry)
479516
},
480517
})
481-
} catch (listenerError) {
482-
safelyNotifyError(onError, listenerError)
518+
519+
if (promiseLikeOrUndefined) {
520+
Promise.resolve(promiseLikeOrUndefined).catch(
521+
(asyncListenerError) => {
522+
safelyNotifyError(onError, asyncListenerError, {
523+
async: true,
524+
raisedBy: 'listener',
525+
phase: currentPhase,
526+
})
527+
}
528+
)
529+
}
530+
} catch (syncListenerError) {
531+
safelyNotifyError(onError, syncListenerError, {
532+
async: false,
533+
raisedBy: 'listener',
534+
phase: currentPhase,
535+
})
483536
}
484537
}
485538
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
@@ -622,7 +622,7 @@ describe('createActionListenerMiddleware', () => {
622622
])
623623
})
624624

625-
test('Notifies listener errors to `onError`, if provided', () => {
625+
test('Notifies sync listener errors to `onError`, if provided', () => {
626626
const onError = jest.fn()
627627
middleware = createActionListenerMiddleware({
628628
onError,
@@ -645,7 +645,43 @@ describe('createActionListenerMiddleware', () => {
645645
})
646646

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

651687
test('condition method resolves promise when the predicate succeeds', async () => {

0 commit comments

Comments
 (0)