1 | import type {
|
2 | CaseReducer,
|
3 | PayloadAction,
|
4 | Draft,
|
5 | Reducer,
|
6 | AnyAction,
|
7 | } from '@reduxjs/toolkit'
|
8 | import { createReducer, createAction, createNextState } from '@reduxjs/toolkit'
|
9 |
|
10 | interface Todo {
|
11 | text: string
|
12 | completed?: boolean
|
13 | }
|
14 |
|
15 | interface AddTodoPayload {
|
16 | newTodo: Todo
|
17 | }
|
18 |
|
19 | interface ToggleTodoPayload {
|
20 | index: number
|
21 | }
|
22 |
|
23 | type TodoState = Todo[]
|
24 | type TodosReducer = Reducer<TodoState, PayloadAction<any>>
|
25 | type AddTodoReducer = CaseReducer<TodoState, PayloadAction<AddTodoPayload>>
|
26 |
|
27 | type ToggleTodoReducer = CaseReducer<
|
28 | TodoState,
|
29 | PayloadAction<ToggleTodoPayload>
|
30 | >
|
31 |
|
32 | describe('createReducer', () => {
|
33 | describe('given impure reducers with immer', () => {
|
34 | const addTodo: AddTodoReducer = (state, action) => {
|
35 | const { newTodo } = action.payload
|
36 |
|
37 |
|
38 | state.push({ ...newTodo, completed: false })
|
39 | }
|
40 |
|
41 | const toggleTodo: ToggleTodoReducer = (state, action) => {
|
42 | const { index } = action.payload
|
43 |
|
44 | const todo = state[index]
|
45 |
|
46 | todo.completed = !todo.completed
|
47 | }
|
48 |
|
49 | const todosReducer = createReducer([] as TodoState, {
|
50 | ADD_TODO: addTodo,
|
51 | TOGGLE_TODO: toggleTodo,
|
52 | })
|
53 |
|
54 | behavesLikeReducer(todosReducer)
|
55 | })
|
56 |
|
57 | describe('Immer in a production environment', () => {
|
58 | let originalNodeEnv = process.env.NODE_ENV
|
59 |
|
60 | beforeEach(() => {
|
61 | jest.resetModules()
|
62 | process.env.NODE_ENV = 'production'
|
63 | })
|
64 |
|
65 | afterEach(() => {
|
66 | process.env.NODE_ENV = originalNodeEnv
|
67 | })
|
68 |
|
69 | test('Freezes data in production', () => {
|
70 | const { createReducer } = require('../createReducer')
|
71 | const addTodo: AddTodoReducer = (state, action) => {
|
72 | const { newTodo } = action.payload
|
73 | state.push({ ...newTodo, completed: false })
|
74 | }
|
75 |
|
76 | const toggleTodo: ToggleTodoReducer = (state, action) => {
|
77 | const { index } = action.payload
|
78 | const todo = state[index]
|
79 | todo.completed = !todo.completed
|
80 | }
|
81 |
|
82 | const todosReducer = createReducer([] as TodoState, {
|
83 | ADD_TODO: addTodo,
|
84 | TOGGLE_TODO: toggleTodo,
|
85 | })
|
86 |
|
87 | const result = todosReducer([], {
|
88 | type: 'ADD_TODO',
|
89 | payload: { text: 'Buy milk' },
|
90 | })
|
91 |
|
92 | const mutateStateOutsideReducer = () => (result[0].text = 'edited')
|
93 | expect(mutateStateOutsideReducer).toThrowError(
|
94 | 'Cannot add property text, object is not extensible'
|
95 | )
|
96 | })
|
97 |
|
98 | test('Freezes initial state', () => {
|
99 | const initialState = [{ text: 'Buy milk' }]
|
100 | const todosReducer = createReducer(initialState, {})
|
101 |
|
102 | const mutateStateOutsideReducer = () => (initialState[0].text = 'edited')
|
103 | expect(mutateStateOutsideReducer).toThrowError(
|
104 | /Cannot assign to read only property/
|
105 | )
|
106 | })
|
107 | })
|
108 |
|
109 | describe('given pure reducers with immutable updates', () => {
|
110 | const addTodo: AddTodoReducer = (state, action) => {
|
111 | const { newTodo } = action.payload
|
112 |
|
113 |
|
114 | return state.concat({ ...newTodo, completed: false })
|
115 | }
|
116 |
|
117 | const toggleTodo: ToggleTodoReducer = (state, action) => {
|
118 | const { index } = action.payload
|
119 |
|
120 |
|
121 | return state.map((todo, i) => {
|
122 | if (i !== index) return todo
|
123 | return { ...todo, completed: !todo.completed }
|
124 | })
|
125 | }
|
126 |
|
127 | const todosReducer = createReducer([] as TodoState, {
|
128 | ADD_TODO: addTodo,
|
129 | TOGGLE_TODO: toggleTodo,
|
130 | })
|
131 |
|
132 | behavesLikeReducer(todosReducer)
|
133 | })
|
134 |
|
135 | describe('given draft state from immer', () => {
|
136 | const addTodo: AddTodoReducer = (state, action) => {
|
137 | const { newTodo } = action.payload
|
138 |
|
139 |
|
140 | state.push({ ...newTodo, completed: false })
|
141 | }
|
142 |
|
143 | const toggleTodo: ToggleTodoReducer = (state, action) => {
|
144 | const { index } = action.payload
|
145 |
|
146 | const todo = state[index]
|
147 |
|
148 | todo.completed = !todo.completed
|
149 | }
|
150 |
|
151 | const todosReducer = createReducer([] as TodoState, {
|
152 | ADD_TODO: addTodo,
|
153 | TOGGLE_TODO: toggleTodo,
|
154 | })
|
155 |
|
156 | const wrappedReducer: TodosReducer = (state = [], action) => {
|
157 | return createNextState(state, (draft: Draft<TodoState>) => {
|
158 | todosReducer(draft, action)
|
159 | })
|
160 | }
|
161 |
|
162 | behavesLikeReducer(wrappedReducer)
|
163 | })
|
164 |
|
165 | describe('actionMatchers argument', () => {
|
166 | const prepareNumberAction = (payload: number) => ({
|
167 | payload,
|
168 | meta: { type: 'number_action' },
|
169 | })
|
170 | const prepareStringAction = (payload: string) => ({
|
171 | payload,
|
172 | meta: { type: 'string_action' },
|
173 | })
|
174 |
|
175 | const numberActionMatcher = (a: AnyAction): a is PayloadAction<number> =>
|
176 | a.meta && a.meta.type === 'number_action'
|
177 | const stringActionMatcher = (a: AnyAction): a is PayloadAction<string> =>
|
178 | a.meta && a.meta.type === 'string_action'
|
179 |
|
180 | const incrementBy = createAction('increment', prepareNumberAction)
|
181 | const decrementBy = createAction('decrement', prepareNumberAction)
|
182 | const concatWith = createAction('concat', prepareStringAction)
|
183 |
|
184 | const initialState = { numberActions: 0, stringActions: 0 }
|
185 | const numberActionsCounter = {
|
186 | matcher: numberActionMatcher,
|
187 | reducer(state: typeof initialState) {
|
188 | state.numberActions = state.numberActions * 10 + 1
|
189 | },
|
190 | }
|
191 | const stringActionsCounter = {
|
192 | matcher: stringActionMatcher,
|
193 | reducer(state: typeof initialState) {
|
194 | state.stringActions = state.stringActions * 10 + 1
|
195 | },
|
196 | }
|
197 |
|
198 | test('uses the reducer of matching actionMatchers', () => {
|
199 | const reducer = createReducer(initialState, {}, [
|
200 | numberActionsCounter,
|
201 | stringActionsCounter,
|
202 | ])
|
203 | expect(reducer(undefined, incrementBy(1))).toEqual({
|
204 | numberActions: 1,
|
205 | stringActions: 0,
|
206 | })
|
207 | expect(reducer(undefined, decrementBy(1))).toEqual({
|
208 | numberActions: 1,
|
209 | stringActions: 0,
|
210 | })
|
211 | expect(reducer(undefined, concatWith('foo'))).toEqual({
|
212 | numberActions: 0,
|
213 | stringActions: 1,
|
214 | })
|
215 | })
|
216 | test('fallback to default case', () => {
|
217 | const reducer = createReducer(
|
218 | initialState,
|
219 | {},
|
220 | [numberActionsCounter, stringActionsCounter],
|
221 | (state) => {
|
222 | state.numberActions = -1
|
223 | state.stringActions = -1
|
224 | }
|
225 | )
|
226 | expect(reducer(undefined, { type: 'somethingElse' })).toEqual({
|
227 | numberActions: -1,
|
228 | stringActions: -1,
|
229 | })
|
230 | })
|
231 | test('runs reducer cases followed by all matching actionMatchers', () => {
|
232 | const reducer = createReducer(
|
233 | initialState,
|
234 | {
|
235 | [incrementBy.type](state) {
|
236 | state.numberActions = state.numberActions * 10 + 2
|
237 | },
|
238 | },
|
239 | [
|
240 | {
|
241 | matcher: numberActionMatcher,
|
242 | reducer(state) {
|
243 | state.numberActions = state.numberActions * 10 + 3
|
244 | },
|
245 | },
|
246 | numberActionsCounter,
|
247 | stringActionsCounter,
|
248 | ]
|
249 | )
|
250 | expect(reducer(undefined, incrementBy(1))).toEqual({
|
251 | numberActions: 231,
|
252 | stringActions: 0,
|
253 | })
|
254 | expect(reducer(undefined, decrementBy(1))).toEqual({
|
255 | numberActions: 31,
|
256 | stringActions: 0,
|
257 | })
|
258 | expect(reducer(undefined, concatWith('foo'))).toEqual({
|
259 | numberActions: 0,
|
260 | stringActions: 1,
|
261 | })
|
262 | })
|
263 | test('works with `actionCreator.match`', () => {
|
264 | const reducer = createReducer(initialState, {}, [
|
265 | {
|
266 | matcher: incrementBy.match,
|
267 | reducer(state) {
|
268 | state.numberActions += 100
|
269 | },
|
270 | },
|
271 | ])
|
272 | expect(reducer(undefined, incrementBy(1))).toEqual({
|
273 | numberActions: 100,
|
274 | stringActions: 0,
|
275 | })
|
276 | })
|
277 | })
|
278 |
|
279 | describe('alternative builder callback for actionMap', () => {
|
280 | const increment = createAction<number, 'increment'>('increment')
|
281 | const decrement = createAction<number, 'decrement'>('decrement')
|
282 |
|
283 | test('can be used with ActionCreators', () => {
|
284 | const reducer = createReducer(0, (builder) =>
|
285 | builder
|
286 | .addCase(increment, (state, action) => state + action.payload)
|
287 | .addCase(decrement, (state, action) => state - action.payload)
|
288 | )
|
289 | expect(reducer(0, increment(5))).toBe(5)
|
290 | expect(reducer(5, decrement(5))).toBe(0)
|
291 | })
|
292 | test('can be used with string types', () => {
|
293 | const reducer = createReducer(0, (builder) =>
|
294 | builder
|
295 | .addCase(
|
296 | 'increment',
|
297 | (state, action: { type: 'increment'; payload: number }) =>
|
298 | state + action.payload
|
299 | )
|
300 | .addCase(
|
301 | 'decrement',
|
302 | (state, action: { type: 'decrement'; payload: number }) =>
|
303 | state - action.payload
|
304 | )
|
305 | )
|
306 | expect(reducer(0, increment(5))).toBe(5)
|
307 | expect(reducer(5, decrement(5))).toBe(0)
|
308 | })
|
309 | test('can be used with ActionCreators and string types combined', () => {
|
310 | const reducer = createReducer(0, (builder) =>
|
311 | builder
|
312 | .addCase(increment, (state, action) => state + action.payload)
|
313 | .addCase(
|
314 | 'decrement',
|
315 | (state, action: { type: 'decrement'; payload: number }) =>
|
316 | state - action.payload
|
317 | )
|
318 | )
|
319 | expect(reducer(0, increment(5))).toBe(5)
|
320 | expect(reducer(5, decrement(5))).toBe(0)
|
321 | })
|
322 | test('will throw an error when returning undefined from a non-draftable state', () => {
|
323 | const reducer = createReducer(0, (builder) =>
|
324 | builder.addCase(
|
325 | 'decrement',
|
326 | (state, action: { type: 'decrement'; payload: number }) => {}
|
327 | )
|
328 | )
|
329 | expect(() => reducer(5, decrement(5))).toThrowErrorMatchingInlineSnapshot(
|
330 | `"A case reducer on a non-draftable value must not return undefined"`
|
331 | )
|
332 | })
|
333 | test('allows you to return undefined if the state was null, thus skipping an update', () => {
|
334 | const reducer = createReducer(null as number | null, (builder) =>
|
335 | builder.addCase(
|
336 | 'decrement',
|
337 | (state, action: { type: 'decrement'; payload: number }) => {
|
338 | if (typeof state === 'number') {
|
339 | return state - action.payload
|
340 | }
|
341 | return undefined
|
342 | }
|
343 | )
|
344 | )
|
345 | expect(reducer(0, decrement(5))).toBe(-5)
|
346 | expect(reducer(null, decrement(5))).toBe(null)
|
347 | })
|
348 | test('allows you to return null', () => {
|
349 | const reducer = createReducer(0 as number | null, (builder) =>
|
350 | builder.addCase(
|
351 | 'decrement',
|
352 | (state, action: { type: 'decrement'; payload: number }) => {
|
353 | return null
|
354 | }
|
355 | )
|
356 | )
|
357 | expect(reducer(5, decrement(5))).toBe(null)
|
358 | })
|
359 | test('allows you to return 0', () => {
|
360 | const reducer = createReducer(0, (builder) =>
|
361 | builder.addCase(
|
362 | 'decrement',
|
363 | (state, action: { type: 'decrement'; payload: number }) =>
|
364 | state - action.payload
|
365 | )
|
366 | )
|
367 | expect(reducer(5, decrement(5))).toBe(0)
|
368 | })
|
369 | test('will throw if the same type is used twice', () => {
|
370 | expect(() =>
|
371 | createReducer(0, (builder) =>
|
372 | builder
|
373 | .addCase(increment, (state, action) => state + action.payload)
|
374 | .addCase(increment, (state, action) => state + action.payload)
|
375 | .addCase(decrement, (state, action) => state - action.payload)
|
376 | )
|
377 | ).toThrowErrorMatchingInlineSnapshot(
|
378 | `"addCase cannot be called with two reducers for the same action type"`
|
379 | )
|
380 | expect(() =>
|
381 | createReducer(0, (builder) =>
|
382 | builder
|
383 | .addCase(increment, (state, action) => state + action.payload)
|
384 | .addCase('increment', (state) => state + 1)
|
385 | .addCase(decrement, (state, action) => state - action.payload)
|
386 | )
|
387 | ).toThrowErrorMatchingInlineSnapshot(
|
388 | `"addCase cannot be called with two reducers for the same action type"`
|
389 | )
|
390 | })
|
391 | })
|
392 |
|
393 | describe('builder "addMatcher" method', () => {
|
394 | const prepareNumberAction = (payload: number) => ({
|
395 | payload,
|
396 | meta: { type: 'number_action' },
|
397 | })
|
398 | const prepareStringAction = (payload: string) => ({
|
399 | payload,
|
400 | meta: { type: 'string_action' },
|
401 | })
|
402 |
|
403 | const numberActionMatcher = (a: AnyAction): a is PayloadAction<number> =>
|
404 | a.meta && a.meta.type === 'number_action'
|
405 | const stringActionMatcher = (a: AnyAction): a is PayloadAction<string> =>
|
406 | a.meta && a.meta.type === 'string_action'
|
407 |
|
408 | const incrementBy = createAction('increment', prepareNumberAction)
|
409 | const decrementBy = createAction('decrement', prepareNumberAction)
|
410 | const concatWith = createAction('concat', prepareStringAction)
|
411 |
|
412 | const initialState = { numberActions: 0, stringActions: 0 }
|
413 |
|
414 | test('uses the reducer of matching actionMatchers', () => {
|
415 | const reducer = createReducer(initialState, (builder) =>
|
416 | builder
|
417 | .addMatcher(numberActionMatcher, (state) => {
|
418 | state.numberActions += 1
|
419 | })
|
420 | .addMatcher(stringActionMatcher, (state) => {
|
421 | state.stringActions += 1
|
422 | })
|
423 | )
|
424 | expect(reducer(undefined, incrementBy(1))).toEqual({
|
425 | numberActions: 1,
|
426 | stringActions: 0,
|
427 | })
|
428 | expect(reducer(undefined, decrementBy(1))).toEqual({
|
429 | numberActions: 1,
|
430 | stringActions: 0,
|
431 | })
|
432 | expect(reducer(undefined, concatWith('foo'))).toEqual({
|
433 | numberActions: 0,
|
434 | stringActions: 1,
|
435 | })
|
436 | })
|
437 | test('falls back to defaultCase', () => {
|
438 | const reducer = createReducer(initialState, (builder) =>
|
439 | builder
|
440 | .addCase(concatWith, (state) => {
|
441 | state.stringActions += 1
|
442 | })
|
443 | .addMatcher(numberActionMatcher, (state) => {
|
444 | state.numberActions += 1
|
445 | })
|
446 | .addDefaultCase((state) => {
|
447 | state.numberActions = -1
|
448 | state.stringActions = -1
|
449 | })
|
450 | )
|
451 | expect(reducer(undefined, { type: 'somethingElse' })).toEqual({
|
452 | numberActions: -1,
|
453 | stringActions: -1,
|
454 | })
|
455 | })
|
456 | test('runs reducer cases followed by all matching actionMatchers', () => {
|
457 | const reducer = createReducer(initialState, (builder) =>
|
458 | builder
|
459 | .addCase(incrementBy, (state) => {
|
460 | state.numberActions = state.numberActions * 10 + 1
|
461 | })
|
462 | .addMatcher(numberActionMatcher, (state) => {
|
463 | state.numberActions = state.numberActions * 10 + 2
|
464 | })
|
465 | .addMatcher(stringActionMatcher, (state) => {
|
466 | state.stringActions = state.stringActions * 10 + 1
|
467 | })
|
468 | .addMatcher(numberActionMatcher, (state) => {
|
469 | state.numberActions = state.numberActions * 10 + 3
|
470 | })
|
471 | )
|
472 | expect(reducer(undefined, incrementBy(1))).toEqual({
|
473 | numberActions: 123,
|
474 | stringActions: 0,
|
475 | })
|
476 | expect(reducer(undefined, decrementBy(1))).toEqual({
|
477 | numberActions: 23,
|
478 | stringActions: 0,
|
479 | })
|
480 | expect(reducer(undefined, concatWith('foo'))).toEqual({
|
481 | numberActions: 0,
|
482 | stringActions: 1,
|
483 | })
|
484 | })
|
485 | test('works with `actionCreator.match`', () => {
|
486 | const reducer = createReducer(initialState, (builder) =>
|
487 | builder.addMatcher(incrementBy.match, (state) => {
|
488 | state.numberActions += 100
|
489 | })
|
490 | )
|
491 | expect(reducer(undefined, incrementBy(1))).toEqual({
|
492 | numberActions: 100,
|
493 | stringActions: 0,
|
494 | })
|
495 | })
|
496 | test('calling addCase, addMatcher and addDefaultCase in a nonsensical order should result in an error in development mode', () => {
|
497 | expect(() =>
|
498 | createReducer(initialState, (builder: any) =>
|
499 | builder
|
500 | .addMatcher(numberActionMatcher, () => {})
|
501 | .addCase(incrementBy, () => {})
|
502 | )
|
503 | ).toThrowErrorMatchingInlineSnapshot(
|
504 | `"\`builder.addCase\` should only be called before calling \`builder.addMatcher\`"`
|
505 | )
|
506 | expect(() =>
|
507 | createReducer(initialState, (builder: any) =>
|
508 | builder.addDefaultCase(() => {}).addCase(incrementBy, () => {})
|
509 | )
|
510 | ).toThrowErrorMatchingInlineSnapshot(
|
511 | `"\`builder.addCase\` should only be called before calling \`builder.addDefaultCase\`"`
|
512 | )
|
513 | expect(() =>
|
514 | createReducer(initialState, (builder: any) =>
|
515 | builder
|
516 | .addDefaultCase(() => {})
|
517 | .addMatcher(numberActionMatcher, () => {})
|
518 | )
|
519 | ).toThrowErrorMatchingInlineSnapshot(
|
520 | `"\`builder.addMatcher\` should only be called before calling \`builder.addDefaultCase\`"`
|
521 | )
|
522 | expect(() =>
|
523 | createReducer(initialState, (builder: any) =>
|
524 | builder.addDefaultCase(() => {}).addDefaultCase(() => {})
|
525 | )
|
526 | ).toThrowErrorMatchingInlineSnapshot(
|
527 | `"\`builder.addDefaultCase\` can only be called once"`
|
528 | )
|
529 | })
|
530 | })
|
531 | })
|
532 |
|
533 | function behavesLikeReducer(todosReducer: TodosReducer) {
|
534 | it('should handle initial state', () => {
|
535 | const initialAction = { type: '', payload: undefined }
|
536 | expect(todosReducer(undefined, initialAction)).toEqual([])
|
537 | })
|
538 |
|
539 | it('should handle ADD_TODO', () => {
|
540 | expect(
|
541 | todosReducer([], {
|
542 | type: 'ADD_TODO',
|
543 | payload: { newTodo: { text: 'Run the tests' } },
|
544 | })
|
545 | ).toEqual([
|
546 | {
|
547 | text: 'Run the tests',
|
548 | completed: false,
|
549 | },
|
550 | ])
|
551 |
|
552 | expect(
|
553 | todosReducer(
|
554 | [
|
555 | {
|
556 | text: 'Run the tests',
|
557 | completed: false,
|
558 | },
|
559 | ],
|
560 | {
|
561 | type: 'ADD_TODO',
|
562 | payload: { newTodo: { text: 'Use Redux' } },
|
563 | }
|
564 | )
|
565 | ).toEqual([
|
566 | {
|
567 | text: 'Run the tests',
|
568 | completed: false,
|
569 | },
|
570 | {
|
571 | text: 'Use Redux',
|
572 | completed: false,
|
573 | },
|
574 | ])
|
575 |
|
576 | expect(
|
577 | todosReducer(
|
578 | [
|
579 | {
|
580 | text: 'Run the tests',
|
581 | completed: false,
|
582 | },
|
583 | {
|
584 | text: 'Use Redux',
|
585 | completed: false,
|
586 | },
|
587 | ],
|
588 | {
|
589 | type: 'ADD_TODO',
|
590 | payload: { newTodo: { text: 'Fix the tests' } },
|
591 | }
|
592 | )
|
593 | ).toEqual([
|
594 | {
|
595 | text: 'Run the tests',
|
596 | completed: false,
|
597 | },
|
598 | {
|
599 | text: 'Use Redux',
|
600 | completed: false,
|
601 | },
|
602 | {
|
603 | text: 'Fix the tests',
|
604 | completed: false,
|
605 | },
|
606 | ])
|
607 | })
|
608 |
|
609 | it('should handle TOGGLE_TODO', () => {
|
610 | expect(
|
611 | todosReducer(
|
612 | [
|
613 | {
|
614 | text: 'Run the tests',
|
615 | completed: false,
|
616 | },
|
617 | {
|
618 | text: 'Use Redux',
|
619 | completed: false,
|
620 | },
|
621 | ],
|
622 | {
|
623 | type: 'TOGGLE_TODO',
|
624 | payload: { index: 0 },
|
625 | }
|
626 | )
|
627 | ).toEqual([
|
628 | {
|
629 | text: 'Run the tests',
|
630 | completed: true,
|
631 | },
|
632 | {
|
633 | text: 'Use Redux',
|
634 | completed: false,
|
635 | },
|
636 | ])
|
637 | })
|
638 | }
|