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