diff --git a/etc/redux-toolkit.api.md b/etc/redux-toolkit.api.md
index 92233aa9e8..dfc3d17c16 100644
--- a/etc/redux-toolkit.api.md
+++ b/etc/redux-toolkit.api.md
@@ -102,32 +102,37 @@ export function createAction
(type: T): Payl
export function createAction, T extends string = string>(type: T, prepareAction: PA): PayloadActionCreator['payload'], T, PA>;
// @alpha (undocumented)
-export function createAsyncThunk = AsyncThunksArgs>(type: ActionType, payloadCreator: (args: ActionParams, thunkArgs: TA) => Promise | Returned): ((args: ActionParams) => (dispatch: TA["dispatch"], getState: TA["getState"], extra: TA["extra"]) => Promise = AsyncThunksArgs>(type: ActionType, payloadCreator: (args: ActionParams, thunkArgs: TA) => Promise | Returned): ((args: ActionParams) => (dispatch: TA["dispatch"], getState: TA["getState"], extra: TA["extra"]) => Promise | import("./createAction").PayloadAction>) & {
+}, any> | import("./createAction").PayloadAction> & {
+ abort: (reason?: string) => void;
+}) & {
pending: import("./createAction").ActionCreatorWithPreparedPayload<[string, ActionParams], undefined, string, never, {
args: ActionParams;
requestId: string;
}>;
- rejected: import("./createAction").ActionCreatorWithPreparedPayload<[Error, string, ActionParams], undefined, string, Error, {
+ rejected: import("./createAction").ActionCreatorWithPreparedPayload<[Error, string, ActionParams], undefined, string, any, {
args: ActionParams;
requestId: string;
- }>;
- fulfilled: import("./createAction").ActionCreatorWithPreparedPayload<[Returned, string, ActionParams], Returned, string, never, {
+ } | {
+ aborted: boolean;
+ abortReason: string;
args: ActionParams;
requestId: string;
}>;
- unwrapResult: (returned: import("./createAction").PayloadAction | import("./createAction").PayloadAction) => Returned;
+ }>;
};
// @alpha (undocumented)
@@ -273,6 +278,13 @@ export type SliceCaseReducers = {
export { ThunkAction }
+// @alpha (undocumented)
+export function unwrapResult(returned: {
+ error: any;
+} | {
+ payload: NonNullable;
+}): NonNullable;
+
// @alpha (undocumented)
export type Update = UpdateStr | UpdateNum;
diff --git a/src/createAsyncThunk.test.ts b/src/createAsyncThunk.test.ts
index 8a59fe0dee..8c9b3b708c 100644
--- a/src/createAsyncThunk.test.ts
+++ b/src/createAsyncThunk.test.ts
@@ -1,5 +1,10 @@
-import { createAsyncThunk, miniSerializeError } from './createAsyncThunk'
+import {
+ createAsyncThunk,
+ miniSerializeError,
+ unwrapResult
+} from './createAsyncThunk'
import { configureStore } from './configureStore'
+import { AnyAction } from 'redux'
describe('createAsyncThunk', () => {
it('creates the action types', () => {
@@ -104,3 +109,85 @@ describe('createAsyncThunk', () => {
expect(errorAction.meta.args).toBe(args)
})
})
+
+describe('createAsyncThunk with abortController', () => {
+ const asyncThunk = createAsyncThunk('test', function abortablePayloadCreator(
+ _: any,
+ { signal }
+ ) {
+ return new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(
+ new DOMException(
+ 'This should never be reached as it should already be handled.',
+ 'AbortError'
+ )
+ )
+ }
+ signal.addEventListener('abort', () => {
+ reject(new DOMException('Was aborted while running', 'AbortError'))
+ })
+ setTimeout(resolve, 100)
+ })
+ })
+
+ let store = configureStore({
+ reducer(store: AnyAction[] = []) {
+ return store
+ }
+ })
+
+ beforeEach(() => {
+ store = configureStore({
+ reducer(store: AnyAction[] = [], action) {
+ return [...store, action]
+ }
+ })
+ })
+
+ test('normal usage', async () => {
+ await store.dispatch(asyncThunk({}))
+ expect(store.getState()).toEqual([
+ expect.any(Object),
+ expect.objectContaining({ type: 'test/pending' }),
+ expect.objectContaining({ type: 'test/fulfilled' })
+ ])
+ })
+
+ test('abort after dispatch', async () => {
+ const promise = store.dispatch(asyncThunk({}))
+ promise.abort('AbortReason')
+ const result = await promise
+ const expectedAbortedAction = {
+ type: 'test/rejected',
+ error: {
+ message: 'AbortReason',
+ name: 'AbortError'
+ },
+ meta: { aborted: true, abortReason: 'AbortReason' }
+ }
+ // abortedAction with reason is dispatched after test/pending is dispatched
+ expect(store.getState()).toMatchObject([
+ {},
+ { type: 'test/pending' },
+ expectedAbortedAction
+ ])
+
+ // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
+ expect(result).toMatchObject({
+ ...expectedAbortedAction,
+ error: {
+ message: 'Was aborted while running',
+ name: 'AbortError'
+ }
+ })
+
+ // calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
+ expect(() => unwrapResult(result)).toThrowError(
+ expect.objectContaining({
+ message: 'Was aborted while running',
+ name: 'AbortError'
+ })
+ )
+ })
+})
diff --git a/src/createAsyncThunk.ts b/src/createAsyncThunk.ts
index 1e0c21608e..d601e591c2 100644
--- a/src/createAsyncThunk.ts
+++ b/src/createAsyncThunk.ts
@@ -2,13 +2,12 @@ import { Dispatch } from 'redux'
import nanoid from 'nanoid'
import { createAction } from './createAction'
-type Await = P extends PromiseLike ? T : P
-
type AsyncThunksArgs = {
dispatch: D
getState: S
extra: E
requestId: string
+ signal: AbortSignal
}
interface SimpleError {
@@ -89,61 +88,92 @@ export function createAsyncThunk<
(error: Error, requestId: string, args: ActionParams) => {
return {
payload: undefined,
- error,
- meta: { args, requestId }
+ error: miniSerializeError(error),
+ meta: {
+ args,
+ requestId,
+ ...(error &&
+ error.name === 'AbortError' && {
+ aborted: true,
+ abortReason: error.message
+ })
+ }
}
}
)
function actionCreator(args: ActionParams) {
- return async (
+ return (
dispatch: TA['dispatch'],
getState: TA['getState'],
extra: TA['extra']
) => {
const requestId = nanoid()
+ const abortController = new AbortController()
+ let abortAction: ReturnType | undefined
- let finalAction: ReturnType
- try {
- dispatch(pending(requestId, args))
-
- finalAction = fulfilled(
- await payloadCreator(args, {
- dispatch,
- getState,
- extra,
- requestId
- } as TA),
+ function abort(reason: string = 'Aborted.') {
+ abortController.abort()
+ abortAction = rejected(
+ { name: 'AbortError', message: reason },
requestId,
args
)
- } catch (err) {
- const serializedError = miniSerializeError(err)
- finalAction = rejected(serializedError, requestId, args)
+ dispatch(abortAction)
}
- // We dispatch "success" _after_ the catch, to avoid having any errors
- // here get swallowed by the try/catch block,
- // per https://twitter.com/dan_abramov/status/770914221638942720
- // and https://redux-toolkit.js.org/tutorials/advanced-tutorial#async-error-handling-logic-in-thunks
- dispatch(finalAction)
- return finalAction
- }
- }
-
- function unwrapResult(
- returned: Await>>
- ) {
- if (rejected.match(returned)) {
- throw returned.error
+ const promise = (async function() {
+ let finalAction: ReturnType
+ try {
+ dispatch(pending(requestId, args))
+
+ finalAction = fulfilled(
+ await payloadCreator(args, {
+ dispatch,
+ getState,
+ extra,
+ requestId,
+ signal: abortController.signal
+ } as TA),
+ requestId,
+ args
+ )
+ } catch (err) {
+ if (err && err.name === 'AbortError' && abortAction) {
+ // abortAction has already been dispatched, no further action should be dispatched
+ // by this thunk.
+ // return a copy of the dispatched abortAction, but attach the AbortError to it.
+ return { ...abortAction, error: miniSerializeError(err) }
+ }
+ finalAction = rejected(err, requestId, args)
+ }
+
+ // We dispatch "success" _after_ the catch, to avoid having any errors
+ // here get swallowed by the try/catch block,
+ // per https://twitter.com/dan_abramov/status/770914221638942720
+ // and https://redux-toolkit.js.org/tutorials/advanced-tutorial#async-error-handling-logic-in-thunks
+ dispatch(finalAction)
+ return finalAction
+ })()
+ return Object.assign(promise, { abort })
}
- return returned.payload
}
return Object.assign(actionCreator, {
pending,
rejected,
- fulfilled,
- unwrapResult
+ fulfilled
})
}
+
+/**
+ * @alpha
+ */
+export function unwrapResult(
+ returned: { error: any } | { payload: NonNullable }
+): NonNullable {
+ if ('error' in returned) {
+ throw returned.error
+ }
+ return returned.payload
+}
diff --git a/src/index.ts b/src/index.ts
index 3897e2f2fa..86b9b1064f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -73,4 +73,4 @@ export {
Comparer
} from './entities/models'
-export { createAsyncThunk } from './createAsyncThunk'
+export { createAsyncThunk, unwrapResult } from './createAsyncThunk'
diff --git a/type-tests/files/createAsyncThunk.typetest.ts b/type-tests/files/createAsyncThunk.typetest.ts
index 04a0bf113d..8027e2f1e2 100644
--- a/type-tests/files/createAsyncThunk.typetest.ts
+++ b/type-tests/files/createAsyncThunk.typetest.ts
@@ -1,6 +1,7 @@
import { createAsyncThunk, Dispatch, createReducer, AnyAction } from 'src'
import { ThunkDispatch } from 'redux-thunk'
import { promises } from 'fs'
+import { unwrapResult } from 'src/createAsyncThunk'
function expectType(t: T) {
return t
@@ -44,7 +45,7 @@ function fn() {}
}
promise
- .then(async.unwrapResult)
+ .then(unwrapResult)
.then(result => {
expectType(result)
// typings:expect-error