Spaces:
Sleeping
Sleeping
| import { noop } from '@internal/listenerMiddleware/utils' | |
| import { isNestedFrozen } from '@internal/serializableStateInvariantMiddleware' | |
| import type { Reducer } from '@reduxjs/toolkit' | |
| import { | |
| configureStore, | |
| createNextState, | |
| createSerializableStateInvariantMiddleware, | |
| findNonSerializableValue, | |
| isPlain, | |
| Tuple, | |
| } from '@reduxjs/toolkit' | |
| // Mocking console | |
| const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(noop) | |
| const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(noop) | |
| afterEach(() => { | |
| vi.clearAllMocks() | |
| }) | |
| afterAll(() => { | |
| vi.restoreAllMocks() | |
| }) | |
| 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: () => new Tuple(serializableStateInvariantMiddleware), | |
| }) | |
| const symbol = Symbol.for('SOME_CONSTANT') | |
| const dispatchedAction = { type: 'an-action', payload: symbol } | |
| store.dispatch(dispatchedAction) | |
| expect(consoleErrorSpy).toHaveBeenCalledOnce() | |
| expect(consoleErrorSpy).toHaveBeenLastCalledWith( | |
| `A non-serializable value was detected in an action, in the path: \`payload\`. Value:`, | |
| symbol, | |
| `\nTake a look at the logic that dispatched this action: `, | |
| dispatchedAction, | |
| `\n(See https://redux.js.org/faq/actions#why-should-type-be-a-string-or-at-least-serializable-why-should-my-action-types-be-constants)`, | |
| `\n(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: () => new Tuple(serializableStateInvariantMiddleware), | |
| }) | |
| store.dispatch({ type: ACTION_TYPE }) | |
| expect(consoleErrorSpy).toHaveBeenCalledOnce() | |
| expect(consoleErrorSpy).toHaveBeenLastCalledWith( | |
| `A non-serializable value was detected in the state, in the path: \`testSlice.a\`. Value:`, | |
| badValue, | |
| `\nTake 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: () => new Tuple(serializableStateInvariantMiddleware), | |
| }) | |
| store.dispatch({ type: ACTION_TYPE }) | |
| expect(consoleErrorSpy).toHaveBeenCalledOnce() | |
| // since default options are used, the `entries` function in `serializableObject` will cause the error | |
| expect(consoleErrorSpy).toHaveBeenLastCalledWith( | |
| `A non-serializable value was detected in the state, in the path: \`testSlice.a.entries\`. Value:`, | |
| serializableObject.entries, | |
| `\nTake 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: () => new Tuple(serializableStateInvariantMiddleware), | |
| }) | |
| store.dispatch({ type: ACTION_TYPE }) | |
| expect(consoleErrorSpy).toHaveBeenCalledOnce() | |
| // error reported is from a nested class instance, rather than the `entries` function `serializableObject` | |
| expect(consoleErrorSpy).toHaveBeenLastCalledWith( | |
| `A non-serializable value was detected in the state, in the path: \`testSlice.a.third.bad-map-instance\`. Value:`, | |
| nonSerializableValue, | |
| `\nTake 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: () => new Tuple(serializableStateInvariantMiddleware), | |
| }) | |
| store.dispatch({ type: ACTION_TYPE }) | |
| // Supplied 'isSerializable' considers all values serializable, hence | |
| // no error logging is expected: | |
| expect(consoleErrorSpy).not.toHaveBeenCalled() | |
| }) | |
| 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: () => new Tuple(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: () => | |
| new Tuple(createSerializableStateInvariantMiddleware()), | |
| }).dispatch({ type: 'test', meta: { arg: nonSerializableValue } }) | |
| expect(consoleErrorSpy).not.toHaveBeenCalled() | |
| }) | |
| it('default value can be overridden', () => { | |
| configureStore({ | |
| reducer, | |
| middleware: () => | |
| new Tuple( | |
| createSerializableStateInvariantMiddleware({ | |
| ignoredActionPaths: [], | |
| }), | |
| ), | |
| }).dispatch({ type: 'test', meta: { arg: nonSerializableValue } }) | |
| expect(consoleErrorSpy).toHaveBeenCalledOnce() | |
| expect(consoleErrorSpy).toHaveBeenLastCalledWith( | |
| `A non-serializable value was detected in an action, in the path: \`meta.arg\`. Value:`, | |
| nonSerializableValue, | |
| `\nTake a look at the logic that dispatched this action: `, | |
| { type: 'test', meta: { arg: nonSerializableValue } }, | |
| `\n(See https://redux.js.org/faq/actions#why-should-type-be-a-string-or-at-least-serializable-why-should-my-action-types-be-constants)`, | |
| `\n(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: () => | |
| new Tuple( | |
| createSerializableStateInvariantMiddleware({ | |
| ignoredActionPaths: ['payload', 'meta.arg'], | |
| }), | |
| ), | |
| }).dispatch({ | |
| type: 'test', | |
| payload: { arg: nonSerializableValue }, | |
| meta: { arg: nonSerializableValue }, | |
| }) | |
| expect(consoleErrorSpy).not.toHaveBeenCalled() | |
| }) | |
| it('can specify regexp', () => { | |
| configureStore({ | |
| reducer, | |
| middleware: () => | |
| new Tuple( | |
| createSerializableStateInvariantMiddleware({ | |
| ignoredActionPaths: [/^payload\..*$/], | |
| }), | |
| ), | |
| }).dispatch({ | |
| type: 'test', | |
| payload: { arg: nonSerializableValue }, | |
| }) | |
| expect(consoleErrorSpy).not.toHaveBeenCalled() | |
| }) | |
| }) | |
| it('allows ignoring actions entirely', () => { | |
| let numTimesCalled = 0 | |
| const serializableStateMiddleware = | |
| createSerializableStateInvariantMiddleware({ | |
| isSerializable: () => { | |
| numTimesCalled++ | |
| return true | |
| }, | |
| ignoreActions: true, | |
| }) | |
| const store = configureStore({ | |
| reducer: () => ({}), | |
| middleware: () => new Tuple(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: () => new Tuple(serializableStateInvariantMiddleware), | |
| }) | |
| store.dispatch({ type: ACTION_TYPE }) | |
| expect(consoleErrorSpy).toHaveBeenCalledOnce() | |
| // testSlice.b.d was not covered in ignoredPaths, so will still log the error | |
| expect(consoleErrorSpy).toHaveBeenLastCalledWith( | |
| `A non-serializable value was detected in the state, in the path: \`testSlice.b.d\`. Value:`, | |
| badValue, | |
| `\nTake 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: () => | |
| new Tuple( | |
| createSerializableStateInvariantMiddleware({ | |
| isSerializable: () => { | |
| numTimesCalled++ | |
| return true | |
| }, | |
| ignoreState: true, | |
| }), | |
| ), | |
| }) | |
| expect(numTimesCalled).toBe(0) | |
| store.dispatch({ type: 'test' }) | |
| expect(consoleErrorSpy).not.toHaveBeenCalled() | |
| // 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: () => | |
| new Tuple( | |
| 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: () => new Tuple(serializableStateInvariantMiddleware), | |
| }) | |
| store.dispatch({ | |
| type: 'SOME_ACTION', | |
| payload: new Array(10_000).fill({ value: 'more' }), | |
| }) | |
| expect(consoleWarnSpy).toHaveBeenCalledOnce() | |
| expect(consoleWarnSpy).toHaveBeenLastCalledWith( | |
| expect.stringMatching( | |
| /^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: () => new Tuple(serializableStateInvariantMiddleware), | |
| }) | |
| store.dispatch({ type: 'SOME_ACTION' }) | |
| expect(consoleErrorSpy).not.toHaveBeenCalled() | |
| }) | |
| 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: () => new Tuple(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) | |
| }) | |
| }) | |