Skip to content

Commit b72d7f5

Browse files
committed
feat(createAsyncThunk): signal.reason contains the first argument provided to asyncThunkHandle.abort #2395
Other changes: fixes signal.aborted not set in AbortController shim
1 parent b0f59fc commit b72d7f5

File tree

9 files changed

+146
-79
lines changed

9 files changed

+146
-79
lines changed

packages/toolkit/src/createAsyncThunk.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { createAction } from './createAction'
77
import type { ThunkDispatch } from 'redux-thunk'
88
import type { FallbackIfUnknown, IsAny, IsUnknown } from './tsHelpers'
99
import { nanoid } from './nanoid'
10+
import type { AbortSignalWithReason } from './function-utils'
11+
import { abortControllerWithReason } from './function-utils'
1012

1113
// @ts-ignore we need the import of these types due to a bundling issue.
1214
type _Keep = PayloadAction | ActionCreatorWithPreparedPayload<any, unknown>
@@ -541,7 +543,7 @@ export function createAsyncThunk<
541543
reason: undefined,
542544
throwIfAborted() {},
543545
}
544-
abort() {
546+
abort(reason?: any) {
545547
if (process.env.NODE_ENV !== 'production') {
546548
if (!displayedWarning) {
547549
displayedWarning = true
@@ -551,6 +553,12 @@ If you want to use the AbortController to react to \`abort\` events, please cons
551553
)
552554
}
553555
}
556+
557+
if (!this.signal.aborted) {
558+
this.signal.aborted = true
559+
;(this.signal as AbortSignalWithReason<typeof reason>).reason =
560+
reason
561+
}
554562
}
555563
}
556564

@@ -575,7 +583,7 @@ If you want to use the AbortController to react to \`abort\` events, please cons
575583
function abort(reason?: string) {
576584
if (started) {
577585
abortReason = reason
578-
abortController.abort()
586+
abortControllerWithReason(abortController, reason)
579587
}
580588
}
581589

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* @internal
3+
* At the time of writing `lib.dom.ts` does not provide `abortSignal.reason`.
4+
*/
5+
export type AbortSignalWithReason<T> = AbortSignal & { reason?: T }
6+
7+
/**
8+
* Calls `abortController.abort(reason)` and patches `signal.reason`.
9+
* if it is not supported.
10+
*
11+
* At the time of writing `signal.reason` is available in FF chrome, edge node 17 and deno.
12+
* @param abortController
13+
* @param reason
14+
* @returns
15+
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/reason
16+
*/
17+
export const abortControllerWithReason = <T>(
18+
abortController: AbortController,
19+
reason: T
20+
): void => {
21+
type Consumer<T> = (val: T) => void
22+
23+
const signal = abortController.signal as AbortSignalWithReason<T>
24+
25+
if (signal.aborted) {
26+
return
27+
}
28+
29+
// Patch `reason` if necessary.
30+
// - We use defineProperty here because reason is a getter of `AbortSignal.__proto__`.
31+
// - We need to patch 'reason' before calling `.abort()` because listeners to the 'abort'
32+
// event are are notified immediately.
33+
if (!('reason' in signal)) {
34+
Object.defineProperty(signal, 'reason', {
35+
enumerable: true,
36+
value: reason,
37+
configurable: true,
38+
writable: true,
39+
})
40+
}
41+
42+
;(abortController.abort as Consumer<typeof reason>)(reason)
43+
}

packages/toolkit/src/listenerMiddleware/index.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import type { Dispatch, AnyAction, MiddlewareAPI } from 'redux'
22
import type { ThunkDispatch } from 'redux-thunk'
33
import { createAction } from '../createAction'
44
import { nanoid } from '../nanoid'
5-
5+
import { abortControllerWithReason } from '../function-utils'
6+
import type { AbortSignalWithReason } from '../function-utils'
67
import type {
78
ListenerMiddleware,
89
ListenerMiddlewareInstance,
@@ -21,15 +22,9 @@ import type {
2122
ForkedTask,
2223
TypedRemoveListener,
2324
TaskResult,
24-
AbortSignalWithReason,
2525
UnsubscribeListenerOptions,
2626
} from './types'
27-
import {
28-
abortControllerWithReason,
29-
addAbortSignalListener,
30-
assertFunction,
31-
catchRejection,
32-
} from './utils'
27+
import { addAbortSignalListener, assertFunction, catchRejection } from './utils'
3328
import {
3429
listenerCancelled,
3530
listenerCompleted,

packages/toolkit/src/listenerMiddleware/task.ts

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

56
/**

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
import type { EnhancedStore } from '@reduxjs/toolkit'
22
import { configureStore, createSlice, createAction } from '@reduxjs/toolkit'
3-
3+
import type { AbortSignalWithReason } from '../../function-utils'
44
import type { PayloadAction } from '@reduxjs/toolkit'
5-
import type {
6-
AbortSignalWithReason,
7-
ForkedTaskExecutor,
8-
TaskResult,
9-
} from '../types'
5+
import type { ForkedTaskExecutor, TaskResult } from '../types'
106
import { createListenerMiddleware, TaskAbortError } from '../index'
117
import {
128
listenerCancelled,

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

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,8 @@ import type {
2525
UnsubscribeListener,
2626
ListenerMiddleware,
2727
} from '../index'
28-
import type {
29-
AbortSignalWithReason,
30-
AddListenerOverloads,
31-
TypedRemoveListener,
32-
} from '../types'
28+
import type { AbortSignalWithReason } from '../../function-utils'
29+
import type { AddListenerOverloads, TypedRemoveListener } from '../types'
3330
import { listenerCancelled, listenerCompleted } from '../exceptions'
3431

3532
const middlewareApi = {
@@ -185,7 +182,7 @@ describe('createListenerMiddleware', () => {
185182
middleware: (gDM) => gDM().prepend(listenerMiddleware.middleware),
186183
})
187184

188-
let foundExtra = null
185+
let foundExtra: number | null = null
189186

190187
const typedAddListener =
191188
listenerMiddleware.startListening as TypedStartListening<
@@ -1122,31 +1119,34 @@ describe('createListenerMiddleware', () => {
11221119
expect(takeResult).toEqual([increment(), stateCurrent, stateBefore])
11231120
})
11241121

1125-
test("take resolves to `[A, CurrentState, PreviousState] | null` if a possibly undefined timeout parameter is provided", async () => {
1122+
test('take resolves to `[A, CurrentState, PreviousState] | null` if a possibly undefined timeout parameter is provided', async () => {
11261123
const store = configureStore({
11271124
reducer: counterSlice.reducer,
11281125
middleware: (gDM) => gDM().prepend(middleware),
11291126
})
11301127

1131-
type ExpectedTakeResultType = readonly [ReturnType<typeof increment>, CounterState, CounterState] | null
1128+
type ExpectedTakeResultType =
1129+
| readonly [ReturnType<typeof increment>, CounterState, CounterState]
1130+
| null
11321131

11331132
let timeout: number | undefined = undefined
11341133
let done = false
11351134

1136-
const startAppListening = startListening as TypedStartListening<CounterState>
1135+
const startAppListening =
1136+
startListening as TypedStartListening<CounterState>
11371137
startAppListening({
11381138
predicate: incrementByAmount.match,
11391139
effect: async (_, listenerApi) => {
11401140
const stateBefore = listenerApi.getState()
1141-
1141+
11421142
let takeResult = await listenerApi.take(increment.match, timeout)
11431143
const stateCurrent = listenerApi.getState()
11441144
expect(takeResult).toEqual([increment(), stateCurrent, stateBefore])
1145-
1145+
11461146
timeout = 1
11471147
takeResult = await listenerApi.take(increment.match, timeout)
11481148
expect(takeResult).toBeNull()
1149-
1149+
11501150
expectType<ExpectedTakeResultType>(takeResult)
11511151

11521152
done = true
@@ -1156,7 +1156,7 @@ describe('createListenerMiddleware', () => {
11561156
store.dispatch(increment())
11571157

11581158
await delay(25)
1159-
expect(done).toBe(true);
1159+
expect(done).toBe(true)
11601160
})
11611161

11621162
test('condition method resolves promise when the predicate succeeds', async () => {

packages/toolkit/src/listenerMiddleware/types.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,6 @@ import type {
99
import type { ThunkDispatch } from 'redux-thunk'
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-
1812
/**
1913
* Types copied from RTK
2014
*/
@@ -177,9 +171,9 @@ export interface ListenerEffectAPI<
177171
* rejects if the listener has been cancelled or is completed.
178172
*
179173
* The return value is `true` if the predicate succeeds or `false` if a timeout is provided and expires first.
180-
*
174+
*
181175
* ### Example
182-
*
176+
*
183177
* ```ts
184178
* const updateBy = createAction<number>('counter/updateBy');
185179
*
@@ -201,7 +195,7 @@ export interface ListenerEffectAPI<
201195
*
202196
* The return value is the `[action, currentState, previousState]` combination that the predicate saw as arguments.
203197
*
204-
* The promise resolves to null if a timeout is provided and expires first,
198+
* The promise resolves to null if a timeout is provided and expires first,
205199
*
206200
* ### Example
207201
*
Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import type { AbortSignalWithReason } from './types'
2-
31
export const assertFunction: (
42
func: unknown,
53
expected: string
@@ -29,41 +27,3 @@ export const addAbortSignalListener = (
2927
) => {
3028
abortSignal.addEventListener('abort', callback, { once: true })
3129
}
32-
33-
/**
34-
* Calls `abortController.abort(reason)` and patches `signal.reason`.
35-
* if it is not supported.
36-
*
37-
* At the time of writing `signal.reason` is available in FF chrome, edge node 17 and deno.
38-
* @param abortController
39-
* @param reason
40-
* @returns
41-
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/reason
42-
*/
43-
export const abortControllerWithReason = <T>(
44-
abortController: AbortController,
45-
reason: T
46-
): void => {
47-
type Consumer<T> = (val: T) => void
48-
49-
const signal = abortController.signal as AbortSignalWithReason<T>
50-
51-
if (signal.aborted) {
52-
return
53-
}
54-
55-
// Patch `reason` if necessary.
56-
// - We use defineProperty here because reason is a getter of `AbortSignal.__proto__`.
57-
// - We need to patch 'reason' before calling `.abort()` because listeners to the 'abort'
58-
// event are are notified immediately.
59-
if (!('reason' in signal)) {
60-
Object.defineProperty(signal, 'reason', {
61-
enumerable: true,
62-
value: reason,
63-
configurable: true,
64-
writable: true,
65-
})
66-
}
67-
68-
;(abortController.abort as Consumer<typeof reason>)(reason)
69-
}

packages/toolkit/src/tests/createAsyncThunk.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getLog,
1414
} from 'console-testing-library/pure'
1515
import { expectType } from './helpers'
16+
import type { AbortSignalWithReason } from '../function-utils'
1617

1718
declare global {
1819
interface Window {
@@ -426,6 +427,47 @@ describe('createAsyncThunk with abortController', () => {
426427
)
427428
})
428429

430+
test('signal.reason contains the first argument provided to asyncThunkHandle.abort', async () => {
431+
const rejectionReason = 'custom-abort-reason'
432+
let apiSignal: AbortSignalWithReason<typeof rejectionReason> | undefined
433+
434+
const signalReasonAsyncThunk = createAsyncThunk(
435+
'test-signal-reason',
436+
function abortablePayloadCreator(_: any, { signal }) {
437+
apiSignal = signal
438+
return new Promise((resolve, reject) => {
439+
if (signal.aborted) {
440+
reject(
441+
new DOMException(
442+
'This should never be reached as it should already be handled.',
443+
'AbortError'
444+
)
445+
)
446+
}
447+
signal.addEventListener('abort', () => {
448+
reject((signal as NonNullable<typeof apiSignal>).reason)
449+
})
450+
setTimeout(resolve, 10)
451+
})
452+
}
453+
)
454+
455+
const asyncThunkHandle = store.dispatch(signalReasonAsyncThunk({}))
456+
457+
expect(apiSignal).toHaveProperty(['aborted'], false)
458+
expect(apiSignal).not.toHaveProperty(['reason'], rejectionReason)
459+
460+
asyncThunkHandle.abort(rejectionReason)
461+
462+
const result = await asyncThunkHandle
463+
464+
// calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
465+
expect(() => unwrapResult(result)).toThrowError()
466+
467+
expect(apiSignal).toHaveProperty(['aborted'], true)
468+
expect(apiSignal).toHaveProperty(['reason'], rejectionReason)
469+
})
470+
429471
test('even when the payloadCreator does not directly support the signal, no further actions are dispatched', async () => {
430472
const unawareAsyncThunk = createAsyncThunk('unaware', async () => {
431473
await new Promise((resolve) => setTimeout(resolve, 100))
@@ -520,6 +562,34 @@ describe('createAsyncThunk with abortController', () => {
520562
If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'."
521563
`)
522564
})
565+
566+
test('signal.reason contains the first argument provided to asyncThunkHandle.abort', async () => {
567+
const rejectionReason = 'custom-abort-reason'
568+
let apiSignal: AbortSignalWithReason<typeof rejectionReason> | undefined
569+
570+
const asyncThunk = freshlyLoadedModule.createAsyncThunk(
571+
'longRunning',
572+
async (_: unknown, { signal }) => {
573+
await new Promise((resolve) => {
574+
apiSignal = signal
575+
576+
setTimeout(resolve, 10)
577+
})
578+
}
579+
)
580+
581+
const asyncThunkHandle = store.dispatch(asyncThunk({}))
582+
583+
expect(apiSignal).toHaveProperty(['aborted'], false)
584+
expect(apiSignal).not.toHaveProperty(['reason'], rejectionReason)
585+
586+
asyncThunkHandle.abort(rejectionReason)
587+
588+
const result = await asyncThunkHandle
589+
590+
expect(apiSignal).toHaveProperty(['aborted'], true)
591+
expect(apiSignal).toHaveProperty(['reason'], rejectionReason)
592+
})
523593
})
524594
})
525595

0 commit comments

Comments
 (0)