UNPKG

19.8 kBPlain TextView Raw
1import type {
2 CaseReducer,
3 PayloadAction,
4 Draft,
5 Reducer,
6 AnyAction,
7} from '@reduxjs/toolkit'
8import { createReducer, createAction, createNextState } from '@reduxjs/toolkit'
9
10interface Todo {
11 text: string
12 completed?: boolean
13}
14
15interface AddTodoPayload {
16 newTodo: Todo
17}
18
19interface ToggleTodoPayload {
20 index: number
21}
22
23type TodoState = Todo[]
24type TodosReducer = Reducer<TodoState, PayloadAction<any>>
25type AddTodoReducer = CaseReducer<TodoState, PayloadAction<AddTodoPayload>>
26
27type ToggleTodoReducer = CaseReducer<
28 TodoState,
29 PayloadAction<ToggleTodoPayload>
30>
31
32describe('createReducer', () => {
33 describe('given impure reducers with immer', () => {
34 const addTodo: AddTodoReducer = (state, action) => {
35 const { newTodo } = action.payload
36
37 // Can safely call state.push() here
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 // Can directly modify the todo object
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 // Updates the state immutably without relying on immer
114 return state.concat({ ...newTodo, completed: false })
115 }
116
117 const toggleTodo: ToggleTodoReducer = (state, action) => {
118 const { index } = action.payload
119
120 // Updates the todo object immutably withot relying on immer
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 // Can safely call state.push() here
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 // Can directly modify the todo object
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
533function 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}