Skip to content

Commit

Permalink
Merge pull request #3775 from julian-ford/pr/add-listenerapi-cancel
Browse files Browse the repository at this point in the history
  • Loading branch information
markerikson authored Oct 14, 2023
2 parents 9d804fd + 47e6200 commit 777734c
Show file tree
Hide file tree
Showing 4 changed files with 51 additions and 9 deletions.
7 changes: 6 additions & 1 deletion docs/api/createListenerMiddleware.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,10 @@ export interface ListenerEffectAPI<
* Cancels all other running instances of this same listener except for the one that made this call.
*/
cancelActiveListeners: () => void
/**
* Cancels the listener instance that made this call.
*/
cancel: () => void
/**
* An abort signal whose `aborted` property is set to `true`
* if the listener execution is either aborted or completed.
Expand Down Expand Up @@ -403,6 +407,7 @@ These can be divided into several categories.
- `unsubscribe: () => void`: removes the listener entry from the middleware, and prevent future instances of the listener from running. (This does _not_ cancel any active instances.)
- `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.
- `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 @@ -666,7 +671,7 @@ listenerMiddleware.startListening({
### Complex Async Workflows
The provided async workflow primitives (`cancelActiveListeners`, `unsubscribe`, `subscribe`, `take`, `condition`, `pause`, `delay`) can be used to implement behavior that is equivalent to many of the more complex async workflow capabilities found in the Redux-Saga library. This includes effects such as `throttle`, `debounce`, `takeLatest`, `takeLeading`, and `fork/join`. Some examples from the test suite:
The provided async workflow primitives (`cancelActiveListeners`, `cancel`, `unsubscribe`, `subscribe`, `take`, `condition`, `pause`, `delay`) can be used to implement behavior that is equivalent to many of the more complex async workflow capabilities found in the Redux-Saga library. This includes effects such as `throttle`, `debounce`, `takeLatest`, `takeLeading`, and `fork/join`. Some examples from the test suite:
```js
test('debounce / takeLatest', async () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/toolkit/src/listenerMiddleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,13 @@ export function createListenerMiddleware<
}
})
},
cancel: () => {
abortControllerWithReason(
internalTaskController,
listenerCancelled
)
entry.pending.delete(internalTaskController)
},
})
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const middlewareApi = {
unsubscribe: expect.any(Function),
subscribe: expect.any(Function),
cancelActiveListeners: expect.any(Function),
cancel: expect.any(Function),
}

const noop = () => {}
Expand Down Expand Up @@ -184,7 +185,7 @@ describe('createListenerMiddleware', () => {
middleware: (gDM) => gDM().prepend(listenerMiddleware.middleware),
})

let foundExtra: number | null = null
let foundExtra: number | null = null

const typedAddListener =
listenerMiddleware.startListening as TypedStartListening<
Expand Down Expand Up @@ -645,7 +646,32 @@ describe('createListenerMiddleware', () => {
expect(await deferredCompletedSignalReason).toBe(listenerCompleted)
})

test('"can unsubscribe via middleware api', () => {
test('can self-cancel via middleware api', async () => {
const notifyDeferred = createAction<Deferred<string>>('notify-deferred')

startListening({
actionCreator: notifyDeferred,
effect: async ({ payload }, { signal, cancel, delay }) => {
signal.addEventListener(
'abort',
() => {
payload.resolve((signal as AbortSignalWithReason<string>).reason)
},
{ once: true }
)

cancel()
},
})

const deferredCancelledSignalReason = store.dispatch(
notifyDeferred(deferred<string>())
).payload

expect(await deferredCancelledSignalReason).toBe(listenerCancelled)
})

test('can unsubscribe via middleware api', () => {
const effect = jest.fn(
(action: TestAction1, api: ListenerEffectAPI<any, any>) => {
if (action.payload === 'b') {
Expand Down Expand Up @@ -1126,7 +1152,7 @@ describe('createListenerMiddleware', () => {
expect(takeResult).toEqual([increment(), stateCurrent, stateBefore])
})

test("take resolves to `[A, CurrentState, PreviousState] | null` if a possibly undefined timeout parameter is provided", async () => {
test('take resolves to `[A, CurrentState, PreviousState] | null` if a possibly undefined timeout parameter is provided', async () => {
const store = configureStore({
reducer: counterSlice.reducer,
middleware: (gDM) => gDM().prepend(middleware),
Expand Down Expand Up @@ -1160,7 +1186,7 @@ describe('createListenerMiddleware', () => {
store.dispatch(increment())

await delay(25)
expect(done).toBe(true);
expect(done).toBe(true)
})

test('condition method resolves promise when the predicate succeeds', async () => {
Expand Down
12 changes: 8 additions & 4 deletions packages/toolkit/src/listenerMiddleware/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export interface ForkOptions {
* If true, causes the parent task to not be marked as complete until
* all autoJoined forks have completed or failed.
*/
autoJoin: boolean;
autoJoin: boolean
}

/** @public */
Expand Down Expand Up @@ -186,9 +186,9 @@ export interface ListenerEffectAPI<
* rejects if the listener has been cancelled or is completed.
*
* The return value is `true` if the predicate succeeds or `false` if a timeout is provided and expires first.
*
*
* ### Example
*
*
* ```ts
* const updateBy = createAction<number>('counter/updateBy');
*
Expand All @@ -210,7 +210,7 @@ export interface ListenerEffectAPI<
*
* The return value is the `[action, currentState, previousState]` combination that the predicate saw as arguments.
*
* The promise resolves to null if a timeout is provided and expires first,
* The promise resolves to null if a timeout is provided and expires first,
*
* ### Example
*
Expand All @@ -233,6 +233,10 @@ export interface ListenerEffectAPI<
* Cancels all other running instances of this same listener except for the one that made this call.
*/
cancelActiveListeners: () => void
/**
* Cancels the instance of this listener that made this call.
*/
cancel: () => 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 777734c

Please sign in to comment.