Skip to content

add .abort() to the createAsyncThunk thunkAction #362

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions etc/redux-toolkit.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,32 +102,37 @@ export function createAction<P = void, T extends string = string>(type: T): Payl
export function createAction<PA extends PrepareAction<any>, T extends string = string>(type: T, prepareAction: PA): PayloadActionCreator<ReturnType<PA>['payload'], T, PA>;

// @alpha (undocumented)
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, {
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, {
args: ActionParams;
requestId: string;
}, never> | import("./createAction").PayloadAction<undefined, string, {
} | {
aborted: boolean;
abortReason: string;
args: ActionParams;
requestId: string;
}, Error>>) & {
}, any> | import("./createAction").PayloadAction<Returned, string, {
args: ActionParams;
requestId: string;
}, never>> & {
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<Returned, string, {
args: ActionParams;
requestId: string;
}, never> | import("./createAction").PayloadAction<undefined, string, {
fulfilled: import("./createAction").ActionCreatorWithPreparedPayload<[Returned, string, ActionParams], Returned, string, never, {
args: ActionParams;
requestId: string;
}, Error>) => Returned;
}>;
};

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

export { ThunkAction }

// @alpha (undocumented)
export function unwrapResult<T>(returned: {
error: any;
} | {
payload: NonNullable<T>;
}): NonNullable<T>;

// @alpha (undocumented)
export type Update<T> = UpdateStr<T> | UpdateNum<T>;

Expand Down
89 changes: 88 additions & 1 deletion src/createAsyncThunk.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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'
})
)
})
})
102 changes: 66 additions & 36 deletions src/createAsyncThunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import { Dispatch } from 'redux'
import nanoid from 'nanoid'
import { createAction } from './createAction'

type Await<P> = P extends PromiseLike<infer T> ? T : P

type AsyncThunksArgs<S, E, D extends Dispatch = Dispatch> = {
dispatch: D
getState: S
extra: E
requestId: string
signal: AbortSignal
}

interface SimpleError {
Expand Down Expand Up @@ -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<typeof rejected> | undefined

let finalAction: ReturnType<typeof fulfilled | typeof rejected>
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<ReturnType<ReturnType<typeof actionCreator>>>
) {
if (rejected.match(returned)) {
throw returned.error
const promise = (async function() {
let finalAction: ReturnType<typeof fulfilled | typeof rejected>
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<T>(
returned: { error: any } | { payload: NonNullable<T> }
): NonNullable<T> {
if ('error' in returned) {
throw returned.error
}
return returned.payload
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,4 @@ export {
Comparer
} from './entities/models'

export { createAsyncThunk } from './createAsyncThunk'
export { createAsyncThunk, unwrapResult } from './createAsyncThunk'
3 changes: 2 additions & 1 deletion type-tests/files/createAsyncThunk.typetest.ts
Original file line number Diff line number Diff line change
@@ -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: T) {
return t
Expand Down Expand Up @@ -44,7 +45,7 @@ function fn() {}
}

promise
.then(async.unwrapResult)
.then(unwrapResult)
.then(result => {
expectType<number>(result)
// typings:expect-error
Expand Down