planning
All checks were successful
Publish To Prod / deploy_and_publish (push) Successful in 35s

This commit is contained in:
2024-10-14 09:15:30 +02:00
parent bcba00a730
commit 6e64e138e2
21059 changed files with 2317811 additions and 1 deletions

View File

@@ -0,0 +1,135 @@
import { configureStore } from '@reduxjs/toolkit'
import type { StoreEnhancer } from 'redux'
declare const expectType: <T>(t: T) => T
declare const enhancer1: StoreEnhancer<
{
has1: true
},
{ stateHas1: true }
>
declare const enhancer2: StoreEnhancer<
{
has2: true
},
{ stateHas2: true }
>
{
// prepend single element
{
const store = configureStore({
reducer: () => 0,
enhancers: (dE) => dE.prepend(enhancer1),
})
expectType<true>(store.has1)
expectType<true>(store.getState().stateHas1)
// @ts-expect-error
expectType<true>(store.has2)
// @ts-expect-error
expectType<true>(store.getState().stateHas2)
}
// prepend multiple (rest)
{
const store = configureStore({
reducer: () => 0,
enhancers: (dE) => dE.prepend(enhancer1, enhancer2),
})
expectType<true>(store.has1)
expectType<true>(store.getState().stateHas1)
expectType<true>(store.has2)
expectType<true>(store.getState().stateHas2)
// @ts-expect-error
expectType<true>(store.has3)
// @ts-expect-error
expectType<true>(store.getState().stateHas3)
}
// prepend multiple (array notation)
{
const store = configureStore({
reducer: () => 0,
enhancers: (dE) => dE.prepend([enhancer1, enhancer2] as const),
})
expectType<true>(store.has1)
expectType<true>(store.getState().stateHas1)
expectType<true>(store.has2)
expectType<true>(store.getState().stateHas2)
// @ts-expect-error
expectType<true>(store.has3)
// @ts-expect-error
expectType<true>(store.getState().stateHas3)
}
// concat single element
{
const store = configureStore({
reducer: () => 0,
enhancers: (dE) => dE.concat(enhancer1),
})
expectType<true>(store.has1)
expectType<true>(store.getState().stateHas1)
// @ts-expect-error
expectType<true>(store.has2)
// @ts-expect-error
expectType<true>(store.getState().stateHas2)
}
// prepend multiple (rest)
{
const store = configureStore({
reducer: () => 0,
enhancers: (dE) => dE.concat(enhancer1, enhancer2),
})
expectType<true>(store.has1)
expectType<true>(store.getState().stateHas1)
expectType<true>(store.has2)
expectType<true>(store.getState().stateHas2)
// @ts-expect-error
expectType<true>(store.has3)
// @ts-expect-error
expectType<true>(store.getState().stateHas3)
}
// concat multiple (array notation)
{
const store = configureStore({
reducer: () => 0,
enhancers: (dE) => dE.concat([enhancer1, enhancer2] as const),
})
expectType<true>(store.has1)
expectType<true>(store.getState().stateHas1)
expectType<true>(store.has2)
expectType<true>(store.getState().stateHas2)
// @ts-expect-error
expectType<true>(store.has3)
// @ts-expect-error
expectType<true>(store.getState().stateHas3)
}
// concat and prepend
{
const store = configureStore({
reducer: () => 0,
enhancers: (dE) => dE.concat(enhancer1).prepend(enhancer2),
})
expectType<true>(store.has1)
expectType<true>(store.getState().stateHas1)
expectType<true>(store.has2)
expectType<true>(store.getState().stateHas2)
// @ts-expect-error
expectType<true>(store.has3)
// @ts-expect-error
expectType<true>(store.getState().stateHas3)
}
}

View File

@@ -0,0 +1,118 @@
import { getDefaultMiddleware, configureStore } from '@reduxjs/toolkit'
import type { Middleware } from 'redux'
declare const expectType: <T>(t: T) => T
declare const middleware1: Middleware<{
(_: string): number
}>
declare const middleware2: Middleware<{
(_: number): string
}>
type ThunkReturn = Promise<'thunk'>
declare const thunkCreator: () => () => ThunkReturn
{
// prepend single element
{
const store = configureStore({
reducer: () => 0,
middleware: (gDM) => gDM().prepend(middleware1),
})
expectType<number>(store.dispatch('foo'))
expectType<ThunkReturn>(store.dispatch(thunkCreator()))
// @ts-expect-error
expectType<string>(store.dispatch('foo'))
}
// prepend multiple (rest)
{
const store = configureStore({
reducer: () => 0,
middleware: (gDM) => gDM().prepend(middleware1, middleware2),
})
expectType<number>(store.dispatch('foo'))
expectType<string>(store.dispatch(5))
expectType<ThunkReturn>(store.dispatch(thunkCreator()))
// @ts-expect-error
expectType<string>(store.dispatch('foo'))
}
// prepend multiple (array notation)
{
const store = configureStore({
reducer: () => 0,
middleware: (gDM) => gDM().prepend([middleware1, middleware2] as const),
})
expectType<number>(store.dispatch('foo'))
expectType<string>(store.dispatch(5))
expectType<ThunkReturn>(store.dispatch(thunkCreator()))
// @ts-expect-error
expectType<string>(store.dispatch('foo'))
}
// concat single element
{
const store = configureStore({
reducer: () => 0,
middleware: (gDM) => gDM().concat(middleware1),
})
expectType<number>(store.dispatch('foo'))
expectType<ThunkReturn>(store.dispatch(thunkCreator()))
// @ts-expect-error
expectType<string>(store.dispatch('foo'))
}
// prepend multiple (rest)
{
const store = configureStore({
reducer: () => 0,
middleware: (gDM) => gDM().concat(middleware1, middleware2),
})
expectType<number>(store.dispatch('foo'))
expectType<string>(store.dispatch(5))
expectType<ThunkReturn>(store.dispatch(thunkCreator()))
// @ts-expect-error
expectType<string>(store.dispatch('foo'))
}
// concat multiple (array notation)
{
const store = configureStore({
reducer: () => 0,
middleware: (gDM) => gDM().concat([middleware1, middleware2] as const),
})
expectType<number>(store.dispatch('foo'))
expectType<string>(store.dispatch(5))
expectType<ThunkReturn>(store.dispatch(thunkCreator()))
// @ts-expect-error
expectType<string>(store.dispatch('foo'))
}
// concat and prepend
{
const store = configureStore({
reducer: () => 0,
middleware: (gDM) => gDM().concat(middleware1).prepend(middleware2),
})
expectType<number>(store.dispatch('foo'))
expectType<string>(store.dispatch(5))
expectType<ThunkReturn>(store.dispatch(thunkCreator()))
// @ts-expect-error
expectType<string>(store.dispatch('foo'))
}
}

View File

@@ -0,0 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createAsyncThunk with abortController behaviour with missing AbortController calling \`abort\` on an asyncThunk works with a FallbackAbortController if no global abortController is not available 1`] = `
"This platform does not implement AbortController.
If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'."
`;
exports[`non-serializable arguments are ignored by serializableStateInvariantMiddleware 1`] = `""`;

View File

@@ -0,0 +1,54 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`serializableStateInvariantMiddleware Should log an error when a non-serializable action is dispatched 1`] = `
"A non-serializable value was detected in an action, in the path: \`type\`. Value: Symbol(SOME_CONSTANT)
Take a look at the logic that dispatched this action: Object {
\\"type\\": Symbol(SOME_CONSTANT),
}
(See https://redux.js.org/faq/actions#why-should-type-be-a-string-or-at-least-serializable-why-should-my-action-types-be-constants)
(To allow non-serializable values see: https://redux-toolkit.js.org/usage/usage-guide#working-with-non-serializable-data)"
`;
exports[`serializableStateInvariantMiddleware Should log an error when a non-serializable value is in state 1`] = `
"A non-serializable value was detected in the state, in the path: \`testSlice.a\`. Value: Map {}
Take a look at the reducer(s) handling this action type: TEST_ACTION.
(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)"
`;
exports[`serializableStateInvariantMiddleware allows ignoring state entirely 1`] = `""`;
exports[`serializableStateInvariantMiddleware consumer tolerated structures Should log an error when a non-serializable value is nested in state 1`] = `
"A non-serializable value was detected in the state, in the path: \`testSlice.a.entries\`. Value: [Function entries]
Take a look at the reducer(s) handling this action type: TEST_ACTION.
(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)"
`;
exports[`serializableStateInvariantMiddleware consumer tolerated structures Should use consumer supplied isSerializable and getEntries options to tolerate certain structures 1`] = `
"A non-serializable value was detected in the state, in the path: \`testSlice.a.third.bad-map-instance\`. Value: Map {}
Take a look at the reducer(s) handling this action type: TEST_ACTION.
(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)"
`;
exports[`serializableStateInvariantMiddleware ignored action paths can specify (multiple) different values 1`] = `""`;
exports[`serializableStateInvariantMiddleware ignored action paths can specify regexp 1`] = `""`;
exports[`serializableStateInvariantMiddleware ignored action paths default value can be overridden 1`] = `
"A non-serializable value was detected in an action, in the path: \`meta.arg\`. Value: Map {}
Take a look at the logic that dispatched this action: Object {
\\"meta\\": Object {
\\"arg\\": Map {},
},
\\"type\\": \\"test\\",
}
(See https://redux.js.org/faq/actions#why-should-type-be-a-string-or-at-least-serializable-why-should-my-action-types-be-constants)
(To allow non-serializable values see: https://redux-toolkit.js.org/usage/usage-guide#working-with-non-serializable-data)"
`;
exports[`serializableStateInvariantMiddleware ignored action paths default value: meta.arg 1`] = `""`;
exports[`serializableStateInvariantMiddleware should not check serializability for ignored slice names 1`] = `
"A non-serializable value was detected in the state, in the path: \`testSlice.b.d\`. Value: Map {}
Take a look at the reducer(s) handling this action type: TEST_ACTION.
(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)"
`;

View File

@@ -0,0 +1,64 @@
import type { ActionCreatorInvariantMiddlewareOptions } from '@internal/actionCreatorInvariantMiddleware'
import { getMessage } from '@internal/actionCreatorInvariantMiddleware'
import { createActionCreatorInvariantMiddleware } from '@internal/actionCreatorInvariantMiddleware'
import type { Dispatch, MiddlewareAPI } from '@reduxjs/toolkit'
import { createAction } from '@reduxjs/toolkit'
describe('createActionCreatorInvariantMiddleware', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
afterEach(() => {
consoleSpy.mockClear()
})
afterAll(() => {
consoleSpy.mockRestore()
})
const dummyAction = createAction('aSlice/anAction')
it('sends the action through the middleware chain', () => {
const next: Dispatch = (action) => ({
...action,
returned: true,
})
const dispatch = createActionCreatorInvariantMiddleware()(
{} as MiddlewareAPI
)(next)
expect(dispatch(dummyAction())).toEqual({
...dummyAction(),
returned: true,
})
})
const makeActionTester = (
options?: ActionCreatorInvariantMiddlewareOptions
) =>
createActionCreatorInvariantMiddleware(options)({} as MiddlewareAPI)(
(action) => action
)
it('logs a warning to console if an action creator is mistakenly dispatched', () => {
const testAction = makeActionTester()
testAction(dummyAction())
expect(consoleSpy).not.toHaveBeenCalled()
testAction(dummyAction)
expect(consoleSpy).toHaveBeenLastCalledWith(getMessage(dummyAction.type))
})
it('allows passing a custom predicate', () => {
let predicateCalled = false
const testAction = makeActionTester({
isActionCreator(action): action is Function {
predicateCalled = true
return false
},
})
testAction(dummyAction())
expect(predicateCalled).toBe(true)
})
})

View File

@@ -0,0 +1,126 @@
import { configureStore } from '../configureStore'
import { createSlice } from '../createSlice'
import type { AutoBatchOptions } from '../autoBatchEnhancer'
import { autoBatchEnhancer, prepareAutoBatched } from '../autoBatchEnhancer'
import { delay } from '../utils'
import { debounce } from 'lodash'
interface CounterState {
value: number
}
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 } as CounterState,
reducers: {
incrementBatched: {
// Batched, low-priority
reducer(state) {
state.value += 1
},
prepare: prepareAutoBatched<void>(),
},
// Not batched, normal priority
decrementUnbatched(state) {
state.value -= 1
},
},
})
const { incrementBatched, decrementUnbatched } = counterSlice.actions
const makeStore = (autoBatchOptions?: AutoBatchOptions) => {
return configureStore({
reducer: counterSlice.reducer,
enhancers: (existingEnhancers) => {
return existingEnhancers.concat(autoBatchEnhancer(autoBatchOptions))
},
})
}
let store: ReturnType<typeof makeStore>
let subscriptionNotifications = 0
const cases: AutoBatchOptions[] = [
{ type: 'tick' },
{ type: 'raf' },
{ type: 'timer', timeout: 0 },
{ type: 'timer', timeout: 10 },
{ type: 'timer', timeout: 20 },
{
type: 'callback',
queueNotification: debounce((notify: () => void) => {
notify()
}, 5),
},
]
describe.each(cases)('autoBatchEnhancer: %j', (autoBatchOptions) => {
beforeEach(() => {
subscriptionNotifications = 0
store = makeStore(autoBatchOptions)
store.subscribe(() => {
subscriptionNotifications++
})
})
test('Does not alter normal subscription notification behavior', async () => {
store.dispatch(decrementUnbatched())
expect(subscriptionNotifications).toBe(1)
store.dispatch(decrementUnbatched())
expect(subscriptionNotifications).toBe(2)
store.dispatch(decrementUnbatched())
expect(subscriptionNotifications).toBe(3)
store.dispatch(decrementUnbatched())
await delay(25)
expect(subscriptionNotifications).toBe(4)
})
test('Only notifies once if several batched actions are dispatched in a row', async () => {
store.dispatch(incrementBatched())
expect(subscriptionNotifications).toBe(0)
store.dispatch(incrementBatched())
expect(subscriptionNotifications).toBe(0)
store.dispatch(incrementBatched())
expect(subscriptionNotifications).toBe(0)
store.dispatch(incrementBatched())
await delay(25)
expect(subscriptionNotifications).toBe(1)
})
test('Notifies immediately if a non-batched action is dispatched', async () => {
store.dispatch(incrementBatched())
expect(subscriptionNotifications).toBe(0)
store.dispatch(incrementBatched())
expect(subscriptionNotifications).toBe(0)
store.dispatch(decrementUnbatched())
expect(subscriptionNotifications).toBe(1)
store.dispatch(incrementBatched())
await delay(25)
expect(subscriptionNotifications).toBe(2)
})
test('Does not notify at end of tick if last action was normal priority', async () => {
store.dispatch(incrementBatched())
expect(subscriptionNotifications).toBe(0)
store.dispatch(incrementBatched())
expect(subscriptionNotifications).toBe(0)
store.dispatch(decrementUnbatched())
expect(subscriptionNotifications).toBe(1)
store.dispatch(incrementBatched())
store.dispatch(decrementUnbatched())
expect(subscriptionNotifications).toBe(2)
store.dispatch(decrementUnbatched())
expect(subscriptionNotifications).toBe(3)
await delay(25)
expect(subscriptionNotifications).toBe(3)
})
})

View File

@@ -0,0 +1,133 @@
import type { PayloadAction } from '@reduxjs/toolkit'
import {
createAsyncThunk,
createAction,
createSlice,
configureStore,
createEntityAdapter,
} from '@reduxjs/toolkit'
import type { EntityAdapter } from '@internal/entities/models'
import type { BookModel } from '@internal/entities/tests/fixtures/book'
describe('Combined entity slice', () => {
let adapter: EntityAdapter<BookModel>
beforeEach(() => {
adapter = createEntityAdapter({
selectId: (book: BookModel) => book.id,
sortComparer: (a, b) => a.title.localeCompare(b.title),
})
})
it('Entity and async features all works together', async () => {
const upsertBook = createAction<BookModel>('otherBooks/upsert')
type BooksState = ReturnType<typeof adapter.getInitialState> & {
loading: 'initial' | 'pending' | 'finished' | 'failed'
lastRequestId: string | null
}
const initialState: BooksState = adapter.getInitialState({
loading: 'initial',
lastRequestId: null,
})
const fakeBooks: BookModel[] = [
{ id: 'b', title: 'Second' },
{ id: 'a', title: 'First' },
]
const fetchBooksTAC = createAsyncThunk<
BookModel[],
void,
{
state: { books: BooksState }
}
>(
'books/fetch',
async (arg, { getState, dispatch, extra, requestId, signal }) => {
const state = getState()
return fakeBooks
}
)
const booksSlice = createSlice({
name: 'books',
initialState,
reducers: {
addOne: adapter.addOne,
removeOne(state, action: PayloadAction<string>) {
const sizeBefore = state.ids.length
// Originally, having nested `produce` calls don't mutate `state` here as I would have expected.
// (note that `state` here is actually an Immer Draft<S>, from `createReducer`)
// One woarkound was to return the new plain result value instead
// See https://github.com/immerjs/immer/issues/533
// However, after tweaking `createStateOperator` to check if the argument is a draft,
// we can just treat the operator as strictly mutating, without returning a result,
// and the result should be correct.
const result = adapter.removeOne(state, action)
const sizeAfter = state.ids.length
if (sizeBefore > 0) {
expect(sizeAfter).toBe(sizeBefore - 1)
}
//Deliberately _don't_ return result
},
},
extraReducers: (builder) => {
builder.addCase(upsertBook, (state, action) => {
return adapter.upsertOne(state, action)
})
builder.addCase(fetchBooksTAC.pending, (state, action) => {
state.loading = 'pending'
state.lastRequestId = action.meta.requestId
})
builder.addCase(fetchBooksTAC.fulfilled, (state, action) => {
if (
state.loading === 'pending' &&
action.meta.requestId === state.lastRequestId
) {
adapter.setAll(state, action.payload)
state.loading = 'finished'
state.lastRequestId = null
}
})
},
})
const { addOne, removeOne } = booksSlice.actions
const { reducer } = booksSlice
const store = configureStore({
reducer: {
books: reducer,
},
})
await store.dispatch(fetchBooksTAC())
const { books: booksAfterLoaded } = store.getState()
// Sorted, so "First" goes first
expect(booksAfterLoaded.ids).toEqual(['a', 'b'])
expect(booksAfterLoaded.lastRequestId).toBe(null)
expect(booksAfterLoaded.loading).toBe('finished')
store.dispatch(addOne({ id: 'd', title: 'Remove Me' }))
store.dispatch(removeOne('d'))
store.dispatch(addOne({ id: 'c', title: 'Middle' }))
const { books: booksAfterAddOne } = store.getState()
// Sorted, so "Middle" goes in the middle
expect(booksAfterAddOne.ids).toEqual(['a', 'c', 'b'])
store.dispatch(upsertBook({ id: 'c', title: 'Zeroth' }))
const { books: booksAfterUpsert } = store.getState()
// Sorted, so "Zeroth" goes last
expect(booksAfterUpsert.ids).toEqual(['a', 'b', 'c'])
})
})

View File

@@ -0,0 +1,241 @@
import type { StoreEnhancer, StoreEnhancerStoreCreator } from '@reduxjs/toolkit'
import { configureStore } from '@reduxjs/toolkit'
import * as RTK from '@reduxjs/toolkit'
import * as redux from 'redux'
import * as devtools from '@internal/devtoolsExtension'
describe('configureStore', () => {
jest.spyOn(redux, 'applyMiddleware')
jest.spyOn(redux, 'combineReducers')
jest.spyOn(redux, 'compose')
jest.spyOn(redux, 'createStore')
jest.spyOn(devtools, 'composeWithDevTools') // @remap-prod-remove-line
const reducer: redux.Reducer = (state = {}, _action) => state
beforeEach(() => jest.clearAllMocks())
describe('given a function reducer', () => {
it('calls createStore with the reducer', () => {
configureStore({ reducer })
expect(configureStore({ reducer })).toBeInstanceOf(Object)
expect(redux.applyMiddleware).toHaveBeenCalled()
expect(devtools.composeWithDevTools).toHaveBeenCalled() // @remap-prod-remove-line
expect(redux.createStore).toHaveBeenCalledWith(
reducer,
undefined,
expect.any(Function)
)
})
})
describe('given an object of reducers', () => {
it('calls createStore with the combined reducers', () => {
const reducer = {
reducer() {
return true
},
}
expect(configureStore({ reducer })).toBeInstanceOf(Object)
expect(redux.combineReducers).toHaveBeenCalledWith(reducer)
expect(redux.applyMiddleware).toHaveBeenCalled()
expect(devtools.composeWithDevTools).toHaveBeenCalled() // @remap-prod-remove-line-line
expect(redux.createStore).toHaveBeenCalledWith(
expect.any(Function),
undefined,
expect.any(Function)
)
})
})
describe('given no reducer', () => {
it('throws', () => {
expect(configureStore).toThrow(
'"reducer" is a required argument, and must be a function or an object of functions that can be passed to combineReducers'
)
})
})
describe('given no middleware', () => {
it('calls createStore without any middleware', () => {
expect(configureStore({ middleware: [], reducer })).toBeInstanceOf(Object)
expect(redux.applyMiddleware).toHaveBeenCalledWith()
expect(devtools.composeWithDevTools).toHaveBeenCalled() // @remap-prod-remove-line-line
expect(redux.createStore).toHaveBeenCalledWith(
reducer,
undefined,
expect.any(Function)
)
})
})
describe('given undefined middleware', () => {
it('calls createStore with default middleware', () => {
expect(configureStore({ middleware: undefined, reducer })).toBeInstanceOf(
Object
)
expect(redux.applyMiddleware).toHaveBeenCalledWith(
expect.any(Function), // immutableCheck
expect.any(Function), // thunk
expect.any(Function), // serializableCheck
expect.any(Function) // actionCreatorCheck
)
expect(devtools.composeWithDevTools).toHaveBeenCalled() // @remap-prod-remove-line-line
expect(redux.createStore).toHaveBeenCalledWith(
reducer,
undefined,
expect.any(Function)
)
})
})
describe('given a middleware creation function that returns undefined', () => {
it('throws an error', () => {
const invalidBuilder = jest.fn((getDefaultMiddleware) => undefined as any)
expect(() =>
configureStore({ middleware: invalidBuilder, reducer })
).toThrow(
'when using a middleware builder function, an array of middleware must be returned'
)
})
})
describe('given a middleware creation function that returns an array with non-functions', () => {
it('throws an error', () => {
const invalidBuilder = jest.fn((getDefaultMiddleware) => [true] as any)
expect(() =>
configureStore({ middleware: invalidBuilder, reducer })
).toThrow('each middleware provided to configureStore must be a function')
})
})
describe('given custom middleware that contains non-functions', () => {
it('throws an error', () => {
expect(() =>
configureStore({ middleware: [true] as any, reducer })
).toThrow('each middleware provided to configureStore must be a function')
})
})
describe('given custom middleware', () => {
it('calls createStore with custom middleware and without default middleware', () => {
const thank: redux.Middleware = (_store) => (next) => (action) =>
next(action)
expect(configureStore({ middleware: [thank], reducer })).toBeInstanceOf(
Object
)
expect(redux.applyMiddleware).toHaveBeenCalledWith(thank)
expect(devtools.composeWithDevTools).toHaveBeenCalled() // @remap-prod-remove-line-line
expect(redux.createStore).toHaveBeenCalledWith(
reducer,
undefined,
expect.any(Function)
)
})
})
describe('middleware builder notation', () => {
it('calls builder, passes getDefaultMiddleware and uses returned middlewares', () => {
const thank = jest.fn(
((_store) => (next) => (action) => 'foobar') as redux.Middleware
)
const builder = jest.fn((getDefaultMiddleware) => {
expect(getDefaultMiddleware).toEqual(expect.any(Function))
expect(getDefaultMiddleware()).toEqual(expect.any(Array))
return [thank]
})
const store = configureStore({ middleware: builder, reducer })
expect(builder).toHaveBeenCalled()
expect(store.dispatch({ type: 'test' })).toBe('foobar')
})
})
describe('with devTools disabled', () => {
it('calls createStore without devTools enhancer', () => {
expect(configureStore({ devTools: false, reducer })).toBeInstanceOf(
Object
)
expect(redux.applyMiddleware).toHaveBeenCalled()
expect(redux.compose).toHaveBeenCalled()
expect(redux.createStore).toHaveBeenCalledWith(
reducer,
undefined,
expect.any(Function)
)
})
})
describe('with devTools options', () => {
it('calls createStore with devTools enhancer and option', () => {
const options = {
name: 'myApp',
trace: true,
}
expect(configureStore({ devTools: options, reducer })).toBeInstanceOf(
Object
)
expect(redux.applyMiddleware).toHaveBeenCalled()
expect(devtools.composeWithDevTools).toHaveBeenCalledWith(options) // @remap-prod-remove-line
expect(redux.createStore).toHaveBeenCalledWith(
reducer,
undefined,
expect.any(Function)
)
})
})
describe('given preloadedState', () => {
it('calls createStore with preloadedState', () => {
expect(configureStore({ reducer })).toBeInstanceOf(Object)
expect(redux.applyMiddleware).toHaveBeenCalled()
expect(devtools.composeWithDevTools).toHaveBeenCalled() // @remap-prod-remove-line
expect(redux.createStore).toHaveBeenCalledWith(
reducer,
undefined,
expect.any(Function)
)
})
})
describe('given enhancers', () => {
it('calls createStore with enhancers', () => {
const enhancer: redux.StoreEnhancer = (next) => next
expect(configureStore({ enhancers: [enhancer], reducer })).toBeInstanceOf(
Object
)
expect(redux.applyMiddleware).toHaveBeenCalled()
expect(devtools.composeWithDevTools).toHaveBeenCalled() // @remap-prod-remove-line
expect(redux.createStore).toHaveBeenCalledWith(
reducer,
undefined,
expect.any(Function)
)
})
it('accepts a callback for customizing enhancers', () => {
let dummyEnhancerCalled = false
const dummyEnhancer: StoreEnhancer =
(createStore: StoreEnhancerStoreCreator) =>
(reducer, ...args: any[]) => {
dummyEnhancerCalled = true
return createStore(reducer, ...args)
}
const reducer = () => ({})
const store = configureStore({
reducer,
enhancers: (defaultEnhancers) => defaultEnhancers.concat(dummyEnhancer),
})
expect(dummyEnhancerCalled).toBe(true)
})
})
})

View File

@@ -0,0 +1,695 @@
/* eslint-disable no-lone-blocks */
import type {
Dispatch,
AnyAction,
Middleware,
Reducer,
Store,
Action,
StoreEnhancer,
} from 'redux'
import { applyMiddleware } from 'redux'
import type { PayloadAction, ConfigureStoreOptions } from '@reduxjs/toolkit'
import {
configureStore,
getDefaultMiddleware,
createSlice,
} from '@reduxjs/toolkit'
import type { ThunkMiddleware, ThunkAction, ThunkDispatch } from 'redux-thunk'
import thunk from 'redux-thunk'
import { expectNotAny, expectType } from './helpers'
const _anyMiddleware: any = () => () => () => {}
/*
* Test: configureStore() requires a valid reducer or reducer map.
*/
{
configureStore({
reducer: (state, action) => 0,
})
configureStore({
reducer: {
counter1: () => 0,
counter2: () => 1,
},
})
// @ts-expect-error
configureStore({ reducer: 'not a reducer' })
// @ts-expect-error
configureStore({ reducer: { a: 'not a reducer' } })
// @ts-expect-error
configureStore({})
}
/*
* Test: configureStore() infers the store state type.
*/
{
const reducer: Reducer<number> = () => 0
const store = configureStore({ reducer })
const numberStore: Store<number, AnyAction> = store
// @ts-expect-error
const stringStore: Store<string, AnyAction> = store
}
/*
* Test: configureStore() infers the store action type.
*/
{
const reducer: Reducer<number, PayloadAction<number>> = () => 0
const store = configureStore({ reducer })
const numberStore: Store<number, PayloadAction<number>> = store
// @ts-expect-error
const stringStore: Store<number, PayloadAction<string>> = store
}
/*
* Test: configureStore() accepts middleware array.
*/
{
const middleware: Middleware = (store) => (next) => next
configureStore({
reducer: () => 0,
middleware: [middleware],
})
configureStore({
reducer: () => 0,
// @ts-expect-error
middleware: ['not middleware'],
})
}
/*
* Test: configureStore() accepts devTools flag.
*/
{
configureStore({
reducer: () => 0,
devTools: true,
})
configureStore({
reducer: () => 0,
// @ts-expect-error
devTools: 'true',
})
}
/*
* Test: configureStore() accepts devTools EnhancerOptions.
*/
{
configureStore({
reducer: () => 0,
devTools: { name: 'myApp' },
})
configureStore({
reducer: () => 0,
// @ts-expect-error
devTools: { appname: 'myApp' },
})
}
/*
* Test: configureStore() accepts preloadedState.
*/
{
configureStore({
reducer: () => 0,
preloadedState: 0,
})
configureStore({
reducer: () => 0,
// @ts-expect-error
preloadedState: 'non-matching state type',
})
}
/*
* Test: configureStore() accepts store enhancer.
*/
{
{
const store = configureStore({
reducer: () => 0,
enhancers: [applyMiddleware(() => (next) => next)],
})
expectType<Dispatch & ThunkDispatch<number, undefined, AnyAction>>(
store.dispatch
)
}
configureStore({
reducer: () => 0,
// @ts-expect-error
enhancers: ['not a store enhancer'],
})
{
type SomePropertyStoreEnhancer = StoreEnhancer<{ someProperty: string }>
const somePropertyStoreEnhancer: SomePropertyStoreEnhancer = (next) => {
return (reducer, preloadedState) => {
return {
...next(reducer, preloadedState),
someProperty: 'some value',
}
}
}
type AnotherPropertyStoreEnhancer = StoreEnhancer<{
anotherProperty: number
}>
const anotherPropertyStoreEnhancer: AnotherPropertyStoreEnhancer = (
next
) => {
return (reducer, preloadedState) => {
return {
...next(reducer, preloadedState),
anotherProperty: 123,
}
}
}
const store = configureStore({
reducer: () => 0,
enhancers: [somePropertyStoreEnhancer, anotherPropertyStoreEnhancer],
})
expectType<Dispatch & ThunkDispatch<number, undefined, AnyAction>>(
store.dispatch
)
expectType<string>(store.someProperty)
expectType<number>(store.anotherProperty)
const storeWithCallback = configureStore({
reducer: () => 0,
enhancers: (defaultEnhancers) =>
defaultEnhancers
.prepend(anotherPropertyStoreEnhancer)
.concat(somePropertyStoreEnhancer),
})
expectType<Dispatch & ThunkDispatch<number, undefined, AnyAction>>(
store.dispatch
)
expectType<string>(storeWithCallback.someProperty)
expectType<number>(storeWithCallback.anotherProperty)
}
{
type StateExtendingEnhancer = StoreEnhancer<{}, { someProperty: string }>
const someStateExtendingEnhancer: StateExtendingEnhancer =
(next) =>
// @ts-expect-error how do you properly return an enhancer that extends state?
(...args) => {
const store = next(...args)
const getState = () => ({
...store.getState(),
someProperty: 'some value',
})
return {
...store,
getState,
}
}
type AnotherStateExtendingEnhancer = StoreEnhancer<
{},
{ anotherProperty: number }
>
const anotherStateExtendingEnhancer: AnotherStateExtendingEnhancer =
(next) =>
// @ts-expect-error any input on this would be great
(...args) => {
const store = next(...args)
const getState = () => ({
...store.getState(),
anotherProperty: 123,
})
return {
...store,
getState,
}
}
const store = configureStore({
reducer: () => ({ aProperty: 0 }),
enhancers: [
someStateExtendingEnhancer,
anotherStateExtendingEnhancer,
// this doesn't work without the as const
] as const,
})
const state = store.getState()
expectType<number>(state.aProperty)
expectType<string>(state.someProperty)
expectType<number>(state.anotherProperty)
const storeWithCallback = configureStore({
reducer: () => ({ aProperty: 0 }),
enhancers: (dE) =>
dE.concat(someStateExtendingEnhancer, anotherStateExtendingEnhancer),
})
const stateWithCallback = storeWithCallback.getState()
expectType<number>(stateWithCallback.aProperty)
expectType<string>(stateWithCallback.someProperty)
expectType<number>(stateWithCallback.anotherProperty)
}
}
/**
* Test: configureStore() state type inference works when specifying both a
* reducer object and a partial preloaded state.
*/
{
let counterReducer1: Reducer<number> = () => 0
let counterReducer2: Reducer<number> = () => 0
const store = configureStore({
reducer: {
counter1: counterReducer1,
counter2: counterReducer2,
},
preloadedState: {
counter1: 0,
},
})
const counter1: number = store.getState().counter1
const counter2: number = store.getState().counter2
}
/**
* Test: Dispatch typings
*/
{
type StateA = number
const reducerA = () => 0
function thunkA() {
return (() => {}) as any as ThunkAction<Promise<'A'>, StateA, any, any>
}
type StateB = string
function thunkB() {
return (dispatch: Dispatch, getState: () => StateB) => {}
}
/**
* Test: by default, dispatching Thunks is possible
*/
{
const store = configureStore({
reducer: reducerA,
})
store.dispatch(thunkA())
// @ts-expect-error
store.dispatch(thunkB())
const res = store.dispatch((dispatch, getState) => {
return 42
})
const action = store.dispatch({ type: 'foo' })
}
/**
* Test: return type of thunks and actions is inferred correctly
*/
{
const slice = createSlice({
name: 'counter',
initialState: {
value: 0,
},
reducers: {
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
},
},
})
const store = configureStore({
reducer: {
counter: slice.reducer,
},
})
const action = slice.actions.incrementByAmount(2)
const dispatchResult = store.dispatch(action)
expectType<{ type: string; payload: number }>(dispatchResult)
const promiseResult = store.dispatch(async (dispatch) => {
return 42
})
expectType<Promise<number>>(promiseResult)
const store2 = configureStore({
reducer: {
counter: slice.reducer,
},
middleware: (gDM) =>
gDM({
thunk: {
extraArgument: 42,
},
}),
})
const dispatchResult2 = store2.dispatch(action)
expectType<{ type: string; payload: number }>(dispatchResult2)
}
/**
* Test: removing the Thunk Middleware
*/
{
const store = configureStore({
reducer: reducerA,
middleware: [],
})
// @ts-expect-error
store.dispatch(thunkA())
// @ts-expect-error
store.dispatch(thunkB())
}
/**
* Test: adding the thunk middleware by hand
*/
{
const store = configureStore({
reducer: reducerA,
middleware: [thunk] as [ThunkMiddleware<StateA>],
})
store.dispatch(thunkA())
// @ts-expect-error
store.dispatch(thunkB())
}
/**
* Test: using getDefaultMiddleware
*/
{
const store = configureStore({
reducer: reducerA,
middleware: getDefaultMiddleware<StateA>(),
})
store.dispatch(thunkA())
// @ts-expect-error
store.dispatch(thunkB())
}
/**
* Test: custom middleware
*/
{
const store = configureStore({
reducer: reducerA,
middleware: [] as any as [Middleware<(a: StateA) => boolean, StateA>],
})
const result: boolean = store.dispatch(5)
// @ts-expect-error
const result2: string = store.dispatch(5)
}
/**
* Test: read-only middleware tuple
*/
{
const store = configureStore({
reducer: reducerA,
middleware: [] as any as readonly [
Middleware<(a: StateA) => boolean, StateA>
],
})
const result: boolean = store.dispatch(5)
// @ts-expect-error
const result2: string = store.dispatch(5)
}
/**
* Test: multiple custom middleware
*/
{
const middleware = [] as any as [
Middleware<(a: 'a') => 'A', StateA>,
Middleware<(b: 'b') => 'B', StateA>,
ThunkMiddleware<StateA>
]
const store = configureStore({
reducer: reducerA,
middleware,
})
const result: 'A' = store.dispatch('a')
const result2: 'B' = store.dispatch('b')
const result3: Promise<'A'> = store.dispatch(thunkA())
}
/**
* Accepts thunk with `unknown`, `undefined` or `null` ThunkAction extraArgument per default
*/
{
const store = configureStore({ reducer: {} })
// undefined is the default value for the ThunkMiddleware extraArgument
store.dispatch(function () {} as ThunkAction<
void,
{},
undefined,
AnyAction
>)
// `null` for the `extra` generic was previously documented in the RTK "Advanced Tutorial", but
// is a bad pattern and users should use `unknown` instead
// @ts-expect-error
store.dispatch(function () {} as ThunkAction<void, {}, null, AnyAction>)
// unknown is the best way to type a ThunkAction if you do not care
// about the value of the extraArgument, as it will always work with every
// ThunkMiddleware, no matter the actual extraArgument type
store.dispatch(function () {} as ThunkAction<void, {}, unknown, AnyAction>)
// @ts-expect-error
store.dispatch(function () {} as ThunkAction<void, {}, boolean, AnyAction>)
}
/**
* Test: custom middleware and getDefaultMiddleware
*/
{
const middleware = getDefaultMiddleware<StateA>().prepend(
(() => {}) as any as Middleware<(a: 'a') => 'A', StateA>
)
const store = configureStore({
reducer: reducerA,
middleware,
})
const result1: 'A' = store.dispatch('a')
const result2: Promise<'A'> = store.dispatch(thunkA())
// @ts-expect-error
store.dispatch(thunkB())
}
/**
* Test: custom middleware and getDefaultMiddleware, using prepend
*/
{
const otherMiddleware: Middleware<(a: 'a') => 'A', StateA> = _anyMiddleware
const concatenated = getDefaultMiddleware<StateA>().prepend(otherMiddleware)
expectType<
ReadonlyArray<typeof otherMiddleware | ThunkMiddleware | Middleware<{}>>
>(concatenated)
const store = configureStore({
reducer: reducerA,
middleware: concatenated,
})
const result1: 'A' = store.dispatch('a')
const result2: Promise<'A'> = store.dispatch(thunkA())
// @ts-expect-error
store.dispatch(thunkB())
}
/**
* Test: custom middleware and getDefaultMiddleware, using concat
*/
{
const otherMiddleware: Middleware<(a: 'a') => 'A', StateA> = _anyMiddleware
const concatenated = getDefaultMiddleware<StateA>().concat(otherMiddleware)
expectType<
ReadonlyArray<typeof otherMiddleware | ThunkMiddleware | Middleware<{}>>
>(concatenated)
const store = configureStore({
reducer: reducerA,
middleware: concatenated,
})
const result1: 'A' = store.dispatch('a')
const result2: Promise<'A'> = store.dispatch(thunkA())
// @ts-expect-error
store.dispatch(thunkB())
}
/**
* Test: middlewareBuilder notation, getDefaultMiddleware (unconfigured)
*/
{
const store = configureStore({
reducer: reducerA,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend((() => {}) as any as Middleware<
(a: 'a') => 'A',
StateA
>),
})
const result1: 'A' = store.dispatch('a')
const result2: Promise<'A'> = store.dispatch(thunkA())
// @ts-expect-error
store.dispatch(thunkB())
}
/**
* Test: middlewareBuilder notation, getDefaultMiddleware, concat & prepend
*/
{
const otherMiddleware: Middleware<(a: 'a') => 'A', StateA> = _anyMiddleware
const otherMiddleware2: Middleware<(a: 'b') => 'B', StateA> = _anyMiddleware
const store = configureStore({
reducer: reducerA,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(otherMiddleware)
.prepend(otherMiddleware2),
})
const result1: 'A' = store.dispatch('a')
const result2: Promise<'A'> = store.dispatch(thunkA())
const result3: 'B' = store.dispatch('b')
// @ts-expect-error
store.dispatch(thunkB())
}
/**
* Test: middlewareBuilder notation, getDefaultMiddleware (thunk: false)
*/
{
const store = configureStore({
reducer: reducerA,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ thunk: false }).prepend(
(() => {}) as any as Middleware<(a: 'a') => 'A', StateA>
),
})
const result1: 'A' = store.dispatch('a')
// @ts-expect-error
store.dispatch(thunkA())
}
/**
* Test: badly typed middleware won't make `dispatch` `any`
*/
{
const store = configureStore({
reducer: reducerA,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(_anyMiddleware as Middleware<any>),
})
expectNotAny(store.dispatch)
}
/**
* Test: decorated `configureStore` won't make `dispatch` `never`
*/
{
const someSlice = createSlice({
name: 'something',
initialState: null as any,
reducers: {
set(state) {
return state
},
},
})
function configureMyStore<S>(
options: Omit<ConfigureStoreOptions<S>, 'reducer'>
) {
return configureStore({
...options,
reducer: someSlice.reducer,
})
}
const store = configureMyStore({})
expectType<Function>(store.dispatch)
}
{
interface CounterState {
value: number
}
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 } as CounterState,
reducers: {
increment(state) {
state.value += 1
},
decrement(state) {
state.value -= 1
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
},
},
})
type Unsubscribe = () => void
// A fake middleware that tells TS that an unsubscribe callback is being returned for a given action
// This is the same signature that the "listener" middleware uses
const dummyMiddleware: Middleware<
{
(action: Action<'actionListenerMiddleware/add'>): Unsubscribe
},
CounterState
> = (storeApi) => (next) => (action) => {}
const store = configureStore({
reducer: counterSlice.reducer,
middleware: (gDM) => gDM().prepend(dummyMiddleware),
})
// Order matters here! We need the listener type to come first, otherwise
// the thunk middleware type kicks in and TS thinks a plain action is being returned
expectType<
((action: Action<'actionListenerMiddleware/add'>) => Unsubscribe) &
ThunkDispatch<CounterState, undefined, AnyAction> &
Dispatch<AnyAction>
>(store.dispatch)
const unsubscribe = store.dispatch({
type: 'actionListenerMiddleware/add',
} as const)
expectType<Unsubscribe>(unsubscribe)
}
}

View File

@@ -0,0 +1,151 @@
import { createAction, getType, isAction } from '@reduxjs/toolkit'
describe('createAction', () => {
it('should create an action', () => {
const actionCreator = createAction<string>('A_TYPE')
expect(actionCreator('something')).toEqual({
type: 'A_TYPE',
payload: 'something',
})
})
describe('when stringifying action', () => {
it('should return the action type', () => {
const actionCreator = createAction('A_TYPE')
expect(`${actionCreator}`).toEqual('A_TYPE')
})
})
describe('when passing a prepareAction method only returning a payload', () => {
it('should use the payload returned from the prepareAction method', () => {
const actionCreator = createAction('A_TYPE', (a: number) => ({
payload: a * 2,
}))
expect(actionCreator(5).payload).toBe(10)
})
it('should not have a meta attribute on the resulting Action', () => {
const actionCreator = createAction('A_TYPE', (a: number) => ({
payload: a * 2,
}))
expect('meta' in actionCreator(5)).toBeFalsy()
})
})
describe('when passing a prepareAction method returning a payload and meta', () => {
it('should use the payload returned from the prepareAction method', () => {
const actionCreator = createAction('A_TYPE', (a: number) => ({
payload: a * 2,
meta: a / 2,
}))
expect(actionCreator(5).payload).toBe(10)
})
it('should use the meta returned from the prepareAction method', () => {
const actionCreator = createAction('A_TYPE', (a: number) => ({
payload: a * 2,
meta: a / 2,
}))
expect(actionCreator(10).meta).toBe(5)
})
})
describe('when passing a prepareAction method returning a payload and error', () => {
it('should use the payload returned from the prepareAction method', () => {
const actionCreator = createAction('A_TYPE', (a: number) => ({
payload: a * 2,
error: true,
}))
expect(actionCreator(5).payload).toBe(10)
})
it('should use the error returned from the prepareAction method', () => {
const actionCreator = createAction('A_TYPE', (a: number) => ({
payload: a * 2,
error: true,
}))
expect(actionCreator(10).error).toBe(true)
})
})
describe('when passing a prepareAction method returning a payload, meta and error', () => {
it('should use the payload returned from the prepareAction method', () => {
const actionCreator = createAction('A_TYPE', (a: number) => ({
payload: a * 2,
meta: a / 2,
error: true,
}))
expect(actionCreator(5).payload).toBe(10)
})
it('should use the error returned from the prepareAction method', () => {
const actionCreator = createAction('A_TYPE', (a: number) => ({
payload: a * 2,
meta: a / 2,
error: true,
}))
expect(actionCreator(10).error).toBe(true)
})
it('should use the meta returned from the prepareAction method', () => {
const actionCreator = createAction('A_TYPE', (a: number) => ({
payload: a * 2,
meta: a / 2,
error: true,
}))
expect(actionCreator(10).meta).toBe(5)
})
})
describe('when passing a prepareAction that accepts multiple arguments', () => {
it('should pass all arguments of the resulting actionCreator to prepareAction', () => {
const actionCreator = createAction(
'A_TYPE',
(a: string, b: string, c: string) => ({
payload: a + b + c,
})
)
expect(actionCreator('1', '2', '3').payload).toBe('123')
})
})
describe('actionCreator.match', () => {
test('should return true for actions generated by own actionCreator', () => {
const actionCreator = createAction('test')
expect(actionCreator.match(actionCreator())).toBe(true)
})
test('should return true for matching actions', () => {
const actionCreator = createAction('test')
expect(actionCreator.match({ type: 'test' })).toBe(true)
})
test('should return false for other actions', () => {
const actionCreator = createAction('test')
expect(actionCreator.match({ type: 'test-abc' })).toBe(false)
})
})
})
describe('isAction', () => {
it('should only return true for plain objects with a type property', () => {
const actionCreator = createAction('anAction')
class Action {
type = 'totally an action'
}
const testCases: [action: unknown, expected: boolean][] = [
[{ type: 'an action' }, true],
[{ type: 'more props', extra: true }, true],
[actionCreator(), true],
[actionCreator, false],
[Promise.resolve({ type: 'an action' }), false],
[new Action(), false],
['a string', false],
]
for (const [action, expected] of testCases) {
expect(isAction(action)).toBe(expected)
}
})
})
describe('getType', () => {
it('should return the action type', () => {
const actionCreator = createAction('A_TYPE')
expect(getType(actionCreator)).toEqual('A_TYPE')
})
})

View File

@@ -0,0 +1,359 @@
import React from 'react'
import type { Action, AnyAction, ActionCreator } from 'redux'
import type {
PayloadAction,
PayloadActionCreator,
ActionCreatorWithoutPayload,
ActionCreatorWithOptionalPayload,
ActionCreatorWithPayload,
ActionCreatorWithNonInferrablePayload,
ActionCreatorWithPreparedPayload,
} from '@reduxjs/toolkit'
import { createAction } from '@reduxjs/toolkit'
import type { IsAny } from '@internal/tsHelpers'
import { expectType } from './helpers'
/* PayloadAction */
/*
* Test: PayloadAction has type parameter for the payload.
*/
{
const action: PayloadAction<number> = { type: '', payload: 5 }
const numberPayload: number = action.payload
// @ts-expect-error
const stringPayload: string = action.payload
}
/*
* Test: PayloadAction type parameter is required.
*/
{
// @ts-expect-error
const action: PayloadAction = { type: '', payload: 5 }
// @ts-expect-error
const numberPayload: number = action.payload
// @ts-expect-error
const stringPayload: string = action.payload
}
/*
* Test: PayloadAction has a string type tag.
*/
{
const action: PayloadAction<number> = { type: '', payload: 5 }
// @ts-expect-error
const action2: PayloadAction = { type: 1, payload: 5 }
}
/*
* Test: PayloadAction is compatible with Action<string>
*/
{
const action: PayloadAction<number> = { type: '', payload: 5 }
const stringAction: Action<string> = action
}
/* PayloadActionCreator */
/*
* Test: PayloadActionCreator returns correctly typed PayloadAction depending
* on whether a payload is passed.
*/
{
const actionCreator = Object.assign(
(payload?: number) => ({
type: 'action',
payload,
}),
{ type: 'action' }
) as PayloadActionCreator<number | undefined>
expectType<PayloadAction<number | undefined>>(actionCreator(1))
expectType<PayloadAction<number | undefined>>(actionCreator())
expectType<PayloadAction<number | undefined>>(actionCreator(undefined))
// @ts-expect-error
expectType<PayloadAction<number>>(actionCreator())
// @ts-expect-error
expectType<PayloadAction<undefined>>(actionCreator(1))
}
/*
* Test: PayloadActionCreator is compatible with ActionCreator.
*/
{
const payloadActionCreator = Object.assign(
(payload?: number) => ({
type: 'action',
payload,
}),
{ type: 'action' }
) as PayloadActionCreator
const actionCreator: ActionCreator<AnyAction> = payloadActionCreator
const payloadActionCreator2 = Object.assign(
(payload?: number) => ({
type: 'action',
payload: payload || 1,
}),
{ type: 'action' }
) as PayloadActionCreator<number>
const actionCreator2: ActionCreator<PayloadAction<number>> =
payloadActionCreator2
}
/* createAction() */
/*
* Test: createAction() has type parameter for the action payload.
*/
{
const increment = createAction<number, 'increment'>('increment')
const n: number = increment(1).payload
// @ts-expect-error
increment('').payload
}
/*
* Test: createAction() type parameter is required, not inferred (defaults to `void`).
*/
{
const increment = createAction('increment')
// @ts-expect-error
const n: number = increment(1).payload
}
/*
* Test: createAction().type is a string literal.
*/
{
const increment = createAction<number, 'increment'>('increment')
const n: string = increment(1).type
const s: 'increment' = increment(1).type
// @ts-expect-error
const r: 'other' = increment(1).type
// @ts-expect-error
const q: number = increment(1).type
}
/*
* Test: type still present when using prepareAction
*/
{
const strLenAction = createAction('strLen', (payload: string) => ({
payload: payload.length,
}))
expectType<string>(strLenAction('test').type)
}
/*
* Test: changing payload type with prepareAction
*/
{
const strLenAction = createAction('strLen', (payload: string) => ({
payload: payload.length,
}))
expectType<number>(strLenAction('test').payload)
// @ts-expect-error
expectType<string>(strLenAction('test').payload)
// @ts-expect-error
const error: any = strLenAction('test').error
}
/*
* Test: adding metadata with prepareAction
*/
{
const strLenMetaAction = createAction('strLenMeta', (payload: string) => ({
payload,
meta: payload.length,
}))
expectType<number>(strLenMetaAction('test').meta)
// @ts-expect-error
expectType<string>(strLenMetaAction('test').meta)
// @ts-expect-error
const error: any = strLenMetaAction('test').error
}
/*
* Test: adding boolean error with prepareAction
*/
{
const boolErrorAction = createAction('boolError', (payload: string) => ({
payload,
error: true,
}))
expectType<boolean>(boolErrorAction('test').error)
// @ts-expect-error
expectType<string>(boolErrorAction('test').error)
}
/*
* Test: adding string error with prepareAction
*/
{
const strErrorAction = createAction('strError', (payload: string) => ({
payload,
error: 'this is an error',
}))
expectType<string>(strErrorAction('test').error)
// @ts-expect-error
expectType<boolean>(strErrorAction('test').error)
}
/*
* regression test for https://github.com/reduxjs/redux-toolkit/issues/214
*/
{
const action = createAction<{ input?: string }>('ACTION')
const t: string | undefined = action({ input: '' }).payload.input
// @ts-expect-error
const u: number = action({ input: '' }).payload.input
// @ts-expect-error
const v: number = action({ input: 3 }).payload.input
}
/*
* regression test for https://github.com/reduxjs/redux-toolkit/issues/224
*/
{
const oops = createAction('oops', (x: any) => ({
payload: x,
error: x,
meta: x,
}))
type Ret = ReturnType<typeof oops>
const payload: IsAny<Ret['payload'], true, false> = true
const error: IsAny<Ret['error'], true, false> = true
const meta: IsAny<Ret['meta'], true, false> = true
// @ts-expect-error
const payloadNotAny: IsAny<Ret['payload'], true, false> = false
// @ts-expect-error
const errorNotAny: IsAny<Ret['error'], true, false> = false
// @ts-expect-error
const metaNotAny: IsAny<Ret['meta'], true, false> = false
}
/**
* Test: createAction.match()
*/
{
// simple use case
{
const actionCreator = createAction<string, 'test'>('test')
const x: Action<unknown> = {} as any
if (actionCreator.match(x)) {
expectType<'test'>(x.type)
expectType<string>(x.payload)
} else {
// @ts-expect-error
expectType<'test'>(x.type)
// @ts-expect-error
expectType<any>(x.payload)
}
}
// special case: optional argument
{
const actionCreator = createAction<string | undefined, 'test'>('test')
const x: Action<unknown> = {} as any
if (actionCreator.match(x)) {
expectType<'test'>(x.type)
expectType<string | undefined>(x.payload)
}
}
// special case: without argument
{
const actionCreator = createAction('test')
const x: Action<unknown> = {} as any
if (actionCreator.match(x)) {
expectType<'test'>(x.type)
// @ts-expect-error
expectType<{}>(x.payload)
}
}
// special case: with prepareAction
{
const actionCreator = createAction('test', () => ({
payload: '',
meta: '',
error: false,
}))
const x: Action<unknown> = {} as any
if (actionCreator.match(x)) {
expectType<'test'>(x.type)
expectType<string>(x.payload)
expectType<string>(x.meta)
expectType<boolean>(x.error)
// @ts-expect-error
expectType<number>(x.payload)
// @ts-expect-error
expectType<number>(x.meta)
// @ts-expect-error
expectType<number>(x.error)
}
}
// potential use: as array filter
{
const actionCreator = createAction<string, 'test'>('test')
const x: Array<Action<unknown>> = []
expectType<Array<PayloadAction<string, 'test'>>>(
x.filter(actionCreator.match)
)
expectType<Array<PayloadAction<number, 'test'>>>(
// @ts-expect-error
x.filter(actionCreator.match)
)
}
}
{
expectType<ActionCreatorWithOptionalPayload<string | undefined>>(
createAction<string | undefined>('')
)
expectType<ActionCreatorWithoutPayload>(createAction<void>(''))
expectType<ActionCreatorWithNonInferrablePayload>(createAction(''))
expectType<ActionCreatorWithPayload<string>>(createAction<string>(''))
expectType<ActionCreatorWithPreparedPayload<[0], 1, '', 2, 3>>(
createAction('', (_: 0) => ({
payload: 1 as 1,
error: 2 as 2,
meta: 3 as 3,
}))
)
const anyCreator = createAction<any>('')
expectType<ActionCreatorWithPayload<any>>(anyCreator)
type AnyPayload = ReturnType<typeof anyCreator>['payload']
expectType<IsAny<AnyPayload, true, false>>(true)
}
// Verify action creators should not be passed directly as arguments
// to React event handlers if there shouldn't be a payload
{
const emptyAction = createAction<void>('empty/action')
function TestComponent() {
// This typically leads to an error like:
// // A non-serializable value was detected in an action, in the path: `payload`.
// @ts-expect-error Should error because `void` and `MouseEvent` aren't compatible
return <button onClick={emptyAction}>+</button>
}
}

View File

@@ -0,0 +1,986 @@
import type { AnyAction } from '@reduxjs/toolkit'
import {
createAsyncThunk,
unwrapResult,
configureStore,
createReducer,
} from '@reduxjs/toolkit'
import { miniSerializeError } from '@internal/createAsyncThunk'
import {
mockConsole,
createConsole,
getLog,
} from 'console-testing-library/pure'
import { expectType } from './helpers'
import { delay } from '../utils'
declare global {
interface Window {
AbortController: AbortController
}
}
describe('createAsyncThunk', () => {
it('creates the action types', () => {
const thunkActionCreator = createAsyncThunk('testType', async () => 42)
expect(thunkActionCreator.fulfilled.type).toBe('testType/fulfilled')
expect(thunkActionCreator.pending.type).toBe('testType/pending')
expect(thunkActionCreator.rejected.type).toBe('testType/rejected')
})
it('exposes the typePrefix it was created with', () => {
const thunkActionCreator = createAsyncThunk('testType', async () => 42)
expect(thunkActionCreator.typePrefix).toBe('testType')
})
it('works without passing arguments to the payload creator', async () => {
const thunkActionCreator = createAsyncThunk('testType', async () => 42)
let timesReducerCalled = 0
const reducer = () => {
timesReducerCalled++
}
const store = configureStore({
reducer,
})
// reset from however many times the store called it
timesReducerCalled = 0
await store.dispatch(thunkActionCreator())
expect(timesReducerCalled).toBe(2)
})
it('accepts arguments and dispatches the actions on resolve', async () => {
const dispatch = jest.fn()
let passedArg: any
const result = 42
const args = 123
let generatedRequestId = ''
const thunkActionCreator = createAsyncThunk(
'testType',
async (arg: number, { requestId }) => {
passedArg = arg
generatedRequestId = requestId
return result
}
)
const thunkFunction = thunkActionCreator(args)
const thunkPromise = thunkFunction(dispatch, () => {}, undefined)
expect(thunkPromise.requestId).toBe(generatedRequestId)
expect(thunkPromise.arg).toBe(args)
await thunkPromise
expect(passedArg).toBe(args)
expect(dispatch).toHaveBeenNthCalledWith(
1,
thunkActionCreator.pending(generatedRequestId, args)
)
expect(dispatch).toHaveBeenNthCalledWith(
2,
thunkActionCreator.fulfilled(result, generatedRequestId, args)
)
})
it('accepts arguments and dispatches the actions on reject', async () => {
const dispatch = jest.fn()
const args = 123
let generatedRequestId = ''
const error = new Error('Panic!')
const thunkActionCreator = createAsyncThunk(
'testType',
async (args: number, { requestId }) => {
generatedRequestId = requestId
throw error
}
)
const thunkFunction = thunkActionCreator(args)
try {
await thunkFunction(dispatch, () => {}, undefined)
} catch (e) {}
expect(dispatch).toHaveBeenNthCalledWith(
1,
thunkActionCreator.pending(generatedRequestId, args)
)
expect(dispatch).toHaveBeenCalledTimes(2)
// 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.arg).toBe(args)
})
it('dispatches an empty error when throwing a random object without serializedError properties', async () => {
const dispatch = jest.fn()
const args = 123
let generatedRequestId = ''
const errorObject = { wny: 'dothis' }
const thunkActionCreator = createAsyncThunk(
'testType',
async (args: number, { requestId }) => {
generatedRequestId = requestId
throw errorObject
}
)
const thunkFunction = thunkActionCreator(args)
try {
await thunkFunction(dispatch, () => {}, undefined)
} catch (e) {}
expect(dispatch).toHaveBeenNthCalledWith(
1,
thunkActionCreator.pending(generatedRequestId, args)
)
expect(dispatch).toHaveBeenCalledTimes(2)
const errorAction = dispatch.mock.calls[1][0]
expect(errorAction.error).toEqual({})
expect(errorAction.meta.requestId).toBe(generatedRequestId)
expect(errorAction.meta.arg).toBe(args)
})
it('dispatches an action with a formatted error when throwing an object with known error keys', async () => {
const dispatch = jest.fn()
const args = 123
let generatedRequestId = ''
const errorObject = {
name: 'Custom thrown error',
message: 'This is not necessary',
code: '400',
}
const thunkActionCreator = createAsyncThunk(
'testType',
async (args: number, { requestId }) => {
generatedRequestId = requestId
throw errorObject
}
)
const thunkFunction = thunkActionCreator(args)
try {
await thunkFunction(dispatch, () => {}, undefined)
} catch (e) {}
expect(dispatch).toHaveBeenNthCalledWith(
1,
thunkActionCreator.pending(generatedRequestId, args)
)
expect(dispatch).toHaveBeenCalledTimes(2)
// 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(errorObject))
expect(Object.keys(errorAction.error)).not.toContain('stack')
expect(errorAction.meta.requestId).toBe(generatedRequestId)
expect(errorAction.meta.arg).toBe(args)
})
it('dispatches a rejected action with a customized payload when a user returns rejectWithValue()', async () => {
const dispatch = jest.fn()
const args = 123
let generatedRequestId = ''
const errorPayload = {
errorMessage:
'I am a fake server-provided 400 payload with validation details',
errors: [
{ field_one: 'Must be a string' },
{ field_two: 'Must be a number' },
],
}
const thunkActionCreator = createAsyncThunk(
'testType',
async (args: number, { requestId, rejectWithValue }) => {
generatedRequestId = requestId
return rejectWithValue(errorPayload)
}
)
const thunkFunction = thunkActionCreator(args)
try {
await thunkFunction(dispatch, () => {}, undefined)
} catch (e) {}
expect(dispatch).toHaveBeenNthCalledWith(
1,
thunkActionCreator.pending(generatedRequestId, args)
)
expect(dispatch).toHaveBeenCalledTimes(2)
// Have to check the bits of the action separately since the error was processed
const errorAction = dispatch.mock.calls[1][0]
expect(errorAction.error.message).toEqual('Rejected')
expect(errorAction.payload).toBe(errorPayload)
expect(errorAction.meta.arg).toBe(args)
})
it('dispatches a rejected action with a customized payload when a user throws rejectWithValue()', async () => {
const dispatch = jest.fn()
const args = 123
let generatedRequestId = ''
const errorPayload = {
errorMessage:
'I am a fake server-provided 400 payload with validation details',
errors: [
{ field_one: 'Must be a string' },
{ field_two: 'Must be a number' },
],
}
const thunkActionCreator = createAsyncThunk(
'testType',
async (args: number, { requestId, rejectWithValue }) => {
generatedRequestId = requestId
throw rejectWithValue(errorPayload)
}
)
const thunkFunction = thunkActionCreator(args)
try {
await thunkFunction(dispatch, () => {}, undefined)
} catch (e) {}
expect(dispatch).toHaveBeenNthCalledWith(
1,
thunkActionCreator.pending(generatedRequestId, args)
)
expect(dispatch).toHaveBeenCalledTimes(2)
// Have to check the bits of the action separately since the error was processed
const errorAction = dispatch.mock.calls[1][0]
expect(errorAction.error.message).toEqual('Rejected')
expect(errorAction.payload).toBe(errorPayload)
expect(errorAction.meta.arg).toBe(args)
})
it('dispatches a rejected action with a miniSerializeError when rejectWithValue conditions are not satisfied', async () => {
const dispatch = jest.fn()
const args = 123
let generatedRequestId = ''
const error = new Error('Panic!')
const errorPayload = {
errorMessage:
'I am a fake server-provided 400 payload with validation details',
errors: [
{ field_one: 'Must be a string' },
{ field_two: 'Must be a number' },
],
}
const thunkActionCreator = createAsyncThunk(
'testType',
async (args: number, { requestId, rejectWithValue }) => {
generatedRequestId = requestId
try {
throw error
} catch (err) {
if (!(err as any).response) {
throw err
}
return rejectWithValue(errorPayload)
}
}
)
const thunkFunction = thunkActionCreator(args)
try {
await thunkFunction(dispatch, () => {}, undefined)
} catch (e) {}
expect(dispatch).toHaveBeenNthCalledWith(
1,
thunkActionCreator.pending(generatedRequestId, args)
)
expect(dispatch).toHaveBeenCalledTimes(2)
// 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.payload).toEqual(undefined)
expect(errorAction.meta.requestId).toBe(generatedRequestId)
expect(errorAction.meta.arg).toBe(args)
})
})
describe('createAsyncThunk with abortController', () => {
const asyncThunk = createAsyncThunk(
'test',
function abortablePayloadCreator(_: any, { signal }) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(
new DOMException(
'This should never be reached as it should already be handled.',
'AbortError'
)
)
}
signal.addEventListener('abort', () => {
reject(new DOMException('Was aborted while running', 'AbortError'))
})
setTimeout(resolve, 100)
})
}
)
let store = configureStore({
reducer(store: AnyAction[] = []) {
return store
},
})
beforeEach(() => {
store = configureStore({
reducer(store: AnyAction[] = [], action) {
return [...store, action]
},
})
})
test('normal usage', async () => {
await store.dispatch(asyncThunk({}))
expect(store.getState()).toEqual([
expect.any(Object),
expect.objectContaining({ type: 'test/pending' }),
expect.objectContaining({ type: 'test/fulfilled' }),
])
})
test('abort after dispatch', async () => {
const promise = store.dispatch(asyncThunk({}))
promise.abort('AbortReason')
const result = await promise
const expectedAbortedAction = {
type: 'test/rejected',
error: {
message: 'AbortReason',
name: 'AbortError',
},
meta: { aborted: true, requestId: promise.requestId },
}
// abortedAction with reason is dispatched after test/pending is dispatched
expect(store.getState()).toMatchObject([
{},
{ type: 'test/pending' },
expectedAbortedAction,
])
// same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
expect(result).toMatchObject(expectedAbortedAction)
// calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
expect(() => unwrapResult(result)).toThrowError(
expect.objectContaining(expectedAbortedAction.error)
)
})
test('even when the payloadCreator does not directly support the signal, no further actions are dispatched', async () => {
const unawareAsyncThunk = createAsyncThunk('unaware', async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
return 'finished'
})
const promise = store.dispatch(unawareAsyncThunk())
promise.abort('AbortReason')
const result = await promise
const expectedAbortedAction = {
type: 'unaware/rejected',
error: {
message: 'AbortReason',
name: 'AbortError',
},
}
// abortedAction with reason is dispatched after test/pending is dispatched
expect(store.getState()).toEqual([
expect.any(Object),
expect.objectContaining({ type: 'unaware/pending' }),
expect.objectContaining(expectedAbortedAction),
])
// same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
expect(result).toMatchObject(expectedAbortedAction)
// calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
expect(() => unwrapResult(result)).toThrowError(
expect.objectContaining(expectedAbortedAction.error)
)
})
test('dispatch(asyncThunk) returns on abort and does not wait for the promiseProvider to finish', async () => {
let running = false
const longRunningAsyncThunk = createAsyncThunk('longRunning', async () => {
running = true
await new Promise((resolve) => setTimeout(resolve, 30000))
running = false
})
const promise = store.dispatch(longRunningAsyncThunk())
expect(running).toBeTruthy()
promise.abort()
const result = await promise
expect(running).toBeTruthy()
expect(result).toMatchObject({
type: 'longRunning/rejected',
error: { message: 'Aborted', name: 'AbortError' },
meta: { aborted: true },
})
})
describe('behaviour with missing AbortController', () => {
let keepAbortController: typeof window['AbortController']
let freshlyLoadedModule: typeof import('../createAsyncThunk')
let restore: () => void
let nodeEnv: string
beforeEach(() => {
keepAbortController = window.AbortController
delete (window as any).AbortController
jest.resetModules()
freshlyLoadedModule = require('../createAsyncThunk')
restore = mockConsole(createConsole())
nodeEnv = process.env.NODE_ENV!
;(process.env as any).NODE_ENV = 'development'
})
afterEach(() => {
;(process.env as any).NODE_ENV = nodeEnv
restore()
window.AbortController = keepAbortController
jest.resetModules()
})
test('calling `abort` on an asyncThunk works with a FallbackAbortController if no global abortController is not available', async () => {
const longRunningAsyncThunk = freshlyLoadedModule.createAsyncThunk(
'longRunning',
async () => {
await new Promise((resolve) => setTimeout(resolve, 30000))
}
)
store.dispatch(longRunningAsyncThunk()).abort()
// should only log once, even if called twice
store.dispatch(longRunningAsyncThunk()).abort()
expect(getLog().log).toMatchInlineSnapshot(`
"This platform does not implement AbortController.
If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'."
`)
})
})
})
test('non-serializable arguments are ignored by serializableStateInvariantMiddleware', async () => {
const restore = mockConsole(createConsole())
const nonSerializableValue = new Map()
const asyncThunk = createAsyncThunk('test', (arg: Map<any, any>) => {})
configureStore({
reducer: () => 0,
}).dispatch(asyncThunk(nonSerializableValue))
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('return falsy from condition does not skip payload creator', async () => {
// Override TS's expectation that this is a boolean
condition.mockReturnValueOnce(undefined as unknown as boolean)
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('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('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('async condition with AbortController signal first', async () => {
const condition = async () => {
await delay(25)
return true
}
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
try {
const thunkPromise = asyncThunk(arg)(dispatch, getState, extra)
thunkPromise.abort()
await thunkPromise
} catch (err) {}
expect(dispatch).toHaveBeenCalledTimes(0)
})
test('rejected action is not dispatched by default', async () => {
const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
await asyncThunk(arg)(dispatch, getState, extra)
expect(dispatch).toHaveBeenCalledTimes(0)
})
test('does not fail when attempting to abort a canceled promise', async () => {
const asyncPayloadCreator = jest.fn(async (x: typeof arg) => {
await delay(200)
return 10
})
const asyncThunk = createAsyncThunk('test', asyncPayloadCreator, {
condition,
})
const promise = asyncThunk(arg)(dispatch, getState, extra)
promise.abort(
`If the promise was 1. somehow canceled, 2. in a 'started' state and 3. we attempted to abort, this would crash the tests`
)
})
test('rejected action can be dispatched via option', async () => {
const asyncThunk = createAsyncThunk('test', payloadCreator, {
condition,
dispatchConditionRejection: true,
})
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,
rejectedWithValue: false,
condition: true,
requestId: expect.stringContaining(''),
requestStatus: 'rejected',
},
payload: undefined,
type: 'test/rejected',
})
)
})
})
test('serializeError implementation', async () => {
function serializeError() {
return 'serialized!'
}
const errorObject = 'something else!'
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 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)
const extra = {}
test('fulfilled case', async () => {
const asyncThunk = createAsyncThunk('test', () => {
return 'fulfilled!' as const
})
const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
unwrapResult
)
await expect(unwrapPromise).resolves.toBe('fulfilled!')
const unwrapPromise2 = asyncThunk()(dispatch, getState, extra)
const res = await unwrapPromise2.unwrap()
expect(res).toBe('fulfilled!')
})
test('error case', async () => {
const error = new Error('Panic!')
const asyncThunk = createAsyncThunk('test', () => {
throw error
})
const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
unwrapResult
)
await expect(unwrapPromise).rejects.toEqual(miniSerializeError(error))
const unwrapPromise2 = asyncThunk()(dispatch, getState, extra)
await expect(unwrapPromise2.unwrap()).rejects.toEqual(
miniSerializeError(error)
)
})
test('rejectWithValue case', async () => {
const asyncThunk = createAsyncThunk('test', (_, { rejectWithValue }) => {
return rejectWithValue('rejectWithValue!')
})
const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
unwrapResult
)
await expect(unwrapPromise).rejects.toBe('rejectWithValue!')
const unwrapPromise2 = asyncThunk()(dispatch, getState, extra)
await expect(unwrapPromise2.unwrap()).rejects.toBe('rejectWithValue!')
})
})
describe('idGenerator option', () => {
const getState = () => ({})
const dispatch = (x: any) => x
const extra = {}
test('idGenerator implementation - can customizes how request IDs are generated', async () => {
function makeFakeIdGenerator() {
let id = 0
return jest.fn(() => {
id++
return `fake-random-id-${id}`
})
}
let generatedRequestId = ''
const idGenerator = makeFakeIdGenerator()
const asyncThunk = createAsyncThunk(
'test',
async (args: void, { requestId }) => {
generatedRequestId = requestId
},
{ idGenerator }
)
// dispatching the thunks should be using the custom id generator
const promise0 = asyncThunk()(dispatch, getState, extra)
expect(generatedRequestId).toEqual('fake-random-id-1')
expect(promise0.requestId).toEqual('fake-random-id-1')
expect((await promise0).meta.requestId).toEqual('fake-random-id-1')
const promise1 = asyncThunk()(dispatch, getState, extra)
expect(generatedRequestId).toEqual('fake-random-id-2')
expect(promise1.requestId).toEqual('fake-random-id-2')
expect((await promise1).meta.requestId).toEqual('fake-random-id-2')
const promise2 = asyncThunk()(dispatch, getState, extra)
expect(generatedRequestId).toEqual('fake-random-id-3')
expect(promise2.requestId).toEqual('fake-random-id-3')
expect((await promise2).meta.requestId).toEqual('fake-random-id-3')
generatedRequestId = ''
const defaultAsyncThunk = createAsyncThunk(
'test',
async (args: void, { requestId }) => {
generatedRequestId = requestId
}
)
// dispatching the default options thunk should still generate an id,
// but not using the custom id generator
const promise3 = defaultAsyncThunk()(dispatch, getState, extra)
expect(generatedRequestId).toEqual(promise3.requestId)
expect(promise3.requestId).not.toEqual('')
expect(promise3.requestId).not.toEqual(
expect.stringContaining('fake-random-id')
)
expect((await promise3).meta.requestId).not.toEqual(
expect.stringContaining('fake-fandom-id')
)
})
test('idGenerator should be called with thunkArg', async () => {
const customIdGenerator = jest.fn((seed) => `fake-unique-random-id-${seed}`)
let generatedRequestId = ''
const asyncThunk = createAsyncThunk(
'test',
async (args: any, { requestId }) => {
generatedRequestId = requestId
},
{ idGenerator: customIdGenerator }
)
const thunkArg = 1
const expected = 'fake-unique-random-id-1'
const asyncThunkPromise = asyncThunk(thunkArg)(dispatch, getState, extra)
expect(customIdGenerator).toHaveBeenCalledWith(thunkArg)
expect(asyncThunkPromise.requestId).toEqual(expected)
expect((await asyncThunkPromise).meta.requestId).toEqual(expected)
})
})
test('`condition` will see state changes from a synchronously invoked asyncThunk', () => {
type State = ReturnType<typeof store.getState>
const onStart = jest.fn()
const asyncThunk = createAsyncThunk<
void,
{ force?: boolean },
{ state: State }
>('test', onStart, {
condition({ force }, { getState }) {
return force || !getState().started
},
})
const store = configureStore({
reducer: createReducer({ started: false }, (builder) => {
builder.addCase(asyncThunk.pending, (state) => {
state.started = true
})
}),
})
store.dispatch(asyncThunk({ force: false }))
expect(onStart).toHaveBeenCalledTimes(1)
store.dispatch(asyncThunk({ force: false }))
expect(onStart).toHaveBeenCalledTimes(1)
store.dispatch(asyncThunk({ force: true }))
expect(onStart).toHaveBeenCalledTimes(2)
})
describe('meta', () => {
const getNewStore = () =>
configureStore({
reducer(actions = [], action) {
return [...actions, action]
},
})
let store = getNewStore()
beforeEach(() => {
const store = getNewStore()
})
test('pendingMeta', () => {
const pendingThunk = createAsyncThunk('test', (arg: string) => {}, {
getPendingMeta({ arg, requestId }) {
expect(arg).toBe('testArg')
expect(requestId).toEqual(expect.any(String))
return { extraProp: 'foo' }
},
})
const ret = store.dispatch(pendingThunk('testArg'))
expect(store.getState()[1]).toEqual({
meta: {
arg: 'testArg',
extraProp: 'foo',
requestId: ret.requestId,
requestStatus: 'pending',
},
payload: undefined,
type: 'test/pending',
})
})
test('fulfilledMeta', async () => {
const fulfilledThunk = createAsyncThunk<
string,
string,
{ fulfilledMeta: { extraProp: string } }
>('test', (arg: string, { fulfillWithValue }) => {
return fulfillWithValue('hooray!', { extraProp: 'bar' })
})
const ret = store.dispatch(fulfilledThunk('testArg'))
expect(await ret).toEqual({
meta: {
arg: 'testArg',
extraProp: 'bar',
requestId: ret.requestId,
requestStatus: 'fulfilled',
},
payload: 'hooray!',
type: 'test/fulfilled',
})
})
test('rejectedMeta', async () => {
const fulfilledThunk = createAsyncThunk<
string,
string,
{ rejectedMeta: { extraProp: string } }
>('test', (arg: string, { rejectWithValue }) => {
return rejectWithValue('damn!', { extraProp: 'baz' })
})
const promise = store.dispatch(fulfilledThunk('testArg'))
const ret = await promise
expect(ret).toEqual({
meta: {
arg: 'testArg',
extraProp: 'baz',
requestId: promise.requestId,
requestStatus: 'rejected',
rejectedWithValue: true,
aborted: false,
condition: false,
},
error: { message: 'Rejected' },
payload: 'damn!',
type: 'test/rejected',
})
if (ret.meta.requestStatus === 'rejected' && ret.meta.rejectedWithValue) {
expectType<string>(ret.meta.extraProp)
} else {
// could be caused by a `throw`, `abort()` or `condition` - no `rejectedMeta` in that case
// @ts-expect-error
ret.meta.extraProp
}
})
test('typed createAsyncThunk.withTypes', () => {
const typedCAT = createAsyncThunk.withTypes<{
state: { s: string }
rejectValue: string
extra: { s: string; n: number }
}>()
const thunk = typedCAT('a', () => 'b')
const expectFunction = expect.any(Function)
expect(thunk.fulfilled).toEqual(expectFunction)
expect(thunk.pending).toEqual(expectFunction)
expect(thunk.rejected).toEqual(expectFunction)
expect(thunk.fulfilled.type).toBe('a/fulfilled')
})
})

View File

@@ -0,0 +1,768 @@
/* eslint-disable no-lone-blocks */
import type { AnyAction, SerializedError, AsyncThunk } from '@reduxjs/toolkit'
import {
createAsyncThunk,
createReducer,
unwrapResult,
createSlice,
configureStore,
} from '@reduxjs/toolkit'
import type { ThunkDispatch } from 'redux-thunk'
import type { AxiosError } from 'axios'
import apiRequest from 'axios'
import type { IsAny, IsUnknown } from '@internal/tsHelpers'
import { expectExactType, expectType } from './helpers'
import type {
AsyncThunkFulfilledActionCreator,
AsyncThunkRejectedActionCreator,
} from '@internal/createAsyncThunk'
import type { TSVersion } from '@phryneas/ts-version'
const ANY = {} as any
const defaultDispatch = (() => {}) as ThunkDispatch<{}, any, AnyAction>
const anyAction = { type: 'foo' } as AnyAction
// basic usage
;(async function () {
const async = createAsyncThunk('test', (id: number) =>
Promise.resolve(id * 2)
)
const reducer = createReducer({}, (builder) =>
builder
.addCase(async.pending, (_, action) => {
expectType<ReturnType<typeof async['pending']>>(action)
})
.addCase(async.fulfilled, (_, action) => {
expectType<ReturnType<typeof async['fulfilled']>>(action)
expectType<number>(action.payload)
})
.addCase(async.rejected, (_, action) => {
expectType<ReturnType<typeof async['rejected']>>(action)
expectType<Partial<Error> | undefined>(action.error)
})
)
const promise = defaultDispatch(async(3))
expectType<string>(promise.requestId)
expectType<number>(promise.arg)
expectType<(reason?: string) => void>(promise.abort)
const result = await promise
if (async.fulfilled.match(result)) {
expectType<ReturnType<typeof async['fulfilled']>>(result)
// @ts-expect-error
expectType<ReturnType<typeof async['rejected']>>(result)
} else {
expectType<ReturnType<typeof async['rejected']>>(result)
// @ts-expect-error
expectType<ReturnType<typeof async['fulfilled']>>(result)
}
promise
.then(unwrapResult)
.then((result) => {
expectType<number>(result)
// @ts-expect-error
expectType<Error>(result)
})
.catch((error) => {
// catch is always any-typed, nothing we can do here
})
})()
// More complex usage of thunk args
;(async function () {
interface BookModel {
id: string
title: string
}
type BooksState = BookModel[]
const fakeBooks: BookModel[] = [
{ id: 'b', title: 'Second' },
{ id: 'a', title: 'First' },
]
const correctDispatch = (() => {}) as ThunkDispatch<
BookModel[],
{ userAPI: Function },
AnyAction
>
// Verify that the the first type args to createAsyncThunk line up right
const fetchBooksTAC = createAsyncThunk<
BookModel[],
number,
{
state: BooksState
extra: { userAPI: Function }
}
>(
'books/fetch',
async (arg, { getState, dispatch, extra, requestId, signal }) => {
const state = getState()
expectType<number>(arg)
expectType<BookModel[]>(state)
expectType<{ userAPI: Function }>(extra)
return fakeBooks
}
)
correctDispatch(fetchBooksTAC(1))
// @ts-expect-error
defaultDispatch(fetchBooksTAC(1))
})()
/**
* returning a rejected action from the promise creator is possible
*/
;(async () => {
type ReturnValue = { data: 'success' }
type RejectValue = { data: 'error' }
const fetchBooksTAC = createAsyncThunk<
ReturnValue,
number,
{
rejectValue: RejectValue
}
>('books/fetch', async (arg, { rejectWithValue }) => {
return rejectWithValue({ data: 'error' })
})
const returned = await defaultDispatch(fetchBooksTAC(1))
if (fetchBooksTAC.rejected.match(returned)) {
expectType<undefined | RejectValue>(returned.payload)
expectType<RejectValue>(returned.payload!)
} else {
expectType<ReturnValue>(returned.payload)
}
expectType<ReturnValue>(unwrapResult(returned))
// @ts-expect-error
expectType<RejectValue>(unwrapResult(returned))
})()
/**
* regression #1156: union return values fall back to allowing only single member
*/
;(async () => {
const fn = createAsyncThunk('session/isAdmin', async () => {
const response: boolean = false
return response
})
})()
/**
* Should handle reject withvalue within a try catch block
*
* Note:
* this is a sample code taken from #1605
*
*/
;(async () => {
type ResultType = {
text: string
}
const demoPromise = async (): Promise<ResultType> =>
new Promise((resolve, _) => resolve({ text: '' }))
const thunk = createAsyncThunk('thunk', async (args, thunkAPI) => {
try {
const result = await demoPromise()
return result
} catch (error) {
return thunkAPI.rejectWithValue(error)
}
})
createReducer({}, (builder) =>
builder.addCase(thunk.fulfilled, (s, action) => {
expectType<ResultType>(action.payload)
})
)
})()
{
interface Item {
name: string
}
interface ErrorFromServer {
error: string
}
interface CallsResponse {
data: Item[]
}
const fetchLiveCallsError = createAsyncThunk<
Item[],
string,
{
rejectValue: ErrorFromServer
}
>('calls/fetchLiveCalls', async (organizationId, { rejectWithValue }) => {
try {
const result = await apiRequest.get<CallsResponse>(
`organizations/${organizationId}/calls/live/iwill404`
)
return result.data.data
} catch (err) {
let error: AxiosError<ErrorFromServer> = err as any // cast for access to AxiosError properties
if (!error.response) {
// let it be handled as any other unknown error
throw err
}
return rejectWithValue(error.response && error.response.data)
}
})
defaultDispatch(fetchLiveCallsError('asd')).then((result) => {
if (fetchLiveCallsError.fulfilled.match(result)) {
//success
expectType<ReturnType<typeof fetchLiveCallsError['fulfilled']>>(result)
expectType<Item[]>(result.payload)
} else {
expectType<ReturnType<typeof fetchLiveCallsError['rejected']>>(result)
if (result.payload) {
// rejected with value
expectType<ErrorFromServer>(result.payload)
} else {
// rejected by throw
expectType<undefined>(result.payload)
expectType<SerializedError>(result.error)
// @ts-expect-error
expectType<IsAny<typeof result['error'], true, false>>(true)
}
}
defaultDispatch(fetchLiveCallsError('asd'))
.then((result) => {
expectType<Item[] | ErrorFromServer | undefined>(result.payload)
// @ts-expect-error
expectType<Item[]>(unwrapped)
return result
})
.then(unwrapResult)
.then((unwrapped) => {
expectType<Item[]>(unwrapped)
// @ts-expect-error
expectType<ErrorFromServer>(unwrapResult(unwrapped))
})
})
}
/**
* payloadCreator first argument type has impact on asyncThunk argument
*/
{
// no argument: asyncThunk has no argument
{
const asyncThunk = createAsyncThunk('test', () => 0)
expectType<() => any>(asyncThunk)
// @ts-expect-error cannot be called with an argument
asyncThunk(0 as any)
}
// one argument, specified as undefined: asyncThunk has no argument
{
const asyncThunk = createAsyncThunk('test', (arg: undefined) => 0)
expectType<() => any>(asyncThunk)
// @ts-expect-error cannot be called with an argument
asyncThunk(0 as any)
}
// one argument, specified as void: asyncThunk has no argument
{
const asyncThunk = createAsyncThunk('test', (arg: void) => 0)
expectType<() => any>(asyncThunk)
// @ts-expect-error cannot be called with an argument
asyncThunk(0 as any)
}
// one argument, specified as optional number: asyncThunk has optional number argument
// this test will fail with strictNullChecks: false, that is to be expected
// in that case, we have to forbid this behaviour or it will make arguments optional everywhere
{
const asyncThunk = createAsyncThunk('test', (arg?: number) => 0)
// Per https://github.com/reduxjs/redux-toolkit/issues/3758#issuecomment-1742152774 , this is a bug in
// TS 5.1 and 5.2, that is fixed in 5.3. Conditionally run the TS assertion here.
type IsTS51Or52 = TSVersion.Major extends 5
? TSVersion.Minor extends 1 | 2
? true
: false
: false
type expectedType = IsTS51Or52 extends true
? (arg: number) => any
: (arg?: number) => any
expectType<expectedType>(asyncThunk)
// We _should_ be able to call this with no arguments, but we run into that error in 5.1 and 5.2.
// Disabling this for now.
// asyncThunk()
asyncThunk(5)
// @ts-expect-error
asyncThunk('string')
}
// one argument, specified as number|undefined: asyncThunk has optional number argument
// this test will fail with strictNullChecks: false, that is to be expected
// in that case, we have to forbid this behaviour or it will make arguments optional everywhere
{
const asyncThunk = createAsyncThunk('test', (arg: number | undefined) => 0)
expectType<(arg?: number) => any>(asyncThunk)
asyncThunk()
asyncThunk(5)
// @ts-expect-error
asyncThunk('string')
}
// one argument, specified as number|void: asyncThunk has optional number argument
{
const asyncThunk = createAsyncThunk('test', (arg: number | void) => 0)
expectType<(arg?: number) => any>(asyncThunk)
asyncThunk()
asyncThunk(5)
// @ts-expect-error
asyncThunk('string')
}
// one argument, specified as any: asyncThunk has required any argument
{
const asyncThunk = createAsyncThunk('test', (arg: any) => 0)
expectType<IsAny<Parameters<typeof asyncThunk>[0], true, false>>(true)
asyncThunk(5)
// @ts-expect-error
asyncThunk()
}
// one argument, specified as unknown: asyncThunk has required unknown argument
{
const asyncThunk = createAsyncThunk('test', (arg: unknown) => 0)
expectType<IsUnknown<Parameters<typeof asyncThunk>[0], true, false>>(true)
asyncThunk(5)
// @ts-expect-error
asyncThunk()
}
// one argument, specified as number: asyncThunk has required number argument
{
const asyncThunk = createAsyncThunk('test', (arg: number) => 0)
expectType<(arg: number) => any>(asyncThunk)
asyncThunk(5)
// @ts-expect-error
asyncThunk()
}
// two arguments, first specified as undefined: asyncThunk has no argument
{
const asyncThunk = createAsyncThunk('test', (arg: undefined, thunkApi) => 0)
expectType<() => any>(asyncThunk)
// @ts-expect-error cannot be called with an argument
asyncThunk(0 as any)
}
// two arguments, first specified as void: asyncThunk has no argument
{
const asyncThunk = createAsyncThunk('test', (arg: void, thunkApi) => 0)
expectType<() => any>(asyncThunk)
// @ts-expect-error cannot be called with an argument
asyncThunk(0 as any)
}
// two arguments, first specified as number|undefined: asyncThunk has optional number argument
// this test will fail with strictNullChecks: false, that is to be expected
// in that case, we have to forbid this behaviour or it will make arguments optional everywhere
{
const asyncThunk = createAsyncThunk(
'test',
(arg: number | undefined, thunkApi) => 0
)
expectType<(arg?: number) => any>(asyncThunk)
asyncThunk()
asyncThunk(5)
// @ts-expect-error
asyncThunk('string')
}
// two arguments, first specified as number|void: asyncThunk has optional number argument
{
const asyncThunk = createAsyncThunk(
'test',
(arg: number | void, thunkApi) => 0
)
expectType<(arg?: number) => any>(asyncThunk)
asyncThunk()
asyncThunk(5)
// @ts-expect-error
asyncThunk('string')
}
// two arguments, first specified as any: asyncThunk has required any argument
{
const asyncThunk = createAsyncThunk('test', (arg: any, thunkApi) => 0)
expectType<IsAny<Parameters<typeof asyncThunk>[0], true, false>>(true)
asyncThunk(5)
// @ts-expect-error
asyncThunk()
}
// two arguments, first specified as unknown: asyncThunk has required unknown argument
{
const asyncThunk = createAsyncThunk('test', (arg: unknown, thunkApi) => 0)
expectType<IsUnknown<Parameters<typeof asyncThunk>[0], true, false>>(true)
asyncThunk(5)
// @ts-expect-error
asyncThunk()
}
// two arguments, first specified as number: asyncThunk has required number argument
{
const asyncThunk = createAsyncThunk('test', (arg: number, thunkApi) => 0)
expectType<(arg: number) => any>(asyncThunk)
asyncThunk(5)
// @ts-expect-error
asyncThunk()
}
}
{
// createAsyncThunk without generics
const thunk = createAsyncThunk('test', () => {
return 'ret' as const
})
expectType<AsyncThunk<'ret', void, {}>>(thunk)
}
{
// createAsyncThunk without generics, accessing `api` does not break return type
const thunk = createAsyncThunk('test', (_: void, api) => {
console.log(api)
return 'ret' as const
})
expectType<AsyncThunk<'ret', void, {}>>(thunk)
}
// createAsyncThunk rejectWithValue without generics: Expect correct return type
{
const asyncThunk = createAsyncThunk(
'test',
(_: void, { rejectWithValue }) => {
try {
return Promise.resolve(true)
} catch (e) {
return rejectWithValue(e)
}
}
)
defaultDispatch(asyncThunk())
.then((result) => {
if (asyncThunk.fulfilled.match(result)) {
expectType<ReturnType<AsyncThunkFulfilledActionCreator<boolean, void>>>(
result
)
expectType<boolean>(result.payload)
// @ts-expect-error
expectType<any>(result.error)
} else {
expectType<ReturnType<AsyncThunkRejectedActionCreator<unknown, void>>>(
result
)
expectType<SerializedError>(result.error)
expectType<unknown>(result.payload)
}
return result
})
.then(unwrapResult)
.then((unwrapped) => {
expectType<boolean>(unwrapped)
})
}
{
type Funky = { somethingElse: 'Funky!' }
function funkySerializeError(err: any): Funky {
return { somethingElse: 'Funky!' }
}
// has to stay on one line or type tests fail in older TS versions
// prettier-ignore
// @ts-expect-error
const shouldFail = createAsyncThunk('without generics', () => {}, { serializeError: funkySerializeError })
const shouldWork = createAsyncThunk<
any,
void,
{ serializedErrorType: Funky }
>('with generics', () => {}, {
serializeError: funkySerializeError,
})
if (shouldWork.rejected.match(anyAction)) {
expectType<Funky>(anyAction.error)
}
}
/**
* `idGenerator` option takes no arguments, and returns a string
*/
{
const returnsNumWithArgs = (foo: any) => 100
// has to stay on one line or type tests fail in older TS versions
// prettier-ignore
// @ts-expect-error
const shouldFailNumWithArgs = createAsyncThunk('foo', () => {}, { idGenerator: returnsNumWithArgs })
const returnsNumWithoutArgs = () => 100
// prettier-ignore
// @ts-expect-error
const shouldFailNumWithoutArgs = createAsyncThunk('foo', () => {}, { idGenerator: returnsNumWithoutArgs })
const returnsStrWithNumberArg = (foo: number) => 'foo'
// prettier-ignore
// @ts-expect-error
const shouldFailWrongArgs = createAsyncThunk('foo', (arg: string) => {}, { idGenerator: returnsStrWithNumberArg })
const returnsStrWithStringArg = (foo: string) => 'foo'
const shoulducceedCorrectArgs = createAsyncThunk('foo', (arg: string) => {}, {
idGenerator: returnsStrWithStringArg,
})
const returnsStrWithoutArgs = () => 'foo'
const shouldSucceed = createAsyncThunk('foo', () => {}, {
idGenerator: returnsStrWithoutArgs,
})
}
{
// https://github.com/reduxjs/redux-toolkit/issues/2886
// fulfillWithValue should infer return value
const initialState = {
loading: false,
obj: { magic: '' },
}
const getObj = createAsyncThunk(
'slice/getObj',
async (_: any, { fulfillWithValue, rejectWithValue }) => {
try {
return fulfillWithValue({ magic: 'object' })
} catch (rejected: any) {
return rejectWithValue(rejected?.response?.error || rejected)
}
}
)
createSlice({
name: 'slice',
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(getObj.fulfilled, (state, action) => {
expectExactType<{ magic: string }>(ANY)(action.payload)
})
},
})
}
// meta return values
{
// return values
createAsyncThunk<'ret', void, {}>('test', (_, api) => 'ret' as const)
createAsyncThunk<'ret', void, {}>('test', async (_, api) => 'ret' as const)
createAsyncThunk<'ret', void, { fulfilledMeta: string }>('test', (_, api) =>
api.fulfillWithValue('ret' as const, '')
)
createAsyncThunk<'ret', void, { fulfilledMeta: string }>(
'test',
async (_, api) => api.fulfillWithValue('ret' as const, '')
)
createAsyncThunk<'ret', void, { fulfilledMeta: string }>(
'test',
// @ts-expect-error has to be a fulfilledWithValue call
(_, api) => 'ret' as const
)
createAsyncThunk<'ret', void, { fulfilledMeta: string }>(
'test',
// @ts-expect-error has to be a fulfilledWithValue call
async (_, api) => 'ret' as const
)
createAsyncThunk<'ret', void, { fulfilledMeta: string }>(
'test', // @ts-expect-error should only allow returning with 'test'
(_, api) => api.fulfillWithValue(5, '')
)
createAsyncThunk<'ret', void, { fulfilledMeta: string }>(
'test', // @ts-expect-error should only allow returning with 'test'
async (_, api) => api.fulfillWithValue(5, '')
)
// reject values
createAsyncThunk<'ret', void, { rejectValue: string }>('test', (_, api) =>
api.rejectWithValue('ret')
)
createAsyncThunk<'ret', void, { rejectValue: string }>(
'test',
async (_, api) => api.rejectWithValue('ret')
)
createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>(
'test',
(_, api) => api.rejectWithValue('ret', 5)
)
createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>(
'test',
async (_, api) => api.rejectWithValue('ret', 5)
)
createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>(
'test',
(_, api) => api.rejectWithValue('ret', 5)
)
createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>(
'test',
// @ts-expect-error wrong rejectedMeta type
(_, api) => api.rejectWithValue('ret', '')
)
createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>(
'test',
// @ts-expect-error wrong rejectedMeta type
async (_, api) => api.rejectWithValue('ret', '')
)
createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>(
'test',
// @ts-expect-error wrong rejectValue type
(_, api) => api.rejectWithValue(5, '')
)
createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>(
'test',
// @ts-expect-error wrong rejectValue type
async (_, api) => api.rejectWithValue(5, '')
)
}
{
const typedCAT = createAsyncThunk.withTypes<{
state: RootState
dispatch: AppDispatch
rejectValue: string
extra: { s: string; n: number }
}>()
// inferred usage
const thunk = typedCAT('foo', (arg: number, api) => {
// correct getState Type
const test1: number = api.getState().foo.value
// correct dispatch type
const test2: number = api.dispatch((dispatch, getState) => {
expectExactType<
ThunkDispatch<{ foo: { value: number } }, undefined, AnyAction>
>(ANY)(dispatch)
expectExactType<() => { foo: { value: number } }>(ANY)(getState)
return getState().foo.value
})
// correct extra type
const { s, n } = api.extra
expectExactType<string>(ANY)(s)
expectExactType<number>(ANY)(n)
if (1 < 2)
// @ts-expect-error
return api.rejectWithValue(5)
if (1 < 2) return api.rejectWithValue('test')
return test1 + test2
})
// usage with two generics
const thunk2 = typedCAT<number, string>('foo', (arg, api) => {
expectExactType('' as string)(arg)
// correct getState Type
const test1: number = api.getState().foo.value
// correct dispatch type
const test2: number = api.dispatch((dispatch, getState) => {
expectExactType<
ThunkDispatch<{ foo: { value: number } }, undefined, AnyAction>
>(ANY)(dispatch)
expectExactType<() => { foo: { value: number } }>(ANY)(getState)
return getState().foo.value
})
// correct extra type
const { s, n } = api.extra
expectExactType<string>(ANY)(s)
expectExactType<number>(ANY)(n)
if (1 < 2)
// @ts-expect-error
return api.rejectWithValue(5)
if (1 < 2) return api.rejectWithValue('test')
return test1 + test2
})
// usage with config override generic
const thunk3 = typedCAT<number, string, { rejectValue: number }>(
'foo',
(arg, api) => {
expectExactType('' as string)(arg)
// correct getState Type
const test1: number = api.getState().foo.value
// correct dispatch type
const test2: number = api.dispatch((dispatch, getState) => {
expectExactType<
ThunkDispatch<{ foo: { value: number } }, undefined, AnyAction>
>(ANY)(dispatch)
expectExactType<() => { foo: { value: number } }>(ANY)(getState)
return getState().foo.value
})
// correct extra type
const { s, n } = api.extra
expectExactType<string>(ANY)(s)
expectExactType<number>(ANY)(n)
if (1 < 2) return api.rejectWithValue(5)
if (1 < 2)
// @ts-expect-error
return api.rejectWithValue('test')
return 5
}
)
const slice = createSlice({
name: 'foo',
initialState: { value: 0 },
reducers: {},
extraReducers(builder) {
builder
.addCase(thunk.fulfilled, (state, action) => {
state.value += action.payload
})
.addCase(thunk.rejected, (state, action) => {
expectExactType('' as string | undefined)(action.payload)
})
.addCase(thunk2.fulfilled, (state, action) => {
state.value += action.payload
})
.addCase(thunk2.rejected, (state, action) => {
expectExactType('' as string | undefined)(action.payload)
})
.addCase(thunk3.fulfilled, (state, action) => {
state.value += action.payload
})
.addCase(thunk3.rejected, (state, action) => {
expectExactType(0 as number | undefined)(action.payload)
})
},
})
const store = configureStore({
reducer: {
foo: slice.reducer,
},
})
type RootState = ReturnType<typeof store.getState>
type AppDispatch = typeof store.dispatch
}

View File

@@ -0,0 +1,36 @@
import { createDraftSafeSelector, createSelector } from '@reduxjs/toolkit'
import { produce } from 'immer'
type State = { value: number }
const selectSelf = (state: State) => state
test('handles normal values correctly', () => {
const unsafeSelector = createSelector(selectSelf, (x) => x.value)
const draftSafeSelector = createDraftSafeSelector(selectSelf, (x) => x.value)
let state = { value: 1 }
expect(unsafeSelector(state)).toBe(1)
expect(draftSafeSelector(state)).toBe(1)
state = { value: 2 }
expect(unsafeSelector(state)).toBe(2)
expect(draftSafeSelector(state)).toBe(2)
})
test('handles drafts correctly', () => {
const unsafeSelector = createSelector(selectSelf, (state) => state.value)
const draftSafeSelector = createDraftSafeSelector(
selectSelf,
(state) => state.value
)
produce({ value: 1 }, (state) => {
expect(unsafeSelector(state)).toBe(1)
expect(draftSafeSelector(state)).toBe(1)
state.value = 2
expect(unsafeSelector(state)).toBe(1)
expect(draftSafeSelector(state)).toBe(2)
})
})

View File

@@ -0,0 +1,132 @@
import type {
EntityAdapter,
ActionCreatorWithPayload,
ActionCreatorWithoutPayload,
EntityStateAdapter,
EntityId,
Update,
} from '@reduxjs/toolkit'
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit'
import { expectType } from './helpers'
function extractReducers<T>(
adapter: EntityAdapter<T>
): Omit<EntityStateAdapter<T>, 'map'> {
const { selectId, sortComparer, getInitialState, getSelectors, ...rest } =
adapter
return rest
}
/**
* should be usable in a slice, with all the "reducer-like" functions
*/
{
type Entity = {
value: string
}
const adapter = createEntityAdapter<Entity>()
const slice = createSlice({
name: 'test',
initialState: adapter.getInitialState(),
reducers: {
...extractReducers(adapter),
},
})
expectType<ActionCreatorWithPayload<Entity>>(slice.actions.addOne)
expectType<
ActionCreatorWithPayload<ReadonlyArray<Entity> | Record<string, Entity>>
>(slice.actions.addMany)
expectType<
ActionCreatorWithPayload<ReadonlyArray<Entity> | Record<string, Entity>>
>(slice.actions.setAll)
expectType<ActionCreatorWithPayload<Entity[] | Record<string, Entity>>>(
// @ts-expect-error
slice.actions.addMany
)
expectType<ActionCreatorWithPayload<Entity[] | Record<string, Entity>>>(
// @ts-expect-error
slice.actions.setAll
)
expectType<ActionCreatorWithPayload<EntityId>>(slice.actions.removeOne)
expectType<ActionCreatorWithPayload<ReadonlyArray<EntityId>>>(
slice.actions.removeMany
)
// @ts-expect-error
expectType<ActionCreatorWithPayload<EntityId[]>>(slice.actions.removeMany)
expectType<ActionCreatorWithoutPayload>(slice.actions.removeAll)
expectType<ActionCreatorWithPayload<Update<Entity>>>(slice.actions.updateOne)
expectType<ActionCreatorWithPayload<Update<Entity>[]>>(
// @ts-expect-error
slice.actions.updateMany
)
expectType<ActionCreatorWithPayload<ReadonlyArray<Update<Entity>>>>(
slice.actions.updateMany
)
expectType<ActionCreatorWithPayload<Entity>>(slice.actions.upsertOne)
expectType<
ActionCreatorWithPayload<ReadonlyArray<Entity> | Record<string, Entity>>
>(slice.actions.upsertMany)
expectType<ActionCreatorWithPayload<Entity[] | Record<string, Entity>>>(
// @ts-expect-error
slice.actions.upsertMany
)
}
/**
* should not be able to mix with a different EntityAdapter
*/
{
type Entity = {
value: string
}
type Entity2 = {
value2: string
}
const adapter = createEntityAdapter<Entity>()
const adapter2 = createEntityAdapter<Entity2>()
createSlice({
name: 'test',
initialState: adapter.getInitialState(),
reducers: {
addOne: adapter.addOne,
// @ts-expect-error
addOne2: adapter2.addOne,
},
})
}
/**
* should be usable in a slice with extra properties
*/
{
type Entity = {
value: string
}
const adapter = createEntityAdapter<Entity>()
createSlice({
name: 'test',
initialState: adapter.getInitialState({ extraData: 'test' }),
reducers: {
addOne: adapter.addOne,
},
})
}
/**
* should not be usable in a slice with an unfitting state
*/
{
type Entity = {
value: string
}
const adapter = createEntityAdapter<Entity>()
createSlice({
name: 'test',
initialState: { somethingElse: '' },
reducers: {
// @ts-expect-error
addOne: adapter.addOne,
},
})
}

View File

@@ -0,0 +1,741 @@
import type {
CaseReducer,
PayloadAction,
Draft,
Reducer,
AnyAction,
} from '@reduxjs/toolkit'
import { createReducer, createAction, createNextState } from '@reduxjs/toolkit'
import {
mockConsole,
createConsole,
getLog,
} from 'console-testing-library/pure'
interface Todo {
text: string
completed?: boolean
}
interface AddTodoPayload {
newTodo: Todo
}
interface ToggleTodoPayload {
index: number
}
type TodoState = Todo[]
type TodosReducer = Reducer<TodoState, PayloadAction<any>>
type AddTodoReducer = CaseReducer<TodoState, PayloadAction<AddTodoPayload>>
type ToggleTodoReducer = CaseReducer<
TodoState,
PayloadAction<ToggleTodoPayload>
>
type CreateReducer = typeof createReducer
describe('createReducer', () => {
let restore: () => void
beforeEach(() => {
restore = mockConsole(createConsole())
})
describe('given impure reducers with immer', () => {
const addTodo: AddTodoReducer = (state, action) => {
const { newTodo } = action.payload
// Can safely call state.push() here
state.push({ ...newTodo, completed: false })
}
const toggleTodo: ToggleTodoReducer = (state, action) => {
const { index } = action.payload
const todo = state[index]
// Can directly modify the todo object
todo.completed = !todo.completed
}
const todosReducer = createReducer([] as TodoState, {
ADD_TODO: addTodo,
TOGGLE_TODO: toggleTodo,
})
behavesLikeReducer(todosReducer)
})
describe('Deprecation warnings', () => {
let originalNodeEnv = process.env.NODE_ENV
beforeEach(() => {
jest.resetModules()
})
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv
})
it('Warns about object notation deprecation, once', () => {
const { createReducer } = require('../createReducer')
let dummyReducer = (createReducer as CreateReducer)([] as TodoState, {})
expect(getLog().levels.warn).toMatch(
/The object notation for `createReducer` is deprecated/
)
restore = mockConsole(createConsole())
dummyReducer = (createReducer as CreateReducer)([] as TodoState, {})
expect(getLog().levels.warn).toBe('')
})
it('Does not warn in production', () => {
process.env.NODE_ENV = 'production'
const { createReducer } = require('../createReducer')
let dummyReducer = (createReducer as CreateReducer)([] as TodoState, {})
expect(getLog().levels.warn).toBe('')
})
})
describe('Immer in a production environment', () => {
let originalNodeEnv = process.env.NODE_ENV
beforeEach(() => {
jest.resetModules()
process.env.NODE_ENV = 'production'
})
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv
})
test('Freezes data in production', () => {
const { createReducer } = require('../createReducer')
const addTodo: AddTodoReducer = (state, action) => {
const { newTodo } = action.payload
state.push({ ...newTodo, completed: false })
}
const toggleTodo: ToggleTodoReducer = (state, action) => {
const { index } = action.payload
const todo = state[index]
todo.completed = !todo.completed
}
const todosReducer = createReducer([] as TodoState, {
ADD_TODO: addTodo,
TOGGLE_TODO: toggleTodo,
})
const result = todosReducer([], {
type: 'ADD_TODO',
payload: { text: 'Buy milk' },
})
const mutateStateOutsideReducer = () => (result[0].text = 'edited')
expect(mutateStateOutsideReducer).toThrowError(
'Cannot add property text, object is not extensible'
)
})
test('Freezes initial state', () => {
const initialState = [{ text: 'Buy milk' }]
const todosReducer = createReducer(initialState, {})
const frozenInitialState = todosReducer(undefined, { type: 'dummy' })
const mutateStateOutsideReducer = () =>
(frozenInitialState[0].text = 'edited')
expect(mutateStateOutsideReducer).toThrowError(
/Cannot assign to read only property/
)
})
test('does not throw error if initial state is not draftable', () => {
expect(() => createReducer(new URLSearchParams(), {})).not.toThrowError()
})
})
describe('given pure reducers with immutable updates', () => {
const addTodo: AddTodoReducer = (state, action) => {
const { newTodo } = action.payload
// Updates the state immutably without relying on immer
return state.concat({ ...newTodo, completed: false })
}
const toggleTodo: ToggleTodoReducer = (state, action) => {
const { index } = action.payload
// Updates the todo object immutably withot relying on immer
return state.map((todo, i) => {
if (i !== index) return todo
return { ...todo, completed: !todo.completed }
})
}
const todosReducer = createReducer([] as TodoState, {
ADD_TODO: addTodo,
TOGGLE_TODO: toggleTodo,
})
behavesLikeReducer(todosReducer)
})
describe('Accepts a lazy state init function to generate initial state', () => {
const addTodo: AddTodoReducer = (state, action) => {
const { newTodo } = action.payload
state.push({ ...newTodo, completed: false })
}
const toggleTodo: ToggleTodoReducer = (state, action) => {
const { index } = action.payload
const todo = state[index]
todo.completed = !todo.completed
}
const lazyStateInit = () => [] as TodoState
const todosReducer = createReducer(lazyStateInit, {
ADD_TODO: addTodo,
TOGGLE_TODO: toggleTodo,
})
behavesLikeReducer(todosReducer)
it('Should only call the init function when `undefined` state is passed in', () => {
const spy = jest.fn().mockReturnValue(42)
const dummyReducer = createReducer(spy, {})
expect(spy).not.toHaveBeenCalled()
dummyReducer(123, { type: 'dummy' })
expect(spy).not.toHaveBeenCalled()
const initialState = dummyReducer(undefined, { type: 'dummy' })
expect(spy).toHaveBeenCalledTimes(1)
})
})
describe('given draft state from immer', () => {
const addTodo: AddTodoReducer = (state, action) => {
const { newTodo } = action.payload
// Can safely call state.push() here
state.push({ ...newTodo, completed: false })
}
const toggleTodo: ToggleTodoReducer = (state, action) => {
const { index } = action.payload
const todo = state[index]
// Can directly modify the todo object
todo.completed = !todo.completed
}
const todosReducer = createReducer([] as TodoState, {
ADD_TODO: addTodo,
TOGGLE_TODO: toggleTodo,
})
const wrappedReducer: TodosReducer = (state = [], action) => {
return createNextState(state, (draft: Draft<TodoState>) => {
todosReducer(draft, action)
})
}
behavesLikeReducer(wrappedReducer)
})
describe('actionMatchers argument', () => {
const prepareNumberAction = (payload: number) => ({
payload,
meta: { type: 'number_action' },
})
const prepareStringAction = (payload: string) => ({
payload,
meta: { type: 'string_action' },
})
const numberActionMatcher = (a: AnyAction): a is PayloadAction<number> =>
a.meta && a.meta.type === 'number_action'
const stringActionMatcher = (a: AnyAction): a is PayloadAction<string> =>
a.meta && a.meta.type === 'string_action'
const incrementBy = createAction('increment', prepareNumberAction)
const decrementBy = createAction('decrement', prepareNumberAction)
const concatWith = createAction('concat', prepareStringAction)
const initialState = { numberActions: 0, stringActions: 0 }
const numberActionsCounter = {
matcher: numberActionMatcher,
reducer(state: typeof initialState) {
state.numberActions = state.numberActions * 10 + 1
},
}
const stringActionsCounter = {
matcher: stringActionMatcher,
reducer(state: typeof initialState) {
state.stringActions = state.stringActions * 10 + 1
},
}
test('uses the reducer of matching actionMatchers', () => {
const reducer = createReducer(initialState, {}, [
numberActionsCounter,
stringActionsCounter,
])
expect(reducer(undefined, incrementBy(1))).toEqual({
numberActions: 1,
stringActions: 0,
})
expect(reducer(undefined, decrementBy(1))).toEqual({
numberActions: 1,
stringActions: 0,
})
expect(reducer(undefined, concatWith('foo'))).toEqual({
numberActions: 0,
stringActions: 1,
})
})
test('fallback to default case', () => {
const reducer = createReducer(
initialState,
{},
[numberActionsCounter, stringActionsCounter],
(state) => {
state.numberActions = -1
state.stringActions = -1
}
)
expect(reducer(undefined, { type: 'somethingElse' })).toEqual({
numberActions: -1,
stringActions: -1,
})
})
test('runs reducer cases followed by all matching actionMatchers', () => {
const reducer = createReducer(
initialState,
{
[incrementBy.type](state) {
state.numberActions = state.numberActions * 10 + 2
},
},
[
{
matcher: numberActionMatcher,
reducer(state) {
state.numberActions = state.numberActions * 10 + 3
},
},
numberActionsCounter,
stringActionsCounter,
]
)
expect(reducer(undefined, incrementBy(1))).toEqual({
numberActions: 231,
stringActions: 0,
})
expect(reducer(undefined, decrementBy(1))).toEqual({
numberActions: 31,
stringActions: 0,
})
expect(reducer(undefined, concatWith('foo'))).toEqual({
numberActions: 0,
stringActions: 1,
})
})
test('works with `actionCreator.match`', () => {
const reducer = createReducer(initialState, {}, [
{
matcher: incrementBy.match,
reducer(state) {
state.numberActions += 100
},
},
])
expect(reducer(undefined, incrementBy(1))).toEqual({
numberActions: 100,
stringActions: 0,
})
})
})
describe('alternative builder callback for actionMap', () => {
const increment = createAction<number, 'increment'>('increment')
const decrement = createAction<number, 'decrement'>('decrement')
test('can be used with ActionCreators', () => {
const reducer = createReducer(0, (builder) =>
builder
.addCase(increment, (state, action) => state + action.payload)
.addCase(decrement, (state, action) => state - action.payload)
)
expect(reducer(0, increment(5))).toBe(5)
expect(reducer(5, decrement(5))).toBe(0)
})
test('can be used with string types', () => {
const reducer = createReducer(0, (builder) =>
builder
.addCase(
'increment',
(state, action: { type: 'increment'; payload: number }) =>
state + action.payload
)
.addCase(
'decrement',
(state, action: { type: 'decrement'; payload: number }) =>
state - action.payload
)
)
expect(reducer(0, increment(5))).toBe(5)
expect(reducer(5, decrement(5))).toBe(0)
})
test('can be used with ActionCreators and string types combined', () => {
const reducer = createReducer(0, (builder) =>
builder
.addCase(increment, (state, action) => state + action.payload)
.addCase(
'decrement',
(state, action: { type: 'decrement'; payload: number }) =>
state - action.payload
)
)
expect(reducer(0, increment(5))).toBe(5)
expect(reducer(5, decrement(5))).toBe(0)
})
test('will throw an error when returning undefined from a non-draftable state', () => {
const reducer = createReducer(0, (builder) =>
builder.addCase(
'decrement',
(state, action: { type: 'decrement'; payload: number }) => {}
)
)
expect(() => reducer(5, decrement(5))).toThrowErrorMatchingInlineSnapshot(
`"A case reducer on a non-draftable value must not return undefined"`
)
})
test('allows you to return undefined if the state was null, thus skipping an update', () => {
const reducer = createReducer(null as number | null, (builder) =>
builder.addCase(
'decrement',
(state, action: { type: 'decrement'; payload: number }) => {
if (typeof state === 'number') {
return state - action.payload
}
return undefined
}
)
)
expect(reducer(0, decrement(5))).toBe(-5)
expect(reducer(null, decrement(5))).toBe(null)
})
test('allows you to return null', () => {
const reducer = createReducer(0 as number | null, (builder) =>
builder.addCase(
'decrement',
(state, action: { type: 'decrement'; payload: number }) => {
return null
}
)
)
expect(reducer(5, decrement(5))).toBe(null)
})
test('allows you to return 0', () => {
const reducer = createReducer(0, (builder) =>
builder.addCase(
'decrement',
(state, action: { type: 'decrement'; payload: number }) =>
state - action.payload
)
)
expect(reducer(5, decrement(5))).toBe(0)
})
test('will throw if the same type is used twice', () => {
expect(() =>
createReducer(0, (builder) =>
builder
.addCase(increment, (state, action) => state + action.payload)
.addCase(increment, (state, action) => state + action.payload)
.addCase(decrement, (state, action) => state - action.payload)
)
).toThrowErrorMatchingInlineSnapshot(
'"`builder.addCase` cannot be called with two reducers for the same action type"'
)
expect(() =>
createReducer(0, (builder) =>
builder
.addCase(increment, (state, action) => state + action.payload)
.addCase('increment', (state) => state + 1)
.addCase(decrement, (state, action) => state - action.payload)
)
).toThrowErrorMatchingInlineSnapshot(
'"`builder.addCase` cannot be called with two reducers for the same action type"'
)
})
test('will throw if an empty type is used', () => {
const customActionCreator = (payload: number) => ({
type: 'custom_action',
payload,
})
customActionCreator.type = ""
expect(() =>
createReducer(0, (builder) =>
builder.addCase(
customActionCreator,
(state, action) => state + action.payload
)
)
).toThrowErrorMatchingInlineSnapshot(
'"`builder.addCase` cannot be called with an empty action type"'
)
})
})
describe('builder "addMatcher" method', () => {
const prepareNumberAction = (payload: number) => ({
payload,
meta: { type: 'number_action' },
})
const prepareStringAction = (payload: string) => ({
payload,
meta: { type: 'string_action' },
})
const numberActionMatcher = (a: AnyAction): a is PayloadAction<number> =>
a.meta && a.meta.type === 'number_action'
const stringActionMatcher = (a: AnyAction): a is PayloadAction<string> =>
a.meta && a.meta.type === 'string_action'
const incrementBy = createAction('increment', prepareNumberAction)
const decrementBy = createAction('decrement', prepareNumberAction)
const concatWith = createAction('concat', prepareStringAction)
const initialState = { numberActions: 0, stringActions: 0 }
test('uses the reducer of matching actionMatchers', () => {
const reducer = createReducer(initialState, (builder) =>
builder
.addMatcher(numberActionMatcher, (state) => {
state.numberActions += 1
})
.addMatcher(stringActionMatcher, (state) => {
state.stringActions += 1
})
)
expect(reducer(undefined, incrementBy(1))).toEqual({
numberActions: 1,
stringActions: 0,
})
expect(reducer(undefined, decrementBy(1))).toEqual({
numberActions: 1,
stringActions: 0,
})
expect(reducer(undefined, concatWith('foo'))).toEqual({
numberActions: 0,
stringActions: 1,
})
})
test('falls back to defaultCase', () => {
const reducer = createReducer(initialState, (builder) =>
builder
.addCase(concatWith, (state) => {
state.stringActions += 1
})
.addMatcher(numberActionMatcher, (state) => {
state.numberActions += 1
})
.addDefaultCase((state) => {
state.numberActions = -1
state.stringActions = -1
})
)
expect(reducer(undefined, { type: 'somethingElse' })).toEqual({
numberActions: -1,
stringActions: -1,
})
})
test('runs reducer cases followed by all matching actionMatchers', () => {
const reducer = createReducer(initialState, (builder) =>
builder
.addCase(incrementBy, (state) => {
state.numberActions = state.numberActions * 10 + 1
})
.addMatcher(numberActionMatcher, (state) => {
state.numberActions = state.numberActions * 10 + 2
})
.addMatcher(stringActionMatcher, (state) => {
state.stringActions = state.stringActions * 10 + 1
})
.addMatcher(numberActionMatcher, (state) => {
state.numberActions = state.numberActions * 10 + 3
})
)
expect(reducer(undefined, incrementBy(1))).toEqual({
numberActions: 123,
stringActions: 0,
})
expect(reducer(undefined, decrementBy(1))).toEqual({
numberActions: 23,
stringActions: 0,
})
expect(reducer(undefined, concatWith('foo'))).toEqual({
numberActions: 0,
stringActions: 1,
})
})
test('works with `actionCreator.match`', () => {
const reducer = createReducer(initialState, (builder) =>
builder.addMatcher(incrementBy.match, (state) => {
state.numberActions += 100
})
)
expect(reducer(undefined, incrementBy(1))).toEqual({
numberActions: 100,
stringActions: 0,
})
})
test('calling addCase, addMatcher and addDefaultCase in a nonsensical order should result in an error in development mode', () => {
expect(() =>
createReducer(initialState, (builder: any) =>
builder
.addMatcher(numberActionMatcher, () => {})
.addCase(incrementBy, () => {})
)
).toThrowErrorMatchingInlineSnapshot(
`"\`builder.addCase\` should only be called before calling \`builder.addMatcher\`"`
)
expect(() =>
createReducer(initialState, (builder: any) =>
builder.addDefaultCase(() => {}).addCase(incrementBy, () => {})
)
).toThrowErrorMatchingInlineSnapshot(
`"\`builder.addCase\` should only be called before calling \`builder.addDefaultCase\`"`
)
expect(() =>
createReducer(initialState, (builder: any) =>
builder
.addDefaultCase(() => {})
.addMatcher(numberActionMatcher, () => {})
)
).toThrowErrorMatchingInlineSnapshot(
`"\`builder.addMatcher\` should only be called before calling \`builder.addDefaultCase\`"`
)
expect(() =>
createReducer(initialState, (builder: any) =>
builder.addDefaultCase(() => {}).addDefaultCase(() => {})
)
).toThrowErrorMatchingInlineSnapshot(
`"\`builder.addDefaultCase\` can only be called once"`
)
})
})
})
function behavesLikeReducer(todosReducer: TodosReducer) {
it('should handle initial state', () => {
const initialAction = { type: '', payload: undefined }
expect(todosReducer(undefined, initialAction)).toEqual([])
})
it('should handle ADD_TODO', () => {
expect(
todosReducer([], {
type: 'ADD_TODO',
payload: { newTodo: { text: 'Run the tests' } },
})
).toEqual([
{
text: 'Run the tests',
completed: false,
},
])
expect(
todosReducer(
[
{
text: 'Run the tests',
completed: false,
},
],
{
type: 'ADD_TODO',
payload: { newTodo: { text: 'Use Redux' } },
}
)
).toEqual([
{
text: 'Run the tests',
completed: false,
},
{
text: 'Use Redux',
completed: false,
},
])
expect(
todosReducer(
[
{
text: 'Run the tests',
completed: false,
},
{
text: 'Use Redux',
completed: false,
},
],
{
type: 'ADD_TODO',
payload: { newTodo: { text: 'Fix the tests' } },
}
)
).toEqual([
{
text: 'Run the tests',
completed: false,
},
{
text: 'Use Redux',
completed: false,
},
{
text: 'Fix the tests',
completed: false,
},
])
})
it('should handle TOGGLE_TODO', () => {
expect(
todosReducer(
[
{
text: 'Run the tests',
completed: false,
},
{
text: 'Use Redux',
completed: false,
},
],
{
type: 'TOGGLE_TODO',
payload: { index: 0 },
}
)
).toEqual([
{
text: 'Run the tests',
completed: true,
},
{
text: 'Use Redux',
completed: false,
},
])
})
}

View File

@@ -0,0 +1,78 @@
import type { Reducer } from 'redux'
import type { ActionReducerMapBuilder } from '@reduxjs/toolkit'
import { createReducer, createAction } from '@reduxjs/toolkit'
import { expectType } from './helpers'
/*
* Test: createReducer() infers type of returned reducer.
*/
{
type CounterAction =
| { type: 'increment'; payload: number }
| { type: 'decrement'; payload: number }
const incrementHandler = (state: number, action: CounterAction) => state + 1
const decrementHandler = (state: number, action: CounterAction) => state - 1
const reducer = createReducer(0 as number, {
increment: incrementHandler,
decrement: decrementHandler,
})
const numberReducer: Reducer<number> = reducer
// @ts-expect-error
const stringReducer: Reducer<string> = reducer
}
/**
* Test: createReducer() state type can be specified expliclity.
*/
{
type CounterAction =
| { type: 'increment'; payload: number }
| { type: 'decrement'; payload: number }
const incrementHandler = (state: number, action: CounterAction) =>
state + action.payload
const decrementHandler = (state: number, action: CounterAction) =>
state - action.payload
createReducer<number>(0, {
increment: incrementHandler,
decrement: decrementHandler,
})
// @ts-expect-error
createReducer<string>(0, {
increment: incrementHandler,
decrement: decrementHandler,
})
}
/*
* Test: createReducer() ensures state type is mutable within a case reducer.
*/
{
const initialState: { readonly counter: number } = { counter: 0 }
createReducer(initialState, {
increment: (state) => {
state.counter += 1
},
})
}
/** Test: alternative builder callback for actionMap */
{
const increment = createAction<number, 'increment'>('increment')
const reducer = createReducer(0, (builder) =>
expectType<ActionReducerMapBuilder<number>>(builder)
)
expectType<number>(reducer(0, increment(5)))
// @ts-expect-error
expectType<string>(reducer(0, increment(5)))
}

View File

@@ -0,0 +1,434 @@
import type { PayloadAction } from '@reduxjs/toolkit'
import { createSlice, createAction } from '@reduxjs/toolkit'
import {
mockConsole,
createConsole,
getLog,
} from 'console-testing-library/pure'
type CreateSlice = typeof createSlice
describe('createSlice', () => {
let restore: () => void
beforeEach(() => {
restore = mockConsole(createConsole())
})
describe('when slice is undefined', () => {
it('should throw an error', () => {
expect(() =>
// @ts-ignore
createSlice({
reducers: {
increment: (state) => state + 1,
multiply: (state, action: PayloadAction<number>) =>
state * action.payload,
},
initialState: 0,
})
).toThrowError()
})
})
describe('when slice is an empty string', () => {
it('should throw an error', () => {
expect(() =>
createSlice({
name: '',
reducers: {
increment: (state) => state + 1,
multiply: (state, action: PayloadAction<number>) =>
state * action.payload,
},
initialState: 0,
})
).toThrowError()
})
})
describe('when initial state is undefined', () => {
it('should throw an error', () => {
createSlice({
name: 'test',
reducers: {},
initialState: undefined,
})
expect(getLog().log).toBe(
'You must provide an `initialState` value that is not `undefined`. You may have misspelled `initialState`'
)
})
})
describe('when passing slice', () => {
const { actions, reducer, caseReducers } = createSlice({
reducers: {
increment: (state) => state + 1,
},
initialState: 0,
name: 'cool',
})
it('should create increment action', () => {
expect(actions.hasOwnProperty('increment')).toBe(true)
})
it('should have the correct action for increment', () => {
expect(actions.increment()).toEqual({
type: 'cool/increment',
payload: undefined,
})
})
it('should return the correct value from reducer', () => {
expect(reducer(undefined, actions.increment())).toEqual(1)
})
it('should include the generated case reducers', () => {
expect(caseReducers).toBeTruthy()
expect(caseReducers.increment).toBeTruthy()
expect(typeof caseReducers.increment).toBe('function')
})
it('getInitialState should return the state', () => {
const initialState = 42
const slice = createSlice({
name: 'counter',
initialState,
reducers: {},
})
expect(slice.getInitialState()).toBe(initialState)
})
it('should allow non-draftable initial state', () => {
expect(() =>
createSlice({
name: 'params',
initialState: new URLSearchParams(),
reducers: {},
})
).not.toThrowError()
})
})
describe('when initialState is a function', () => {
const initialState = () => ({ user: '' })
const { actions, reducer } = createSlice({
reducers: {
setUserName: (state, action) => {
state.user = action.payload
},
},
initialState,
name: 'user',
})
it('should set the username', () => {
expect(reducer(undefined, actions.setUserName('eric'))).toEqual({
user: 'eric',
})
})
it('getInitialState should return the state', () => {
const initialState = () => 42
const slice = createSlice({
name: 'counter',
initialState,
reducers: {},
})
expect(slice.getInitialState()).toBe(42)
})
it('should allow non-draftable initial state', () => {
expect(() =>
createSlice({
name: 'params',
initialState: () => new URLSearchParams(),
reducers: {},
})
).not.toThrowError()
})
})
describe('when mutating state object', () => {
const initialState = { user: '' }
const { actions, reducer } = createSlice({
reducers: {
setUserName: (state, action) => {
state.user = action.payload
},
},
initialState,
name: 'user',
})
it('should set the username', () => {
expect(reducer(initialState, actions.setUserName('eric'))).toEqual({
user: 'eric',
})
})
})
describe('when passing extra reducers', () => {
const addMore = createAction<{ amount: number }>('ADD_MORE')
const { reducer } = createSlice({
name: 'test',
reducers: {
increment: (state) => state + 1,
multiply: (state, action) => state * action.payload,
},
extraReducers: {
[addMore.type]: (state, action) => state + action.payload.amount,
},
initialState: 0,
})
it('should call extra reducers when their actions are dispatched', () => {
const result = reducer(10, addMore({ amount: 5 }))
expect(result).toBe(15)
})
describe('alternative builder callback for extraReducers', () => {
const increment = createAction<number, 'increment'>('increment')
test('can be used with actionCreators', () => {
const slice = createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: (builder) =>
builder.addCase(
increment,
(state, action) => state + action.payload
),
})
expect(slice.reducer(0, increment(5))).toBe(5)
})
test('can be used with string action types', () => {
const slice = createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: (builder) =>
builder.addCase(
'increment',
(state, action: { type: 'increment'; payload: number }) =>
state + action.payload
),
})
expect(slice.reducer(0, increment(5))).toBe(5)
})
test('prevents the same action type from being specified twice', () => {
expect(() => {
const slice = createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: (builder) =>
builder
.addCase('increment', (state) => state + 1)
.addCase('increment', (state) => state + 1),
})
slice.reducer(undefined, { type: 'unrelated' })
}).toThrowErrorMatchingInlineSnapshot(
`"addCase cannot be called with two reducers for the same action type"`
)
})
test('can be used with addMatcher and type guard functions', () => {
const slice = createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: (builder) =>
builder.addMatcher(
increment.match,
(state, action: { type: 'increment'; payload: number }) =>
state + action.payload
),
})
expect(slice.reducer(0, increment(5))).toBe(5)
})
test('can be used with addDefaultCase', () => {
const slice = createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: (builder) =>
builder.addDefaultCase((state, action) => state + action.payload),
})
expect(slice.reducer(0, increment(5))).toBe(5)
})
// for further tests, see the test of createReducer that goes way more into depth on this
})
})
describe('behaviour with enhanced case reducers', () => {
it('should pass all arguments to the prepare function', () => {
const prepare = jest.fn((payload, somethingElse) => ({ payload }))
const testSlice = createSlice({
name: 'test',
initialState: 0,
reducers: {
testReducer: {
reducer: (s) => s,
prepare,
},
},
})
expect(testSlice.actions.testReducer('a', 1)).toEqual({
type: 'test/testReducer',
payload: 'a',
})
expect(prepare).toHaveBeenCalledWith('a', 1)
})
it('should call the reducer function', () => {
const reducer = jest.fn(() => 5)
const testSlice = createSlice({
name: 'test',
initialState: 0,
reducers: {
testReducer: {
reducer,
prepare: (payload: any) => ({ payload }),
},
},
})
testSlice.reducer(0, testSlice.actions.testReducer('testPayload'))
expect(reducer).toHaveBeenCalledWith(
0,
expect.objectContaining({ payload: 'testPayload' })
)
})
})
describe('circularity', () => {
test('extraReducers can reference each other circularly', () => {
const first = createSlice({
name: 'first',
initialState: 'firstInitial',
reducers: {
something() {
return 'firstSomething'
},
},
extraReducers(builder) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
builder.addCase(second.actions.other, () => {
return 'firstOther'
})
},
})
const second = createSlice({
name: 'second',
initialState: 'secondInitial',
reducers: {
other() {
return 'secondOther'
},
},
extraReducers(builder) {
builder.addCase(first.actions.something, () => {
return 'secondSomething'
})
},
})
expect(first.reducer(undefined, { type: 'unrelated' })).toBe(
'firstInitial'
)
expect(first.reducer(undefined, first.actions.something())).toBe(
'firstSomething'
)
expect(first.reducer(undefined, second.actions.other())).toBe(
'firstOther'
)
expect(second.reducer(undefined, { type: 'unrelated' })).toBe(
'secondInitial'
)
expect(second.reducer(undefined, first.actions.something())).toBe(
'secondSomething'
)
expect(second.reducer(undefined, second.actions.other())).toBe(
'secondOther'
)
})
})
describe.only('Deprecation warnings', () => {
let originalNodeEnv = process.env.NODE_ENV
beforeEach(() => {
jest.resetModules()
restore = mockConsole(createConsole())
})
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv
})
// NOTE: This needs to be in front of the later `createReducer` call to check the one-time warning
it('Warns about object notation deprecation, once', () => {
const { createSlice } = require('../createSlice')
let dummySlice = (createSlice as CreateSlice)({
name: 'dummy',
initialState: [],
reducers: {},
extraReducers: {
a: () => [],
},
})
// Have to trigger the lazy creation
let { reducer } = dummySlice
reducer(undefined, { type: 'dummy' })
expect(getLog().levels.warn).toMatch(
/The object notation for `createSlice.extraReducers` is deprecated/
)
restore = mockConsole(createConsole())
dummySlice = (createSlice as CreateSlice)({
name: 'dummy',
initialState: [],
reducers: {},
extraReducers: {
a: () => [],
},
})
reducer = dummySlice.reducer
reducer(undefined, { type: 'dummy' })
expect(getLog().levels.warn).toBe('')
})
it('Does not warn in production', () => {
process.env.NODE_ENV = 'production'
const { createSlice } = require('../createSlice')
let dummySlice = (createSlice as CreateSlice)({
name: 'dummy',
initialState: [],
reducers: {},
extraReducers: {},
})
expect(getLog().levels.warn).toBe('')
})
})
})

View File

@@ -0,0 +1,501 @@
import type { Action, AnyAction, Reducer } from 'redux'
import type {
ActionCreatorWithNonInferrablePayload,
ActionCreatorWithOptionalPayload,
ActionCreatorWithoutPayload,
ActionCreatorWithPayload,
ActionCreatorWithPreparedPayload,
ActionReducerMapBuilder,
PayloadAction,
SliceCaseReducers,
ValidateSliceCaseReducers,
} from '@reduxjs/toolkit'
import { createAction, createSlice } from '@reduxjs/toolkit'
import { expectType } from './helpers'
/*
* Test: Slice name is strongly typed.
*/
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: (state: number, action) => state + action.payload,
decrement: (state: number, action) => state - action.payload,
},
})
const uiSlice = createSlice({
name: 'ui',
initialState: 0,
reducers: {
goToNext: (state: number, action) => state + action.payload,
goToPrevious: (state: number, action) => state - action.payload,
},
})
const actionCreators = {
[counterSlice.name]: { ...counterSlice.actions },
[uiSlice.name]: { ...uiSlice.actions },
}
expectType<typeof counterSlice.actions>(actionCreators.counter)
expectType<typeof uiSlice.actions>(actionCreators.ui)
// @ts-expect-error
const value = actionCreators.anyKey
/*
* Test: createSlice() infers the returned slice's type.
*/
{
const firstAction = createAction<{ count: number }>('FIRST_ACTION')
const slice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: (state: number, action) => state + action.payload,
decrement: (state: number, action) => state - action.payload,
},
extraReducers: (builder) => {
builder.addCase(
firstAction,
(state, action) => state + action.payload.count
)
},
})
/* Reducer */
expectType<Reducer<number, PayloadAction>>(slice.reducer)
// @ts-expect-error
expectType<Reducer<string, PayloadAction>>(slice.reducer)
// @ts-expect-error
expectType<Reducer<string, AnyAction>>(slice.reducer)
/* Actions */
slice.actions.increment(1)
slice.actions.decrement(1)
// @ts-expect-error
slice.actions.other(1)
}
/*
* Test: Slice action creator types are inferred.
*/
{
const counter = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: (state) => state + 1,
decrement: (state, { payload = 1 }: PayloadAction<number | undefined>) =>
state - payload,
multiply: (state, { payload }: PayloadAction<number | number[]>) =>
Array.isArray(payload)
? payload.reduce((acc, val) => acc * val, state)
: state * payload,
addTwo: {
reducer: (s, { payload }: PayloadAction<number>) => s + payload,
prepare: (a: number, b: number) => ({
payload: a + b,
}),
},
},
})
expectType<ActionCreatorWithoutPayload>(counter.actions.increment)
counter.actions.increment()
expectType<ActionCreatorWithOptionalPayload<number | undefined>>(
counter.actions.decrement
)
counter.actions.decrement()
counter.actions.decrement(2)
expectType<ActionCreatorWithPayload<number | number[]>>(
counter.actions.multiply
)
counter.actions.multiply(2)
counter.actions.multiply([2, 3, 4])
expectType<ActionCreatorWithPreparedPayload<[number, number], number>>(
counter.actions.addTwo
)
counter.actions.addTwo(1, 2)
// @ts-expect-error
counter.actions.multiply()
// @ts-expect-error
counter.actions.multiply('2')
// @ts-expect-error
counter.actions.addTwo(1)
}
/*
* Test: Slice action creator types properties are "string"
*/
{
const counter = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: (state) => state + 1,
decrement: (state) => state - 1,
multiply: (state, { payload }: PayloadAction<number | number[]>) =>
Array.isArray(payload)
? payload.reduce((acc, val) => acc * val, state)
: state * payload,
},
})
const s: 'counter/increment' = counter.actions.increment.type
const sa: 'counter/increment' = counter.actions.increment().type
const t: 'counter/decrement' = counter.actions.decrement.type
const ta: 'counter/decrement' = counter.actions.decrement().type
const u: 'counter/multiply' = counter.actions.multiply.type
const ua: 'counter/multiply' = counter.actions.multiply(1).type
// @ts-expect-error
const y: 'increment' = counter.actions.increment.type
}
/*
* Test: Slice action creator types are inferred for enhanced reducers.
*/
{
const counter = createSlice({
name: 'test',
initialState: { counter: 0, concat: '' },
reducers: {
incrementByStrLen: {
reducer: (state, action: PayloadAction<number>) => {
state.counter += action.payload
},
prepare: (payload: string) => ({
payload: payload.length,
}),
},
concatMetaStrLen: {
reducer: (state, action: PayloadAction<string>) => {
state.concat += action.payload
},
prepare: (payload: string) => ({
payload,
meta: payload.length,
}),
},
},
})
expectType<'test/incrementByStrLen'>(
counter.actions.incrementByStrLen('test').type
)
expectType<number>(counter.actions.incrementByStrLen('test').payload)
expectType<string>(counter.actions.concatMetaStrLen('test').payload)
expectType<number>(counter.actions.concatMetaStrLen('test').meta)
// @ts-expect-error
expectType<string>(counter.actions.incrementByStrLen('test').payload)
// @ts-expect-error
expectType<string>(counter.actions.concatMetaStrLen('test').meta)
}
/**
* Test: access meta and error from reducer
*/
{
const counter = createSlice({
name: 'test',
initialState: { counter: 0, concat: '' },
reducers: {
// case: meta and error not used in reducer
testDefaultMetaAndError: {
reducer(_, action: PayloadAction<number, string>) {},
prepare: (payload: number) => ({
payload,
meta: 'meta' as 'meta',
error: 'error' as 'error',
}),
},
// case: meta and error marked as "unknown" in reducer
testUnknownMetaAndError: {
reducer(_, action: PayloadAction<number, string, unknown, unknown>) {},
prepare: (payload: number) => ({
payload,
meta: 'meta' as 'meta',
error: 'error' as 'error',
}),
},
// case: meta and error are typed in the reducer as returned by prepare
testMetaAndError: {
reducer(_, action: PayloadAction<number, string, 'meta', 'error'>) {},
prepare: (payload: number) => ({
payload,
meta: 'meta' as 'meta',
error: 'error' as 'error',
}),
},
// case: meta is typed differently in the reducer than returned from prepare
testErroneousMeta: {
reducer(_, action: PayloadAction<number, string, 'meta', 'error'>) {},
// @ts-expect-error
prepare: (payload: number) => ({
payload,
meta: 1,
error: 'error' as 'error',
}),
},
// case: error is typed differently in the reducer than returned from prepare
testErroneousError: {
reducer(_, action: PayloadAction<number, string, 'meta', 'error'>) {},
// @ts-expect-error
prepare: (payload: number) => ({
payload,
meta: 'meta' as 'meta',
error: 1,
}),
},
},
})
}
/*
* Test: returned case reducer has the correct type
*/
{
const counter = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment(state, action: PayloadAction<number>) {
return state + action.payload
},
decrement: {
reducer(state, action: PayloadAction<number>) {
return state - action.payload
},
prepare(amount: number) {
return { payload: amount }
},
},
},
})
// Should match positively
expectType<(state: number, action: PayloadAction<number>) => number | void>(
counter.caseReducers.increment
)
// Should match positively for reducers with prepare callback
expectType<(state: number, action: PayloadAction<number>) => number | void>(
counter.caseReducers.decrement
)
// Should not mismatch the payload if it's a simple reducer
expectType<(state: number, action: PayloadAction<string>) => number | void>(
// @ts-expect-error
counter.caseReducers.increment
)
// Should not mismatch the payload if it's a reducer with a prepare callback
expectType<(state: number, action: PayloadAction<string>) => number | void>(
// @ts-expect-error
counter.caseReducers.decrement
)
// Should not include entries that don't exist
expectType<(state: number, action: PayloadAction<string>) => number | void>(
// @ts-expect-error
counter.caseReducers.someThingNonExistant
)
}
/*
* Test: prepared payload does not match action payload - should cause an error.
*/
{
const counter = createSlice({
name: 'counter',
initialState: { counter: 0 },
reducers: {
increment: {
reducer(state, action: PayloadAction<string>) {
state.counter += action.payload.length
},
// @ts-expect-error
prepare(x: string) {
return {
payload: 6,
}
},
},
},
})
}
/*
* Test: if no Payload Type is specified, accept any payload
* see https://github.com/reduxjs/redux-toolkit/issues/165
*/
{
const initialState = {
name: null,
}
const mySlice = createSlice({
name: 'name',
initialState,
reducers: {
setName: (state, action) => {
state.name = action.payload
},
},
})
expectType<ActionCreatorWithNonInferrablePayload>(mySlice.actions.setName)
const x = mySlice.actions.setName
mySlice.actions.setName(null)
mySlice.actions.setName('asd')
mySlice.actions.setName(5)
}
/**
* Test: actions.x.match()
*/
{
const mySlice = createSlice({
name: 'name',
initialState: { name: 'test' },
reducers: {
setName: (state, action: PayloadAction<string>) => {
state.name = action.payload
},
},
})
const x: Action<unknown> = {} as any
if (mySlice.actions.setName.match(x)) {
expectType<'name/setName'>(x.type)
expectType<string>(x.payload)
} else {
// @ts-expect-error
expectType<string>(x.type)
// @ts-expect-error
expectType<string>(x.payload)
}
}
/** Test: alternative builder callback for extraReducers */
{
createSlice({
name: 'test',
initialState: 0,
reducers: {},
extraReducers: (builder) => {
expectType<ActionReducerMapBuilder<number>>(builder)
},
})
}
/** Test: wrapping createSlice should be possible */
{
interface GenericState<T> {
data?: T
status: 'loading' | 'finished' | 'error'
}
const createGenericSlice = <
T,
Reducers extends SliceCaseReducers<GenericState<T>>
>({
name = '',
initialState,
reducers,
}: {
name: string
initialState: GenericState<T>
reducers: ValidateSliceCaseReducers<GenericState<T>, Reducers>
}) => {
return createSlice({
name,
initialState,
reducers: {
start(state) {
state.status = 'loading'
},
success(state: GenericState<T>, action: PayloadAction<T>) {
state.data = action.payload
state.status = 'finished'
},
...reducers,
},
})
}
const wrappedSlice = createGenericSlice({
name: 'test',
initialState: { status: 'loading' } as GenericState<string>,
reducers: {
magic(state) {
expectType<GenericState<string>>(state)
// @ts-expect-error
expectType<GenericState<number>>(state)
state.status = 'finished'
state.data = 'hocus pocus'
},
},
})
expectType<ActionCreatorWithPayload<string>>(wrappedSlice.actions.success)
expectType<ActionCreatorWithoutPayload<string>>(wrappedSlice.actions.magic)
}
{
interface GenericState<T> {
data: T | null
}
function createDataSlice<
T,
Reducers extends SliceCaseReducers<GenericState<T>>
>(
name: string,
reducers: ValidateSliceCaseReducers<GenericState<T>, Reducers>,
initialState: GenericState<T>
) {
const doNothing = createAction<undefined>('doNothing')
const setData = createAction<T>('setData')
const slice = createSlice({
name,
initialState,
reducers,
extraReducers: (builder) => {
builder.addCase(doNothing, (state) => {
return { ...state }
})
builder.addCase(setData, (state, { payload }) => {
return {
...state,
data: payload,
}
})
},
})
return { doNothing, setData, slice }
}
}

View File

@@ -0,0 +1,322 @@
import type {
AnyAction,
Middleware,
ThunkAction,
Action,
ThunkDispatch,
Dispatch,
} from '@reduxjs/toolkit'
import {
getDefaultMiddleware,
MiddlewareArray,
configureStore,
} from '@reduxjs/toolkit'
import thunk from 'redux-thunk'
import type { ThunkMiddleware } from 'redux-thunk'
import { expectType } from './helpers'
import { BaseActionCreator } from '@internal/createAction'
describe('getDefaultMiddleware', () => {
const ORIGINAL_NODE_ENV = process.env.NODE_ENV
afterEach(() => {
process.env.NODE_ENV = ORIGINAL_NODE_ENV
})
it('returns an array with only redux-thunk in production', () => {
process.env.NODE_ENV = 'production'
expect(getDefaultMiddleware()).toEqual([thunk]) // @remap-prod-remove-line
})
it('returns an array with additional middleware in development', () => {
const middleware = getDefaultMiddleware()
expect(middleware).toContain(thunk)
expect(middleware.length).toBeGreaterThan(1)
})
const defaultMiddleware = getDefaultMiddleware()
it('removes the thunk middleware if disabled', () => {
const middleware = getDefaultMiddleware({ thunk: false })
// @ts-ignore
expect(middleware.includes(thunk)).toBe(false)
expect(middleware.length).toBe(defaultMiddleware.length - 1)
})
it('removes the immutable middleware if disabled', () => {
const middleware = getDefaultMiddleware({ immutableCheck: false })
expect(middleware.length).toBe(defaultMiddleware.length - 1)
})
it('removes the serializable middleware if disabled', () => {
const middleware = getDefaultMiddleware({ serializableCheck: false })
expect(middleware.length).toBe(defaultMiddleware.length - 1)
})
it('removes the action creator middleware if disabled', () => {
const middleware = getDefaultMiddleware({ actionCreatorCheck: false })
expect(middleware.length).toBe(defaultMiddleware.length - 1)
})
it('allows passing options to thunk', () => {
const extraArgument = 42 as const
const middleware = getDefaultMiddleware({
thunk: { extraArgument },
immutableCheck: false,
serializableCheck: false,
actionCreatorCheck: false,
})
const m2 = getDefaultMiddleware({
thunk: false,
})
expectType<MiddlewareArray<[]>>(m2)
const dummyMiddleware: Middleware<
{
(action: Action<'actionListenerMiddleware/add'>): () => void
},
{ counter: number }
> = (storeApi) => (next) => (action) => {}
const dummyMiddleware2: Middleware = (storeApi) => (next) => (action) => {}
const m3 = middleware.concat(dummyMiddleware, dummyMiddleware2)
expectType<
MiddlewareArray<
[
ThunkMiddleware<any, AnyAction, 42>,
Middleware<
(action: Action<'actionListenerMiddleware/add'>) => () => void,
{
counter: number
},
Dispatch<AnyAction>
>,
Middleware<{}, any, Dispatch<AnyAction>>
]
>
>(m3)
const testThunk: ThunkAction<void, {}, number, AnyAction> = (
dispatch,
getState,
extraArg
) => {
expect(extraArg).toBe(extraArgument)
}
const reducer = () => ({})
const store = configureStore({
reducer,
middleware,
})
expectType<ThunkDispatch<any, 42, AnyAction> & Dispatch<AnyAction>>(
store.dispatch
)
store.dispatch(testThunk)
})
it('allows passing options to immutableCheck', () => {
let immutableCheckWasCalled = false
const middleware = getDefaultMiddleware({
thunk: false,
immutableCheck: {
isImmutable: () => {
immutableCheckWasCalled = true
return true
},
},
serializableCheck: false,
actionCreatorCheck: false,
})
const reducer = () => ({})
const store = configureStore({
reducer,
middleware,
})
expect(immutableCheckWasCalled).toBe(true)
})
it('allows passing options to serializableCheck', () => {
let serializableCheckWasCalled = false
const middleware = getDefaultMiddleware({
thunk: false,
immutableCheck: false,
serializableCheck: {
isSerializable: () => {
serializableCheckWasCalled = true
return true
},
},
actionCreatorCheck: false,
})
const reducer = () => ({})
const store = configureStore({
reducer,
middleware,
})
store.dispatch({ type: 'TEST_ACTION' })
expect(serializableCheckWasCalled).toBe(true)
})
})
it('allows passing options to actionCreatorCheck', () => {
let actionCreatorCheckWasCalled = false
const middleware = getDefaultMiddleware({
thunk: false,
immutableCheck: false,
serializableCheck: false,
actionCreatorCheck: {
isActionCreator: (action: unknown): action is Function => {
actionCreatorCheckWasCalled = true
return false
},
},
})
const reducer = () => ({})
const store = configureStore({
reducer,
middleware,
})
store.dispatch({ type: 'TEST_ACTION' })
expect(actionCreatorCheckWasCalled).toBe(true)
})
describe('MiddlewareArray functionality', () => {
const middleware1: Middleware = () => (next) => (action) => next(action)
const middleware2: Middleware = () => (next) => (action) => next(action)
const defaultMiddleware = getDefaultMiddleware()
const originalDefaultMiddleware = [...defaultMiddleware]
test('allows to prepend a single value', () => {
const prepended = defaultMiddleware.prepend(middleware1)
// value is prepended
expect(prepended).toEqual([middleware1, ...defaultMiddleware])
// returned value is of correct type
expect(prepended).toBeInstanceOf(MiddlewareArray)
// prepended is a new array
expect(prepended).not.toEqual(defaultMiddleware)
// defaultMiddleware is not modified
expect(defaultMiddleware).toEqual(originalDefaultMiddleware)
})
test('allows to prepend multiple values (array as first argument)', () => {
const prepended = defaultMiddleware.prepend([middleware1, middleware2])
// value is prepended
expect(prepended).toEqual([middleware1, middleware2, ...defaultMiddleware])
// returned value is of correct type
expect(prepended).toBeInstanceOf(MiddlewareArray)
// prepended is a new array
expect(prepended).not.toEqual(defaultMiddleware)
// defaultMiddleware is not modified
expect(defaultMiddleware).toEqual(originalDefaultMiddleware)
})
test('allows to prepend multiple values (rest)', () => {
const prepended = defaultMiddleware.prepend(middleware1, middleware2)
// value is prepended
expect(prepended).toEqual([middleware1, middleware2, ...defaultMiddleware])
// returned value is of correct type
expect(prepended).toBeInstanceOf(MiddlewareArray)
// prepended is a new array
expect(prepended).not.toEqual(defaultMiddleware)
// defaultMiddleware is not modified
expect(defaultMiddleware).toEqual(originalDefaultMiddleware)
})
test('allows to concat a single value', () => {
const concatenated = defaultMiddleware.concat(middleware1)
// value is concatenated
expect(concatenated).toEqual([...defaultMiddleware, middleware1])
// returned value is of correct type
expect(concatenated).toBeInstanceOf(MiddlewareArray)
// concatenated is a new array
expect(concatenated).not.toEqual(defaultMiddleware)
// defaultMiddleware is not modified
expect(defaultMiddleware).toEqual(originalDefaultMiddleware)
})
test('allows to concat multiple values (array as first argument)', () => {
const concatenated = defaultMiddleware.concat([middleware1, middleware2])
// value is concatenated
expect(concatenated).toEqual([
...defaultMiddleware,
middleware1,
middleware2,
])
// returned value is of correct type
expect(concatenated).toBeInstanceOf(MiddlewareArray)
// concatenated is a new array
expect(concatenated).not.toEqual(defaultMiddleware)
// defaultMiddleware is not modified
expect(defaultMiddleware).toEqual(originalDefaultMiddleware)
})
test('allows to concat multiple values (rest)', () => {
const concatenated = defaultMiddleware.concat(middleware1, middleware2)
// value is concatenated
expect(concatenated).toEqual([
...defaultMiddleware,
middleware1,
middleware2,
])
// returned value is of correct type
expect(concatenated).toBeInstanceOf(MiddlewareArray)
// concatenated is a new array
expect(concatenated).not.toEqual(defaultMiddleware)
// defaultMiddleware is not modified
expect(defaultMiddleware).toEqual(originalDefaultMiddleware)
})
test('allows to concat and then prepend', () => {
const concatenated = defaultMiddleware
.concat(middleware1)
.prepend(middleware2)
expect(concatenated).toEqual([
middleware2,
...defaultMiddleware,
middleware1,
])
})
test('allows to prepend and then concat', () => {
const concatenated = defaultMiddleware
.prepend(middleware2)
.concat(middleware1)
expect(concatenated).toEqual([
middleware2,
...defaultMiddleware,
middleware1,
])
})
})

48
node_modules/@reduxjs/toolkit/src/tests/helpers.ts generated vendored Normal file
View File

@@ -0,0 +1,48 @@
import type { IsAny, IsUnknown } from '../../src/tsHelpers'
export function expectType<T>(t: T): T {
return t
}
type Equals<T, U> = IsAny<
T,
never,
IsAny<U, never, [T] extends [U] ? ([U] extends [T] ? any : never) : never>
>
export function expectExactType<T>(t: T) {
return <U extends Equals<T, U>>(u: U) => {}
}
type EnsureUnknown<T extends any> = IsUnknown<T, any, never>
export function expectUnknown<T extends EnsureUnknown<T>>(t: T) {
return t
}
type EnsureAny<T extends any> = IsAny<T, any, never>
export function expectExactAny<T extends EnsureAny<T>>(t: T) {
return t
}
type IsNotAny<T> = IsAny<T, never, any>
export function expectNotAny<T extends IsNotAny<T>>(t: T): T {
return t
}
expectType<string>('5' as string)
expectType<string>('5' as const)
expectType<string>('5' as any)
expectExactType('5' as const)('5' as const)
// @ts-expect-error
expectExactType('5' as string)('5' as const)
// @ts-expect-error
expectExactType('5' as any)('5' as const)
expectUnknown('5' as unknown)
// @ts-expect-error
expectUnknown('5' as const)
// @ts-expect-error
expectUnknown('5' as any)
expectExactAny('5' as any)
// @ts-expect-error
expectExactAny('5' as const)
// @ts-expect-error
expectExactAny('5' as unknown)

View File

@@ -0,0 +1,510 @@
import type {
Store,
MiddlewareAPI,
Dispatch,
ImmutableStateInvariantMiddlewareOptions,
} from '@reduxjs/toolkit'
import {
createImmutableStateInvariantMiddleware,
isImmutableDefault,
} from '@reduxjs/toolkit'
import { trackForMutations } from '@internal/immutableStateInvariantMiddleware'
import { mockConsole, createConsole, getLog } from 'console-testing-library'
describe('createImmutableStateInvariantMiddleware', () => {
let state: { foo: { bar: number[]; baz: string } }
const getState: Store['getState'] = () => state
function middleware(options: ImmutableStateInvariantMiddlewareOptions = {}) {
return createImmutableStateInvariantMiddleware(options)({
getState,
} as MiddlewareAPI)
}
beforeEach(() => {
state = { foo: { bar: [2, 3, 4], baz: 'baz' } }
})
it('sends the action through the middleware chain', () => {
const next: Dispatch = (action) => ({ ...action, returned: true })
const dispatch = middleware()(next)
expect(dispatch({ type: 'SOME_ACTION' })).toEqual({
type: 'SOME_ACTION',
returned: true,
})
})
it('throws if mutating inside the dispatch', () => {
const next: Dispatch = (action) => {
state.foo.bar.push(5)
return action
}
const dispatch = middleware()(next)
expect(() => {
dispatch({ type: 'SOME_ACTION' })
}).toThrow(new RegExp('foo\\.bar\\.3'))
})
it('throws if mutating between dispatches', () => {
const next: Dispatch = (action) => action
const dispatch = middleware()(next)
dispatch({ type: 'SOME_ACTION' })
state.foo.bar.push(5)
expect(() => {
dispatch({ type: 'SOME_OTHER_ACTION' })
}).toThrow(new RegExp('foo\\.bar\\.3'))
})
it('does not throw if not mutating inside the dispatch', () => {
const next: Dispatch = (action) => {
state = { ...state, foo: { ...state.foo, baz: 'changed!' } }
return action
}
const dispatch = middleware()(next)
expect(() => {
dispatch({ type: 'SOME_ACTION' })
}).not.toThrow()
})
it('does not throw if not mutating between dispatches', () => {
const next: Dispatch = (action) => action
const dispatch = middleware()(next)
dispatch({ type: 'SOME_ACTION' })
state = { ...state, foo: { ...state.foo, baz: 'changed!' } }
expect(() => {
dispatch({ type: 'SOME_OTHER_ACTION' })
}).not.toThrow()
})
it('works correctly with circular references', () => {
const next: Dispatch = (action) => action
const dispatch = middleware()(next)
let x: any = {}
let y: any = {}
x.y = y
y.x = x
expect(() => {
dispatch({ type: 'SOME_ACTION', x })
}).not.toThrow()
})
it('respects "isImmutable" option', function () {
const isImmutable = (value: any) => true
const next: Dispatch = (action) => {
state.foo.bar.push(5)
return action
}
const dispatch = middleware({ isImmutable })(next)
expect(() => {
dispatch({ type: 'SOME_ACTION' })
}).not.toThrow()
})
it('respects "ignoredPaths" option', () => {
const next: Dispatch = (action) => {
state.foo.bar.push(5)
return action
}
const dispatch1 = middleware({ ignoredPaths: ['foo.bar'] })(next)
expect(() => {
dispatch1({ type: 'SOME_ACTION' })
}).not.toThrow()
const dispatch2 = middleware({ ignoredPaths: [/^foo/] })(next)
expect(() => {
dispatch2({ type: 'SOME_ACTION' })
}).not.toThrow()
})
it('alias "ignore" to "ignoredPath" and respects option', () => {
const next: Dispatch = (action) => {
state.foo.bar.push(5)
return action
}
const dispatch = middleware({ ignore: ['foo.bar'] })(next)
expect(() => {
dispatch({ type: 'SOME_ACTION' })
}).not.toThrow()
})
it('Should print a warning if execution takes too long', () => {
state.foo.bar = new Array(10000).fill({ value: 'more' })
const next: Dispatch = (action) => action
const dispatch = middleware({ warnAfter: 4 })(next)
const restore = mockConsole(createConsole())
try {
dispatch({ type: 'SOME_ACTION' })
expect(getLog().log).toMatch(
/^ImmutableStateInvariantMiddleware took \d*ms, which is more than the warning threshold of 4ms./
)
} finally {
restore()
}
})
it('Should not print a warning if "next" takes too long', () => {
const next: Dispatch = (action) => {
const started = Date.now()
while (Date.now() - started < 8) {}
return action
}
const dispatch = middleware({ warnAfter: 4 })(next)
const restore = mockConsole(createConsole())
try {
dispatch({ type: 'SOME_ACTION' })
expect(getLog().log).toEqual('')
} finally {
restore()
}
})
})
describe('trackForMutations', () => {
function testCasesForMutation(spec: any) {
it('returns true and the mutated path', () => {
const state = spec.getState()
const options = spec.middlewareOptions || {}
const { isImmutable = isImmutableDefault, ignoredPaths } = options
const tracker = trackForMutations(isImmutable, ignoredPaths, state)
const newState = spec.fn(state)
expect(tracker.detectMutations()).toEqual({
wasMutated: true,
path: spec.path.join('.'),
})
})
}
function testCasesForNonMutation(spec: any) {
it('returns false', () => {
const state = spec.getState()
const options = spec.middlewareOptions || {}
const { isImmutable = isImmutableDefault, ignoredPaths } = options
const tracker = trackForMutations(isImmutable, ignoredPaths, state)
const newState = spec.fn(state)
expect(tracker.detectMutations()).toEqual({ wasMutated: false })
})
}
interface TestConfig {
getState: Store['getState']
fn: (s: any) => typeof s | object
middlewareOptions?: ImmutableStateInvariantMiddlewareOptions
path?: string[]
}
const mutations: Record<string, TestConfig> = {
'adding to nested array': {
getState: () => ({
foo: {
bar: [2, 3, 4],
baz: 'baz',
},
stuff: [],
}),
fn: (s) => {
s.foo.bar.push(5)
return s
},
path: ['foo', 'bar', '3'],
},
'adding to nested array and setting new root object': {
getState: () => ({
foo: {
bar: [2, 3, 4],
baz: 'baz',
},
stuff: [],
}),
fn: (s) => {
s.foo.bar.push(5)
return { ...s }
},
path: ['foo', 'bar', '3'],
},
'changing nested string': {
getState: () => ({
foo: {
bar: [2, 3, 4],
baz: 'baz',
},
stuff: [],
}),
fn: (s) => {
s.foo.baz = 'changed!'
return s
},
path: ['foo', 'baz'],
},
'removing nested state': {
getState: () => ({
foo: {
bar: [2, 3, 4],
baz: 'baz',
},
stuff: [],
}),
fn: (s) => {
delete s.foo
return s
},
path: ['foo'],
},
'adding to array': {
getState: () => ({
foo: {
bar: [2, 3, 4],
baz: 'baz',
},
stuff: [],
}),
fn: (s) => {
s.stuff.push(1)
return s
},
path: ['stuff', '0'],
},
'adding object to array': {
getState: () => ({
stuff: [],
}),
fn: (s) => {
s.stuff.push({ foo: 1, bar: 2 })
return s
},
path: ['stuff', '0'],
},
'mutating previous state and returning new state': {
getState: () => ({ counter: 0 }),
fn: (s) => {
s.mutation = true
return { ...s, counter: s.counter + 1 }
},
path: ['mutation'],
},
'mutating previous state with non immutable type and returning new state': {
getState: () => ({ counter: 0 }),
fn: (s) => {
s.mutation = [1, 2, 3]
return { ...s, counter: s.counter + 1 }
},
path: ['mutation'],
},
'mutating previous state with non immutable type and returning new state without that property':
{
getState: () => ({ counter: 0 }),
fn: (s) => {
s.mutation = [1, 2, 3]
return { counter: s.counter + 1 }
},
path: ['mutation'],
},
'mutating previous state with non immutable type and returning new simple state':
{
getState: () => ({ counter: 0 }),
fn: (s) => {
s.mutation = [1, 2, 3]
return 1
},
path: ['mutation'],
},
'mutating previous state by deleting property and returning new state without that property':
{
getState: () => ({ counter: 0, toBeDeleted: true }),
fn: (s) => {
delete s.toBeDeleted
return { counter: s.counter + 1 }
},
path: ['toBeDeleted'],
},
'mutating previous state by deleting nested property': {
getState: () => ({ nested: { counter: 0, toBeDeleted: true }, foo: 1 }),
fn: (s) => {
delete s.nested.toBeDeleted
return { nested: { counter: s.counter + 1 } }
},
path: ['nested', 'toBeDeleted'],
},
'update reference': {
getState: () => ({ foo: {} }),
fn: (s) => {
s.foo = {}
return s
},
path: ['foo'],
},
'cannot ignore root state': {
getState: () => ({ foo: {} }),
fn: (s) => {
s.foo = {}
return s
},
middlewareOptions: {
ignoredPaths: [''],
},
path: ['foo'],
},
'catching state mutation in non-ignored branch': {
getState: () => ({
foo: {
bar: [1, 2],
},
boo: {
yah: [1, 2],
},
}),
fn: (s) => {
s.foo.bar.push(3)
s.boo.yah.push(3)
return s
},
middlewareOptions: {
ignoredPaths: ['foo'],
},
path: ['boo', 'yah', '2'],
},
}
Object.keys(mutations).forEach((mutationDesc) => {
describe(mutationDesc, () => {
testCasesForMutation(mutations[mutationDesc])
})
})
const nonMutations: Record<string, TestConfig> = {
'not doing anything': {
getState: () => ({ a: 1, b: 2 }),
fn: (s) => s,
},
'from undefined to something': {
getState: () => undefined,
fn: (s) => ({ foo: 'bar' }),
},
'returning same state': {
getState: () => ({
foo: {
bar: [2, 3, 4],
baz: 'baz',
},
stuff: [],
}),
fn: (s) => s,
},
'returning a new state object with nested new string': {
getState: () => ({
foo: {
bar: [2, 3, 4],
baz: 'baz',
},
stuff: [],
}),
fn: (s) => {
return { ...s, foo: { ...s.foo, baz: 'changed!' } }
},
},
'returning a new state object with nested new array': {
getState: () => ({
foo: {
bar: [2, 3, 4],
baz: 'baz',
},
stuff: [],
}),
fn: (s) => {
return { ...s, foo: { ...s.foo, bar: [...s.foo.bar, 5] } }
},
},
'removing nested state': {
getState: () => ({
foo: {
bar: [2, 3, 4],
baz: 'baz',
},
stuff: [],
}),
fn: (s) => {
return { ...s, foo: {} }
},
},
'having a NaN in the state': {
getState: () => ({ a: NaN, b: Number.NaN }),
fn: (s) => s,
},
'ignoring branches from mutation detection': {
getState: () => ({
foo: {
bar: 'bar',
},
}),
fn: (s) => {
s.foo.bar = 'baz'
return s
},
middlewareOptions: {
ignoredPaths: ['foo'],
},
},
'ignoring nested branches from mutation detection': {
getState: () => ({
foo: {
bar: [1, 2],
boo: {
yah: [1, 2],
},
},
}),
fn: (s) => {
s.foo.bar.push(3)
s.foo.boo.yah.push(3)
return s
},
middlewareOptions: {
ignoredPaths: ['foo.bar', 'foo.boo.yah'],
},
},
'ignoring nested array indices from mutation detection': {
getState: () => ({
stuff: [{ a: 1 }, { a: 2 }],
}),
fn: (s) => {
s.stuff[1].a = 3
return s
},
middlewareOptions: {
ignoredPaths: ['stuff.1'],
},
},
}
Object.keys(nonMutations).forEach((nonMutationDesc) => {
describe(nonMutationDesc, () => {
testCasesForNonMutation(nonMutations[nonMutationDesc])
})
})
})

View File

@@ -0,0 +1,64 @@
/* eslint-disable import/first */
// @ts-nocheck
// reducer.ts or whatever
import { combineSlices } from '@reduxjs/toolkit'
import { sliceA } from 'fileA'
import { sliceB } from 'fileB'
import { lazySliceC } from 'fileC'
import type { lazySliceD } from 'fileD'
import { anotherReducer } from 'somewhere'
export interface LazyLoadedSlices {}
export const rootReducer = combineSlices(sliceA, sliceB, {
another: anotherReducer,
}).withLazyLoadedSlices<LazyLoadedSlices>()
/*
results in a return type of
{
[sliceA.name]: SliceAState,
[sliceB.name]: SliceBState,
another: AnotherState,
[lazySliceC.name]?: SliceCState, // see fileC.ts to understand why this appears here
[lazySliceD.name]?: SliceDState, // see fileD.ts to understand why this appears here
}
*/
// fileC.ts
// "naive" approach
import type { RootState } from './reducer';
import { rootReducer } from './reducer'
import { createSlice } from '@reduxjs/toolkit'
interface SliceCState {
foo: string
}
declare module './reducer' {
export interface LazyLoadedSlices {
[lazySliceC.name]: SliceCState
}
}
export const lazySliceC = createSlice({
/* ... */
})
/**
* Synchronously call `injectSlice` in file.
*/
rootReducer.injectSlice(lazySliceC)
// might want to add code for HMR as well here
// this will still error - `lazySliceC` is optional here
const naiveSelectFoo = (state: RootState) => state.lazySliceC.foo
const selectFoo = rootReducer.withSlice(lazySliceC).selector((state) => {
// `lazySlice` is guaranteed to not be `undefined` here.
return state.lazySlice.foo
})

View File

@@ -0,0 +1,25 @@
import { isPlainObject } from '@reduxjs/toolkit'
import vm from 'vm'
describe('isPlainObject', () => {
it('returns true only if plain object', () => {
class Test {
prop: number
constructor() {
this.prop = 1
}
}
const sandbox = { fromAnotherRealm: false }
vm.runInNewContext('fromAnotherRealm = {}', sandbox)
expect(isPlainObject(sandbox.fromAnotherRealm)).toBe(true)
expect(isPlainObject(new Test())).toBe(false)
expect(isPlainObject(new Date())).toBe(false)
expect(isPlainObject([1, 2, 3])).toBe(false)
expect(isPlainObject(null)).toBe(false)
expect(isPlainObject(undefined)).toBe(false)
expect(isPlainObject({ x: 1, y: 2 })).toBe(true)
expect(isPlainObject(Object.create(null))).toBe(true)
})
})

View File

@@ -0,0 +1,248 @@
import type { SerializedError } from '@internal/createAsyncThunk'
import { createAsyncThunk } from '@internal/createAsyncThunk'
import { executeReducerBuilderCallback } from '@internal/mapBuilders'
import type { AnyAction } from '@reduxjs/toolkit'
import { createAction } from '@reduxjs/toolkit'
import { expectExactType, expectType } from './helpers'
/** Test: alternative builder callback for actionMap */
{
const increment = createAction<number, 'increment'>('increment')
const decrement = createAction<number, 'decrement'>('decrement')
executeReducerBuilderCallback<number>((builder) => {
builder.addCase(increment, (state, action) => {
expectType<number>(state)
expectType<{ type: 'increment'; payload: number }>(action)
// @ts-expect-error
expectType<string>(state)
// @ts-expect-error
expectType<{ type: 'increment'; payload: string }>(action)
// @ts-expect-error
expectType<{ type: 'decrement'; payload: number }>(action)
})
builder.addCase('increment', (state, action) => {
expectType<number>(state)
expectType<{ type: 'increment' }>(action)
// @ts-expect-error
expectType<{ type: 'decrement' }>(action)
// @ts-expect-error - this cannot be inferred and has to be manually specified
expectType<{ type: 'increment'; payload: number }>(action)
})
builder.addCase(
increment,
(state, action: ReturnType<typeof increment>) => state
)
// @ts-expect-error
builder.addCase(
increment,
(state, action: ReturnType<typeof decrement>) => state
)
builder.addCase(
'increment',
(state, action: ReturnType<typeof increment>) => state
)
// @ts-expect-error
builder.addCase(
'decrement',
(state, action: ReturnType<typeof increment>) => state
)
// action type is inferred
builder.addMatcher(increment.match, (state, action) => {
expectType<ReturnType<typeof increment>>(action)
})
{
// action type is inferred when type predicate lacks `type` property
type PredicateWithoutTypeProperty = {
payload: number
}
builder.addMatcher(
(action): action is PredicateWithoutTypeProperty => true,
(state, action) => {
expectType<PredicateWithoutTypeProperty>(action)
expectType<AnyAction>(action)
}
)
}
// action type defaults to AnyAction if no type predicate matcher is passed
builder.addMatcher(
() => true,
(state, action) => {
expectExactType({} as AnyAction)(action)
}
)
// with a boolean checker, action can also be typed by type argument
builder.addMatcher<{ foo: boolean }>(
() => true,
(state, action) => {
expectType<{ foo: boolean }>(action)
expectType<AnyAction>(action)
}
)
// addCase().addMatcher() is possible, action type inferred correctly
builder
.addCase(
'increment',
(state, action: ReturnType<typeof increment>) => state
)
.addMatcher(decrement.match, (state, action) => {
expectType<ReturnType<typeof decrement>>(action)
})
// addCase().addDefaultCase() is possible, action type is AnyAction
builder
.addCase(
'increment',
(state, action: ReturnType<typeof increment>) => state
)
.addDefaultCase((state, action) => {
expectType<AnyAction>(action)
})
{
// addMatcher() should prevent further calls to addCase()
const b = builder.addMatcher(increment.match, () => {})
// @ts-expect-error
b.addCase(increment, () => {})
b.addMatcher(increment.match, () => {})
b.addDefaultCase(() => {})
}
{
// addDefaultCase() should prevent further calls to addCase(), addMatcher() and addDefaultCase
const b = builder.addDefaultCase(() => {})
// @ts-expect-error
b.addCase(increment, () => {})
// @ts-expect-error
b.addMatcher(increment.match, () => {})
// @ts-expect-error
b.addDefaultCase(() => {})
}
// `createAsyncThunk` actions work with `mapBuilder`
{
// case 1: normal `createAsyncThunk`
{
const thunk = createAsyncThunk('test', () => {
return 'ret' as const
})
builder.addCase(thunk.pending, (_, action) => {
expectType<{
payload: undefined
meta: {
arg: void
requestId: string
requestStatus: 'pending'
}
}>(action)
})
builder.addCase(thunk.rejected, (_, action) => {
expectType<{
payload: unknown
error: SerializedError
meta: {
arg: void
requestId: string
requestStatus: 'rejected'
aborted: boolean
condition: boolean
rejectedWithValue: boolean
}
}>(action)
})
builder.addCase(thunk.fulfilled, (_, action) => {
expectType<{
payload: 'ret'
meta: {
arg: void
requestId: string
requestStatus: 'fulfilled'
}
}>(action)
})
}
}
// case 2: `createAsyncThunk` with `meta`
{
const thunk = createAsyncThunk<
'ret',
void,
{
pendingMeta: { startedTimeStamp: number }
fulfilledMeta: {
fulfilledTimeStamp: number
baseQueryMeta: 'meta!'
}
rejectedMeta: {
baseQueryMeta: 'meta!'
}
}
>(
'test',
(_, api) => {
return api.fulfillWithValue('ret' as const, {
fulfilledTimeStamp: 5,
baseQueryMeta: 'meta!',
})
},
{
getPendingMeta() {
return { startedTimeStamp: 0 }
},
}
)
builder.addCase(thunk.pending, (_, action) => {
expectType<{
payload: undefined
meta: {
arg: void
requestId: string
requestStatus: 'pending'
startedTimeStamp: number
}
}>(action)
})
builder.addCase(thunk.rejected, (_, action) => {
expectType<{
payload: unknown
error: SerializedError
meta: {
arg: void
requestId: string
requestStatus: 'rejected'
aborted: boolean
condition: boolean
rejectedWithValue: boolean
baseQueryMeta?: 'meta!'
}
}>(action)
if (action.meta.rejectedWithValue) {
expectType<'meta!'>(action.meta.baseQueryMeta)
}
})
builder.addCase(thunk.fulfilled, (_, action) => {
expectType<{
payload: 'ret'
meta: {
arg: void
requestId: string
requestStatus: 'fulfilled'
baseQueryMeta: 'meta!'
}
}>(action)
})
}
})
}

View File

@@ -0,0 +1,460 @@
import type { ThunkAction, AnyAction } from '@reduxjs/toolkit'
import {
isAllOf,
isAnyOf,
isAsyncThunkAction,
isFulfilled,
isPending,
isRejected,
isRejectedWithValue,
createAction,
createAsyncThunk,
createReducer,
} from '@reduxjs/toolkit'
const thunk: ThunkAction<any, any, any, AnyAction> = () => {}
describe('isAnyOf', () => {
it('returns true only if any matchers match (match function)', () => {
const actionA = createAction<string>('a')
const actionB = createAction<number>('b')
const trueAction = {
type: 'a',
payload: 'payload',
}
expect(isAnyOf(actionA, actionB)(trueAction)).toEqual(true)
const falseAction = {
type: 'c',
payload: 'payload',
}
expect(isAnyOf(actionA, actionB)(falseAction)).toEqual(false)
})
it('returns true only if any type guards match', () => {
const actionA = createAction<string>('a')
const actionB = createAction<number>('b')
const isActionA = actionA.match
const isActionB = actionB.match
const trueAction = {
type: 'a',
payload: 'payload',
}
expect(isAnyOf(isActionA, isActionB)(trueAction)).toEqual(true)
const falseAction = {
type: 'c',
payload: 'payload',
}
expect(isAnyOf(isActionA, isActionB)(falseAction)).toEqual(false)
})
it('returns true only if any matchers match (thunk action creators)', () => {
const thunkA = createAsyncThunk<string>('a', () => {
return 'noop'
})
const thunkB = createAsyncThunk<number>('b', () => {
return 0
})
const action = thunkA.fulfilled('fakeRequestId', 'test')
expect(isAnyOf(thunkA.fulfilled, thunkB.fulfilled)(action)).toEqual(true)
expect(
isAnyOf(thunkA.pending, thunkA.rejected, thunkB.fulfilled)(action)
).toEqual(false)
})
it('works with reducers', () => {
const actionA = createAction<string>('a')
const actionB = createAction<number>('b')
const trueAction = {
type: 'a',
payload: 'payload',
}
const initialState = { value: false }
const reducer = createReducer(initialState, (builder) => {
builder.addMatcher(isAnyOf(actionA, actionB), (state) => {
return { ...state, value: true }
})
})
expect(reducer(initialState, trueAction)).toEqual({ value: true })
const falseAction = {
type: 'c',
payload: 'payload',
}
expect(reducer(initialState, falseAction)).toEqual(initialState)
})
})
describe('isAllOf', () => {
it('returns true only if all matchers match', () => {
const actionA = createAction<string>('a')
interface SpecialAction {
payload: 'SPECIAL'
}
const isActionSpecial = (action: any): action is SpecialAction => {
return action.payload === 'SPECIAL'
}
const trueAction = {
type: 'a',
payload: 'SPECIAL',
}
expect(isAllOf(actionA, isActionSpecial)(trueAction)).toEqual(true)
const falseAction = {
type: 'a',
payload: 'ORDINARY',
}
expect(isAllOf(actionA, isActionSpecial)(falseAction)).toEqual(false)
const thunkA = createAsyncThunk<string>('a', () => 'result')
const specialThunkAction = thunkA.fulfilled('SPECIAL', 'fakeRequestId')
expect(isAllOf(thunkA.fulfilled, isActionSpecial)(specialThunkAction)).toBe(
true
)
const ordinaryThunkAction = thunkA.fulfilled('ORDINARY', 'fakeRequestId')
expect(
isAllOf(thunkA.fulfilled, isActionSpecial)(ordinaryThunkAction)
).toBe(false)
})
})
describe('isPending', () => {
test('should return false for a regular action', () => {
const action = createAction<string>('action/type')('testPayload')
expect(isPending()(action)).toBe(false)
expect(isPending(action)).toBe(false)
expect(isPending(thunk)).toBe(false)
})
test('should return true only for pending async thunk actions', () => {
const thunk = createAsyncThunk<string>('a', () => 'result')
const pendingAction = thunk.pending('fakeRequestId')
expect(isPending()(pendingAction)).toBe(true)
expect(isPending(pendingAction)).toBe(true)
const rejectedAction = thunk.rejected(
new Error('rejected'),
'fakeRequestId'
)
expect(isPending()(rejectedAction)).toBe(false)
const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
expect(isPending()(fulfilledAction)).toBe(false)
})
test('should return true only for thunks provided as arguments', () => {
const thunkA = createAsyncThunk<string>('a', () => 'result')
const thunkB = createAsyncThunk<string>('b', () => 'result')
const thunkC = createAsyncThunk<string>('c', () => 'result')
const matchAC = isPending(thunkA, thunkC)
const matchB = isPending(thunkB)
function testPendingAction(
thunk: typeof thunkA | typeof thunkB | typeof thunkC,
expected: boolean
) {
const pendingAction = thunk.pending('fakeRequestId')
expect(matchAC(pendingAction)).toBe(expected)
expect(matchB(pendingAction)).toBe(!expected)
const rejectedAction = thunk.rejected(
new Error('rejected'),
'fakeRequestId'
)
expect(matchAC(rejectedAction)).toBe(false)
const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
expect(matchAC(fulfilledAction)).toBe(false)
}
testPendingAction(thunkA, true)
testPendingAction(thunkC, true)
testPendingAction(thunkB, false)
})
})
describe('isRejected', () => {
test('should return false for a regular action', () => {
const action = createAction<string>('action/type')('testPayload')
expect(isRejected()(action)).toBe(false)
expect(isRejected(action)).toBe(false)
expect(isRejected(thunk)).toBe(false)
})
test('should return true only for rejected async thunk actions', () => {
const thunk = createAsyncThunk<string>('a', () => 'result')
const pendingAction = thunk.pending('fakeRequestId')
expect(isRejected()(pendingAction)).toBe(false)
const rejectedAction = thunk.rejected(
new Error('rejected'),
'fakeRequestId'
)
expect(isRejected()(rejectedAction)).toBe(true)
expect(isRejected(rejectedAction)).toBe(true)
const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
expect(isRejected()(fulfilledAction)).toBe(false)
})
test('should return true only for thunks provided as arguments', () => {
const thunkA = createAsyncThunk<string>('a', () => 'result')
const thunkB = createAsyncThunk<string>('b', () => 'result')
const thunkC = createAsyncThunk<string>('c', () => 'result')
const matchAC = isRejected(thunkA, thunkC)
const matchB = isRejected(thunkB)
function testRejectedAction(
thunk: typeof thunkA | typeof thunkB | typeof thunkC,
expected: boolean
) {
const pendingAction = thunk.pending('fakeRequestId')
expect(matchAC(pendingAction)).toBe(false)
const rejectedAction = thunk.rejected(
new Error('rejected'),
'fakeRequestId'
)
expect(matchAC(rejectedAction)).toBe(expected)
expect(matchB(rejectedAction)).toBe(!expected)
const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
expect(matchAC(fulfilledAction)).toBe(false)
}
testRejectedAction(thunkA, true)
testRejectedAction(thunkC, true)
testRejectedAction(thunkB, false)
})
})
describe('isRejectedWithValue', () => {
test('should return false for a regular action', () => {
const action = createAction<string>('action/type')('testPayload')
expect(isRejectedWithValue()(action)).toBe(false)
expect(isRejectedWithValue(action)).toBe(false)
expect(isRejectedWithValue(thunk)).toBe(false)
})
test('should return true only for rejected-with-value async thunk actions', async () => {
const thunk = createAsyncThunk<string>('a', (_, { rejectWithValue }) => {
return rejectWithValue('rejectWithValue!')
})
const pendingAction = thunk.pending('fakeRequestId')
expect(isRejectedWithValue()(pendingAction)).toBe(false)
const rejectedAction = thunk.rejected(
new Error('rejected'),
'fakeRequestId'
)
expect(isRejectedWithValue()(rejectedAction)).toBe(false)
const getState = jest.fn(() => ({}))
const dispatch = jest.fn((x: any) => x)
const extra = {}
// note: doesn't throw because we don't unwrap it
const rejectedWithValueAction = await thunk()(dispatch, getState, extra)
expect(isRejectedWithValue()(rejectedWithValueAction)).toBe(true)
const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
expect(isRejectedWithValue()(fulfilledAction)).toBe(false)
})
test('should return true only for thunks provided as arguments', async () => {
const payloadCreator = (_: any, { rejectWithValue }: any) => {
return rejectWithValue('rejectWithValue!')
}
const thunkA = createAsyncThunk<string>('a', payloadCreator)
const thunkB = createAsyncThunk<string>('b', payloadCreator)
const thunkC = createAsyncThunk<string>('c', payloadCreator)
const matchAC = isRejectedWithValue(thunkA, thunkC)
const matchB = isRejectedWithValue(thunkB)
async function testRejectedAction(
thunk: typeof thunkA | typeof thunkB | typeof thunkC,
expected: boolean
) {
const pendingAction = thunk.pending('fakeRequestId')
expect(matchAC(pendingAction)).toBe(false)
const rejectedAction = thunk.rejected(
new Error('rejected'),
'fakeRequestId'
)
// rejected-with-value is a narrower requirement than rejected
expect(matchAC(rejectedAction)).toBe(false)
const getState = jest.fn(() => ({}))
const dispatch = jest.fn((x: any) => x)
const extra = {}
// note: doesn't throw because we don't unwrap it
const rejectedWithValueAction = await thunk()(dispatch, getState, extra)
expect(matchAC(rejectedWithValueAction)).toBe(expected)
expect(matchB(rejectedWithValueAction)).toBe(!expected)
const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
expect(matchAC(fulfilledAction)).toBe(false)
}
await testRejectedAction(thunkA, true)
await testRejectedAction(thunkC, true)
await testRejectedAction(thunkB, false)
})
})
describe('isFulfilled', () => {
test('should return false for a regular action', () => {
const action = createAction<string>('action/type')('testPayload')
expect(isFulfilled()(action)).toBe(false)
expect(isFulfilled(action)).toBe(false)
expect(isFulfilled(thunk)).toBe(false)
})
test('should return true only for fulfilled async thunk actions', () => {
const thunk = createAsyncThunk<string>('a', () => 'result')
const pendingAction = thunk.pending('fakeRequestId')
expect(isFulfilled()(pendingAction)).toBe(false)
const rejectedAction = thunk.rejected(
new Error('rejected'),
'fakeRequestId'
)
expect(isFulfilled()(rejectedAction)).toBe(false)
const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
expect(isFulfilled()(fulfilledAction)).toBe(true)
expect(isFulfilled(fulfilledAction)).toBe(true)
})
test('should return true only for thunks provided as arguments', () => {
const thunkA = createAsyncThunk<string>('a', () => 'result')
const thunkB = createAsyncThunk<string>('b', () => 'result')
const thunkC = createAsyncThunk<string>('c', () => 'result')
const matchAC = isFulfilled(thunkA, thunkC)
const matchB = isFulfilled(thunkB)
function testFulfilledAction(
thunk: typeof thunkA | typeof thunkB | typeof thunkC,
expected: boolean
) {
const pendingAction = thunk.pending('fakeRequestId')
expect(matchAC(pendingAction)).toBe(false)
const rejectedAction = thunk.rejected(
new Error('rejected'),
'fakeRequestId'
)
expect(matchAC(rejectedAction)).toBe(false)
const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
expect(matchAC(fulfilledAction)).toBe(expected)
expect(matchB(fulfilledAction)).toBe(!expected)
}
testFulfilledAction(thunkA, true)
testFulfilledAction(thunkC, true)
testFulfilledAction(thunkB, false)
})
})
describe('isAsyncThunkAction', () => {
test('should return false for a regular action', () => {
const action = createAction<string>('action/type')('testPayload')
expect(isAsyncThunkAction()(action)).toBe(false)
expect(isAsyncThunkAction(action)).toBe(false)
expect(isAsyncThunkAction(thunk)).toBe(false)
})
test('should return true for any async thunk action if no arguments were provided', () => {
const thunk = createAsyncThunk<string>('a', () => 'result')
const matcher = isAsyncThunkAction()
const pendingAction = thunk.pending('fakeRequestId')
expect(matcher(pendingAction)).toBe(true)
const rejectedAction = thunk.rejected(
new Error('rejected'),
'fakeRequestId'
)
expect(matcher(rejectedAction)).toBe(true)
const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
expect(matcher(fulfilledAction)).toBe(true)
})
test('should return true only for thunks provided as arguments', () => {
const thunkA = createAsyncThunk<string>('a', () => 'result')
const thunkB = createAsyncThunk<string>('b', () => 'result')
const thunkC = createAsyncThunk<string>('c', () => 'result')
const matchAC = isAsyncThunkAction(thunkA, thunkC)
const matchB = isAsyncThunkAction(thunkB)
function testAllActions(
thunk: typeof thunkA | typeof thunkB | typeof thunkC,
expected: boolean
) {
const pendingAction = thunk.pending('fakeRequestId')
expect(matchAC(pendingAction)).toBe(expected)
expect(matchB(pendingAction)).toBe(!expected)
const rejectedAction = thunk.rejected(
new Error('rejected'),
'fakeRequestId'
)
expect(matchAC(rejectedAction)).toBe(expected)
expect(matchB(rejectedAction)).toBe(!expected)
const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
expect(matchAC(fulfilledAction)).toBe(expected)
expect(matchB(fulfilledAction)).toBe(!expected)
}
testAllActions(thunkA, true)
testAllActions(thunkC, true)
testAllActions(thunkB, false)
})
})

View File

@@ -0,0 +1,331 @@
import { expectExactType, expectUnknown } from './helpers'
import { IsUnknown } from '@internal/tsHelpers'
import type { AnyAction } from 'redux'
import type { SerializedError } from '../../src'
import {
createAction,
createAsyncThunk,
isAllOf,
isAnyOf,
isAsyncThunkAction,
isFulfilled,
isPending,
isRejected,
isRejectedWithValue,
} from '../../src'
/* isAnyOf */
/*
* Test: isAnyOf correctly narrows types when used with action creators
*/
function isAnyOfActionTest(action: AnyAction) {
const actionA = createAction('a', () => {
return {
payload: {
prop1: 1,
prop3: 2,
},
}
})
const actionB = createAction('b', () => {
return {
payload: {
prop1: 1,
prop2: 2,
},
}
})
if (isAnyOf(actionA, actionB)(action)) {
return {
prop1: action.payload.prop1,
// @ts-expect-error
prop2: action.payload.prop2,
// @ts-expect-error
prop3: action.payload.prop3,
}
}
}
/*
* Test: isAnyOf correctly narrows types when used with async thunks
*/
function isAnyOfThunkTest(action: AnyAction) {
const asyncThunk1 = createAsyncThunk<{ prop1: number; prop3: number }>(
'asyncThunk1',
async () => {
return {
prop1: 1,
prop3: 3,
}
}
)
const asyncThunk2 = createAsyncThunk<{ prop1: number; prop2: number }>(
'asyncThunk2',
async () => {
return {
prop1: 1,
prop2: 2,
}
}
)
if (isAnyOf(asyncThunk1.fulfilled, asyncThunk2.fulfilled)(action)) {
return {
prop1: action.payload.prop1,
// @ts-expect-error
prop2: action.payload.prop2,
// @ts-expect-error
prop3: action.payload.prop3,
}
}
}
/*
* Test: isAnyOf correctly narrows types when used with type guards
*/
function isAnyOfTypeGuardTest(action: AnyAction) {
interface ActionA {
type: 'a'
payload: {
prop1: 1
prop3: 2
}
}
interface ActionB {
type: 'b'
payload: {
prop1: 1
prop2: 2
}
}
const guardA = (v: any): v is ActionA => {
return v.type === 'a'
}
const guardB = (v: any): v is ActionB => {
return v.type === 'b'
}
if (isAnyOf(guardA, guardB)(action)) {
return {
prop1: action.payload.prop1,
// @ts-expect-error
prop2: action.payload.prop2,
// @ts-expect-error
prop3: action.payload.prop3,
}
}
}
/* isAllOf */
interface SpecialAction {
payload: {
special: boolean
}
}
const isSpecialAction = (v: any): v is SpecialAction => {
return v.meta.isSpecial
}
/*
* Test: isAllOf correctly narrows types when used with action creators
* and type guards
*/
function isAllOfActionTest(action: AnyAction) {
const actionA = createAction('a', () => {
return {
payload: {
prop1: 1,
prop3: 2,
},
}
})
if (isAllOf(actionA, isSpecialAction)(action)) {
return {
prop1: action.payload.prop1,
// @ts-expect-error
prop2: action.payload.prop2,
prop3: action.payload.prop3,
special: action.payload.special,
}
}
}
/*
* Test: isAllOf correctly narrows types when used with async thunks
* and type guards
*/
function isAllOfThunkTest(action: AnyAction) {
const asyncThunk1 = createAsyncThunk<{ prop1: number; prop3: number }>(
'asyncThunk1',
async () => {
return {
prop1: 1,
prop3: 3,
}
}
)
if (isAllOf(asyncThunk1.fulfilled, isSpecialAction)(action)) {
return {
prop1: action.payload.prop1,
// @ts-expect-error
prop2: action.payload.prop2,
prop3: action.payload.prop3,
special: action.payload.special,
}
}
}
/*
* Test: isAnyOf correctly narrows types when used with type guards
*/
function isAllOfTypeGuardTest(action: AnyAction) {
interface ActionA {
type: 'a'
payload: {
prop1: 1
prop3: 2
}
}
const guardA = (v: any): v is ActionA => {
return v.type === 'a'
}
if (isAllOf(guardA, isSpecialAction)(action)) {
return {
prop1: action.payload.prop1,
// @ts-expect-error
prop2: action.payload.prop2,
prop3: action.payload.prop3,
special: action.payload.special,
}
}
}
/*
* Test: isPending correctly narrows types
*/
function isPendingTest(action: AnyAction) {
if (isPending(action)) {
expectExactType<undefined>(action.payload)
// @ts-expect-error
action.error
}
const thunk = createAsyncThunk<string>('a', () => 'result')
if (isPending(thunk)(action)) {
expectExactType<undefined>(action.payload)
// @ts-expect-error
action.error
}
}
/*
* Test: isRejected correctly narrows types
*/
function isRejectedTest(action: AnyAction) {
if (isRejected(action)) {
// might be there if rejected with payload
expectUnknown(action.payload)
expectExactType<SerializedError>(action.error)
}
const thunk = createAsyncThunk<string>('a', () => 'result')
if (isRejected(thunk)(action)) {
// might be there if rejected with payload
expectUnknown(action.payload)
expectExactType<SerializedError>(action.error)
}
}
/*
* Test: isFulfilled correctly narrows types
*/
function isFulfilledTest(action: AnyAction) {
if (isFulfilled(action)) {
expectUnknown(action.payload)
// @ts-expect-error
action.error
}
const thunk = createAsyncThunk<string>('a', () => 'result')
if (isFulfilled(thunk)(action)) {
expectExactType('' as string)(action.payload)
// @ts-expect-error
action.error
}
}
/*
* Test: isAsyncThunkAction correctly narrows types
*/
function isAsyncThunkActionTest(action: AnyAction) {
if (isAsyncThunkAction(action)) {
expectUnknown(action.payload)
// do not expect an error property because pending/fulfilled lack it
// @ts-expect-error
action.error
}
const thunk = createAsyncThunk<string>('a', () => 'result')
if (isAsyncThunkAction(thunk)(action)) {
// we should expect the payload to be available, but of unknown type because the action may be pending/rejected
expectUnknown(action.payload)
// do not expect an error property because pending/fulfilled lack it
// @ts-expect-error
action.error
}
}
/*
* Test: isRejectedWithValue correctly narrows types
*/
function isRejectedWithValueTest(action: AnyAction) {
if (isRejectedWithValue(action)) {
expectUnknown(action.payload)
expectExactType<SerializedError>(action.error)
}
const thunk = createAsyncThunk<
string,
void,
{ rejectValue: { message: string } }
>('a', () => 'result')
if (isRejectedWithValue(thunk)(action)) {
expectExactType({ message: '' as string })(action.payload)
expectExactType<SerializedError>(action.error)
}
}
function matchersAcceptSpreadArguments() {
const thunk1 = createAsyncThunk('a', () => 'a')
const thunk2 = createAsyncThunk('b', () => 'b')
const interestingThunks = [thunk1, thunk2]
const interestingPendingThunks = interestingThunks.map(
(thunk) => thunk.pending
)
const interestingFulfilledThunks = interestingThunks.map(
(thunk) => thunk.fulfilled
)
const isLoading = isAnyOf(...interestingPendingThunks)
const isNotLoading = isAnyOf(...interestingFulfilledThunks)
const isAllLoading = isAllOf(...interestingPendingThunks)
}

View File

@@ -0,0 +1,635 @@
import {
mockConsole,
createConsole,
getLog,
} from 'console-testing-library/pure'
import type { AnyAction, Reducer } from '@reduxjs/toolkit'
import {
createNextState,
configureStore,
createSerializableStateInvariantMiddleware,
findNonSerializableValue,
isPlain,
} from '@reduxjs/toolkit'
import { isNestedFrozen } from '@internal/serializableStateInvariantMiddleware'
// Mocking console
let restore = () => {}
beforeEach(() => {
restore = mockConsole(createConsole())
})
afterEach(() => restore())
describe('findNonSerializableValue', () => {
it('Should return false if no matching values are found', () => {
const obj = {
a: 42,
b: {
b1: 'test',
},
c: [99, { d: 123 }],
}
const result = findNonSerializableValue(obj)
expect(result).toBe(false)
})
it('Should return a keypath and the value if it finds a non-serializable value', () => {
function testFunction() {}
const obj = {
a: 42,
b: {
b1: testFunction,
},
c: [99, { d: 123 }],
}
const result = findNonSerializableValue(obj)
expect(result).toEqual({ keyPath: 'b.b1', value: testFunction })
})
it('Should return the first non-serializable value it finds', () => {
const map = new Map()
const symbol = Symbol.for('testSymbol')
const obj = {
a: 42,
b: {
b1: 1,
},
c: [99, { d: 123 }, map, symbol, 'test'],
d: symbol,
}
const result = findNonSerializableValue(obj)
expect(result).toEqual({ keyPath: 'c.2', value: map })
})
it('Should return a specific value if the root object is non-serializable', () => {
const value = new Map()
const result = findNonSerializableValue(value)
expect(result).toEqual({ keyPath: '<root>', value })
})
it('Should accept null as a valid value', () => {
const obj = {
a: 42,
b: {
b1: 1,
},
c: null,
}
const result = findNonSerializableValue(obj)
expect(result).toEqual(false)
})
})
describe('serializableStateInvariantMiddleware', () => {
it('Should log an error when a non-serializable action is dispatched', () => {
const reducer: Reducer = (state = 0, _action) => state + 1
const serializableStateInvariantMiddleware =
createSerializableStateInvariantMiddleware()
const store = configureStore({
reducer,
middleware: [serializableStateInvariantMiddleware],
})
const type = Symbol.for('SOME_CONSTANT')
const dispatchedAction = { type }
store.dispatch(dispatchedAction)
expect(getLog().log).toMatchInlineSnapshot(`
"A non-serializable value was detected in an action, in the path: \`type\`. Value: Symbol(SOME_CONSTANT)
Take a look at the logic that dispatched this action: Object {
\\"type\\": Symbol(SOME_CONSTANT),
}
(See https://redux.js.org/faq/actions#why-should-type-be-a-string-or-at-least-serializable-why-should-my-action-types-be-constants)
(To allow non-serializable values see: https://redux-toolkit.js.org/usage/usage-guide#working-with-non-serializable-data)"
`)
})
it('Should log an error when a non-serializable value is in state', () => {
const ACTION_TYPE = 'TEST_ACTION'
const initialState = {
a: 0,
}
const badValue = new Map()
const reducer: Reducer = (state = initialState, action) => {
switch (action.type) {
case ACTION_TYPE: {
return {
a: badValue,
}
}
default:
return state
}
}
const serializableStateInvariantMiddleware =
createSerializableStateInvariantMiddleware()
const store = configureStore({
reducer: {
testSlice: reducer,
},
middleware: [serializableStateInvariantMiddleware],
})
store.dispatch({ type: ACTION_TYPE })
expect(getLog().log).toMatchInlineSnapshot(`
"A non-serializable value was detected in the state, in the path: \`testSlice.a\`. Value: Map {}
Take a look at the reducer(s) handling this action type: TEST_ACTION.
(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)"
`)
})
describe('consumer tolerated structures', () => {
const nonSerializableValue = new Map()
const nestedSerializableObjectWithBadValue = {
isSerializable: true,
entries: (): [string, any][] => [
['good-string', 'Good!'],
['good-number', 1337],
['bad-map-instance', nonSerializableValue],
],
}
const serializableObject = {
isSerializable: true,
entries: (): [string, any][] => [
['first', 1],
['second', 'B!'],
['third', nestedSerializableObjectWithBadValue],
],
}
it('Should log an error when a non-serializable value is nested in state', () => {
const ACTION_TYPE = 'TEST_ACTION'
const initialState = {
a: 0,
}
const reducer: Reducer = (state = initialState, action) => {
switch (action.type) {
case ACTION_TYPE: {
return {
a: serializableObject,
}
}
default:
return state
}
}
// use default options
const serializableStateInvariantMiddleware =
createSerializableStateInvariantMiddleware()
const store = configureStore({
reducer: {
testSlice: reducer,
},
middleware: [serializableStateInvariantMiddleware],
})
store.dispatch({ type: ACTION_TYPE })
// since default options are used, the `entries` function in `serializableObject` will cause the error
expect(getLog().log).toMatchInlineSnapshot(`
"A non-serializable value was detected in the state, in the path: \`testSlice.a.entries\`. Value: [Function entries]
Take a look at the reducer(s) handling this action type: TEST_ACTION.
(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)"
`)
})
it('Should use consumer supplied isSerializable and getEntries options to tolerate certain structures', () => {
const ACTION_TYPE = 'TEST_ACTION'
const initialState = {
a: 0,
}
const isSerializable = (val: any): boolean =>
val.isSerializable || isPlain(val)
const getEntries = (val: any): [string, any][] =>
val.isSerializable ? val.entries() : Object.entries(val)
const reducer: Reducer = (state = initialState, action) => {
switch (action.type) {
case ACTION_TYPE: {
return {
a: serializableObject,
}
}
default:
return state
}
}
const serializableStateInvariantMiddleware =
createSerializableStateInvariantMiddleware({
isSerializable,
getEntries,
})
const store = configureStore({
reducer: {
testSlice: reducer,
},
middleware: [serializableStateInvariantMiddleware],
})
store.dispatch({ type: ACTION_TYPE })
// error reported is from a nested class instance, rather than the `entries` function `serializableObject`
expect(getLog().log).toMatchInlineSnapshot(`
"A non-serializable value was detected in the state, in the path: \`testSlice.a.third.bad-map-instance\`. Value: Map {}
Take a look at the reducer(s) handling this action type: TEST_ACTION.
(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)"
`)
})
})
it('Should use the supplied isSerializable function to determine serializability', () => {
const ACTION_TYPE = 'TEST_ACTION'
const initialState = {
a: 0,
}
const badValue = new Map()
const reducer: Reducer = (state = initialState, action) => {
switch (action.type) {
case ACTION_TYPE: {
return {
a: badValue,
}
}
default:
return state
}
}
const serializableStateInvariantMiddleware =
createSerializableStateInvariantMiddleware({
isSerializable: () => true,
})
const store = configureStore({
reducer: {
testSlice: reducer,
},
middleware: [serializableStateInvariantMiddleware],
})
store.dispatch({ type: ACTION_TYPE })
// Supplied 'isSerializable' considers all values serializable, hence
// no error logging is expected:
expect(getLog().log).toBe('')
})
it('should not check serializability for ignored action types', () => {
let numTimesCalled = 0
const serializableStateMiddleware =
createSerializableStateInvariantMiddleware({
isSerializable: () => {
numTimesCalled++
return true
},
ignoredActions: ['IGNORE_ME'],
})
const store = configureStore({
reducer: () => ({}),
middleware: [serializableStateMiddleware],
})
expect(numTimesCalled).toBe(0)
store.dispatch({ type: 'IGNORE_ME' })
// The state check only calls `isSerializable` once
expect(numTimesCalled).toBe(1)
store.dispatch({ type: 'ANY_OTHER_ACTION' })
// Action checks call `isSerializable` 2+ times when enabled
expect(numTimesCalled).toBeGreaterThanOrEqual(3)
})
describe('ignored action paths', () => {
function reducer() {
return 0
}
const nonSerializableValue = new Map()
it('default value: meta.arg', () => {
configureStore({
reducer,
middleware: [createSerializableStateInvariantMiddleware()],
}).dispatch({ type: 'test', meta: { arg: nonSerializableValue } })
expect(getLog().log).toMatchInlineSnapshot(`""`)
})
it('default value can be overridden', () => {
configureStore({
reducer,
middleware: [
createSerializableStateInvariantMiddleware({
ignoredActionPaths: [],
}),
],
}).dispatch({ type: 'test', meta: { arg: nonSerializableValue } })
expect(getLog().log).toMatchInlineSnapshot(`
"A non-serializable value was detected in an action, in the path: \`meta.arg\`. Value: Map {}
Take a look at the logic that dispatched this action: Object {
\\"meta\\": Object {
\\"arg\\": Map {},
},
\\"type\\": \\"test\\",
}
(See https://redux.js.org/faq/actions#why-should-type-be-a-string-or-at-least-serializable-why-should-my-action-types-be-constants)
(To allow non-serializable values see: https://redux-toolkit.js.org/usage/usage-guide#working-with-non-serializable-data)"
`)
})
it('can specify (multiple) different values', () => {
configureStore({
reducer,
middleware: [
createSerializableStateInvariantMiddleware({
ignoredActionPaths: ['payload', 'meta.arg'],
}),
],
}).dispatch({
type: 'test',
payload: { arg: nonSerializableValue },
meta: { arg: nonSerializableValue },
})
expect(getLog().log).toMatchInlineSnapshot(`""`)
})
it('can specify regexp', () => {
configureStore({
reducer,
middleware: [
createSerializableStateInvariantMiddleware({
ignoredActionPaths: [/^payload\..*$/],
}),
],
}).dispatch({
type: 'test',
payload: { arg: nonSerializableValue },
})
expect(getLog().log).toMatchInlineSnapshot(`""`)
})
})
it('allows ignoring actions entirely', () => {
let numTimesCalled = 0
const serializableStateMiddleware =
createSerializableStateInvariantMiddleware({
isSerializable: () => {
numTimesCalled++
return true
},
ignoreActions: true,
})
const store = configureStore({
reducer: () => ({}),
middleware: [serializableStateMiddleware],
})
expect(numTimesCalled).toBe(0)
store.dispatch({ type: 'THIS_DOESNT_MATTER' })
// `isSerializable` is called once for a state check
expect(numTimesCalled).toBe(1)
store.dispatch({ type: 'THIS_DOESNT_MATTER_AGAIN' })
expect(numTimesCalled).toBe(2)
})
it('should not check serializability for ignored slice names', () => {
const ACTION_TYPE = 'TEST_ACTION'
const initialState = {
a: 0,
}
const badValue = new Map()
const reducer: Reducer = (state = initialState, action) => {
switch (action.type) {
case ACTION_TYPE: {
return {
a: badValue,
b: {
c: badValue,
d: badValue,
},
e: { f: badValue },
g: {
h: badValue,
i: badValue,
},
}
}
default:
return state
}
}
const serializableStateInvariantMiddleware =
createSerializableStateInvariantMiddleware({
ignoredPaths: [
// Test for ignoring a single value
'testSlice.a',
// Test for ignoring a single nested value
'testSlice.b.c',
// Test for ignoring an object and its children
'testSlice.e',
// Test for ignoring based on RegExp
/^testSlice\.g\..*$/,
],
})
const store = configureStore({
reducer: {
testSlice: reducer,
},
middleware: [serializableStateInvariantMiddleware],
})
store.dispatch({ type: ACTION_TYPE })
// testSlice.b.d was not covered in ignoredPaths, so will still log the error
expect(getLog().log).toMatchInlineSnapshot(`
"A non-serializable value was detected in the state, in the path: \`testSlice.b.d\`. Value: Map {}
Take a look at the reducer(s) handling this action type: TEST_ACTION.
(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)"
`)
})
it('allows ignoring state entirely', () => {
const badValue = new Map()
let numTimesCalled = 0
const reducer = () => badValue
const store = configureStore({
reducer,
middleware: [
createSerializableStateInvariantMiddleware({
isSerializable: () => {
numTimesCalled++
return true
},
ignoreState: true,
}),
],
})
expect(numTimesCalled).toBe(0)
store.dispatch({ type: 'test' })
expect(getLog().log).toMatchInlineSnapshot(`""`)
// Should be called twice for the action - there is an initial check for early returns, then a second and potentially 3rd for nested properties
expect(numTimesCalled).toBe(2)
})
it('never calls isSerializable if both ignoreState and ignoreActions are true', () => {
const badValue = new Map()
let numTimesCalled = 0
const reducer = () => badValue
const store = configureStore({
reducer,
middleware: [
createSerializableStateInvariantMiddleware({
isSerializable: () => {
numTimesCalled++
return true
},
ignoreState: true,
ignoreActions: true,
}),
],
})
expect(numTimesCalled).toBe(0)
store.dispatch({ type: 'TEST', payload: new Date() })
store.dispatch({ type: 'OTHER_THING' })
expect(numTimesCalled).toBe(0)
})
it('Should print a warning if execution takes too long', () => {
const reducer: Reducer = (state = 42, action) => {
return state
}
const serializableStateInvariantMiddleware =
createSerializableStateInvariantMiddleware({ warnAfter: 4 })
const store = configureStore({
reducer: {
testSlice: reducer,
},
middleware: [serializableStateInvariantMiddleware],
})
store.dispatch({
type: 'SOME_ACTION',
payload: new Array(10000).fill({ value: 'more' }),
})
expect(getLog().log).toMatch(
/^SerializableStateInvariantMiddleware took \d*ms, which is more than the warning threshold of 4ms./
)
})
it('Should not print a warning if "reducer" takes too long', () => {
const reducer: Reducer = (state = 42, action) => {
const started = Date.now()
while (Date.now() - started < 8) {}
return state
}
const serializableStateInvariantMiddleware =
createSerializableStateInvariantMiddleware({ warnAfter: 4 })
const store = configureStore({
reducer: {
testSlice: reducer,
},
middleware: [serializableStateInvariantMiddleware],
})
store.dispatch({ type: 'SOME_ACTION' })
expect(getLog().log).toMatch('')
})
it('Should cache its results', () => {
let numPlainChecks = 0
const countPlainChecks = (x: any) => {
numPlainChecks++
return isPlain(x)
}
const serializableStateInvariantMiddleware =
createSerializableStateInvariantMiddleware({
isSerializable: countPlainChecks,
})
const store = configureStore({
reducer: (state = [], action) => {
if (action.type === 'SET_STATE') return action.payload
return state
},
middleware: [serializableStateInvariantMiddleware],
})
const state = createNextState([], () =>
new Array(50).fill(0).map((x, i) => ({ i }))
)
expect(isNestedFrozen(state)).toBe(true)
store.dispatch({
type: 'SET_STATE',
payload: state,
})
expect(numPlainChecks).toBeGreaterThan(state.length)
numPlainChecks = 0
store.dispatch({ type: 'NOOP' })
expect(numPlainChecks).toBeLessThan(10)
})
})

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.test.json"
}

View File

@@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.test.json",
"compilerOptions": {
"skipLibCheck": true
}
}