UNPKG

10.6 kBPlain TextView Raw
1import {
2 configureStore,
3 createAction,
4 createSlice,
5 isAnyOf,
6} from '@reduxjs/toolkit'
7
8import type { AnyAction, PayloadAction, Action } from '@reduxjs/toolkit'
9
10import { createListenerMiddleware, TaskAbortError } from '../index'
11
12import type { TypedAddListener } from '../index'
13
14describe('Saga-style Effects Scenarios', () => {
15 interface CounterState {
16 value: number
17 }
18
19 const counterSlice = createSlice({
20 name: 'counter',
21 initialState: { value: 0 } as CounterState,
22 reducers: {
23 increment(state) {
24 state.value += 1
25 },
26 decrement(state) {
27 state.value -= 1
28 },
29 // Use the PayloadAction type to declare the contents of `action.payload`
30 incrementByAmount: (state, action: PayloadAction<number>) => {
31 state.value += action.payload
32 },
33 },
34 })
35 const { increment, decrement, incrementByAmount } = counterSlice.actions
36
37 let { reducer } = counterSlice
38 let listenerMiddleware = createListenerMiddleware<CounterState>()
39 let { middleware, startListening, stopListening } = listenerMiddleware
40
41 let store = configureStore({
42 reducer,
43 middleware: (gDM) => gDM().prepend(middleware),
44 })
45
46 const testAction1 = createAction<string>('testAction1')
47 type TestAction1 = ReturnType<typeof testAction1>
48 const testAction2 = createAction<string>('testAction2')
49 type TestAction2 = ReturnType<typeof testAction2>
50 const testAction3 = createAction<string>('testAction3')
51 type TestAction3 = ReturnType<typeof testAction3>
52
53 type RootState = ReturnType<typeof store.getState>
54
55 function delay(ms: number) {
56 return new Promise((resolve) => setTimeout(resolve, ms))
57 }
58
59 beforeAll(() => {
60 const noop = () => {}
61 jest.spyOn(console, 'error').mockImplementation(noop)
62 })
63
64 beforeEach(() => {
65 listenerMiddleware = createListenerMiddleware<CounterState>()
66 middleware = listenerMiddleware.middleware
67 startListening = listenerMiddleware.startListening
68 store = configureStore({
69 reducer,
70 middleware: (gDM) => gDM().prepend(middleware),
71 })
72 })
73
74 test('throttle', async () => {
75 // Ignore incoming actions for a given period of time while processing a task.
76 // Ref: https://redux-saga.js.org/docs/api#throttlems-pattern-saga-args
77
78 let listenerCalls = 0
79 let workPerformed = 0
80
81 startListening({
82 actionCreator: increment,
83 effect: (action, listenerApi) => {
84 listenerCalls++
85
86 // Stop listening until further notice
87 listenerApi.unsubscribe()
88
89 // Queue to start listening again after a delay
90 setTimeout(listenerApi.subscribe, 15)
91 workPerformed++
92 },
93 })
94
95 // Dispatch 3 actions. First triggers listener, next two ignored.
96 store.dispatch(increment())
97 store.dispatch(increment())
98 store.dispatch(increment())
99
100 // Wait for resubscription
101 await delay(25)
102
103 // Dispatch 2 more actions, first triggers, second ignored
104 store.dispatch(increment())
105 store.dispatch(increment())
106
107 // Wait for work
108 await delay(5)
109
110 // Both listener calls completed
111 expect(listenerCalls).toBe(2)
112 expect(workPerformed).toBe(2)
113 })
114
115 test('debounce / takeLatest', async () => {
116 // Repeated calls cancel previous ones, no work performed
117 // until the specified delay elapses without another call
118 // NOTE: This is also basically identical to `takeLatest`.
119 // Ref: https://redux-saga.js.org/docs/api#debouncems-pattern-saga-args
120 // Ref: https://redux-saga.js.org/docs/api#takelatestpattern-saga-args
121
122 let listenerCalls = 0
123 let workPerformed = 0
124
125 startListening({
126 actionCreator: increment,
127 effect: async (action, listenerApi) => {
128 listenerCalls++
129
130 // Cancel any in-progress instances of this listener
131 listenerApi.cancelActiveListeners()
132
133 // Delay before starting actual work
134 await listenerApi.delay(15)
135
136 workPerformed++
137 },
138 })
139
140 // First action, listener 1 starts, nothing to cancel
141 store.dispatch(increment())
142 // Second action, listener 2 starts, cancels 1
143 store.dispatch(increment())
144 // Third action, listener 3 starts, cancels 2
145 store.dispatch(increment())
146
147 // 3 listeners started, third is still paused
148 expect(listenerCalls).toBe(3)
149 expect(workPerformed).toBe(0)
150
151 await delay(25)
152
153 // All 3 started
154 expect(listenerCalls).toBe(3)
155 // First two canceled, `delay()` threw JobCanceled and skipped work.
156 // Third actually completed.
157 expect(workPerformed).toBe(1)
158 })
159
160 test('takeEvery', async () => {
161 // Runs the listener on every action match
162 // Ref: https://redux-saga.js.org/docs/api#takeeverypattern-saga-args
163
164 // NOTE: This is already the default behavior - nothing special here!
165
166 let listenerCalls = 0
167 startListening({
168 actionCreator: increment,
169 effect: (action, listenerApi) => {
170 listenerCalls++
171 },
172 })
173
174 store.dispatch(increment())
175 expect(listenerCalls).toBe(1)
176
177 store.dispatch(increment())
178 expect(listenerCalls).toBe(2)
179 })
180
181 test('takeLeading', async () => {
182 // Starts listener on first action, ignores others until task completes
183 // Ref: https://redux-saga.js.org/docs/api#takeleadingpattern-saga-args
184
185 let listenerCalls = 0
186 let workPerformed = 0
187
188 startListening({
189 actionCreator: increment,
190 effect: async (action, listenerApi) => {
191 listenerCalls++
192
193 // Stop listening for this action
194 listenerApi.unsubscribe()
195
196 // Pretend we're doing expensive work
197 await listenerApi.delay(15)
198
199 workPerformed++
200
201 // Re-enable the listener
202 listenerApi.subscribe()
203 },
204 })
205
206 // First action starts the listener, which unsubscribes
207 store.dispatch(increment())
208 // Second action is ignored
209 store.dispatch(increment())
210
211 // One instance in progress, but not complete
212 expect(listenerCalls).toBe(1)
213 expect(workPerformed).toBe(0)
214
215 await delay(5)
216
217 // In-progress listener not done yet
218 store.dispatch(increment())
219
220 // No changes in status
221 expect(listenerCalls).toBe(1)
222 expect(workPerformed).toBe(0)
223
224 await delay(20)
225
226 // Work finished, should have resubscribed
227 expect(workPerformed).toBe(1)
228
229 // Listener is re-subscribed, will trigger again
230 store.dispatch(increment())
231
232 expect(listenerCalls).toBe(2)
233 expect(workPerformed).toBe(1)
234
235 await delay(20)
236
237 expect(workPerformed).toBe(2)
238 })
239
240 test('fork + join', async () => {
241 // fork starts a child job, join waits for the child to complete and return a value
242 // Ref: https://redux-saga.js.org/docs/api#forkfn-args
243 // Ref: https://redux-saga.js.org/docs/api#jointask
244
245 let childResult = 0
246
247 startListening({
248 actionCreator: increment,
249 effect: async (_, listenerApi) => {
250 const childOutput = 42
251 // Spawn a child job and start it immediately
252 const result = await listenerApi.fork(async () => {
253 // Artificially wait a bit inside the child
254 await listenerApi.delay(5)
255 // Complete the child by returning an Outcome-wrapped value
256 return childOutput
257 }).result
258
259 // Unwrap the child result in the listener
260 if (result.status === 'ok') {
261 childResult = result.value
262 }
263 },
264 })
265
266 store.dispatch(increment())
267
268 await delay(10)
269 expect(childResult).toBe(42)
270 })
271
272 test('fork + cancel', async () => {
273 // fork starts a child job, cancel will raise an exception if the
274 // child is paused in the middle of an effect
275 // Ref: https://redux-saga.js.org/docs/api#forkfn-args
276
277 let childResult = 0
278 let listenerCompleted = false
279
280 startListening({
281 actionCreator: increment,
282 effect: async (action, listenerApi) => {
283 // Spawn a child job and start it immediately
284 const forkedTask = listenerApi.fork(async () => {
285 // Artificially wait a bit inside the child
286 await listenerApi.delay(15)
287 // Complete the child by returning an Outcome-wrapped value
288 childResult = 42
289
290 return 0
291 })
292
293 await listenerApi.delay(5)
294 forkedTask.cancel()
295 listenerCompleted = true
296 },
297 })
298
299 // Starts listener, which starts child
300 store.dispatch(increment())
301
302 // Wait for child to have maybe completed
303 await delay(20)
304
305 // Listener finished, but the child was canceled and threw an exception, so it never finished
306 expect(listenerCompleted).toBe(true)
307 expect(childResult).toBe(0)
308 })
309
310 test('canceled', async () => {
311 // canceled allows checking if the current task was canceled
312 // Ref: https://redux-saga.js.org/docs/api#cancelled
313
314 let canceledAndCaught = false
315 let canceledCheck = false
316
317 startListening({
318 matcher: isAnyOf(increment, decrement, incrementByAmount),
319 effect: async (action, listenerApi) => {
320 if (increment.match(action)) {
321 // Have this branch wait around to be canceled by the other
322 try {
323 await listenerApi.delay(10)
324 } catch (err) {
325 // Can check cancelation based on the exception and its reason
326 if (err instanceof TaskAbortError) {
327 canceledAndCaught = true
328 }
329 }
330 } else if (incrementByAmount.match(action)) {
331 // do a non-cancelation-aware wait
332 await delay(15)
333 if (listenerApi.signal.aborted) {
334 canceledCheck = true
335 }
336 } else if (decrement.match(action)) {
337 listenerApi.cancelActiveListeners()
338 }
339 },
340 })
341
342 // Start first branch
343 store.dispatch(increment())
344 // Cancel first listener
345 store.dispatch(decrement())
346
347 // Have to wait for the delay to resolve
348 // TODO Can we make ``Job.delay()` be a race?
349 await delay(15)
350
351 expect(canceledAndCaught).toBe(true)
352
353 // Start second branch
354 store.dispatch(incrementByAmount(42))
355 // Cancel second listener, although it won't know about that until later
356 store.dispatch(decrement())
357
358 expect(canceledCheck).toBe(false)
359
360 await delay(20)
361
362 expect(canceledCheck).toBe(true)
363 })
364})