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 = { '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 = { '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]) }) }) })