Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add listenerApi.throwIfCancelled() #3802

Merged
merged 1 commit into from
Oct 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading