Skip to content

Commit 690869c

Browse files
authored
support passing an external abortsignal to createAsyncThunk (#4860)
* support passing an external abortsignal to createAsyncThunk * make our own promiseWithResolvers * jsdoc * add once option * added docs * added no-transpile
1 parent 4e35821 commit 690869c

File tree

5 files changed

+129
-17
lines changed

5 files changed

+129
-17
lines changed

Diff for: docs/api/createAsyncThunk.mdx

+12
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,18 @@ When dispatched, the thunk will:
133133
- if the promise failed and was not handled with `rejectWithValue`, dispatch the `rejected` action with a serialized version of the error value as `action.error`
134134
- Return a fulfilled promise containing the final dispatched action (either the `fulfilled` or `rejected` action object)
135135

136+
## Thunk Dispatch Options
137+
138+
The returned thunk action creator accepts an optional second argument with the following options:
139+
140+
- `signal`: an optional [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that will be tracked by the internal abort signal (see [Canceling While Running](#canceling-while-running))
141+
142+
```ts no-transpile
143+
const externalController = new AbortController()
144+
dispatch(fetchUserById(123, { signal: externalController.signal }))
145+
externalController.abort()
146+
```
147+
136148
## Promise Lifecycle Actions
137149

138150
`createAsyncThunk` will generate three Redux action creators using [`createAction`](./createAction.mdx): `pending`, `fulfilled`, and `rejected`. Each lifecycle action creator will be attached to the returned thunk action creator so that your reducer logic can reference the action types and respond to the actions when dispatched. Each action object will contain the current unique `requestId` and `arg` values under `action.meta`.

Diff for: packages/toolkit/src/createAsyncThunk.ts

+41-3
Original file line numberDiff line numberDiff line change
@@ -246,36 +246,59 @@ export type AsyncThunkAction<
246246
unwrap: () => Promise<Returned>
247247
}
248248

249+
/**
250+
* Config provided when calling the async thunk action creator.
251+
*/
252+
export interface AsyncThunkDispatchConfig {
253+
/**
254+
* An external `AbortSignal` that will be tracked by the internal `AbortSignal`.
255+
*/
256+
signal?: AbortSignal
257+
}
258+
249259
type AsyncThunkActionCreator<
250260
Returned,
251261
ThunkArg,
252262
ThunkApiConfig extends AsyncThunkConfig,
253263
> = IsAny<
254264
ThunkArg,
255265
// any handling
256-
(arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>,
266+
(
267+
arg: ThunkArg,
268+
config?: AsyncThunkDispatchConfig,
269+
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>,
257270
// unknown handling
258271
unknown extends ThunkArg
259-
? (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument not specified or specified as void or undefined
272+
? (
273+
arg: ThunkArg,
274+
config?: AsyncThunkDispatchConfig,
275+
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument not specified or specified as void or undefined
260276
: [ThunkArg] extends [void] | [undefined]
261-
? () => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument contains void
277+
? (
278+
arg?: undefined,
279+
config?: AsyncThunkDispatchConfig,
280+
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument contains void
262281
: [void] extends [ThunkArg] // make optional
263282
? (
264283
arg?: ThunkArg,
284+
config?: AsyncThunkDispatchConfig,
265285
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument contains undefined
266286
: [undefined] extends [ThunkArg]
267287
? WithStrictNullChecks<
268288
// with strict nullChecks: make optional
269289
(
270290
arg?: ThunkArg,
291+
config?: AsyncThunkDispatchConfig,
271292
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>,
272293
// without strict null checks this will match everything, so don't make it optional
273294
(
274295
arg: ThunkArg,
296+
config?: AsyncThunkDispatchConfig,
275297
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
276298
> // default case: normal argument
277299
: (
278300
arg: ThunkArg,
301+
config?: AsyncThunkDispatchConfig,
279302
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
280303
>
281304

@@ -492,6 +515,8 @@ type CreateAsyncThunk<CurriedThunkApiConfig extends AsyncThunkConfig> =
492515
>
493516
}
494517

518+
const externalAbortMessage = 'External signal was aborted'
519+
495520
export const createAsyncThunk = /* @__PURE__ */ (() => {
496521
function createAsyncThunk<
497522
Returned,
@@ -575,6 +600,7 @@ export const createAsyncThunk = /* @__PURE__ */ (() => {
575600

576601
function actionCreator(
577602
arg: ThunkArg,
603+
{ signal }: AsyncThunkDispatchConfig = {},
578604
): AsyncThunkAction<Returned, ThunkArg, Required<ThunkApiConfig>> {
579605
return (dispatch, getState, extra) => {
580606
const requestId = options?.idGenerator
@@ -590,6 +616,18 @@ export const createAsyncThunk = /* @__PURE__ */ (() => {
590616
abortController.abort()
591617
}
592618

619+
if (signal) {
620+
if (signal.aborted) {
621+
abort(externalAbortMessage)
622+
} else {
623+
signal.addEventListener(
624+
'abort',
625+
() => abort(externalAbortMessage),
626+
{ once: true },
627+
)
628+
}
629+
}
630+
593631
const promise = (async function () {
594632
let finalAction: ReturnType<typeof fulfilled | typeof rejected>
595633
try {

Diff for: packages/toolkit/src/tests/createAsyncThunk.test-d.ts

+13-5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import type { TSVersion } from '@phryneas/ts-version'
1616
import type { AxiosError } from 'axios'
1717
import apiRequest from 'axios'
18+
import type { AsyncThunkDispatchConfig } from '@internal/createAsyncThunk'
1819

1920
const defaultDispatch = (() => {}) as ThunkDispatch<{}, any, UnknownAction>
2021
const unknownAction = { type: 'foo' } as UnknownAction
@@ -269,7 +270,9 @@ describe('type tests', () => {
269270

270271
expectTypeOf(asyncThunk).toMatchTypeOf<() => any>()
271272

272-
expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>()
273+
expectTypeOf(asyncThunk).parameters.toEqualTypeOf<
274+
[undefined?, AsyncThunkDispatchConfig?]
275+
>()
273276

274277
expectTypeOf(asyncThunk).returns.toBeFunction()
275278
})
@@ -279,7 +282,9 @@ describe('type tests', () => {
279282

280283
expectTypeOf(asyncThunk).toMatchTypeOf<() => any>()
281284

282-
expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>()
285+
expectTypeOf(asyncThunk).parameters.toEqualTypeOf<
286+
[undefined?, AsyncThunkDispatchConfig?]
287+
>()
283288
})
284289

285290
test('one argument, specified as void: asyncThunk has no argument', () => {
@@ -388,13 +393,14 @@ describe('type tests', () => {
388393

389394
expectTypeOf(asyncThunk).toBeCallableWith()
390395

391-
// @ts-expect-error cannot be called with an argument, even if the argument is `undefined`
392396
expectTypeOf(asyncThunk).toBeCallableWith(undefined)
393397

394398
// cannot be called with an argument
395399
expectTypeOf(asyncThunk).parameter(0).not.toBeAny()
396400

397-
expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>()
401+
expectTypeOf(asyncThunk).parameters.toEqualTypeOf<
402+
[undefined?, AsyncThunkDispatchConfig?]
403+
>()
398404
})
399405

400406
test('two arguments, first specified as void: asyncThunk has no argument', () => {
@@ -409,7 +415,9 @@ describe('type tests', () => {
409415
// cannot be called with an argument
410416
expectTypeOf(asyncThunk).parameter(0).not.toBeAny()
411417

412-
expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>()
418+
expectTypeOf(asyncThunk).parameters.toEqualTypeOf<
419+
[undefined?, AsyncThunkDispatchConfig?]
420+
>()
413421
})
414422

415423
test('two arguments, first specified as number|undefined: asyncThunk has optional number argument', () => {

Diff for: packages/toolkit/src/tests/createAsyncThunk.test.ts

+49-9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { noop } from '@internal/listenerMiddleware/utils'
2-
import { delay } from '@internal/utils'
2+
import { delay, promiseWithResolvers } from '@internal/utils'
33
import type { CreateAsyncThunkFunction, UnknownAction } from '@reduxjs/toolkit'
44
import {
55
configureStore,
@@ -879,17 +879,18 @@ test('`condition` will see state changes from a synchronously invoked asyncThunk
879879
expect(onStart).toHaveBeenCalledTimes(2)
880880
})
881881

882+
const getNewStore = () =>
883+
configureStore({
884+
reducer(actions: UnknownAction[] = [], action) {
885+
return [...actions, action]
886+
},
887+
})
888+
882889
describe('meta', () => {
883-
const getNewStore = () =>
884-
configureStore({
885-
reducer(actions = [], action) {
886-
return [...actions, action]
887-
},
888-
})
889-
const store = getNewStore()
890+
let store = getNewStore()
890891

891892
beforeEach(() => {
892-
const store = getNewStore()
893+
store = getNewStore()
893894
})
894895

895896
test('pendingMeta', () => {
@@ -1003,3 +1004,42 @@ describe('meta', () => {
10031004
expect(result.error).toEqual('serialized!')
10041005
})
10051006
})
1007+
1008+
describe('dispatch config', () => {
1009+
let store = getNewStore()
1010+
1011+
beforeEach(() => {
1012+
store = getNewStore()
1013+
})
1014+
test('accepts external signal', async () => {
1015+
const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => {
1016+
signal.throwIfAborted()
1017+
const { promise, reject } = promiseWithResolvers<never>()
1018+
signal.addEventListener('abort', () => reject(signal.reason))
1019+
return promise
1020+
})
1021+
1022+
const abortController = new AbortController()
1023+
const promise = store.dispatch(
1024+
asyncThunk(undefined, { signal: abortController.signal }),
1025+
)
1026+
abortController.abort()
1027+
await expect(promise.unwrap()).rejects.toThrow(
1028+
'External signal was aborted',
1029+
)
1030+
})
1031+
test('handles already aborted external signal', async () => {
1032+
const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => {
1033+
signal.throwIfAborted()
1034+
const { promise, reject } = promiseWithResolvers<never>()
1035+
signal.addEventListener('abort', () => reject(signal.reason))
1036+
return promise
1037+
})
1038+
1039+
const signal = AbortSignal.abort()
1040+
const promise = store.dispatch(asyncThunk(undefined, { signal }))
1041+
await expect(promise.unwrap()).rejects.toThrow(
1042+
'Aborted due to condition callback returning false.',
1043+
)
1044+
})
1045+
})

Diff for: packages/toolkit/src/utils.ts

+14
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,17 @@ export function getOrInsertComputed<K extends object, V>(
109109

110110
return map.set(key, compute(key)).get(key) as V
111111
}
112+
113+
export function promiseWithResolvers<T>(): {
114+
promise: Promise<T>
115+
resolve: (value: T | PromiseLike<T>) => void
116+
reject: (reason?: any) => void
117+
} {
118+
let resolve: any
119+
let reject: any
120+
const promise = new Promise<T>((res, rej) => {
121+
resolve = res
122+
reject = rej
123+
})
124+
return { promise, resolve, reject }
125+
}

0 commit comments

Comments
 (0)