UNPKG

13 kBPlain TextView Raw
1import type {
2 Store,
3 MiddlewareAPI,
4 Dispatch,
5 ImmutableStateInvariantMiddlewareOptions,
6} from '@reduxjs/toolkit'
7import {
8 createImmutableStateInvariantMiddleware,
9 isImmutableDefault,
10} from '@reduxjs/toolkit'
11
12import { trackForMutations } from '@internal/immutableStateInvariantMiddleware'
13import { mockConsole, createConsole, getLog } from 'console-testing-library'
14
15describe('createImmutableStateInvariantMiddleware', () => {
16 let state: { foo: { bar: number[]; baz: string } }
17 const getState: Store['getState'] = () => state
18
19 function middleware(options: ImmutableStateInvariantMiddlewareOptions = {}) {
20 return createImmutableStateInvariantMiddleware(options)({
21 getState,
22 } as MiddlewareAPI)
23 }
24
25 beforeEach(() => {
26 state = { foo: { bar: [2, 3, 4], baz: 'baz' } }
27 })
28
29 it('sends the action through the middleware chain', () => {
30 const next: Dispatch = (action) => ({ ...action, returned: true })
31 const dispatch = middleware()(next)
32
33 expect(dispatch({ type: 'SOME_ACTION' })).toEqual({
34 type: 'SOME_ACTION',
35 returned: true,
36 })
37 })
38
39 it('throws if mutating inside the dispatch', () => {
40 const next: Dispatch = (action) => {
41 state.foo.bar.push(5)
42 return action
43 }
44
45 const dispatch = middleware()(next)
46
47 expect(() => {
48 dispatch({ type: 'SOME_ACTION' })
49 }).toThrow(new RegExp('foo\\.bar\\.3'))
50 })
51
52 it('throws if mutating between dispatches', () => {
53 const next: Dispatch = (action) => action
54
55 const dispatch = middleware()(next)
56
57 dispatch({ type: 'SOME_ACTION' })
58 state.foo.bar.push(5)
59 expect(() => {
60 dispatch({ type: 'SOME_OTHER_ACTION' })
61 }).toThrow(new RegExp('foo\\.bar\\.3'))
62 })
63
64 it('does not throw if not mutating inside the dispatch', () => {
65 const next: Dispatch = (action) => {
66 state = { ...state, foo: { ...state.foo, baz: 'changed!' } }
67 return action
68 }
69
70 const dispatch = middleware()(next)
71
72 expect(() => {
73 dispatch({ type: 'SOME_ACTION' })
74 }).not.toThrow()
75 })
76
77 it('does not throw if not mutating between dispatches', () => {
78 const next: Dispatch = (action) => action
79
80 const dispatch = middleware()(next)
81
82 dispatch({ type: 'SOME_ACTION' })
83 state = { ...state, foo: { ...state.foo, baz: 'changed!' } }
84 expect(() => {
85 dispatch({ type: 'SOME_OTHER_ACTION' })
86 }).not.toThrow()
87 })
88
89 it('works correctly with circular references', () => {
90 const next: Dispatch = (action) => action
91
92 const dispatch = middleware()(next)
93
94 let x: any = {}
95 let y: any = {}
96 x.y = y
97 y.x = x
98
99 expect(() => {
100 dispatch({ type: 'SOME_ACTION', x })
101 }).not.toThrow()
102 })
103
104 it('respects "isImmutable" option', function () {
105 const isImmutable = (value: any) => true
106 const next: Dispatch = (action) => {
107 state.foo.bar.push(5)
108 return action
109 }
110
111 const dispatch = middleware({ isImmutable })(next)
112
113 expect(() => {
114 dispatch({ type: 'SOME_ACTION' })
115 }).not.toThrow()
116 })
117
118 it('respects "ignoredPaths" option', () => {
119 const next: Dispatch = (action) => {
120 state.foo.bar.push(5)
121 return action
122 }
123
124 const dispatch = middleware({ ignoredPaths: ['foo.bar'] })(next)
125
126 expect(() => {
127 dispatch({ type: 'SOME_ACTION' })
128 }).not.toThrow()
129 })
130
131 it('alias "ignore" to "ignoredPath" and respects option', () => {
132 const next: Dispatch = (action) => {
133 state.foo.bar.push(5)
134 return action
135 }
136
137 const dispatch = middleware({ ignore: ['foo.bar'] })(next)
138
139 expect(() => {
140 dispatch({ type: 'SOME_ACTION' })
141 }).not.toThrow()
142 })
143
144 it('Should print a warning if execution takes too long', () => {
145 state.foo.bar = new Array(10000).fill({ value: 'more' })
146
147 const next: Dispatch = (action) => action
148
149 const dispatch = middleware({ warnAfter: 4 })(next)
150
151 const restore = mockConsole(createConsole())
152 try {
153 dispatch({ type: 'SOME_ACTION' })
154 expect(getLog().log).toMatch(
155 /^ImmutableStateInvariantMiddleware took \d*ms, which is more than the warning threshold of 4ms./
156 )
157 } finally {
158 restore()
159 }
160 })
161
162 it('Should not print a warning if "next" takes too long', () => {
163 const next: Dispatch = (action) => {
164 const started = Date.now()
165 while (Date.now() - started < 8) {}
166 return action
167 }
168
169 const dispatch = middleware({ warnAfter: 4 })(next)
170
171 const restore = mockConsole(createConsole())
172 try {
173 dispatch({ type: 'SOME_ACTION' })
174 expect(getLog().log).toEqual('')
175 } finally {
176 restore()
177 }
178 })
179})
180
181describe('trackForMutations', () => {
182 function testCasesForMutation(spec: any) {
183 it('returns true and the mutated path', () => {
184 const state = spec.getState()
185 const options = spec.middlewareOptions || {}
186 const { isImmutable = isImmutableDefault, ignoredPaths } = options
187 const tracker = trackForMutations(isImmutable, ignoredPaths, state)
188 const newState = spec.fn(state)
189
190 expect(tracker.detectMutations()).toEqual({
191 wasMutated: true,
192 path: spec.path.join('.'),
193 })
194 })
195 }
196
197 function testCasesForNonMutation(spec: any) {
198 it('returns false', () => {
199 const state = spec.getState()
200 const options = spec.middlewareOptions || {}
201 const { isImmutable = isImmutableDefault, ignoredPaths } = options
202 const tracker = trackForMutations(isImmutable, ignoredPaths, state)
203 const newState = spec.fn(state)
204
205 expect(tracker.detectMutations()).toEqual({ wasMutated: false })
206 })
207 }
208
209 interface TestConfig {
210 getState: Store['getState']
211 fn: (s: any) => typeof s | object
212 middlewareOptions?: ImmutableStateInvariantMiddlewareOptions
213 path?: string[]
214 }
215
216 const mutations: Record<string, TestConfig> = {
217 'adding to nested array': {
218 getState: () => ({
219 foo: {
220 bar: [2, 3, 4],
221 baz: 'baz',
222 },
223 stuff: [],
224 }),
225 fn: (s) => {
226 s.foo.bar.push(5)
227 return s
228 },
229 path: ['foo', 'bar', '3'],
230 },
231 'adding to nested array and setting new root object': {
232 getState: () => ({
233 foo: {
234 bar: [2, 3, 4],
235 baz: 'baz',
236 },
237 stuff: [],
238 }),
239 fn: (s) => {
240 s.foo.bar.push(5)
241 return { ...s }
242 },
243 path: ['foo', 'bar', '3'],
244 },
245 'changing nested string': {
246 getState: () => ({
247 foo: {
248 bar: [2, 3, 4],
249 baz: 'baz',
250 },
251 stuff: [],
252 }),
253 fn: (s) => {
254 s.foo.baz = 'changed!'
255 return s
256 },
257 path: ['foo', 'baz'],
258 },
259 'removing nested state': {
260 getState: () => ({
261 foo: {
262 bar: [2, 3, 4],
263 baz: 'baz',
264 },
265 stuff: [],
266 }),
267 fn: (s) => {
268 delete s.foo
269 return s
270 },
271 path: ['foo'],
272 },
273 'adding to array': {
274 getState: () => ({
275 foo: {
276 bar: [2, 3, 4],
277 baz: 'baz',
278 },
279 stuff: [],
280 }),
281 fn: (s) => {
282 s.stuff.push(1)
283 return s
284 },
285 path: ['stuff', '0'],
286 },
287 'adding object to array': {
288 getState: () => ({
289 stuff: [],
290 }),
291 fn: (s) => {
292 s.stuff.push({ foo: 1, bar: 2 })
293 return s
294 },
295 path: ['stuff', '0'],
296 },
297 'mutating previous state and returning new state': {
298 getState: () => ({ counter: 0 }),
299 fn: (s) => {
300 s.mutation = true
301 return { ...s, counter: s.counter + 1 }
302 },
303 path: ['mutation'],
304 },
305 'mutating previous state with non immutable type and returning new state': {
306 getState: () => ({ counter: 0 }),
307 fn: (s) => {
308 s.mutation = [1, 2, 3]
309 return { ...s, counter: s.counter + 1 }
310 },
311 path: ['mutation'],
312 },
313 'mutating previous state with non immutable type and returning new state without that property':
314 {
315 getState: () => ({ counter: 0 }),
316 fn: (s) => {
317 s.mutation = [1, 2, 3]
318 return { counter: s.counter + 1 }
319 },
320 path: ['mutation'],
321 },
322 'mutating previous state with non immutable type and returning new simple state':
323 {
324 getState: () => ({ counter: 0 }),
325 fn: (s) => {
326 s.mutation = [1, 2, 3]
327 return 1
328 },
329 path: ['mutation'],
330 },
331 'mutating previous state by deleting property and returning new state without that property':
332 {
333 getState: () => ({ counter: 0, toBeDeleted: true }),
334 fn: (s) => {
335 delete s.toBeDeleted
336 return { counter: s.counter + 1 }
337 },
338 path: ['toBeDeleted'],
339 },
340 'mutating previous state by deleting nested property': {
341 getState: () => ({ nested: { counter: 0, toBeDeleted: true }, foo: 1 }),
342 fn: (s) => {
343 delete s.nested.toBeDeleted
344 return { nested: { counter: s.counter + 1 } }
345 },
346 path: ['nested', 'toBeDeleted'],
347 },
348 'update reference': {
349 getState: () => ({ foo: {} }),
350 fn: (s) => {
351 s.foo = {}
352 return s
353 },
354 path: ['foo'],
355 },
356 'cannot ignore root state': {
357 getState: () => ({ foo: {} }),
358 fn: (s) => {
359 s.foo = {}
360 return s
361 },
362 middlewareOptions: {
363 ignoredPaths: [''],
364 },
365 path: ['foo'],
366 },
367 'catching state mutation in non-ignored branch': {
368 getState: () => ({
369 foo: {
370 bar: [1, 2],
371 },
372 boo: {
373 yah: [1, 2],
374 },
375 }),
376 fn: (s) => {
377 s.foo.bar.push(3)
378 s.boo.yah.push(3)
379 return s
380 },
381 middlewareOptions: {
382 ignoredPaths: ['foo'],
383 },
384 path: ['boo', 'yah', '2'],
385 },
386 }
387
388 Object.keys(mutations).forEach((mutationDesc) => {
389 describe(mutationDesc, () => {
390 testCasesForMutation(mutations[mutationDesc])
391 })
392 })
393
394 const nonMutations: Record<string, TestConfig> = {
395 'not doing anything': {
396 getState: () => ({ a: 1, b: 2 }),
397 fn: (s) => s,
398 },
399 'from undefined to something': {
400 getState: () => undefined,
401 fn: (s) => ({ foo: 'bar' }),
402 },
403 'returning same state': {
404 getState: () => ({
405 foo: {
406 bar: [2, 3, 4],
407 baz: 'baz',
408 },
409 stuff: [],
410 }),
411 fn: (s) => s,
412 },
413 'returning a new state object with nested new string': {
414 getState: () => ({
415 foo: {
416 bar: [2, 3, 4],
417 baz: 'baz',
418 },
419 stuff: [],
420 }),
421 fn: (s) => {
422 return { ...s, foo: { ...s.foo, baz: 'changed!' } }
423 },
424 },
425 'returning a new state object with nested new array': {
426 getState: () => ({
427 foo: {
428 bar: [2, 3, 4],
429 baz: 'baz',
430 },
431 stuff: [],
432 }),
433 fn: (s) => {
434 return { ...s, foo: { ...s.foo, bar: [...s.foo.bar, 5] } }
435 },
436 },
437 'removing nested state': {
438 getState: () => ({
439 foo: {
440 bar: [2, 3, 4],
441 baz: 'baz',
442 },
443 stuff: [],
444 }),
445 fn: (s) => {
446 return { ...s, foo: {} }
447 },
448 },
449 'having a NaN in the state': {
450 getState: () => ({ a: NaN, b: Number.NaN }),
451 fn: (s) => s,
452 },
453 'ignoring branches from mutation detection': {
454 getState: () => ({
455 foo: {
456 bar: 'bar',
457 },
458 }),
459 fn: (s) => {
460 s.foo.bar = 'baz'
461 return s
462 },
463 middlewareOptions: {
464 ignoredPaths: ['foo'],
465 },
466 },
467 'ignoring nested branches from mutation detection': {
468 getState: () => ({
469 foo: {
470 bar: [1, 2],
471 boo: {
472 yah: [1, 2],
473 },
474 },
475 }),
476 fn: (s) => {
477 s.foo.bar.push(3)
478 s.foo.boo.yah.push(3)
479 return s
480 },
481 middlewareOptions: {
482 ignoredPaths: ['foo.bar', 'foo.boo.yah'],
483 },
484 },
485 'ignoring nested array indices from mutation detection': {
486 getState: () => ({
487 stuff: [{ a: 1 }, { a: 2 }],
488 }),
489 fn: (s) => {
490 s.stuff[1].a = 3
491 return s
492 },
493 middlewareOptions: {
494 ignoredPaths: ['stuff.1'],
495 },
496 },
497 }
498
499 Object.keys(nonMutations).forEach((nonMutationDesc) => {
500 describe(nonMutationDesc, () => {
501 testCasesForNonMutation(nonMutations[nonMutationDesc])
502 })
503 })
504})