Skip to content

Commit c4e1848

Browse files
committed
Add tests demonstrating equivalents to Redux-Saga Effects
1 parent 33213b5 commit c4e1848

File tree

2 files changed

+382
-1
lines changed

2 files changed

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

packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import type {
2222
Unsubscribe,
2323
} from '../index'
2424
import { JobCancellationException } from '../job'
25-
import { createNonNullChain } from 'typescript'
2625

2726
const middlewareApi = {
2827
getState: expect.any(Function),

0 commit comments

Comments
 (0)