1 | import type {
|
2 | Store,
|
3 | MiddlewareAPI,
|
4 | Dispatch,
|
5 | ImmutableStateInvariantMiddlewareOptions,
|
6 | } from '@reduxjs/toolkit'
|
7 | import {
|
8 | createImmutableStateInvariantMiddleware,
|
9 | isImmutableDefault,
|
10 | } from '@reduxjs/toolkit'
|
11 |
|
12 | import { trackForMutations } from '@internal/immutableStateInvariantMiddleware'
|
13 | import { mockConsole, createConsole, getLog } from 'console-testing-library'
|
14 |
|
15 | describe('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 |
|
181 | describe('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 | })
|