Skip to content

Commit 2939891

Browse files
committed
Add listenerApi.throwIfCancelled()
1 parent 777734c commit 2939891

File tree

4 files changed

+76
-11
lines changed

4 files changed

+76
-11
lines changed

docs/api/createListenerMiddleware.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,10 @@ export interface ListenerEffectAPI<
364364
* Cancels the listener instance that made this call.
365365
*/
366366
cancel: () => void
367+
/**
368+
* Throws a `TaskAbortError` if this listener has been cancelled
369+
*/
370+
throwIfCancelled: () => void
367371
/**
368372
* An abort signal whose `aborted` property is set to `true`
369373
* if the listener execution is either aborted or completed.
@@ -408,6 +412,7 @@ These can be divided into several categories.
408412
- `subscribe: () => void`: will re-subscribe the listener entry if it was previously removed, or no-op if currently subscribed
409413
- `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)
410414
- `cancel: () => void`: cancels the instance of this listener that made this call.
415+
- `throwIfCancelled: () => void`: throws a `TaskAbortError` if the current listener instance was cancelled.
411416
- `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.
412417

413418
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.
@@ -645,6 +650,8 @@ The listener middleware supports cancellation of running listener instances, `ta
645650
646651
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.
647652
653+
`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.
654+
648655
`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:
649656
650657
```ts no-transpile

packages/toolkit/src/listenerMiddleware/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,9 @@ export function createListenerMiddleware<
426426
)
427427
entry.pending.delete(internalTaskController)
428428
},
429+
throwIfCancelled: () => {
430+
validateActive(internalTaskController.signal)
431+
},
429432
})
430433
)
431434
)

packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const middlewareApi = {
4646
subscribe: expect.any(Function),
4747
cancelActiveListeners: expect.any(Function),
4848
cancel: expect.any(Function),
49+
throwIfCancelled: expect.any(Function),
4950
}
5051

5152
const noop = () => {}
@@ -671,6 +672,52 @@ describe('createListenerMiddleware', () => {
671672
expect(await deferredCancelledSignalReason).toBe(listenerCancelled)
672673
})
673674

675+
test('Can easily check if the listener has been cancelled', async () => {
676+
const pauseDeferred = deferred<void>()
677+
678+
let listenerCancelled = false
679+
let listenerStarted = false
680+
let listenerCompleted = false
681+
let cancelListener: () => void = () => {}
682+
let error: TaskAbortError | undefined = undefined
683+
684+
startListening({
685+
actionCreator: testAction1,
686+
effect: async ({ payload }, { throwIfCancelled, cancel }) => {
687+
cancelListener = cancel
688+
try {
689+
listenerStarted = true
690+
throwIfCancelled()
691+
await pauseDeferred
692+
693+
throwIfCancelled()
694+
listenerCompleted = true
695+
} catch (err) {
696+
if (err instanceof TaskAbortError) {
697+
listenerCancelled = true
698+
error = err
699+
}
700+
}
701+
},
702+
})
703+
704+
store.dispatch(testAction1('a'))
705+
expect(listenerStarted).toBe(true)
706+
expect(listenerCompleted).toBe(false)
707+
expect(listenerCancelled).toBe(false)
708+
709+
// Cancel it while the listener is paused at a non-cancel-aware promise
710+
cancelListener()
711+
pauseDeferred.resolve()
712+
713+
await delay(10)
714+
expect(listenerCompleted).toBe(false)
715+
expect(listenerCancelled).toBe(true)
716+
expect((error as any)?.message).toBe(
717+
'task cancelled (reason: listener-cancelled)'
718+
)
719+
})
720+
674721
test('can unsubscribe via middleware api', () => {
675722
const effect = jest.fn(
676723
(action: TestAction1, api: ListenerEffectAPI<any, any>) => {
@@ -1087,12 +1134,13 @@ describe('createListenerMiddleware', () => {
10871134
middleware: (gDM) => gDM().prepend(middleware),
10881135
})
10891136

1090-
const typedAddListener =
1091-
startListening as TypedStartListening<
1092-
CounterState,
1093-
typeof store.dispatch
1094-
>
1095-
let result: [ReturnType<typeof increment>, CounterState, CounterState] | null = null
1137+
const typedAddListener = startListening as TypedStartListening<
1138+
CounterState,
1139+
typeof store.dispatch
1140+
>
1141+
let result:
1142+
| [ReturnType<typeof increment>, CounterState, CounterState]
1143+
| null = null
10961144

10971145
typedAddListener({
10981146
predicate: incrementByAmount.match,
@@ -1158,25 +1206,28 @@ describe('createListenerMiddleware', () => {
11581206
middleware: (gDM) => gDM().prepend(middleware),
11591207
})
11601208

1161-
type ExpectedTakeResultType = readonly [ReturnType<typeof increment>, CounterState, CounterState] | null
1209+
type ExpectedTakeResultType =
1210+
| readonly [ReturnType<typeof increment>, CounterState, CounterState]
1211+
| null
11621212

11631213
let timeout: number | undefined = undefined
11641214
let done = false
11651215

1166-
const startAppListening = startListening as TypedStartListening<CounterState>
1216+
const startAppListening =
1217+
startListening as TypedStartListening<CounterState>
11671218
startAppListening({
11681219
predicate: incrementByAmount.match,
11691220
effect: async (_, listenerApi) => {
11701221
const stateBefore = listenerApi.getState()
1171-
1222+
11721223
let takeResult = await listenerApi.take(increment.match, timeout)
11731224
const stateCurrent = listenerApi.getState()
11741225
expect(takeResult).toEqual([increment(), stateCurrent, stateBefore])
1175-
1226+
11761227
timeout = 1
11771228
takeResult = await listenerApi.take(increment.match, timeout)
11781229
expect(takeResult).toBeNull()
1179-
1230+
11801231
expectType<ExpectedTakeResultType>(takeResult)
11811232

11821233
done = true

packages/toolkit/src/listenerMiddleware/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,10 @@ export interface ListenerEffectAPI<
237237
* Cancels the instance of this listener that made this call.
238238
*/
239239
cancel: () => void
240+
/**
241+
* Throws a `TaskAbortError` if this listener has been cancelled
242+
*/
243+
throwIfCancelled: () => void
240244
/**
241245
* An abort signal whose `aborted` property is set to `true`
242246
* if the listener execution is either aborted or completed.

0 commit comments

Comments
 (0)