Skip to content

Commit 1076ab2

Browse files
authored
add .abort() to the createAsyncThunk thunkAction (#362)
* add .abort() to the createAsyncThunk thunkAction * per review comments * put `abort` on the promise returned by `dispatch(asyncThunk())` * remove reference to DOMException * simplify rejected action creator * fix error==undefined case, reduce diff * update api report
1 parent d13d26a commit 1076ab2

File tree

5 files changed

+180
-50
lines changed

5 files changed

+180
-50
lines changed

etc/redux-toolkit.api.md

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,32 +102,37 @@ export function createAction<P = void, T extends string = string>(type: T): Payl
102102
export function createAction<PA extends PrepareAction<any>, T extends string = string>(type: T, prepareAction: PA): PayloadActionCreator<ReturnType<PA>['payload'], T, PA>;
103103

104104
// @alpha (undocumented)
105-
export function createAsyncThunk<ActionType extends string, Returned, ActionParams = void, TA extends AsyncThunksArgs<any, any, any> = AsyncThunksArgs<unknown, unknown, Dispatch>>(type: ActionType, payloadCreator: (args: ActionParams, thunkArgs: TA) => Promise<Returned> | Returned): ((args: ActionParams) => (dispatch: TA["dispatch"], getState: TA["getState"], extra: TA["extra"]) => Promise<import("./createAction").PayloadAction<Returned, string, {
105+
export function createAsyncThunk<ActionType extends string, Returned, ActionParams = void, TA extends AsyncThunksArgs<any, any, any> = AsyncThunksArgs<unknown, unknown, Dispatch>>(type: ActionType, payloadCreator: (args: ActionParams, thunkArgs: TA) => Promise<Returned> | Returned): ((args: ActionParams) => (dispatch: TA["dispatch"], getState: TA["getState"], extra: TA["extra"]) => Promise<import("./createAction").PayloadAction<undefined, string, {
106106
args: ActionParams;
107107
requestId: string;
108-
}, never> | import("./createAction").PayloadAction<undefined, string, {
108+
} | {
109+
aborted: boolean;
110+
abortReason: string;
109111
args: ActionParams;
110112
requestId: string;
111-
}, Error>>) & {
113+
}, any> | import("./createAction").PayloadAction<Returned, string, {
114+
args: ActionParams;
115+
requestId: string;
116+
}, never>> & {
117+
abort: (reason?: string) => void;
118+
}) & {
112119
pending: import("./createAction").ActionCreatorWithPreparedPayload<[string, ActionParams], undefined, string, never, {
113120
args: ActionParams;
114121
requestId: string;
115122
}>;
116-
rejected: import("./createAction").ActionCreatorWithPreparedPayload<[Error, string, ActionParams], undefined, string, Error, {
123+
rejected: import("./createAction").ActionCreatorWithPreparedPayload<[Error, string, ActionParams], undefined, string, any, {
117124
args: ActionParams;
118125
requestId: string;
119-
}>;
120-
fulfilled: import("./createAction").ActionCreatorWithPreparedPayload<[Returned, string, ActionParams], Returned, string, never, {
126+
} | {
127+
aborted: boolean;
128+
abortReason: string;
121129
args: ActionParams;
122130
requestId: string;
123131
}>;
124-
unwrapResult: (returned: import("./createAction").PayloadAction<Returned, string, {
125-
args: ActionParams;
126-
requestId: string;
127-
}, never> | import("./createAction").PayloadAction<undefined, string, {
132+
fulfilled: import("./createAction").ActionCreatorWithPreparedPayload<[Returned, string, ActionParams], Returned, string, never, {
128133
args: ActionParams;
129134
requestId: string;
130-
}, Error>) => Returned;
135+
}>;
131136
};
132137

133138
// @alpha (undocumented)
@@ -273,6 +278,13 @@ export type SliceCaseReducers<State> = {
273278

274279
export { ThunkAction }
275280

281+
// @alpha (undocumented)
282+
export function unwrapResult<T>(returned: {
283+
error: any;
284+
} | {
285+
payload: NonNullable<T>;
286+
}): NonNullable<T>;
287+
276288
// @alpha (undocumented)
277289
export type Update<T> = UpdateStr<T> | UpdateNum<T>;
278290

src/createAsyncThunk.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import { createAsyncThunk, miniSerializeError } from './createAsyncThunk'
1+
import {
2+
createAsyncThunk,
3+
miniSerializeError,
4+
unwrapResult
5+
} from './createAsyncThunk'
26
import { configureStore } from './configureStore'
7+
import { AnyAction } from 'redux'
38

49
describe('createAsyncThunk', () => {
510
it('creates the action types', () => {
@@ -104,3 +109,85 @@ describe('createAsyncThunk', () => {
104109
expect(errorAction.meta.args).toBe(args)
105110
})
106111
})
112+
113+
describe('createAsyncThunk with abortController', () => {
114+
const asyncThunk = createAsyncThunk('test', function abortablePayloadCreator(
115+
_: any,
116+
{ signal }
117+
) {
118+
return new Promise((resolve, reject) => {
119+
if (signal.aborted) {
120+
reject(
121+
new DOMException(
122+
'This should never be reached as it should already be handled.',
123+
'AbortError'
124+
)
125+
)
126+
}
127+
signal.addEventListener('abort', () => {
128+
reject(new DOMException('Was aborted while running', 'AbortError'))
129+
})
130+
setTimeout(resolve, 100)
131+
})
132+
})
133+
134+
let store = configureStore({
135+
reducer(store: AnyAction[] = []) {
136+
return store
137+
}
138+
})
139+
140+
beforeEach(() => {
141+
store = configureStore({
142+
reducer(store: AnyAction[] = [], action) {
143+
return [...store, action]
144+
}
145+
})
146+
})
147+
148+
test('normal usage', async () => {
149+
await store.dispatch(asyncThunk({}))
150+
expect(store.getState()).toEqual([
151+
expect.any(Object),
152+
expect.objectContaining({ type: 'test/pending' }),
153+
expect.objectContaining({ type: 'test/fulfilled' })
154+
])
155+
})
156+
157+
test('abort after dispatch', async () => {
158+
const promise = store.dispatch(asyncThunk({}))
159+
promise.abort('AbortReason')
160+
const result = await promise
161+
const expectedAbortedAction = {
162+
type: 'test/rejected',
163+
error: {
164+
message: 'AbortReason',
165+
name: 'AbortError'
166+
},
167+
meta: { aborted: true, abortReason: 'AbortReason' }
168+
}
169+
// abortedAction with reason is dispatched after test/pending is dispatched
170+
expect(store.getState()).toMatchObject([
171+
{},
172+
{ type: 'test/pending' },
173+
expectedAbortedAction
174+
])
175+
176+
// same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
177+
expect(result).toMatchObject({
178+
...expectedAbortedAction,
179+
error: {
180+
message: 'Was aborted while running',
181+
name: 'AbortError'
182+
}
183+
})
184+
185+
// calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
186+
expect(() => unwrapResult(result)).toThrowError(
187+
expect.objectContaining({
188+
message: 'Was aborted while running',
189+
name: 'AbortError'
190+
})
191+
)
192+
})
193+
})

src/createAsyncThunk.ts

Lines changed: 66 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@ import { Dispatch } from 'redux'
22
import nanoid from 'nanoid'
33
import { createAction } from './createAction'
44

5-
type Await<P> = P extends PromiseLike<infer T> ? T : P
6-
75
type AsyncThunksArgs<S, E, D extends Dispatch = Dispatch> = {
86
dispatch: D
97
getState: S
108
extra: E
119
requestId: string
10+
signal: AbortSignal
1211
}
1312

1413
interface SimpleError {
@@ -89,61 +88,92 @@ export function createAsyncThunk<
8988
(error: Error, requestId: string, args: ActionParams) => {
9089
return {
9190
payload: undefined,
92-
error,
93-
meta: { args, requestId }
91+
error: miniSerializeError(error),
92+
meta: {
93+
args,
94+
requestId,
95+
...(error &&
96+
error.name === 'AbortError' && {
97+
aborted: true,
98+
abortReason: error.message
99+
})
100+
}
94101
}
95102
}
96103
)
97104

98105
function actionCreator(args: ActionParams) {
99-
return async (
106+
return (
100107
dispatch: TA['dispatch'],
101108
getState: TA['getState'],
102109
extra: TA['extra']
103110
) => {
104111
const requestId = nanoid()
112+
const abortController = new AbortController()
113+
let abortAction: ReturnType<typeof rejected> | undefined
105114

106-
let finalAction: ReturnType<typeof fulfilled | typeof rejected>
107-
try {
108-
dispatch(pending(requestId, args))
109-
110-
finalAction = fulfilled(
111-
await payloadCreator(args, {
112-
dispatch,
113-
getState,
114-
extra,
115-
requestId
116-
} as TA),
115+
function abort(reason: string = 'Aborted.') {
116+
abortController.abort()
117+
abortAction = rejected(
118+
{ name: 'AbortError', message: reason },
117119
requestId,
118120
args
119121
)
120-
} catch (err) {
121-
const serializedError = miniSerializeError(err)
122-
finalAction = rejected(serializedError, requestId, args)
122+
dispatch(abortAction)
123123
}
124124

125-
// We dispatch "success" _after_ the catch, to avoid having any errors
126-
// here get swallowed by the try/catch block,
127-
// per https://twitter.com/dan_abramov/status/770914221638942720
128-
// and https://redux-toolkit.js.org/tutorials/advanced-tutorial#async-error-handling-logic-in-thunks
129-
dispatch(finalAction)
130-
return finalAction
131-
}
132-
}
133-
134-
function unwrapResult(
135-
returned: Await<ReturnType<ReturnType<typeof actionCreator>>>
136-
) {
137-
if (rejected.match(returned)) {
138-
throw returned.error
125+
const promise = (async function() {
126+
let finalAction: ReturnType<typeof fulfilled | typeof rejected>
127+
try {
128+
dispatch(pending(requestId, args))
129+
130+
finalAction = fulfilled(
131+
await payloadCreator(args, {
132+
dispatch,
133+
getState,
134+
extra,
135+
requestId,
136+
signal: abortController.signal
137+
} as TA),
138+
requestId,
139+
args
140+
)
141+
} catch (err) {
142+
if (err && err.name === 'AbortError' && abortAction) {
143+
// abortAction has already been dispatched, no further action should be dispatched
144+
// by this thunk.
145+
// return a copy of the dispatched abortAction, but attach the AbortError to it.
146+
return { ...abortAction, error: miniSerializeError(err) }
147+
}
148+
finalAction = rejected(err, requestId, args)
149+
}
150+
151+
// We dispatch "success" _after_ the catch, to avoid having any errors
152+
// here get swallowed by the try/catch block,
153+
// per https://twitter.com/dan_abramov/status/770914221638942720
154+
// and https://redux-toolkit.js.org/tutorials/advanced-tutorial#async-error-handling-logic-in-thunks
155+
dispatch(finalAction)
156+
return finalAction
157+
})()
158+
return Object.assign(promise, { abort })
139159
}
140-
return returned.payload
141160
}
142161

143162
return Object.assign(actionCreator, {
144163
pending,
145164
rejected,
146-
fulfilled,
147-
unwrapResult
165+
fulfilled
148166
})
149167
}
168+
169+
/**
170+
* @alpha
171+
*/
172+
export function unwrapResult<T>(
173+
returned: { error: any } | { payload: NonNullable<T> }
174+
): NonNullable<T> {
175+
if ('error' in returned) {
176+
throw returned.error
177+
}
178+
return returned.payload
179+
}

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,4 @@ export {
7373
Comparer
7474
} from './entities/models'
7575

76-
export { createAsyncThunk } from './createAsyncThunk'
76+
export { createAsyncThunk, unwrapResult } from './createAsyncThunk'

type-tests/files/createAsyncThunk.typetest.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createAsyncThunk, Dispatch, createReducer, AnyAction } from 'src'
22
import { ThunkDispatch } from 'redux-thunk'
33
import { promises } from 'fs'
4+
import { unwrapResult } from 'src/createAsyncThunk'
45

56
function expectType<T>(t: T) {
67
return t
@@ -44,7 +45,7 @@ function fn() {}
4445
}
4546

4647
promise
47-
.then(async.unwrapResult)
48+
.then(unwrapResult)
4849
.then(result => {
4950
expectType<number>(result)
5051
// typings:expect-error

0 commit comments

Comments
 (0)