Skip to content

allow definition of async thunks from createSlice #637

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

Closed
wants to merge 13 commits into from
Closed
2 changes: 2 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ language: node_js
node_js: node
env:
- TYPESCRIPT_VERSION=rc
- TYPESCRIPT_VERSION=beta
- TYPESCRIPT_VERSION=3.9
- TYPESCRIPT_VERSION=3.8
- TYPESCRIPT_VERSION=3.7
- TYPESCRIPT_VERSION=3.6
Expand Down
6 changes: 4 additions & 2 deletions etc/redux-toolkit.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export type CaseReducer<S = any, A extends Action = AnyAction> = (state: Draft<S
export type CaseReducerActions<CaseReducers extends SliceCaseReducers<any>> = {
[Type in keyof CaseReducers]: CaseReducers[Type] extends {
prepare: any;
} ? ActionCreatorForCaseReducerWithPrepare<CaseReducers[Type]> : ActionCreatorForCaseReducer<CaseReducers[Type]>;
} ? ActionCreatorForCaseReducerWithPrepare<CaseReducers[Type]> : CaseReducers[Type] extends AsyncThunkSliceReducerDefinition<any, infer ThunkArg, infer Returned, infer ThunkApiConfig> ? AsyncThunk<Returned, ThunkArg, ThunkApiConfig> : ActionCreatorForCaseReducer<CaseReducers[Type]>;
};

// @public @deprecated
Expand Down Expand Up @@ -167,7 +167,7 @@ export interface CreateSliceOptions<State = any, CR extends SliceCaseReducers<St
extraReducers?: CaseReducers<NoInfer<State>, any> | ((builder: ActionReducerMapBuilder<NoInfer<State>>) => void);
initialState: State;
name: Name;
reducers: ValidateSliceCaseReducers<State, CR>;
reducers: ValidateSliceCaseReducers<State, CR> | ((creators: ReducerCreators<State>) => CR);
}

export { current }
Expand Down Expand Up @@ -385,6 +385,8 @@ export type SliceActionCreator<P> = PayloadActionCreator<P>;

// @public
export type SliceCaseReducers<State> = {
[K: string]: CaseReducerDefinition<State, PayloadAction<any>> | CaseReducerWithPrepareDefinition<State, PayloadAction<any, string, any, any>> | AsyncThunkSliceReducerDefinition<State, any, any, any>;
} | {
[K: string]: CaseReducer<State, PayloadAction<any>> | CaseReducerWithPrepare<State, PayloadAction<any, string, any, any>>;
};

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"format": "prettier --write \"src/**/*.ts\" \"**/*.md\"",
"format:check": "prettier --list-different \"src/**/*.ts\" \"docs/*/**.md\"",
"lint": "tsdx lint src",
"prepare": "npm run lint && npm run format:check && npm test && npm run build-ci",
"prepare": "echo TODO DISABLED npm run lint && npm run format:check && npm test && npm run build-ci",
"test": "tsdx test"
},
"files": [
Expand Down
20 changes: 20 additions & 0 deletions src/createAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ export type PrepareAction<P> =
| ((...args: any[]) => { payload: P; error: any })
| ((...args: any[]) => { payload: P; meta: any; error: any })

export type _PayloadActionForPrepare<
PA extends PrepareAction<any>,
T extends string = string
> = PA extends PrepareAction<infer P>
? PayloadAction<
P,
T,
ReturnType<PA> extends {
meta: infer M
}
? M
: never,
ReturnType<PA> extends {
error: infer E
}
? E
: never
>
: never

/**
* Internal version of `ActionCreatorWithPreparedPayload`. Not to be used externally.
*
Expand Down
1 change: 1 addition & 0 deletions src/createAsyncThunk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ describe('createAsyncThunk with abortController', () => {

beforeEach(() => {
keepAbortController = window.AbortController
// @ts-ignore
delete window.AbortController
jest.resetModules()
freshlyLoadedModule = require('./createAsyncThunk')
Expand Down
4 changes: 2 additions & 2 deletions src/createAsyncThunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const miniSerializeError = (value: any): SerializedError => {
return { message: String(value) }
}

type AsyncThunkConfig = {
export type AsyncThunkConfig = {
state?: unknown
dispatch?: Dispatch
extra?: unknown
Expand Down Expand Up @@ -191,7 +191,7 @@ type AsyncThunkActionCreator<
: (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
>

interface AsyncThunkOptions<
export interface AsyncThunkOptions<
ThunkArg = void,
ThunkApiConfig extends AsyncThunkConfig = {}
> {
Expand Down
210 changes: 210 additions & 0 deletions src/createSlice.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createSlice } from './createSlice'
import { createAction, PayloadAction } from './createAction'
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'

describe('createSlice', () => {
describe('when slice is undefined', () => {
Expand Down Expand Up @@ -227,4 +229,212 @@ describe('createSlice', () => {
)
})
})

describe('reducers definition with asyncThunks', () => {
function pending(state: any[], action: any) {
state.push(['pendingReducer', action])
}
function fulfilled(state: any[], action: any) {
state.push(['fulfilledReducer', action])
}
function rejected(state: any[], action: any) {
state.push(['rejectedReducer', action])
}

test('successful thunk', async () => {
const slice = createSlice({
name: 'test',
initialState: [],
reducers: create => ({
thunkReducers: create.asyncThunk(
function payloadCreator(arg, api) {
return Promise.resolve('resolved payload')
},
{ pending, fulfilled, rejected }
)
})
})

const store = createStore(slice.reducer, applyMiddleware(thunk))
// @ts-ignore
await store.dispatch(slice.actions.thunkReducers('test'))
expect(store.getState()).toMatchObject([
[
'pendingReducer',
{
type: 'test/thunkReducers/pending',
payload: undefined
}
],
[
'fulfilledReducer',
{
type: 'test/thunkReducers/fulfilled',
payload: 'resolved payload'
}
]
])
})

test('rejected thunk', async () => {
const slice = createSlice({
name: 'test',
initialState: [],
reducers: create => ({
thunkReducers: create.asyncThunk(
function payloadCreator(arg, api) {
throw new Error('')
},
{ pending, fulfilled, rejected }
)
})
})

const store = createStore(slice.reducer, applyMiddleware(thunk))
// @ts-ignore
await store.dispatch(slice.actions.thunkReducers('test'))
expect(store.getState()).toMatchObject([
[
'pendingReducer',
{
type: 'test/thunkReducers/pending',
payload: undefined
}
],
[
'rejectedReducer',
{
type: 'test/thunkReducers/rejected',
payload: undefined
}
]
])
})

test('with options', async () => {
const slice = createSlice({
name: 'test',
initialState: [],
reducers: create => ({
thunkReducers: create.asyncThunk(
function payloadCreator(arg, api) {
return 'should not call this'
},
{
options: {
condition() {
return false
},
dispatchConditionRejection: true
},
pending,
fulfilled,
rejected
}
)
})
})

const store = createStore(slice.reducer, applyMiddleware(thunk))
// @ts-ignore
await store.dispatch(slice.actions.thunkReducers('test'))
expect(store.getState()).toMatchObject([
[
'rejectedReducer',
{
type: 'test/thunkReducers/rejected',
payload: undefined,
meta: { condition: true }
}
]
])
})

test('has caseReducers for the asyncThunk', async () => {
const slice = createSlice({
name: 'test',
initialState: [],
reducers: create => ({
thunkReducers: create.asyncThunk(
function payloadCreator(arg, api) {
return Promise.resolve('resolved payload')
},
{ pending, fulfilled }
)
})
})

expect(slice.caseReducers.thunkReducers.pending).toBe(pending)
expect(slice.caseReducers.thunkReducers.fulfilled).toBe(fulfilled)
// even though it is not defined above, this should at least be a no-op function to match the TypeScript typings
// and should be callable as a reducer even if it does nothing
expect(() =>
slice.caseReducers.thunkReducers.rejected(
[],
slice.actions.thunkReducers.rejected(
new Error('test'),
'fakeRequestId',
{}
)
)
).not.toThrow()
})

test('can define reducer with prepare statement using create.preparedReducer', async () => {
const slice = createSlice({
name: 'test',
initialState: [] as any[],
reducers: create => ({
prepared: create.preparedReducer(
(p: string, m: number, e: { message: string }) => ({
payload: p,
meta: m,
error: e
}),
(state, action) => {
state.push(action)
}
)
})
})

expect(
slice.reducer([], slice.actions.prepared('test', 1, { message: 'err' }))
).toMatchInlineSnapshot(`
Array [
Object {
"error": Object {
"message": "err",
},
"meta": 1,
"payload": "test",
"type": "test/prepared",
},
]
`)
})

test('throws an error when invoked with a normal `prepare` object that has not gone through a `create.preparedReducer` call', async () => {
expect(() =>
createSlice({
name: 'test',
initialState: [] as any[],
reducers: create => ({
prepared: {
prepare: (p: string, m: number, e: { message: string }) => ({
payload: p,
meta: m,
error: e
}),
reducer: (state, action) => {
state.push(action)
}
}
})
})
).toThrowErrorMatchingInlineSnapshot(
`"Please use the \`create.preparedReducer\` notation for prepared action creators with the \`create\` notation."`
)
})
})
})
Loading