Skip to content

Commit

Permalink
Add listenerApi.throwIfCancelled()
Browse files Browse the repository at this point in the history
  • Loading branch information
markerikson committed Oct 14, 2023
1 parent 777734c commit 2939891
Show file tree
Hide file tree
Showing 4 changed files with 76 additions and 11 deletions.
7 changes: 7 additions & 0 deletions docs/api/createListenerMiddleware.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,10 @@ export interface ListenerEffectAPI<
* Cancels the listener instance that made this call.
*/
cancel: () => void
/**
* Throws a `TaskAbortError` if this listener has been cancelled
*/
throwIfCancelled: () => void
/**
* An abort signal whose `aborted` property is set to `true`
* if the listener execution is either aborted or completed.
Expand Down Expand Up @@ -408,6 +412,7 @@ These can be divided into several categories.
- `subscribe: () => void`: will re-subscribe the listener entry if it was previously removed, or no-op if currently subscribed
- `cancelActiveListeners: () => void`: cancels all other running instances of this same listener _except_ for the one that made this call. (The cancellation will only have a meaningful effect if the other instances are paused using one of the cancellation-aware APIs like `take/cancel/pause/delay` - see "Cancelation and Task Management" in the "Usage" section for more details)
- `cancel: () => void`: cancels the instance of this listener that made this call.
- `throwIfCancelled: () => void`: throws a `TaskAbortError` if the current listener instance was cancelled.
- `signal: AbortSignal`: An [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) whose `aborted` property will be set to `true` if the listener execution is aborted or completed.

Dynamically unsubscribing and re-subscribing this listener allows for more complex async workflows, such as avoiding duplicate running instances by calling `listenerApi.unsubscribe()` at the start of a listener, or calling `listenerApi.cancelActiveListeners()` to ensure that only the most recent instance is allowed to complete.
Expand Down Expand Up @@ -645,6 +650,8 @@ The listener middleware supports cancellation of running listener instances, `ta
The `listenerApi.pause/delay()` functions provide a cancellation-aware way to have the current listener sleep. `pause()` accepts a promise, while `delay` accepts a timeout value. If the listener is cancelled while waiting, a `TaskAbortError` will be thrown. In addition, both `take` and `condition` support cancellation interruption as well.
`listenerApi.cancelActiveListeners()` will cancel _other_ existing instances that are running, while `listenerApi.cancel()` can be used to cancel the _current_ instance (which may be useful from a fork, which could be deeply nested and not able to directly throw a promise to break out of the effect execution). `listenerAPi.throwIfCancelled()` can also be useful to bail out of workflows in case cancellation happened while the effect was doing other work.
`listenerApi.fork()` can used to launch "child tasks" that can do additional work. These can be waited on to collect their results. An example of this might look like:
```ts no-transpile
Expand Down
3 changes: 3 additions & 0 deletions packages/toolkit/src/listenerMiddleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,9 @@ export function createListenerMiddleware<
)
entry.pending.delete(internalTaskController)
},
throwIfCancelled: () => {
validateActive(internalTaskController.signal)
},
})
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const middlewareApi = {
subscribe: expect.any(Function),
cancelActiveListeners: expect.any(Function),
cancel: expect.any(Function),
throwIfCancelled: expect.any(Function),
}

const noop = () => {}
Expand Down Expand Up @@ -671,6 +672,52 @@ describe('createListenerMiddleware', () => {
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 = jest.fn(
(action: TestAction1, api: ListenerEffectAPI<any, any>) => {
Expand Down Expand Up @@ -1087,12 +1134,13 @@ describe('createListenerMiddleware', () => {
middleware: (gDM) => gDM().prepend(middleware),
})

const typedAddListener =
startListening as TypedStartListening<
CounterState,
typeof store.dispatch
>
let result: [ReturnType<typeof increment>, CounterState, CounterState] | null = null
const typedAddListener = startListening as TypedStartListening<
CounterState,
typeof store.dispatch
>
let result:
| [ReturnType<typeof increment>, CounterState, CounterState]
| null = null

typedAddListener({
predicate: incrementByAmount.match,
Expand Down Expand Up @@ -1158,25 +1206,28 @@ describe('createListenerMiddleware', () => {
middleware: (gDM) => gDM().prepend(middleware),
})

type ExpectedTakeResultType = readonly [ReturnType<typeof increment>, CounterState, CounterState] | null
type ExpectedTakeResultType =
| readonly [ReturnType<typeof increment>, CounterState, CounterState]
| null

let timeout: number | undefined = undefined
let done = false

const startAppListening = startListening as TypedStartListening<CounterState>
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
Expand Down
4 changes: 4 additions & 0 deletions packages/toolkit/src/listenerMiddleware/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ export interface ListenerEffectAPI<
* Cancels the instance of this listener that made this call.
*/
cancel: () => void
/**
* Throws a `TaskAbortError` if this listener has been cancelled
*/
throwIfCancelled: () => void
/**
* An abort signal whose `aborted` property is set to `true`
* if the listener execution is either aborted or completed.
Expand Down

0 comments on commit 2939891

Please sign in to comment.