diff --git a/docs/api/createAsyncThunk.mdx b/docs/api/createAsyncThunk.mdx index 40e805028d..872d146655 100644 --- a/docs/api/createAsyncThunk.mdx +++ b/docs/api/createAsyncThunk.mdx @@ -133,6 +133,18 @@ When dispatched, the thunk will: - 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` - Return a fulfilled promise containing the final dispatched action (either the `fulfilled` or `rejected` action object) +## Thunk Dispatch Options + +The returned thunk action creator accepts an optional second argument with the following options: + +- `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)) + +```ts no-transpile +const externalController = new AbortController() +dispatch(fetchUserById(123, { signal: externalController.signal })) +externalController.abort() +``` + ## Promise Lifecycle Actions `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 --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index 733003e6d4..b6d51759fe 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -246,6 +246,16 @@ export type AsyncThunkAction< unwrap: () => Promise } +/** + * Config provided when calling the async thunk action creator. + */ +export interface AsyncThunkDispatchConfig { + /** + * An external `AbortSignal` that will be tracked by the internal `AbortSignal`. + */ + signal?: AbortSignal +} + type AsyncThunkActionCreator< Returned, ThunkArg, @@ -253,29 +263,42 @@ type AsyncThunkActionCreator< > = IsAny< ThunkArg, // any handling - (arg: ThunkArg) => AsyncThunkAction, + ( + arg: ThunkArg, + config?: AsyncThunkDispatchConfig, + ) => AsyncThunkAction, // unknown handling unknown extends ThunkArg - ? (arg: ThunkArg) => AsyncThunkAction // argument not specified or specified as void or undefined + ? ( + arg: ThunkArg, + config?: AsyncThunkDispatchConfig, + ) => AsyncThunkAction // argument not specified or specified as void or undefined : [ThunkArg] extends [void] | [undefined] - ? () => AsyncThunkAction // argument contains void + ? ( + arg?: undefined, + config?: AsyncThunkDispatchConfig, + ) => AsyncThunkAction // argument contains void : [void] extends [ThunkArg] // make optional ? ( arg?: ThunkArg, + config?: AsyncThunkDispatchConfig, ) => AsyncThunkAction // argument contains undefined : [undefined] extends [ThunkArg] ? WithStrictNullChecks< // with strict nullChecks: make optional ( arg?: ThunkArg, + config?: AsyncThunkDispatchConfig, ) => AsyncThunkAction, // without strict null checks this will match everything, so don't make it optional ( arg: ThunkArg, + config?: AsyncThunkDispatchConfig, ) => AsyncThunkAction > // default case: normal argument : ( arg: ThunkArg, + config?: AsyncThunkDispatchConfig, ) => AsyncThunkAction > @@ -492,6 +515,8 @@ type CreateAsyncThunk = > } +const externalAbortMessage = 'External signal was aborted' + export const createAsyncThunk = /* @__PURE__ */ (() => { function createAsyncThunk< Returned, @@ -575,6 +600,7 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { function actionCreator( arg: ThunkArg, + { signal }: AsyncThunkDispatchConfig = {}, ): AsyncThunkAction> { return (dispatch, getState, extra) => { const requestId = options?.idGenerator @@ -590,6 +616,18 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { abortController.abort() } + if (signal) { + if (signal.aborted) { + abort(externalAbortMessage) + } else { + signal.addEventListener( + 'abort', + () => abort(externalAbortMessage), + { once: true }, + ) + } + } + const promise = (async function () { let finalAction: ReturnType try { diff --git a/packages/toolkit/src/tests/createAsyncThunk.test-d.ts b/packages/toolkit/src/tests/createAsyncThunk.test-d.ts index a6978d4838..19163f50a0 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test-d.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test-d.ts @@ -15,6 +15,7 @@ import { import type { TSVersion } from '@phryneas/ts-version' import type { AxiosError } from 'axios' import apiRequest from 'axios' +import type { AsyncThunkDispatchConfig } from '@internal/createAsyncThunk' const defaultDispatch = (() => {}) as ThunkDispatch<{}, any, UnknownAction> const unknownAction = { type: 'foo' } as UnknownAction @@ -269,7 +270,9 @@ describe('type tests', () => { expectTypeOf(asyncThunk).toMatchTypeOf<() => any>() - expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + expectTypeOf(asyncThunk).parameters.toEqualTypeOf< + [undefined?, AsyncThunkDispatchConfig?] + >() expectTypeOf(asyncThunk).returns.toBeFunction() }) @@ -279,7 +282,9 @@ describe('type tests', () => { expectTypeOf(asyncThunk).toMatchTypeOf<() => any>() - expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + expectTypeOf(asyncThunk).parameters.toEqualTypeOf< + [undefined?, AsyncThunkDispatchConfig?] + >() }) test('one argument, specified as void: asyncThunk has no argument', () => { @@ -388,13 +393,14 @@ describe('type tests', () => { expectTypeOf(asyncThunk).toBeCallableWith() - // @ts-expect-error cannot be called with an argument, even if the argument is `undefined` expectTypeOf(asyncThunk).toBeCallableWith(undefined) // cannot be called with an argument expectTypeOf(asyncThunk).parameter(0).not.toBeAny() - expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + expectTypeOf(asyncThunk).parameters.toEqualTypeOf< + [undefined?, AsyncThunkDispatchConfig?] + >() }) test('two arguments, first specified as void: asyncThunk has no argument', () => { @@ -409,7 +415,9 @@ describe('type tests', () => { // cannot be called with an argument expectTypeOf(asyncThunk).parameter(0).not.toBeAny() - expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + expectTypeOf(asyncThunk).parameters.toEqualTypeOf< + [undefined?, AsyncThunkDispatchConfig?] + >() }) test('two arguments, first specified as number|undefined: asyncThunk has optional number argument', () => { diff --git a/packages/toolkit/src/tests/createAsyncThunk.test.ts b/packages/toolkit/src/tests/createAsyncThunk.test.ts index ef67002899..3dcda30098 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test.ts @@ -1,5 +1,5 @@ import { noop } from '@internal/listenerMiddleware/utils' -import { delay } from '@internal/utils' +import { delay, promiseWithResolvers } from '@internal/utils' import type { CreateAsyncThunkFunction, UnknownAction } from '@reduxjs/toolkit' import { configureStore, @@ -879,17 +879,18 @@ test('`condition` will see state changes from a synchronously invoked asyncThunk expect(onStart).toHaveBeenCalledTimes(2) }) +const getNewStore = () => + configureStore({ + reducer(actions: UnknownAction[] = [], action) { + return [...actions, action] + }, + }) + describe('meta', () => { - const getNewStore = () => - configureStore({ - reducer(actions = [], action) { - return [...actions, action] - }, - }) - const store = getNewStore() + let store = getNewStore() beforeEach(() => { - const store = getNewStore() + store = getNewStore() }) test('pendingMeta', () => { @@ -1003,3 +1004,42 @@ describe('meta', () => { expect(result.error).toEqual('serialized!') }) }) + +describe('dispatch config', () => { + let store = getNewStore() + + beforeEach(() => { + store = getNewStore() + }) + test('accepts external signal', async () => { + const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => { + signal.throwIfAborted() + const { promise, reject } = promiseWithResolvers() + signal.addEventListener('abort', () => reject(signal.reason)) + return promise + }) + + const abortController = new AbortController() + const promise = store.dispatch( + asyncThunk(undefined, { signal: abortController.signal }), + ) + abortController.abort() + await expect(promise.unwrap()).rejects.toThrow( + 'External signal was aborted', + ) + }) + test('handles already aborted external signal', async () => { + const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => { + signal.throwIfAborted() + const { promise, reject } = promiseWithResolvers() + signal.addEventListener('abort', () => reject(signal.reason)) + return promise + }) + + const signal = AbortSignal.abort() + const promise = store.dispatch(asyncThunk(undefined, { signal })) + await expect(promise.unwrap()).rejects.toThrow( + 'Aborted due to condition callback returning false.', + ) + }) +}) diff --git a/packages/toolkit/src/utils.ts b/packages/toolkit/src/utils.ts index 6607f4b339..1b7955a2e9 100644 --- a/packages/toolkit/src/utils.ts +++ b/packages/toolkit/src/utils.ts @@ -109,3 +109,17 @@ export function getOrInsertComputed( return map.set(key, compute(key)).get(key) as V } + +export function promiseWithResolvers(): { + promise: Promise + resolve: (value: T | PromiseLike) => void + reject: (reason?: any) => void +} { + let resolve: any + let reject: any + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +}