Skip to content

Commit

Permalink
feat(createAsyncThunk): async condition (#1496)
Browse files Browse the repository at this point in the history
  • Loading branch information
thorn0 committed Oct 1, 2021
1 parent 38a9316 commit 3fb4526
Show file tree
Hide file tree
Showing 3 changed files with 71 additions and 36 deletions.
4 changes: 2 additions & 2 deletions docs/api/createAsyncThunk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ The logic in the `payloadCreator` function may use any of these values as needed

An object with the following optional fields:

- `condition(arg, { getState, extra } ): boolean`: a callback that can be used to skip execution of the payload creator and all action dispatches, if desired. See [Canceling Before Execution](#canceling-before-execution) for a complete description.
- `condition(arg, { getState, extra } ): boolean | Promise<boolean>`: a callback that can be used to skip execution of the payload creator and all action dispatches, if desired. See [Canceling Before Execution](#canceling-before-execution) for a complete description.
- `dispatchConditionRejection`: if `condition()` returns `false`, the default behavior is that no actions will be dispatched at all. If you still want a "rejected" action to be dispatched when the thunk was canceled, set this flag to `true`.
- `idGenerator(): string`: a function to use when generating the `requestId` for the request sequence. Defaults to use [nanoid](./otherExports.mdx/#nanoid).
- `serializeError(error: unknown) => any` to replace the internal `miniSerializeError` method with your own serialization logic.
Expand Down Expand Up @@ -357,7 +357,7 @@ const updateUser = createAsyncThunk(

### Canceling Before Execution

If you need to cancel a thunk before the payload creator is called, you may provide a `condition` callback as an option after the payload creator. The callback will receive the thunk argument and an object with `{getState, extra}` as parameters, and use those to decide whether to continue or not. If the execution should be canceled, the `condition` callback should return a literal `false` value:
If you need to cancel a thunk before the payload creator is called, you may provide a `condition` callback as an option after the payload creator. The callback will receive the thunk argument and an object with `{getState, extra}` as parameters, and use those to decide whether to continue or not. If the execution should be canceled, the `condition` callback should return a literal `false` value or a promise that should resolve to `false`. If a promise is returned, the thunk waits for it to get fulfilled before dispatching the `pending` action, otherwise it proceeds with dispatching synchronously.

```js
const fetchUserById = createAsyncThunk(
Expand Down
20 changes: 14 additions & 6 deletions packages/toolkit/src/createAsyncThunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ export type AsyncThunkOptions<
condition?(
arg: ThunkArg,
api: Pick<GetThunkAPI<ThunkApiConfig>, 'getState' | 'extra'>
): boolean | undefined
): MaybePromise<boolean | undefined>
/**
* If `condition` returns `false`, the asyncThunk will be skipped.
* This option allows you to control whether a `rejected` action with `meta.condition == false`
Expand Down Expand Up @@ -553,11 +553,11 @@ If you want to use the AbortController to react to \`abort\` events, please cons
const promise = (async function () {
let finalAction: ReturnType<typeof fulfilled | typeof rejected>
try {
if (
options &&
options.condition &&
options.condition(arg, { getState, extra }) === false
) {
let conditionResult = options?.condition?.(arg, { getState, extra })
if (isThenable(conditionResult)) {
conditionResult = await conditionResult
}
if (conditionResult === false) {
// eslint-disable-next-line no-throw-literal
throw {
name: 'ConditionError',
Expand Down Expand Up @@ -678,3 +678,11 @@ export function unwrapResult<R extends UnwrappableAction>(
type WithStrictNullChecks<True, False> = undefined extends boolean
? False
: True

function isThenable(value: any): value is PromiseLike<any> {
return (
value !== null &&
typeof value === 'object' &&
typeof value.then === 'function'
)
}
83 changes: 55 additions & 28 deletions packages/toolkit/src/tests/createAsyncThunk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,32 @@ describe('conditional skipping of asyncThunks', () => {
)
})

test('pending is dispatched synchronously if condition is synchronous', async () => {
const condition = () => true
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
const thunkCallPromise = asyncThunk(arg)(dispatch, getState, extra)
expect(dispatch).toHaveBeenCalledTimes(1)
await thunkCallPromise
expect(dispatch).toHaveBeenCalledTimes(2)
})

test('async condition', async () => {
const condition = () => Promise.resolve(false)
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
await asyncThunk(arg)(dispatch, getState, extra)
expect(dispatch).toHaveBeenCalledTimes(0)
})

test('async condition with rejected promise', async () => {
const condition = () => Promise.reject()
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
await asyncThunk(arg)(dispatch, getState, extra)
expect(dispatch).toHaveBeenCalledTimes(1)
expect(dispatch).toHaveBeenLastCalledWith(
expect.objectContaining({ type: 'test/rejected' })
)
})

test('rejected action is not dispatched by default', async () => {
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
await asyncThunk(arg)(dispatch, getState, extra)
Expand Down Expand Up @@ -644,38 +670,39 @@ describe('conditional skipping of asyncThunks', () => {
})
)
})
})

test('serializeError implementation', async () => {
function serializeError() {
return 'serialized!'
}
const errorObject = 'something else!'
test('serializeError implementation', async () => {
function serializeError() {
return 'serialized!'
}
const errorObject = 'something else!'

const store = configureStore({
reducer: (state = [], action) => [...state, action],
})
const store = configureStore({
reducer: (state = [], action) => [...state, action],
})

const asyncThunk = createAsyncThunk<
unknown,
void,
{ serializedErrorType: string }
>('test', () => Promise.reject(errorObject), { serializeError })
const rejected = await store.dispatch(asyncThunk())
if (!asyncThunk.rejected.match(rejected)) {
throw new Error()
}
const asyncThunk = createAsyncThunk<
unknown,
void,
{ serializedErrorType: string }
>('test', () => Promise.reject(errorObject), { serializeError })
const rejected = await store.dispatch(asyncThunk())
if (!asyncThunk.rejected.match(rejected)) {
throw new Error()
}

const expectation = {
type: 'test/rejected',
payload: undefined,
error: 'serialized!',
meta: expect.any(Object),
}
expect(rejected).toEqual(expectation)
expect(store.getState()[2]).toEqual(expectation)
expect(rejected.error).not.toEqual(miniSerializeError(errorObject))
})
const expectation = {
type: 'test/rejected',
payload: undefined,
error: 'serialized!',
meta: expect.any(Object),
}
expect(rejected).toEqual(expectation)
expect(store.getState()[2]).toEqual(expectation)
expect(rejected.error).not.toEqual(miniSerializeError(errorObject))
})

describe('unwrapResult', () => {
const getState = jest.fn(() => ({}))
const dispatch = jest.fn((x: any) => x)
Expand Down Expand Up @@ -790,7 +817,7 @@ describe('idGenerator option', () => {
})
})

test('`condition` will see state changes from a synchonously invoked asyncThunk', () => {
test('`condition` will see state changes from a synchronously invoked asyncThunk', () => {
type State = ReturnType<typeof store.getState>
const onStart = jest.fn()
const asyncThunk = createAsyncThunk<
Expand Down

0 comments on commit 3fb4526

Please sign in to comment.