Skip to content

Commit effab14

Browse files
authored
Merge pull request #2074 from reduxjs/feature/listener-abort-task
2 parents 88819e3 + 19b8904 commit effab14

File tree

6 files changed

+116
-12
lines changed

6 files changed

+116
-12
lines changed

packages/toolkit/src/listenerMiddleware/exceptions.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ const completed = 'completed'
66
const cancelled = 'cancelled'
77

88
/* TaskAbortError error codes */
9-
export const taskCancelled = `${task}-${cancelled}` as const
10-
export const taskCompleted = `${task}-${completed}` as const
9+
export const taskCancelled = `task-${cancelled}` as const
10+
export const taskCompleted = `task-${completed}` as const
1111
export const listenerCancelled = `${listener}-${cancelled}` as const
1212
export const listenerCompleted = `${listener}-${completed}` as const
1313

1414
export class TaskAbortError implements SerializedError {
1515
name = 'TaskAbortError'
16-
message = ''
17-
constructor(public code = 'unknown') {
18-
this.message = `task cancelled (reason: ${code})`
16+
message: string
17+
constructor(public code: string | undefined) {
18+
this.message = `${task} ${cancelled} (reason: ${code})`
1919
}
2020
}

packages/toolkit/src/listenerMiddleware/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ import type {
2121
ForkedTask,
2222
TypedRemoveListener,
2323
TaskResult,
24+
AbortSignalWithReason,
2425
} from './types'
2526
import {
2627
abortControllerWithReason,
28+
addAbortSignalListener,
2729
assertFunction,
2830
catchRejection,
2931
} from './utils'
@@ -74,11 +76,18 @@ const INTERNAL_NIL_TOKEN = {} as const
7476

7577
const alm = 'listenerMiddleware' as const
7678

77-
const createFork = (parentAbortSignal: AbortSignal) => {
79+
const createFork = (parentAbortSignal: AbortSignalWithReason<unknown>) => {
80+
const linkControllers = (controller: AbortController) =>
81+
addAbortSignalListener(parentAbortSignal, () =>
82+
abortControllerWithReason(controller, parentAbortSignal.reason)
83+
)
84+
7885
return <T>(taskExecutor: ForkedTaskExecutor<T>): ForkedTask<T> => {
7986
assertFunction(taskExecutor, 'taskExecutor')
8087
const childAbortController = new AbortController()
8188

89+
linkControllers(childAbortController)
90+
8291
const result = runTask<T>(
8392
async (): Promise<T> => {
8493
validateActive(parentAbortSignal)

packages/toolkit/src/listenerMiddleware/task.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { TaskAbortError } from './exceptions'
22
import type { AbortSignalWithReason, TaskResult } from './types'
3-
import { catchRejection } from './utils'
3+
import { addAbortSignalListener, catchRejection } from './utils'
44

55
/**
66
* Synchronously raises {@link TaskAbortError} if the task tied to the input `signal` has been cancelled.
@@ -29,7 +29,7 @@ export const promisifyAbortSignal = (
2929
if (signal.aborted) {
3030
notifyRejection()
3131
} else {
32-
signal.addEventListener('abort', notifyRejection, { once: true })
32+
addAbortSignalListener(signal, notifyRejection)
3333
}
3434
})
3535
)

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

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@ import type { EnhancedStore } from '@reduxjs/toolkit'
22
import { configureStore, createSlice, createAction } from '@reduxjs/toolkit'
33

44
import type { PayloadAction } from '@reduxjs/toolkit'
5-
import type { ForkedTaskExecutor, TaskResult } from '../types'
5+
import type {
6+
AbortSignalWithReason,
7+
ForkedTaskExecutor,
8+
TaskResult,
9+
} from '../types'
610
import { createListenerMiddleware, TaskAbortError } from '../index'
7-
import { listenerCancelled, taskCancelled } from '../exceptions'
11+
import {
12+
listenerCancelled,
13+
listenerCompleted,
14+
taskCancelled,
15+
} from '../exceptions'
816

917
function delay(ms: number) {
1018
return new Promise((resolve) => setTimeout(resolve, ms))
@@ -312,6 +320,58 @@ describe('fork', () => {
312320
})
313321
})
314322

323+
test('forkApi.delay rejects as soon as the parent listener is cancelled', async () => {
324+
let deferredResult = deferred()
325+
326+
startListening({
327+
actionCreator: increment,
328+
effect: async (_, listenerApi) => {
329+
listenerApi.cancelActiveListeners()
330+
await listenerApi.fork(async (forkApi) => {
331+
await forkApi
332+
.delay(100)
333+
.then(deferredResult.resolve, deferredResult.resolve)
334+
335+
return 4
336+
}).result
337+
338+
deferredResult.resolve(new Error('unreachable'))
339+
},
340+
})
341+
342+
store.dispatch(increment())
343+
344+
await Promise.resolve()
345+
346+
store.dispatch(increment())
347+
expect(await deferredResult).toEqual(
348+
new TaskAbortError(listenerCancelled)
349+
)
350+
})
351+
352+
test('forkApi.signal listener is invoked as soon as the parent listener is cancelled or completed', async () => {
353+
let deferredResult = deferred()
354+
355+
startListening({
356+
actionCreator: increment,
357+
async effect(_, listenerApi) {
358+
const wronglyDoNotAwaitResultOfTask = listenerApi.fork(
359+
async (forkApi) => {
360+
forkApi.signal.addEventListener('abort', () => {
361+
deferredResult.resolve(
362+
(forkApi.signal as AbortSignalWithReason<unknown>).reason
363+
)
364+
})
365+
}
366+
)
367+
},
368+
})
369+
370+
store.dispatch(increment)
371+
372+
expect(await deferredResult).toBe(listenerCompleted)
373+
})
374+
315375
test('fork.delay does not trigger unhandledRejections for completed or cancelled tasks', async () => {
316376
let deferredCompletedEvt = deferred()
317377
let deferredCancelledEvt = deferred()
@@ -384,6 +444,34 @@ describe('fork', () => {
384444
})
385445
})
386446

447+
test('forkApi.pause rejects as soon as the parent listener is cancelled', async () => {
448+
let deferredResult = deferred()
449+
450+
startListening({
451+
actionCreator: increment,
452+
effect: async (_, listenerApi) => {
453+
listenerApi.cancelActiveListeners()
454+
const forkedTask = listenerApi.fork(async (forkApi) => {
455+
await forkApi
456+
.pause(delay(100))
457+
.then(deferredResult.resolve, deferredResult.resolve)
458+
459+
return 4
460+
})
461+
462+
await forkedTask.result
463+
deferredResult.resolve(new Error('unreachable'))
464+
},
465+
})
466+
467+
store.dispatch(increment())
468+
469+
await Promise.resolve()
470+
471+
store.dispatch(increment())
472+
expect(await deferredResult).toEqual(new TaskAbortError(listenerCancelled))
473+
})
474+
387475
test('forkApi.pause rejects if listener is cancelled', async () => {
388476
const incrementByInListener = createAction<number>('incrementByInListener')
389477

packages/toolkit/src/listenerMiddleware/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,12 @@ export type MatchFunction<T> = (v: any) => v is T
5454
export interface ForkedTaskAPI {
5555
/**
5656
* Returns a promise that resolves when `waitFor` resolves or
57-
* rejects if the task has been cancelled or completed.
57+
* rejects if the task or the parent listener has been cancelled or is completed.
5858
*/
5959
pause<W>(waitFor: Promise<W>): Promise<W>
6060
/**
6161
* Returns a promise resolves after `timeoutMs` or
62-
* rejects if the task has been cancelled or is completed.
62+
* rejects if the task or the parent listener has been cancelled or is completed.
6363
* @param timeoutMs
6464
*/
6565
delay(timeoutMs: number): Promise<void>

packages/toolkit/src/listenerMiddleware/utils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ export const catchRejection = <T>(
2323
return promise
2424
}
2525

26+
export const addAbortSignalListener = (
27+
abortSignal: AbortSignal,
28+
callback: (evt: Event) => void
29+
) => {
30+
abortSignal.addEventListener('abort', callback, { once: true })
31+
}
32+
2633
/**
2734
* Calls `abortController.abort(reason)` and patches `signal.reason`.
2835
* if it is not supported.

0 commit comments

Comments
 (0)