Skip to content

Commit

Permalink
feat(action-listener-middleware): add async error handling
Browse files Browse the repository at this point in the history
  • Loading branch information
FaberVitale committed Dec 1, 2021
1 parent 09939c1 commit 15ed8c3
Show file tree
Hide file tree
Showing 4 changed files with 114 additions and 11 deletions.
2 changes: 1 addition & 1 deletion packages/action-listener-middleware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Current options are:

- `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).

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

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

Expand Down
37 changes: 30 additions & 7 deletions packages/action-listener-middleware/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
MiddlewarePhase,
WithMiddlewareType,
TakePattern,
ListenerErrorInfo,
} from './types'

export type {
Expand All @@ -48,7 +49,7 @@ function assertFunction(
expected: string
): asserts func is (...args: unknown[]) => unknown {
if (typeof func !== 'function') {
throw new TypeError(`${expected} in not a function`)
throw new TypeError(`${expected} is not a function`)
}
}

Expand Down Expand Up @@ -141,10 +142,11 @@ export const createListenerEntry: TypedCreateListenerEntry<unknown> = (
*/
const safelyNotifyError = (
errorHandler: ListenerErrorHandler,
errorToNotify: unknown
errorToNotify: unknown,
errorInfo: ListenerErrorInfo
): void => {
try {
errorHandler(errorToNotify)
errorHandler(errorToNotify, errorInfo)
} catch (errorHandlerError) {
// We cannot let an error raised here block the listener queue.
// The error raised here will be picked up by `window.onerror`, `process.on('error')` etc...
Expand Down Expand Up @@ -333,8 +335,13 @@ export function createActionListenerMiddleware<
try {
runListener = entry.predicate(action, currentState, originalState)
} catch (predicateError) {
safelyNotifyError(onError, predicateError)
runListener = false

safelyNotifyError(onError, predicateError, {
async: false,
raisedBy: 'predicate',
phase: currentPhase,
})
}
}

Expand All @@ -343,7 +350,7 @@ export function createActionListenerMiddleware<
}

try {
entry.listener(action, {
let promiseLikeOrUndefined = entry.listener(action, {
...api,
getOriginalState,
condition,
Expand All @@ -355,8 +362,24 @@ export function createActionListenerMiddleware<
listenerMap.set(entry.id, entry)
},
})
} catch (listenerError) {
safelyNotifyError(onError, listenerError)

if (promiseLikeOrUndefined) {
Promise.resolve(promiseLikeOrUndefined).catch(
(asyncListenerError) => {
safelyNotifyError(onError, asyncListenerError, {
async: true,
raisedBy: 'listener',
phase: currentPhase,
})
}
)
}
} catch (syncListenerError) {
safelyNotifyError(onError, syncListenerError, {
async: false,
raisedBy: 'listener',
phase: currentPhase,
})
}
}
if (currentPhase === 'beforeReducer') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ describe('createActionListenerMiddleware', () => {
])
})

test('Notifies listener errors to `onError`, if provided', () => {
test('Notifies sync listener errors to `onError`, if provided', () => {
const onError = jest.fn()
middleware = createActionListenerMiddleware({
onError,
Expand All @@ -649,7 +649,43 @@ describe('createActionListenerMiddleware', () => {
})

store.dispatch(testAction1('a'))
expect(onError).toBeCalledWith(listenerError)
expect(onError).toBeCalledWith(listenerError, {
async: false,
raisedBy: 'listener',
phase: 'afterReducer',
})
})

test('Notifies async listeners errors to `onError`, if provided', async () => {
const onError = jest.fn()
middleware = createActionListenerMiddleware({
onError,
})
reducer = jest.fn(() => ({}))
store = configureStore({
reducer,
middleware: (gDM) => gDM().prepend(middleware),
})

const listenerError = new Error('Boom!')
const matcher = (action: any): action is any => true

middleware.addListener({
matcher,
listener: async () => {
throw listenerError
},
})

store.dispatch(testAction1('a'))

await Promise.resolve()

expect(onError).toBeCalledWith(listenerError, {
async: true,
raisedBy: 'listener',
phase: 'afterReducer',
})
})

test('take resolves to the tuple [A, CurrentState, PreviousState] when the predicate matches the action', (done) => {
Expand Down
46 changes: 45 additions & 1 deletion packages/action-listener-middleware/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export type ActionListener<
A extends AnyAction,
S,
D extends Dispatch<AnyAction>
> = (action: A, api: ActionListenerMiddlewareAPI<S, D>) => void
> = (action: A, api: ActionListenerMiddlewareAPI<S, D>) => void | Promise<void>

export interface ListenerErrorHandler {
(error: unknown): void
Expand Down Expand Up @@ -317,3 +317,47 @@ export type ListenerPredicateGuardedActionType<T> = T extends ListenerPredicate<
>
? Action
: never

export type SyncActionListener<
A extends AnyAction,
S,
D extends Dispatch<AnyAction>
> = (action: A, api: ActionListenerMiddlewareAPI<S, D>) => void

export type AsyncActionListener<
A extends AnyAction,
S,
D extends Dispatch<AnyAction>
> = (action: A, api: ActionListenerMiddlewareAPI<S, D>) => Promise<void>

/**
* Additional infos regarding the error raised.
*/
export interface ListenerErrorInfo {
async: boolean
/**
* Which function has generated the exception.
*/
raisedBy: 'listener' | 'predicate'
/**
* When the function that has raised the error has been called.
*/
phase: MiddlewarePhase
}

/**
* Gets notified with synchronous and asynchronous errors raised by `listeners` or `predicates`.
* @param error The thrown error.
* @param errorInfo Additional information regarding the thrown error.
*/
export interface ListenerErrorHandler {
(error: unknown, errorInfo: ListenerErrorInfo): void
}

export interface CreateListenerMiddlewareOptions<ExtraArgument = unknown> {
extra?: ExtraArgument
/**
* Receives synchronous and asynchronous errors that are raised by `listener` and `listenerOption.predicate`.
*/
onError?: ListenerErrorHandler
}

0 comments on commit 15ed8c3

Please sign in to comment.