All checks were successful
Publish To Prod / deploy_and_publish (push) Successful in 35s
511 lines
13 KiB
TypeScript
511 lines
13 KiB
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 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])
|
|
})
|
|
})
|
|
})
|