Skip to content

Commit 1635f8c

Browse files
authored
Merge pull request #2023 from FaberVitale/feat/alm-improve-task-abort-error
2 parents 5c398cd + 75e8275 commit 1635f8c

File tree

7 files changed

+152
-37
lines changed

7 files changed

+152
-37
lines changed
Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
export class TaskAbortError implements Error {
1+
import type { SerializedError } from '@reduxjs/toolkit'
2+
3+
const task = 'task'
4+
const listener = 'listener'
5+
const completed = 'completed'
6+
const cancelled = 'cancelled'
7+
8+
/* TaskAbortError error codes */
9+
export const taskCancelled = `${task}-${cancelled}` as const
10+
export const taskCompleted = `${task}-${completed}` as const
11+
export const listenerCancelled = `${listener}-${cancelled}` as const
12+
export const listenerCompleted = `${listener}-${completed}` as const
13+
14+
export class TaskAbortError implements SerializedError {
215
name = 'TaskAbortError'
316
message = ''
4-
constructor(public reason = 'unknown') {
5-
this.message = `task cancelled (reason: ${reason})`
17+
constructor(public code = 'unknown') {
18+
this.message = `task cancelled (reason: ${code})`
619
}
720
}

packages/action-listener-middleware/src/index.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,18 @@ import type {
2929
TypedRemoveListener,
3030
TypedStopListening,
3131
} from './types'
32-
import { assertFunction, catchRejection } from './utils'
33-
import { TaskAbortError } from './exceptions'
32+
import {
33+
abortControllerWithReason,
34+
assertFunction,
35+
catchRejection,
36+
} from './utils'
37+
import {
38+
listenerCancelled,
39+
listenerCompleted,
40+
TaskAbortError,
41+
taskCancelled,
42+
taskCompleted,
43+
} from './exceptions'
3444
import {
3545
runTask,
3646
promisifyAbortSignal,
@@ -75,21 +85,24 @@ const createFork = (parentAbortSignal: AbortSignal) => {
7585
assertFunction(taskExecutor, 'taskExecutor')
7686
const childAbortController = new AbortController()
7787
const cancel = () => {
78-
childAbortController.abort()
88+
abortControllerWithReason(childAbortController, taskCancelled)
7989
}
8090

81-
const result = runTask<T>(async (): Promise<T> => {
82-
validateActive(parentAbortSignal)
83-
validateActive(childAbortController.signal)
84-
const result = (await taskExecutor({
85-
pause: createPause(childAbortController.signal),
86-
delay: createDelay(childAbortController.signal),
87-
signal: childAbortController.signal,
88-
})) as T
89-
validateActive(parentAbortSignal)
90-
validateActive(childAbortController.signal)
91-
return result
92-
}, cancel)
91+
const result = runTask<T>(
92+
async (): Promise<T> => {
93+
validateActive(parentAbortSignal)
94+
validateActive(childAbortController.signal)
95+
const result = (await taskExecutor({
96+
pause: createPause(childAbortController.signal),
97+
delay: createDelay(childAbortController.signal),
98+
signal: childAbortController.signal,
99+
})) as T
100+
validateActive(parentAbortSignal)
101+
validateActive(childAbortController.signal)
102+
return result
103+
},
104+
() => abortControllerWithReason(childAbortController, taskCompleted)
105+
)
93106

94107
return {
95108
result,
@@ -211,7 +224,7 @@ const createClearListenerMiddleware = (
211224
return () => {
212225
listenerMap.forEach((entry) => {
213226
entry.pending.forEach((controller) => {
214-
controller.abort()
227+
abortControllerWithReason(controller, listenerCancelled)
215228
})
216229
})
217230

@@ -363,7 +376,7 @@ export function createListenerMiddleware<
363376
cancelActiveListeners: () => {
364377
entry.pending.forEach((controller, _, set) => {
365378
if (controller !== internalTaskController) {
366-
controller.abort()
379+
abortControllerWithReason(controller, listenerCancelled)
367380
set.delete(controller)
368381
}
369382
})
@@ -378,7 +391,7 @@ export function createListenerMiddleware<
378391
})
379392
}
380393
} finally {
381-
internalTaskController.abort() // Notify that the task has completed
394+
abortControllerWithReason(internalTaskController, listenerCompleted) // Notify that the task has completed
382395
entry.pending.delete(internalTaskController)
383396
}
384397
}

packages/action-listener-middleware/src/task.ts

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

55
/**
66
* Synchronously raises {@link TaskAbortError} if the task tied to the input `signal` has been cancelled.
77
* @param signal
88
* @param reason
99
* @see {TaskAbortError}
1010
*/
11-
export const validateActive = (signal: AbortSignal, reason?: string): void => {
11+
export const validateActive = (signal: AbortSignal): void => {
1212
if (signal.aborted) {
13-
throw new TaskAbortError(reason)
13+
throw new TaskAbortError((signal as AbortSignalWithReason<string>).reason)
1414
}
1515
}
1616

@@ -20,12 +20,11 @@ export const validateActive = (signal: AbortSignal, reason?: string): void => {
2020
* @returns
2121
*/
2222
export const promisifyAbortSignal = (
23-
signal: AbortSignal,
24-
reason?: string
23+
signal: AbortSignalWithReason<string>
2524
): Promise<never> => {
2625
return catchRejection(
2726
new Promise<never>((_, reject) => {
28-
const notifyRejection = () => reject(new TaskAbortError(reason))
27+
const notifyRejection = () => reject(new TaskAbortError(signal.reason))
2928

3029
if (signal.aborted) {
3130
notifyRejection()

packages/action-listener-middleware/src/tests/fork.test.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { configureStore, createSlice } from '@reduxjs/toolkit'
44
import type { PayloadAction } from '@reduxjs/toolkit'
55
import type { ForkedTaskExecutor, TaskResult } from '../types'
66
import { createListenerMiddleware, TaskAbortError } from '../index'
7+
import { listenerCancelled, taskCancelled } from '../exceptions'
78

89
function delay(ms: number) {
910
return new Promise((resolve) => setTimeout(resolve, ms))
@@ -122,7 +123,9 @@ describe('fork', () => {
122123
store.dispatch(increment())
123124
store.dispatch(increment())
124125

125-
expect(await deferredForkedTaskError).toEqual(new TaskAbortError())
126+
expect(await deferredForkedTaskError).toEqual(
127+
new TaskAbortError(listenerCancelled)
128+
)
126129
})
127130

128131
it('synchronously throws TypeError error if the provided executor is not a function', () => {
@@ -193,7 +196,10 @@ describe('fork', () => {
193196
desc: 'sync exec - sync cancel',
194197
executor: () => 42,
195198
cancelAfterMs: -1,
196-
expected: { status: 'cancelled', error: new TaskAbortError() },
199+
expected: {
200+
status: 'cancelled',
201+
error: new TaskAbortError(taskCancelled),
202+
},
197203
},
198204
{
199205
desc: 'sync exec - async cancel',
@@ -208,7 +214,10 @@ describe('fork', () => {
208214
throw new Error('2020')
209215
},
210216
cancelAfterMs: 10,
211-
expected: { status: 'cancelled', error: new TaskAbortError() },
217+
expected: {
218+
status: 'cancelled',
219+
error: new TaskAbortError(taskCancelled),
220+
},
212221
},
213222
{
214223
desc: 'async exec - success',
@@ -300,7 +309,7 @@ describe('fork', () => {
300309

301310
expect(await deferredResult).toEqual({
302311
status: 'cancelled',
303-
error: new TaskAbortError(),
312+
error: new TaskAbortError(taskCancelled),
304313
})
305314
})
306315

@@ -357,12 +366,12 @@ describe('fork', () => {
357366
actionCreator: increment,
358367
effect: async (_, listenerApi) => {
359368
const forkedTask = listenerApi.fork(async (forkApi) => {
360-
await forkApi.pause(delay(30))
369+
await forkApi.pause(delay(1_000))
361370

362371
return 4
363372
})
364373

365-
await listenerApi.delay(10)
374+
await Promise.resolve()
366375
forkedTask.cancel()
367376
deferredResult.resolve(await forkedTask.result)
368377
},
@@ -372,7 +381,7 @@ describe('fork', () => {
372381

373382
expect(await deferredResult).toEqual({
374383
status: 'cancelled',
375-
error: new TaskAbortError(),
384+
error: new TaskAbortError(taskCancelled),
376385
})
377386
})
378387

@@ -396,7 +405,7 @@ describe('fork', () => {
396405

397406
expect(await deferredResult).toEqual({
398407
status: 'cancelled',
399-
error: new TaskAbortError(),
408+
error: new TaskAbortError(listenerCancelled),
400409
})
401410
})
402411
})

packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ import type {
2525
Unsubscribe,
2626
ListenerMiddleware,
2727
} from '../index'
28-
import type { AddListenerOverloads, TypedRemoveListener } from '../types'
28+
import type {
29+
AbortSignalWithReason,
30+
AddListenerOverloads,
31+
TypedRemoveListener,
32+
} from '../types'
33+
import { listenerCancelled, listenerCompleted } from '../exceptions'
2934

3035
const middlewareApi = {
3136
getState: expect.any(Function),
@@ -537,6 +542,36 @@ describe('createListenerMiddleware', () => {
537542
}
538543
)
539544

545+
test('listenerApi.signal has correct reason when listener is cancelled or completes', async () => {
546+
const notifyDeferred = createAction<Deferred<string>>('notify-deferred')
547+
548+
startListening({
549+
actionCreator: notifyDeferred,
550+
async effect({ payload }, { signal, cancelActiveListeners, delay }) {
551+
signal.addEventListener(
552+
'abort',
553+
() => {
554+
payload.resolve((signal as AbortSignalWithReason<string>).reason)
555+
},
556+
{ once: true }
557+
)
558+
559+
cancelActiveListeners()
560+
delay(10)
561+
},
562+
})
563+
564+
const deferredCancelledSignalReason = store.dispatch(
565+
notifyDeferred(deferred<string>())
566+
).payload
567+
const deferredCompletedSignalReason = store.dispatch(
568+
notifyDeferred(deferred<string>())
569+
).payload
570+
571+
expect(await deferredCancelledSignalReason).toBe(listenerCancelled)
572+
expect(await deferredCompletedSignalReason).toBe(listenerCompleted)
573+
})
574+
540575
test('"can unsubscribe via middleware api', () => {
541576
const effect = jest.fn(
542577
(action: TestAction1, api: ListenerEffectAPI<any, any>) => {

packages/action-listener-middleware/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import type {
99
} from '@reduxjs/toolkit'
1010
import type { TaskAbortError } from './exceptions'
1111

12+
/**
13+
* @internal
14+
* At the time of writing `lib.dom.ts` does not provide `abortSignal.reason`.
15+
*/
16+
export type AbortSignalWithReason<T> = AbortSignal & { reason?: T }
17+
1218
/**
1319
* Types copied from RTK
1420
*/

packages/action-listener-middleware/src/utils.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { AbortSignalWithReason } from './types'
2+
13
export const assertFunction: (
24
func: unknown,
35
expected: string
@@ -20,3 +22,41 @@ export const catchRejection = <T>(
2022

2123
return promise
2224
}
25+
26+
/**
27+
* Calls `abortController.abort(reason)` and patches `signal.reason`.
28+
* if it is not supported.
29+
*
30+
* At the time of writing `signal.reason` is available in FF chrome, edge node 17 and deno.
31+
* @param abortController
32+
* @param reason
33+
* @returns
34+
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/reason
35+
*/
36+
export const abortControllerWithReason = <T>(
37+
abortController: AbortController,
38+
reason: T
39+
): void => {
40+
type Consumer<T> = (val: T) => void
41+
42+
const signal = abortController.signal as AbortSignalWithReason<T>
43+
44+
if (signal.aborted) {
45+
return
46+
}
47+
48+
// Patch `reason` if necessary.
49+
// - We use defineProperty here because reason is a getter of `AbortSignal.__proto__`.
50+
// - We need to patch 'reason' before calling `.abort()` because listeners to the 'abort'
51+
// event are are notified immediately.
52+
if (!('reason' in signal)) {
53+
Object.defineProperty(signal, 'reason', {
54+
enumerable: true,
55+
value: reason,
56+
configurable: true,
57+
writable: true,
58+
})
59+
}
60+
61+
;(abortController.abort as Consumer<typeof reason>)(reason)
62+
}

0 commit comments

Comments
 (0)