diff --git a/docs/api/createListenerMiddleware.mdx b/docs/api/createListenerMiddleware.mdx index bbab7b4ae6..e04dd23d67 100644 --- a/docs/api/createListenerMiddleware.mdx +++ b/docs/api/createListenerMiddleware.mdx @@ -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. @@ -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. @@ -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 diff --git a/packages/toolkit/src/listenerMiddleware/index.ts b/packages/toolkit/src/listenerMiddleware/index.ts index 05cdf721db..f502fa99bc 100644 --- a/packages/toolkit/src/listenerMiddleware/index.ts +++ b/packages/toolkit/src/listenerMiddleware/index.ts @@ -426,6 +426,9 @@ export function createListenerMiddleware< ) entry.pending.delete(internalTaskController) }, + throwIfCancelled: () => { + validateActive(internalTaskController.signal) + }, }) ) ) diff --git a/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts index 83469620a1..1b39d5fdd3 100644 --- a/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts +++ b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts @@ -671,6 +671,52 @@ describe('createListenerMiddleware', () => { expect(await deferredCancelledSignalReason).toBe(listenerCancelled) }) + test('Can easily check if the listener has been cancelled', async () => { + const pauseDeferred = deferred() + + 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) => { @@ -1087,12 +1133,13 @@ describe('createListenerMiddleware', () => { middleware: (gDM) => gDM().prepend(middleware), }) - const typedAddListener = - startListening as TypedStartListening< - CounterState, - typeof store.dispatch - > - let result: [ReturnType, CounterState, CounterState] | null = null + const typedAddListener = startListening as TypedStartListening< + CounterState, + typeof store.dispatch + > + let result: + | [ReturnType, CounterState, CounterState] + | null = null typedAddListener({ predicate: incrementByAmount.match, @@ -1158,25 +1205,28 @@ describe('createListenerMiddleware', () => { middleware: (gDM) => gDM().prepend(middleware), }) - type ExpectedTakeResultType = readonly [ReturnType, CounterState, CounterState] | null + type ExpectedTakeResultType = + | readonly [ReturnType, CounterState, CounterState] + | null let timeout: number | undefined = undefined let done = false - const startAppListening = startListening as TypedStartListening + const startAppListening = + startListening as TypedStartListening 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(takeResult) done = true diff --git a/packages/toolkit/src/listenerMiddleware/types.ts b/packages/toolkit/src/listenerMiddleware/types.ts index 6082349a7d..42c8530370 100644 --- a/packages/toolkit/src/listenerMiddleware/types.ts +++ b/packages/toolkit/src/listenerMiddleware/types.ts @@ -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.