@reduxjs/toolkit
Version:
The official, opinionated, batteries-included toolset for efficient Redux development
505 lines (457 loc) • 13 kB
text/typescript
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 dispatch = middleware({ ignoredPaths: ['foo.bar'] })(next)
expect(() => {
dispatch({ 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])
})
})
})