694 lines
21 KiB
Plaintext
694 lines
21 KiB
Plaintext
import type {
|
|
CaseReducer,
|
|
Draft,
|
|
PayloadAction,
|
|
Reducer,
|
|
UnknownAction,
|
|
} from '@reduxjs/toolkit'
|
|
import {
|
|
createAction,
|
|
createAsyncThunk,
|
|
createNextState,
|
|
createReducer,
|
|
isPlainObject,
|
|
} from '@reduxjs/toolkit'
|
|
import { waitMs } from './utils/helpers'
|
|
|
|
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, 'ADD_TODO'>
|
|
>
|
|
|
|
type ToggleTodoReducer = CaseReducer<
|
|
TodoState,
|
|
PayloadAction<ToggleTodoPayload, 'TOGGLE_TODO'>
|
|
>
|
|
|
|
type CreateReducer = typeof createReducer
|
|
|
|
const addTodoThunk = createAsyncThunk('todos/add', (todo: Todo) => todo)
|
|
|
|
describe('createReducer', () => {
|
|
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, (builder) => {
|
|
builder.addCase('ADD_TODO', addTodo).addCase('TOGGLE_TODO', toggleTodo)
|
|
})
|
|
|
|
behavesLikeReducer(todosReducer)
|
|
})
|
|
|
|
describe('Deprecation warnings', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('Throws an error if the legacy object notation is used', async () => {
|
|
const { createReducer } = await import('../createReducer')
|
|
const wrapper = () => {
|
|
const dummyReducer = (createReducer as CreateReducer)(
|
|
[] as TodoState,
|
|
// @ts-ignore
|
|
{},
|
|
)
|
|
}
|
|
|
|
expect(wrapper).toThrowError(
|
|
/The object notation for `createReducer` has been removed/,
|
|
)
|
|
|
|
expect(wrapper).toThrowError(
|
|
/The object notation for `createReducer` has been removed/,
|
|
)
|
|
})
|
|
|
|
it('Crashes in production', async () => {
|
|
vi.stubEnv('NODE_ENV', 'production')
|
|
const { createReducer } = await import('../createReducer')
|
|
const wrapper = () => {
|
|
const dummyReducer = (createReducer as CreateReducer)(
|
|
[] as TodoState,
|
|
// @ts-ignore
|
|
{},
|
|
)
|
|
}
|
|
|
|
expect(wrapper).toThrowError()
|
|
})
|
|
})
|
|
|
|
describe('Immer in a production environment', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.stubEnv('NODE_ENV', 'production')
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
test('Freezes data in production', async () => {
|
|
const { createReducer } = await import('../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, (builder) => {
|
|
builder.addCase('ADD_TODO', addTodo).addCase('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, (builder) => {
|
|
builder.addCase('ADD_TODO', addTodo).addCase('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([] as TodoState, (builder) => {
|
|
builder.addCase('ADD_TODO', addTodo).addCase('TOGGLE_TODO', toggleTodo)
|
|
})
|
|
|
|
behavesLikeReducer(todosReducer)
|
|
|
|
it('Should only call the init function when `undefined` state is passed in', () => {
|
|
const spy = vi.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).toHaveBeenCalledOnce()
|
|
})
|
|
})
|
|
|
|
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, (builder) => {
|
|
builder.addCase('ADD_TODO', addTodo).addCase('TOGGLE_TODO', toggleTodo)
|
|
})
|
|
|
|
const wrappedReducer: TodosReducer = (state = [], action) => {
|
|
return createNextState(state, (draft: Draft<TodoState>) => {
|
|
todosReducer(draft, action)
|
|
})
|
|
}
|
|
|
|
behavesLikeReducer(wrappedReducer)
|
|
})
|
|
|
|
describe('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(
|
|
`[Error: 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(
|
|
`[Error: \`builder.addCase\` cannot be called with two reducers for the same action type 'increment']`,
|
|
)
|
|
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(
|
|
`[Error: \`builder.addCase\` cannot be called with two reducers for the same action type 'increment']`,
|
|
)
|
|
})
|
|
|
|
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(
|
|
`[Error: \`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: UnknownAction,
|
|
): a is PayloadAction<number> =>
|
|
isPlainObject(a.meta) &&
|
|
'type' in a.meta &&
|
|
(a.meta as Record<'type', unknown>).type === 'number_action'
|
|
|
|
const stringActionMatcher = (
|
|
a: UnknownAction,
|
|
): a is PayloadAction<string> =>
|
|
isPlainObject(a.meta) &&
|
|
'type' in a.meta &&
|
|
(a.meta as Record<'type', unknown>).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(
|
|
`[Error: \`builder.addCase\` should only be called before calling \`builder.addMatcher\`]`,
|
|
)
|
|
expect(() =>
|
|
createReducer(initialState, (builder: any) =>
|
|
builder.addDefaultCase(() => {}).addCase(incrementBy, () => {}),
|
|
),
|
|
).toThrowErrorMatchingInlineSnapshot(
|
|
`[Error: \`builder.addCase\` should only be called before calling \`builder.addDefaultCase\`]`,
|
|
)
|
|
expect(() =>
|
|
createReducer(initialState, (builder: any) =>
|
|
builder
|
|
.addDefaultCase(() => {})
|
|
.addMatcher(numberActionMatcher, () => {}),
|
|
),
|
|
).toThrowErrorMatchingInlineSnapshot(
|
|
`[Error: \`builder.addMatcher\` should only be called before calling \`builder.addDefaultCase\`]`,
|
|
)
|
|
expect(() =>
|
|
createReducer(initialState, (builder: any) =>
|
|
builder.addDefaultCase(() => {}).addDefaultCase(() => {}),
|
|
),
|
|
).toThrowErrorMatchingInlineSnapshot(
|
|
`[Error: \`builder.addDefaultCase\` can only be called once]`,
|
|
)
|
|
})
|
|
})
|
|
describe('builder "addAsyncThunk" method', () => {
|
|
const initialState = { todos: [] as Todo[], loading: false, errored: false }
|
|
test('uses the matching reducer for each action type', () => {
|
|
const reducer = createReducer(initialState, (builder) =>
|
|
builder.addAsyncThunk(addTodoThunk, {
|
|
pending(state) {
|
|
state.loading = true
|
|
},
|
|
fulfilled(state, action) {
|
|
state.todos.push(action.payload)
|
|
},
|
|
rejected(state) {
|
|
state.errored = true
|
|
},
|
|
settled(state) {
|
|
state.loading = false
|
|
},
|
|
}),
|
|
)
|
|
const todo: Todo = { text: 'test' }
|
|
expect(reducer(undefined, addTodoThunk.pending('test', todo))).toEqual({
|
|
todos: [],
|
|
loading: true,
|
|
errored: false,
|
|
})
|
|
expect(
|
|
reducer(undefined, addTodoThunk.fulfilled(todo, 'test', todo)),
|
|
).toEqual({
|
|
todos: [todo],
|
|
loading: false,
|
|
errored: false,
|
|
})
|
|
expect(
|
|
reducer(undefined, addTodoThunk.rejected(new Error(), 'test', todo)),
|
|
).toEqual({
|
|
todos: [],
|
|
loading: false,
|
|
errored: true,
|
|
})
|
|
})
|
|
test('calling addAsyncThunk after addDefaultCase should result in an error in development mode', () => {
|
|
expect(() =>
|
|
createReducer(initialState, (builder: any) =>
|
|
builder.addDefaultCase(() => {}).addAsyncThunk(addTodoThunk, {}),
|
|
),
|
|
).toThrowErrorMatchingInlineSnapshot(
|
|
`[Error: \`builder.addAsyncThunk\` should only be called before calling \`builder.addDefaultCase\`]`,
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
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,
|
|
},
|
|
])
|
|
})
|
|
}
|