Skip to content
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
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