UNPKG

16.8 kBPlain TextView Raw
1import {
2 mockConsole,
3 createConsole,
4 getLog,
5} from 'console-testing-library/pure'
6import type { Reducer } from '@reduxjs/toolkit'
7import {
8 configureStore,
9 createSerializableStateInvariantMiddleware,
10 findNonSerializableValue,
11 isPlain,
12} from '@reduxjs/toolkit'
13
14// Mocking console
15let restore = () => {}
16beforeEach(() => {
17 restore = mockConsole(createConsole())
18})
19afterEach(() => restore())
20
21describe('findNonSerializableValue', () => {
22 it('Should return false if no matching values are found', () => {
23 const obj = {
24 a: 42,
25 b: {
26 b1: 'test',
27 },
28 c: [99, { d: 123 }],
29 }
30
31 const result = findNonSerializableValue(obj)
32
33 expect(result).toBe(false)
34 })
35
36 it('Should return a keypath and the value if it finds a non-serializable value', () => {
37 function testFunction() {}
38
39 const obj = {
40 a: 42,
41 b: {
42 b1: testFunction,
43 },
44 c: [99, { d: 123 }],
45 }
46
47 const result = findNonSerializableValue(obj)
48
49 expect(result).toEqual({ keyPath: 'b.b1', value: testFunction })
50 })
51
52 it('Should return the first non-serializable value it finds', () => {
53 const map = new Map()
54 const symbol = Symbol.for('testSymbol')
55
56 const obj = {
57 a: 42,
58 b: {
59 b1: 1,
60 },
61 c: [99, { d: 123 }, map, symbol, 'test'],
62 d: symbol,
63 }
64
65 const result = findNonSerializableValue(obj)
66
67 expect(result).toEqual({ keyPath: 'c.2', value: map })
68 })
69
70 it('Should return a specific value if the root object is non-serializable', () => {
71 const value = new Map()
72 const result = findNonSerializableValue(value)
73
74 expect(result).toEqual({ keyPath: '<root>', value })
75 })
76
77 it('Should accept null as a valid value', () => {
78 const obj = {
79 a: 42,
80 b: {
81 b1: 1,
82 },
83 c: null,
84 }
85
86 const result = findNonSerializableValue(obj)
87
88 expect(result).toEqual(false)
89 })
90})
91
92describe('serializableStateInvariantMiddleware', () => {
93 it('Should log an error when a non-serializable action is dispatched', () => {
94 const reducer: Reducer = (state = 0, _action) => state + 1
95
96 const serializableStateInvariantMiddleware =
97 createSerializableStateInvariantMiddleware()
98
99 const store = configureStore({
100 reducer,
101 middleware: [serializableStateInvariantMiddleware],
102 })
103
104 const type = Symbol.for('SOME_CONSTANT')
105 const dispatchedAction = { type }
106
107 store.dispatch(dispatchedAction)
108
109 expect(getLog().log).toMatchInlineSnapshot(`
110 "A non-serializable value was detected in an action, in the path: \`type\`. Value: Symbol(SOME_CONSTANT)
111 Take a look at the logic that dispatched this action: Object {
112 \\"type\\": Symbol(SOME_CONSTANT),
113 }
114 (See https://redux.js.org/faq/actions#why-should-type-be-a-string-or-at-least-serializable-why-should-my-action-types-be-constants)
115 (To allow non-serializable values see: https://redux-toolkit.js.org/usage/usage-guide#working-with-non-serializable-data)"
116 `)
117 })
118
119 it('Should log an error when a non-serializable value is in state', () => {
120 const ACTION_TYPE = 'TEST_ACTION'
121
122 const initialState = {
123 a: 0,
124 }
125
126 const badValue = new Map()
127
128 const reducer: Reducer = (state = initialState, action) => {
129 switch (action.type) {
130 case ACTION_TYPE: {
131 return {
132 a: badValue,
133 }
134 }
135 default:
136 return state
137 }
138 }
139
140 const serializableStateInvariantMiddleware =
141 createSerializableStateInvariantMiddleware()
142
143 const store = configureStore({
144 reducer: {
145 testSlice: reducer,
146 },
147 middleware: [serializableStateInvariantMiddleware],
148 })
149
150 store.dispatch({ type: ACTION_TYPE })
151
152 expect(getLog().log).toMatchInlineSnapshot(`
153 "A non-serializable value was detected in the state, in the path: \`testSlice.a\`. Value: Map {}
154 Take a look at the reducer(s) handling this action type: TEST_ACTION.
155 (See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)"
156 `)
157 })
158
159 describe('consumer tolerated structures', () => {
160 const nonSerializableValue = new Map()
161
162 const nestedSerializableObjectWithBadValue = {
163 isSerializable: true,
164 entries: (): [string, any][] => [
165 ['good-string', 'Good!'],
166 ['good-number', 1337],
167 ['bad-map-instance', nonSerializableValue],
168 ],
169 }
170
171 const serializableObject = {
172 isSerializable: true,
173 entries: (): [string, any][] => [
174 ['first', 1],
175 ['second', 'B!'],
176 ['third', nestedSerializableObjectWithBadValue],
177 ],
178 }
179
180 it('Should log an error when a non-serializable value is nested in state', () => {
181 const ACTION_TYPE = 'TEST_ACTION'
182
183 const initialState = {
184 a: 0,
185 }
186
187 const reducer: Reducer = (state = initialState, action) => {
188 switch (action.type) {
189 case ACTION_TYPE: {
190 return {
191 a: serializableObject,
192 }
193 }
194 default:
195 return state
196 }
197 }
198
199 // use default options
200 const serializableStateInvariantMiddleware =
201 createSerializableStateInvariantMiddleware()
202
203 const store = configureStore({
204 reducer: {
205 testSlice: reducer,
206 },
207 middleware: [serializableStateInvariantMiddleware],
208 })
209
210 store.dispatch({ type: ACTION_TYPE })
211
212 // since default options are used, the `entries` function in `serializableObject` will cause the error
213 expect(getLog().log).toMatchInlineSnapshot(`
214 "A non-serializable value was detected in the state, in the path: \`testSlice.a.entries\`. Value: [Function entries]
215 Take a look at the reducer(s) handling this action type: TEST_ACTION.
216 (See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)"
217 `)
218 })
219
220 it('Should use consumer supplied isSerializable and getEntries options to tolerate certain structures', () => {
221 const ACTION_TYPE = 'TEST_ACTION'
222
223 const initialState = {
224 a: 0,
225 }
226
227 const isSerializable = (val: any): boolean =>
228 val.isSerializable || isPlain(val)
229 const getEntries = (val: any): [string, any][] =>
230 val.isSerializable ? val.entries() : Object.entries(val)
231
232 const reducer: Reducer = (state = initialState, action) => {
233 switch (action.type) {
234 case ACTION_TYPE: {
235 return {
236 a: serializableObject,
237 }
238 }
239 default:
240 return state
241 }
242 }
243
244 const serializableStateInvariantMiddleware =
245 createSerializableStateInvariantMiddleware({
246 isSerializable,
247 getEntries,
248 })
249
250 const store = configureStore({
251 reducer: {
252 testSlice: reducer,
253 },
254 middleware: [serializableStateInvariantMiddleware],
255 })
256
257 store.dispatch({ type: ACTION_TYPE })
258
259 // error reported is from a nested class instance, rather than the `entries` function `serializableObject`
260 expect(getLog().log).toMatchInlineSnapshot(`
261 "A non-serializable value was detected in the state, in the path: \`testSlice.a.third.bad-map-instance\`. Value: Map {}
262 Take a look at the reducer(s) handling this action type: TEST_ACTION.
263 (See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)"
264 `)
265 })
266 })
267
268 it('Should use the supplied isSerializable function to determine serializability', () => {
269 const ACTION_TYPE = 'TEST_ACTION'
270
271 const initialState = {
272 a: 0,
273 }
274
275 const badValue = new Map()
276
277 const reducer: Reducer = (state = initialState, action) => {
278 switch (action.type) {
279 case ACTION_TYPE: {
280 return {
281 a: badValue,
282 }
283 }
284 default:
285 return state
286 }
287 }
288
289 const serializableStateInvariantMiddleware =
290 createSerializableStateInvariantMiddleware({
291 isSerializable: () => true,
292 })
293
294 const store = configureStore({
295 reducer: {
296 testSlice: reducer,
297 },
298 middleware: [serializableStateInvariantMiddleware],
299 })
300
301 store.dispatch({ type: ACTION_TYPE })
302
303 // Supplied 'isSerializable' considers all values serializable, hence
304 // no error logging is expected:
305 expect(getLog().log).toBe('')
306 })
307
308 it('should not check serializability for ignored action types', () => {
309 let numTimesCalled = 0
310
311 const serializableStateMiddleware =
312 createSerializableStateInvariantMiddleware({
313 isSerializable: () => {
314 numTimesCalled++
315 return true
316 },
317 ignoredActions: ['IGNORE_ME'],
318 })
319
320 const store = configureStore({
321 reducer: () => ({}),
322 middleware: [serializableStateMiddleware],
323 })
324
325 expect(numTimesCalled).toBe(0)
326
327 store.dispatch({ type: 'IGNORE_ME' })
328
329 // The state check only calls `isSerializable` once
330 expect(numTimesCalled).toBe(1)
331
332 store.dispatch({ type: 'ANY_OTHER_ACTION' })
333
334 // Action checks call `isSerializable` 2+ times when enabled
335 expect(numTimesCalled).toBeGreaterThanOrEqual(3)
336 })
337
338 describe('ignored action paths', () => {
339 function reducer() {
340 return 0
341 }
342 const nonSerializableValue = new Map()
343
344 it('default value: meta.arg', () => {
345 configureStore({
346 reducer,
347 middleware: [createSerializableStateInvariantMiddleware()],
348 }).dispatch({ type: 'test', meta: { arg: nonSerializableValue } })
349
350 expect(getLog().log).toMatchInlineSnapshot(`""`)
351 })
352
353 it('default value can be overridden', () => {
354 configureStore({
355 reducer,
356 middleware: [
357 createSerializableStateInvariantMiddleware({
358 ignoredActionPaths: [],
359 }),
360 ],
361 }).dispatch({ type: 'test', meta: { arg: nonSerializableValue } })
362
363 expect(getLog().log).toMatchInlineSnapshot(`
364 "A non-serializable value was detected in an action, in the path: \`meta.arg\`. Value: Map {}
365 Take a look at the logic that dispatched this action: Object {
366 \\"meta\\": Object {
367 \\"arg\\": Map {},
368 },
369 \\"type\\": \\"test\\",
370 }
371 (See https://redux.js.org/faq/actions#why-should-type-be-a-string-or-at-least-serializable-why-should-my-action-types-be-constants)
372 (To allow non-serializable values see: https://redux-toolkit.js.org/usage/usage-guide#working-with-non-serializable-data)"
373 `)
374 })
375
376 it('can specify (multiple) different values', () => {
377 configureStore({
378 reducer,
379 middleware: [
380 createSerializableStateInvariantMiddleware({
381 ignoredActionPaths: ['payload', 'meta.arg'],
382 }),
383 ],
384 }).dispatch({
385 type: 'test',
386 payload: { arg: nonSerializableValue },
387 meta: { arg: nonSerializableValue },
388 })
389
390 expect(getLog().log).toMatchInlineSnapshot(`""`)
391 })
392 })
393
394 it('allows ignoring actions entirely', () => {
395 let numTimesCalled = 0
396
397 const serializableStateMiddleware =
398 createSerializableStateInvariantMiddleware({
399 isSerializable: () => {
400 numTimesCalled++
401 return true
402 },
403 ignoreActions: true,
404 })
405
406 const store = configureStore({
407 reducer: () => ({}),
408 middleware: [serializableStateMiddleware],
409 })
410
411 expect(numTimesCalled).toBe(0)
412
413 store.dispatch({ type: 'THIS_DOESNT_MATTER' })
414
415 // `isSerializable` is called once for a state check
416 expect(numTimesCalled).toBe(1)
417
418 store.dispatch({ type: 'THIS_DOESNT_MATTER_AGAIN' })
419
420 expect(numTimesCalled).toBe(2)
421 })
422
423 it('should not check serializability for ignored slice names', () => {
424 const ACTION_TYPE = 'TEST_ACTION'
425
426 const initialState = {
427 a: 0,
428 }
429
430 const badValue = new Map()
431
432 const reducer: Reducer = (state = initialState, action) => {
433 switch (action.type) {
434 case ACTION_TYPE: {
435 return {
436 a: badValue,
437 b: {
438 c: badValue,
439 d: badValue,
440 },
441 e: { f: badValue },
442 }
443 }
444 default:
445 return state
446 }
447 }
448
449 const serializableStateInvariantMiddleware =
450 createSerializableStateInvariantMiddleware({
451 ignoredPaths: [
452 // Test for ignoring a single value
453 'testSlice.a',
454 // Test for ignoring a single nested value
455 'testSlice.b.c',
456 // Test for ignoring an object and its children
457 'testSlice.e',
458 ],
459 })
460
461 const store = configureStore({
462 reducer: {
463 testSlice: reducer,
464 },
465 middleware: [serializableStateInvariantMiddleware],
466 })
467
468 store.dispatch({ type: ACTION_TYPE })
469
470 // testSlice.b.d was not covered in ignoredPaths, so will still log the error
471 expect(getLog().log).toMatchInlineSnapshot(`
472 "A non-serializable value was detected in the state, in the path: \`testSlice.b.d\`. Value: Map {}
473 Take a look at the reducer(s) handling this action type: TEST_ACTION.
474 (See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)"
475 `)
476 })
477
478 it('allows ignoring state entirely', () => {
479 const badValue = new Map()
480 let numTimesCalled = 0
481 const reducer = () => badValue
482 const store = configureStore({
483 reducer,
484 middleware: [
485 createSerializableStateInvariantMiddleware({
486 isSerializable: () => {
487 numTimesCalled++
488 return true
489 },
490 ignoreState: true,
491 }),
492 ],
493 })
494
495 expect(numTimesCalled).toBe(0)
496
497 store.dispatch({ type: 'test' })
498
499 expect(getLog().log).toMatchInlineSnapshot(`""`)
500
501 // Should be called twice for the action - there is an initial check for early returns, then a second and potentially 3rd for nested properties
502 expect(numTimesCalled).toBe(2)
503 })
504
505 it('never calls isSerializable if both ignoreState and ignoreActions are true', () => {
506 const badValue = new Map()
507 let numTimesCalled = 0
508 const reducer = () => badValue
509 const store = configureStore({
510 reducer,
511 middleware: [
512 createSerializableStateInvariantMiddleware({
513 isSerializable: () => {
514 numTimesCalled++
515 return true
516 },
517 ignoreState: true,
518 ignoreActions: true,
519 }),
520 ],
521 })
522
523 expect(numTimesCalled).toBe(0)
524
525 store.dispatch({ type: 'TEST', payload: new Date() })
526 store.dispatch({ type: 'OTHER_THING' })
527
528 expect(numTimesCalled).toBe(0)
529 })
530
531 it('Should print a warning if execution takes too long', () => {
532 const reducer: Reducer = (state = 42, action) => {
533 return state
534 }
535
536 const serializableStateInvariantMiddleware =
537 createSerializableStateInvariantMiddleware({ warnAfter: 4 })
538
539 const store = configureStore({
540 reducer: {
541 testSlice: reducer,
542 },
543 middleware: [serializableStateInvariantMiddleware],
544 })
545
546 store.dispatch({
547 type: 'SOME_ACTION',
548 payload: new Array(10000).fill({ value: 'more' }),
549 })
550 expect(getLog().log).toMatch(
551 /^SerializableStateInvariantMiddleware took \d*ms, which is more than the warning threshold of 4ms./
552 )
553 })
554
555 it('Should not print a warning if "reducer" takes too long', () => {
556 const reducer: Reducer = (state = 42, action) => {
557 const started = Date.now()
558 while (Date.now() - started < 8) {}
559 return state
560 }
561
562 const serializableStateInvariantMiddleware =
563 createSerializableStateInvariantMiddleware({ warnAfter: 4 })
564
565 const store = configureStore({
566 reducer: {
567 testSlice: reducer,
568 },
569 middleware: [serializableStateInvariantMiddleware],
570 })
571
572 store.dispatch({ type: 'SOME_ACTION' })
573 expect(getLog().log).toMatch('')
574 })
575})