Skip to content

Commit 3855941

Browse files
committed
Add serializable state invariant middleware
1 parent a7333c0 commit 3855941

File tree

2 files changed

+224
-0
lines changed

2 files changed

+224
-0
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import isPlainObject from './isPlainObject'
2+
3+
function isPlain(val) {
4+
return (
5+
typeof val === 'undefined' ||
6+
typeof val === 'string' ||
7+
typeof val === 'boolean' ||
8+
typeof val === 'number' ||
9+
Array.isArray(val) ||
10+
isPlainObject(val)
11+
)
12+
}
13+
14+
const NON_SERIALIZABLE_STATE_MESSAGE = [
15+
'A non-serializable value was detected in the state, in the path: `%s`. Value: %o',
16+
'Take a look at the reducer(s) handling this action type: %s.',
17+
'(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)'
18+
].join('\n')
19+
20+
const NON_SERIALIZABLE_ACTION_MESSAGE = [
21+
'A non-serializable value was detected in an action, in the path: `%s`. Value: %o',
22+
'Take a look at the logic that dispatched this action: %o.',
23+
'(See https://redux.js.org/faq/actions#why-should-type-be-a-string-or-at-least-serializable-why-should-my-action-types-be-constants)'
24+
].join('\n')
25+
26+
export function findNonSerializableValue(
27+
obj,
28+
path = [],
29+
isSerializable = isPlain
30+
) {
31+
let foundNestedSerializable
32+
33+
if (!isSerializable(obj)) {
34+
return { keyPath: path.join('.') || '<root>', value: obj }
35+
}
36+
37+
for (let property in obj) {
38+
if (obj.hasOwnProperty(property)) {
39+
const nestedPath = path.concat(property)
40+
const nestedValue = obj[property]
41+
42+
if (!isSerializable(nestedValue)) {
43+
return { keyPath: nestedPath.join('.'), value: nestedValue }
44+
}
45+
46+
if (typeof nestedValue === 'object') {
47+
foundNestedSerializable = findNonSerializableValue(
48+
nestedValue,
49+
nestedPath
50+
)
51+
52+
if (foundNestedSerializable) {
53+
return foundNestedSerializable
54+
}
55+
}
56+
}
57+
}
58+
59+
return false
60+
}
61+
62+
export default function createSerializableStateInvariantMiddleware(
63+
options = {}
64+
) {
65+
const { isSerializable = isPlain, ignore } = options
66+
67+
return storeAPI => next => action => {
68+
const foundActionNonSerializableValue = findNonSerializableValue(action)
69+
70+
if (foundActionNonSerializableValue) {
71+
const { keyPath, value } = foundActionNonSerializableValue
72+
73+
console.error(NON_SERIALIZABLE_ACTION_MESSAGE, keyPath, value, action)
74+
}
75+
76+
const result = next(action)
77+
78+
const state = storeAPI.getState()
79+
80+
const foundStateNonSerializableValue = findNonSerializableValue(state)
81+
82+
if (foundStateNonSerializableValue) {
83+
const { keyPath, value } = foundStateNonSerializableValue
84+
85+
console.error(NON_SERIALIZABLE_STATE_MESSAGE, keyPath, value, action.type)
86+
}
87+
88+
return result
89+
}
90+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { configureStore } from './configureStore'
2+
3+
import createSerializableStateInvariantMiddleware, {
4+
findNonSerializableValue
5+
} from './serializableStateInvariantMiddleware'
6+
7+
describe('findNonSerializableValue', () => {
8+
it('Should return false if no matching values are found', () => {
9+
const obj = {
10+
a: 42,
11+
b: {
12+
b1: 'test'
13+
},
14+
c: [99, { d: 123 }]
15+
}
16+
17+
const result = findNonSerializableValue(obj)
18+
19+
expect(result).toBe(false)
20+
})
21+
22+
it('Should return a keypath and the value if it finds a non-serializable value', () => {
23+
function testFunction() {}
24+
25+
const obj = {
26+
a: 42,
27+
b: {
28+
b1: testFunction
29+
},
30+
c: [99, { d: 123 }]
31+
}
32+
33+
const result = findNonSerializableValue(obj)
34+
35+
expect(result).toEqual({ keyPath: 'b.b1', value: testFunction })
36+
})
37+
38+
it('Should return the first non-serializable value it finds', () => {
39+
const map = new Map()
40+
const symbol = Symbol.for('testSymbol')
41+
function testFunction() {}
42+
43+
const obj = {
44+
a: 42,
45+
b: {
46+
b1: 1
47+
},
48+
c: [99, { d: 123 }, map, symbol, 'test'],
49+
d: symbol
50+
}
51+
52+
const result = findNonSerializableValue(obj)
53+
54+
expect(result).toEqual({ keyPath: 'c.2', value: map })
55+
})
56+
57+
it('Should return a specific value if the root object is non-serializable', () => {
58+
const value = new Map()
59+
const result = findNonSerializableValue(value)
60+
61+
expect(result).toEqual({ keyPath: '<root>', value })
62+
})
63+
})
64+
65+
describe('serializableStateInvariantMiddleware', () => {
66+
beforeEach(() => {
67+
console.error = jest.fn()
68+
})
69+
70+
it('Should log an error when a non-serializable action is dispatched', () => {
71+
const reducer = (state = 0, action) => state + 1
72+
73+
const serializableStateInvariantMiddleware = createSerializableStateInvariantMiddleware()
74+
75+
const store = configureStore({
76+
reducer,
77+
middleware: [serializableStateInvariantMiddleware]
78+
})
79+
80+
const type = Symbol.for('SOME_CONSTANT')
81+
const dispatchedAction = { type }
82+
83+
store.dispatch(dispatchedAction)
84+
85+
expect(console.error).toHaveBeenCalled()
86+
87+
const [message, keyPath, value, action] = console.error.mock.calls[0]
88+
expect(message).toContain('detected in an action, in the path: `%s`')
89+
expect(keyPath).toBe('type')
90+
expect(value).toBe(type)
91+
expect(action).toBe(dispatchedAction)
92+
})
93+
94+
it('Should log an error when a non-serializable value is in state', () => {
95+
const ACTION_TYPE = 'TEST_ACTION'
96+
97+
const initialState = {
98+
a: 0
99+
}
100+
101+
const badValue = new Map()
102+
103+
const reducer = (state = initialState, action) => {
104+
switch (action.type) {
105+
case ACTION_TYPE: {
106+
return {
107+
a: badValue
108+
}
109+
}
110+
default:
111+
return state
112+
}
113+
}
114+
115+
const serializableStateInvariantMiddleware = createSerializableStateInvariantMiddleware()
116+
117+
const store = configureStore({
118+
reducer: {
119+
testSlice: reducer
120+
},
121+
middleware: [serializableStateInvariantMiddleware]
122+
})
123+
124+
store.dispatch({ type: ACTION_TYPE })
125+
126+
expect(console.error).toHaveBeenCalled()
127+
128+
const [message, keyPath, value, actionType] = console.error.mock.calls[0]
129+
expect(message).toContain('detected in the state, in the path: `%s`')
130+
expect(keyPath).toBe('testSlice.a')
131+
expect(value).toBe(badValue)
132+
expect(actionType).toBe(ACTION_TYPE)
133+
})
134+
})

0 commit comments

Comments
 (0)