Skip to content

Commit b6fbefc

Browse files
committed
add .abort() to the createAsyncThunk thunkAction
1 parent d13d26a commit b6fbefc

File tree

5 files changed

+205
-31
lines changed

5 files changed

+205
-31
lines changed

etc/redux-toolkit.api.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,32 +102,27 @@ 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<Returned, string, {
106106
args: ActionParams;
107107
requestId: string;
108108
}, never> | import("./createAction").PayloadAction<undefined, string, {
109109
args: ActionParams;
110110
requestId: string;
111-
}, Error>>) & {
111+
}, any>>) & {
112+
abort: (reason?: string) => void;
113+
}) & {
112114
pending: import("./createAction").ActionCreatorWithPreparedPayload<[string, ActionParams], undefined, string, never, {
113115
args: ActionParams;
114116
requestId: string;
115117
}>;
116-
rejected: import("./createAction").ActionCreatorWithPreparedPayload<[Error, string, ActionParams], undefined, string, Error, {
118+
rejected: import("./createAction").ActionCreatorWithPreparedPayload<[Error, string, ActionParams], undefined, string, any, {
117119
args: ActionParams;
118120
requestId: string;
119121
}>;
120122
fulfilled: import("./createAction").ActionCreatorWithPreparedPayload<[Returned, string, ActionParams], Returned, string, never, {
121123
args: ActionParams;
122124
requestId: string;
123125
}>;
124-
unwrapResult: (returned: import("./createAction").PayloadAction<Returned, string, {
125-
args: ActionParams;
126-
requestId: string;
127-
}, never> | import("./createAction").PayloadAction<undefined, string, {
128-
args: ActionParams;
129-
requestId: string;
130-
}, Error>) => Returned;
131126
};
132127

133128
// @alpha (undocumented)
@@ -273,6 +268,13 @@ export type SliceCaseReducers<State> = {
273268

274269
export { ThunkAction }
275270

271+
// @alpha (undocumented)
272+
export function unwrapResult<T>(returned: {
273+
error: any;
274+
} | {
275+
payload: NonNullable<T>;
276+
}): NonNullable<T>;
277+
276278
// @alpha (undocumented)
277279
export type Update<T> = UpdateStr<T> | UpdateNum<T>;
278280

src/createAsyncThunk.test.ts

Lines changed: 114 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,111 @@ 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 thunkAction = asyncThunk({})
159+
const promise = store.dispatch(thunkAction)
160+
thunkAction.abort('AbortReason')
161+
const result = await promise
162+
const expectedAbortedAction = {
163+
type: 'test/aborted',
164+
error: {
165+
message: 'AbortReason',
166+
name: 'AbortError'
167+
},
168+
meta: { reason: 'AbortReason' }
169+
}
170+
// abortedAction with reason is dispatched after test/pending is dispatched
171+
expect(store.getState()).toMatchObject([
172+
{},
173+
{ type: 'test/pending' },
174+
expectedAbortedAction
175+
])
176+
177+
// same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
178+
expect(result).toMatchObject({
179+
...expectedAbortedAction,
180+
error: {
181+
message: 'Was aborted while running',
182+
name: 'AbortError'
183+
}
184+
})
185+
186+
// calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
187+
expect(() => unwrapResult(result)).toThrowError(
188+
expect.objectContaining({
189+
message: 'Was aborted while running',
190+
name: 'AbortError'
191+
})
192+
)
193+
})
194+
195+
test('abort before dispatch', async () => {
196+
const thunkAction = asyncThunk({})
197+
thunkAction.abort('AbortReason')
198+
const result = await store.dispatch(thunkAction)
199+
200+
const expectedAbortedAction = {
201+
type: 'test/aborted',
202+
error: {
203+
message: 'AbortReason',
204+
name: 'AbortError'
205+
},
206+
meta: { reason: 'AbortReason' }
207+
}
208+
// abortedAction with reason is dispatched without test/pending being dispatched
209+
expect(store.getState()).toMatchObject([{}, expectedAbortedAction])
210+
211+
// same abortedAction is returned
212+
expect(result).toMatchObject(expectedAbortedAction)
213+
214+
// unwrapResult throws AbortedError from returned action
215+
expect(() => unwrapResult(result)).toThrowError(
216+
expect.objectContaining(expectedAbortedAction.error)
217+
)
218+
})
219+
})

src/createAsyncThunk.ts

Lines changed: 76 additions & 18 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 {
@@ -84,24 +83,67 @@ export function createAsyncThunk<
8483
}
8584
)
8685

86+
const aborted = createAction(
87+
type + '/aborted',
88+
(requestId: string, args: ActionParams, abortError: DOMException) => {
89+
return {
90+
payload: undefined,
91+
meta: { args, requestId, reason: abortError.message },
92+
error: miniSerializeError(abortError)
93+
}
94+
}
95+
)
96+
8797
const rejected = createAction(
8898
type + '/rejected',
8999
(error: Error, requestId: string, args: ActionParams) => {
90100
return {
91101
payload: undefined,
92-
error,
102+
error: miniSerializeError(error),
93103
meta: { args, requestId }
94104
}
95105
}
96106
)
97107

98108
function actionCreator(args: ActionParams) {
99-
return async (
109+
const abortController = new AbortController()
110+
111+
let dispatchAbort:
112+
| undefined
113+
| ((reason: string) => ReturnType<typeof aborted>)
114+
115+
let abortReason: string
116+
117+
function abort(reason: string = 'Aborted.') {
118+
abortController.abort()
119+
if (dispatchAbort) {
120+
dispatchAbort(reason)
121+
} else {
122+
abortReason = reason
123+
}
124+
}
125+
126+
async function thunkAction(
100127
dispatch: TA['dispatch'],
101128
getState: TA['getState'],
102129
extra: TA['extra']
103-
) => {
130+
) {
104131
const requestId = nanoid()
132+
let abortAction: ReturnType<typeof aborted> | undefined
133+
dispatchAbort = reason => {
134+
abortAction = aborted(
135+
requestId,
136+
args,
137+
new DOMException(reason, 'AbortError')
138+
)
139+
dispatch(abortAction)
140+
return abortAction
141+
}
142+
// if the thunkAction.abort() method has been called before the thunkAction was dispatched,
143+
// just dispatch an aborted-action and never start with the thunk
144+
if (abortController.signal.aborted) {
145+
return dispatchAbort(abortReason)
146+
}
105147

106148
let finalAction: ReturnType<typeof fulfilled | typeof rejected>
107149
try {
@@ -112,14 +154,26 @@ export function createAsyncThunk<
112154
dispatch,
113155
getState,
114156
extra,
115-
requestId
157+
requestId,
158+
signal: abortController.signal
116159
} as TA),
117160
requestId,
118161
args
119162
)
120163
} catch (err) {
121-
const serializedError = miniSerializeError(err)
122-
finalAction = rejected(serializedError, requestId, args)
164+
if (
165+
err instanceof DOMException &&
166+
err.name === 'AbortError' &&
167+
abortAction
168+
) {
169+
// abortAction has already been dispatched, no further action should be dispatched
170+
// by this thunk.
171+
// return a copy of the dispatched abortAction, but attach the AbortError to it.
172+
return Object.assign({}, abortAction, {
173+
error: miniSerializeError(err)
174+
})
175+
}
176+
finalAction = rejected(err, requestId, args)
123177
}
124178

125179
// We dispatch "success" _after_ the catch, to avoid having any errors
@@ -129,21 +183,25 @@ export function createAsyncThunk<
129183
dispatch(finalAction)
130184
return finalAction
131185
}
132-
}
133186

134-
function unwrapResult(
135-
returned: Await<ReturnType<ReturnType<typeof actionCreator>>>
136-
) {
137-
if (rejected.match(returned)) {
138-
throw returned.error
139-
}
140-
return returned.payload
187+
return Object.assign(thunkAction, { abort })
141188
}
142189

143190
return Object.assign(actionCreator, {
144191
pending,
145192
rejected,
146-
fulfilled,
147-
unwrapResult
193+
fulfilled
148194
})
149195
}
196+
197+
/**
198+
* @alpha
199+
*/
200+
export function unwrapResult<T>(
201+
returned: { error: any } | { payload: NonNullable<T> }
202+
): NonNullable<T> {
203+
if ('error' in returned) {
204+
throw returned.error
205+
}
206+
return returned.payload
207+
}

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)