Skip to content

Commit

Permalink
Rework createAsyncThunk error handling behavior
Browse files Browse the repository at this point in the history
- Removed `finished` action
- Serialized `Error` objects to a plain object
- Ensured errors in `fulfilled` dispatches won't get caught wrongly
- Changed to re-throw errors in case the user wants to handle them
  • Loading branch information
markerikson committed Feb 15, 2020
1 parent 9d57d62 commit d0f44b8
Show file tree
Hide file tree
Showing 3 changed files with 56 additions and 39 deletions.
30 changes: 13 additions & 17 deletions src/createAsyncThunk.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createAsyncThunk } from './createAsyncThunk'
import { createAsyncThunk, miniSerializeError } from './createAsyncThunk'
import { configureStore } from './configureStore'

describe('createAsyncThunk', () => {
Expand All @@ -7,7 +7,6 @@ describe('createAsyncThunk', () => {

expect(thunkActionCreator.fulfilled.type).toBe('testType/fulfilled')
expect(thunkActionCreator.pending.type).toBe('testType/pending')
expect(thunkActionCreator.finished.type).toBe('testType/finished')
expect(thunkActionCreator.rejected.type).toBe('testType/rejected')
})

Expand All @@ -29,7 +28,7 @@ describe('createAsyncThunk', () => {

await store.dispatch(thunkActionCreator())

expect(timesReducerCalled).toBe(3)
expect(timesReducerCalled).toBe(2)
})

it('accepts arguments and dispatches the actions on resolve', async () => {
Expand Down Expand Up @@ -65,11 +64,6 @@ describe('createAsyncThunk', () => {
2,
thunkActionCreator.fulfilled(result, generatedRequestId, args)
)

expect(dispatch).toHaveBeenNthCalledWith(
3,
thunkActionCreator.finished(generatedRequestId, args)
)
})

it('accepts arguments and dispatches the actions on reject', async () => {
Expand All @@ -90,21 +84,23 @@ describe('createAsyncThunk', () => {

const thunkFunction = thunkActionCreator(args)

await thunkFunction(dispatch, undefined, undefined)
try {
await thunkFunction(dispatch, undefined, undefined)
} catch (e) {}

expect(dispatch).toHaveBeenNthCalledWith(
1,
thunkActionCreator.pending(generatedRequestId, args)
)

expect(dispatch).toHaveBeenNthCalledWith(
2,
thunkActionCreator.rejected(error, generatedRequestId, args)
)
expect(dispatch).toHaveBeenCalledTimes(2)

expect(dispatch).toHaveBeenNthCalledWith(
3,
thunkActionCreator.finished(generatedRequestId, args)
)
console.log(dispatch.mock.calls)

// Have to check the bits of the action separately since the error was processed
const errorAction = dispatch.mock.calls[1][0]
expect(errorAction.error).toEqual(miniSerializeError(error))
expect(errorAction.meta.requestId).toBe(generatedRequestId)
expect(errorAction.meta.args).toBe(args)
})
})
64 changes: 43 additions & 21 deletions src/createAsyncThunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,36 @@ type AsyncThunksArgs<S, E, D extends Dispatch = Dispatch> = {
requestId: string
}

interface SimpleError {
name?: string
message?: string
stack?: string
code?: string
}

const commonProperties: (keyof SimpleError)[] = [
'name',
'message',
'stack',
'code'
]

// Reworked from https://github.com/sindresorhus/serialize-error
export const miniSerializeError = (value: any): any => {
if (typeof value === 'object' && value !== null) {
const simpleError: SimpleError = {}
for (const property of commonProperties) {
if (typeof value[property] === 'string') {
simpleError[property] = value[property]
}
}

return simpleError
}

return value
}

/**
*
* @param type
Expand Down Expand Up @@ -52,16 +82,6 @@ export function createAsyncThunk<
}
)

const finished = createAction(
type + '/finished',
(requestId: string, args: ActionParams) => {
return {
payload: undefined,
meta: { args, requestId }
}
}
)

const rejected = createAction(
type + '/rejected',
(error: Error, requestId: string, args: ActionParams) => {
Expand All @@ -81,32 +101,34 @@ export function createAsyncThunk<
) => {
const requestId = nanoid()

let result: Returned
try {
dispatch(pending(requestId, args))
// TODO Also ugly types
const result = (await payloadCreator(args, {

result = (await payloadCreator(args, {
dispatch,
getState,
extra,
requestId
} as TA)) as Returned

// TODO How do we avoid errors in here from hitting the catch clause?
return dispatch(fulfilled(result, requestId, args))
} catch (err) {
// TODO Errors aren't serializable
dispatch(rejected(err, requestId, args))
} finally {
// TODO IS there really a benefit from a "finished" action?
dispatch(finished(requestId, args))
const serializedError = miniSerializeError(err)
dispatch(rejected(serializedError, requestId, args))
// Rethrow this so the user can handle if desired
throw err
}

// 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
return dispatch(fulfilled(result!, requestId, args))
}
}

actionCreator.pending = pending
actionCreator.rejected = rejected
actionCreator.fulfilled = fulfilled
actionCreator.finished = finished

return actionCreator
}
1 change: 0 additions & 1 deletion type-tests/files/createAsyncThunk.typetest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,5 @@ function fn() {}
expectType<number>(action.payload)
})
.addCase(async.rejected, (_, action) => {})
.addCase(async.finished, (_, action) => {})
)
}

0 comments on commit d0f44b8

Please sign in to comment.