Skip to content

Commit

Permalink
allow to skip AsyncThunks using a condition callback
Browse files Browse the repository at this point in the history
  • Loading branch information
phryneas committed Apr 19, 2020
1 parent 261ff26 commit 1493609
Show file tree
Hide file tree
Showing 3 changed files with 134 additions and 5 deletions.
4 changes: 3 additions & 1 deletion etc/redux-toolkit.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,14 @@ 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>;

// @public (undocumented)
export function createAsyncThunk<Returned, ThunkArg = void, ThunkApiConfig extends AsyncThunkConfig = {}>(type: string, payloadCreator: (arg: ThunkArg, thunkAPI: GetThunkAPI<ThunkApiConfig>) => Promise<Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>> | Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>): ((arg: ThunkArg) => (dispatch: GetDispatch<ThunkApiConfig>, getState: () => GetState<ThunkApiConfig>, extra: GetExtra<ThunkApiConfig>) => Promise<PayloadAction<Returned, string, {
export function createAsyncThunk<Returned, ThunkArg = void, ThunkApiConfig extends AsyncThunkConfig = {}>(type: string, payloadCreator: (arg: ThunkArg, thunkAPI: GetThunkAPI<ThunkApiConfig>) => Promise<Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>> | Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>, options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>): ((arg: ThunkArg) => (dispatch: GetDispatch<ThunkApiConfig>, getState: () => GetState<ThunkApiConfig>, extra: GetExtra<ThunkApiConfig>) => Promise<PayloadAction<Returned, string, {
arg: ThunkArg;
requestId: string;
}, never> | PayloadAction<GetRejectValue<ThunkApiConfig> | undefined, string, {
arg: ThunkArg;
requestId: string;
aborted: boolean;
condition: boolean;
}, SerializedError>> & {
abort: (reason?: string | undefined) => void;
}) & {
Expand All @@ -123,6 +124,7 @@ export function createAsyncThunk<Returned, ThunkArg = void, ThunkApiConfig exten
arg: ThunkArg;
requestId: string;
aborted: boolean;
condition: boolean;
}>;
fulfilled: ActionCreatorWithPreparedPayload<[Returned, string, ThunkArg], Returned, string, never, {
arg: ThunkArg;
Expand Down
83 changes: 82 additions & 1 deletion src/createAsyncThunk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
unwrapResult
} from './createAsyncThunk'
import { configureStore } from './configureStore'
import { AnyAction } from 'redux'
import { AnyAction, Dispatch } from 'redux'

import {
mockConsole,
Expand Down Expand Up @@ -476,3 +476,84 @@ test('non-serializable arguments are ignored by serializableStateInvariantMiddle
expect(getLog().log).toMatchInlineSnapshot(`""`)
restore()
})

describe('conditional skipping of asyncThunks', () => {
const arg = {}
const getState = jest.fn(() => ({}))
const dispatch = jest.fn((x: any) => x)
const payloadCreator = jest.fn((x: typeof arg) => 10)
const condition = jest.fn(() => false)
const extra = {}

beforeEach(() => {
getState.mockClear()
dispatch.mockClear()
payloadCreator.mockClear()
condition.mockClear()
})

test('returning false from condition skips payloadCreator and returns a rejected action', async () => {
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
const result = await asyncThunk(arg)(dispatch, getState, extra)

expect(condition).toHaveBeenCalled()
expect(payloadCreator).not.toHaveBeenCalled()
expect(asyncThunk.rejected.match(result)).toBe(true)
expect((result as any).meta.condition).toBe(true)
})

test('returning true from condition executes payloadCreator', async () => {
condition.mockReturnValueOnce(true)
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
const result = await asyncThunk(arg)(dispatch, getState, extra)

expect(condition).toHaveBeenCalled()
expect(payloadCreator).toHaveBeenCalled()
expect(asyncThunk.fulfilled.match(result)).toBe(true)
expect(result.payload).toBe(10)
})

test('condition is called with arg, getState and extra', async () => {
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
await asyncThunk(arg)(dispatch, getState, extra)

expect(condition).toHaveBeenCalledTimes(1)
expect(condition).toHaveBeenLastCalledWith(
arg,
expect.objectContaining({ getState, extra })
)
})

test('rejected action is dispatched by default', async () => {
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
await asyncThunk(arg)(dispatch, getState, extra)

expect(dispatch).toHaveBeenCalledTimes(1)
expect(dispatch).toHaveBeenLastCalledWith(
expect.objectContaining({
error: {
message: 'Aborted due to condition callback returning false.',
name: 'ConditionError'
},
meta: {
aborted: false,
arg: arg,
condition: true,
requestId: expect.stringContaining('')
},
payload: undefined,
type: 'test/rejected'
})
)
})

test('rejected action can be prevented from being dispatched', async () => {
const asyncThunk = createAsyncThunk('test', payloadCreator, {
condition,
dispatchConditionRejection: false
})
await asyncThunk(arg)(dispatch, getState, extra)

expect(dispatch).toHaveBeenCalledTimes(0)
})
})
52 changes: 49 additions & 3 deletions src/createAsyncThunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,35 @@ type GetRejectValue<ThunkApiConfig> = ThunkApiConfig extends {
? RejectValue
: unknown

interface AsyncThunkOptions<
ThunkArg = void,
ThunkApiConfig extends AsyncThunkConfig = {}
> {
/**
* A method to control whether the asyncThunk should be executed. Has access to the
* `arg`, `api.getState()` and `api.extra` arguments.
*
* @returns `true` if the asyncThunk should be executed, `false` if it should be skipped
*/
condition?(
arg: ThunkArg,
api: Pick<GetThunkAPI<ThunkApiConfig>, 'getState' | 'extra'>
): boolean
/**
* If `condition` returns `false`, the asyncThunk will be skipped.
* This option allows you to control whether a `rejected` action with `meta.condition == false`
* will be dispatched or not.
*
* @default `true`
*/
dispatchConditionRejection?: boolean
}

/**
*
* @param type
* @param payloadCreator
* @param options
*
* @public
*/
Expand All @@ -122,7 +147,8 @@ export function createAsyncThunk<
) =>
| Promise<Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>>
| Returned
| RejectWithValue<GetRejectValue<ThunkApiConfig>>
| RejectWithValue<GetRejectValue<ThunkApiConfig>>,
options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
) {
type RejectedValue = GetRejectValue<ThunkApiConfig>

Expand Down Expand Up @@ -155,13 +181,15 @@ export function createAsyncThunk<
payload?: RejectedValue
) => {
const aborted = !!error && error.name === 'AbortError'
const condition = !!error && error.name === 'ConditionError'
return {
payload,
error: miniSerializeError(error || 'Rejected'),
meta: {
arg,
requestId,
aborted
aborted,
condition
}
}
}
Expand Down Expand Up @@ -220,6 +248,16 @@ 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 })
) {
throw {
name: 'ConditionError',
message: 'Aborted due to condition callback returning false.'
}
}
dispatch(pending(requestId, arg))
finalAction = await Promise.race([
abortedPromise,
Expand Down Expand Up @@ -249,7 +287,15 @@ If you want to use the AbortController to react to \`abort\` events, please cons
// 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)
const skipDispatch =
options &&
options.dispatchConditionRejection === false &&
rejected.match(finalAction) &&
finalAction.meta.condition

if (!skipDispatch) {
dispatch(finalAction)
}
return finalAction
})()
return Object.assign(promise, { abort })
Expand Down

0 comments on commit 1493609

Please sign in to comment.