@reduxjs/toolkit
Version:
The official, opinionated, batteries-included toolset for efficient Redux development
368 lines (289 loc) • 10.3 kB
text/typescript
import { noop } from '@internal/listenerMiddleware/utils'
import type { PayloadAction } from '@reduxjs/toolkit'
import {
configureStore,
createAction,
createListenerMiddleware,
createSlice,
isAnyOf,
TaskAbortError,
} from '@reduxjs/toolkit'
describe('Saga-style Effects Scenarios', () => {
interface CounterState {
value: number
}
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 } as CounterState,
reducers: {
increment(state) {
state.value += 1
},
decrement(state) {
state.value -= 1
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
},
},
})
const { increment, decrement, incrementByAmount } = counterSlice.actions
let { reducer } = counterSlice
let listenerMiddleware = createListenerMiddleware<CounterState>()
let { middleware, startListening, stopListening } = listenerMiddleware
let store = configureStore({
reducer,
middleware: (gDM) => gDM().prepend(middleware),
})
const testAction1 = createAction<string>('testAction1')
type TestAction1 = ReturnType<typeof testAction1>
const testAction2 = createAction<string>('testAction2')
type TestAction2 = ReturnType<typeof testAction2>
const testAction3 = createAction<string>('testAction3')
type TestAction3 = ReturnType<typeof testAction3>
type RootState = ReturnType<typeof store.getState>
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(noop)
beforeEach(() => {
listenerMiddleware = createListenerMiddleware<CounterState>()
middleware = listenerMiddleware.middleware
startListening = listenerMiddleware.startListening
store = configureStore({
reducer,
middleware: (gDM) => gDM().prepend(middleware),
})
})
afterEach(() => {
vi.clearAllMocks()
})
afterAll(() => {
vi.restoreAllMocks()
})
test('throttle', async () => {
// Ignore incoming actions for a given period of time while processing a task.
// Ref: https://redux-saga.js.org/docs/api#throttlems-pattern-saga-args
let listenerCalls = 0
let workPerformed = 0
startListening({
actionCreator: increment,
effect: (action, listenerApi) => {
listenerCalls++
// Stop listening until further notice
listenerApi.unsubscribe()
// Queue to start listening again after a delay
setTimeout(listenerApi.subscribe, 15)
workPerformed++
},
})
// Dispatch 3 actions. First triggers listener, next two ignored.
store.dispatch(increment())
store.dispatch(increment())
store.dispatch(increment())
// Wait for resubscription
await delay(25)
// Dispatch 2 more actions, first triggers, second ignored
store.dispatch(increment())
store.dispatch(increment())
// Wait for work
await delay(5)
// Both listener calls completed
expect(listenerCalls).toBe(2)
expect(workPerformed).toBe(2)
})
test('debounce / takeLatest', async () => {
// Repeated calls cancel previous ones, no work performed
// until the specified delay elapses without another call
// NOTE: This is also basically identical to `takeLatest`.
// Ref: https://redux-saga.js.org/docs/api#debouncems-pattern-saga-args
// Ref: https://redux-saga.js.org/docs/api#takelatestpattern-saga-args
let listenerCalls = 0
let workPerformed = 0
startListening({
actionCreator: increment,
effect: async (action, listenerApi) => {
listenerCalls++
// Cancel any in-progress instances of this listener
listenerApi.cancelActiveListeners()
// Delay before starting actual work
await listenerApi.delay(15)
workPerformed++
},
})
// First action, listener 1 starts, nothing to cancel
store.dispatch(increment())
// Second action, listener 2 starts, cancels 1
store.dispatch(increment())
// Third action, listener 3 starts, cancels 2
store.dispatch(increment())
// 3 listeners started, third is still paused
expect(listenerCalls).toBe(3)
expect(workPerformed).toBe(0)
await delay(25)
// All 3 started
expect(listenerCalls).toBe(3)
// First two canceled, `delay()` threw JobCanceled and skipped work.
// Third actually completed.
expect(workPerformed).toBe(1)
})
test('takeEvery', async () => {
// Runs the listener on every action match
// Ref: https://redux-saga.js.org/docs/api#takeeverypattern-saga-args
// NOTE: This is already the default behavior - nothing special here!
let listenerCalls = 0
startListening({
actionCreator: increment,
effect: (action, listenerApi) => {
listenerCalls++
},
})
store.dispatch(increment())
expect(listenerCalls).toBe(1)
store.dispatch(increment())
expect(listenerCalls).toBe(2)
})
test('takeLeading', async () => {
// Starts listener on first action, ignores others until task completes
// Ref: https://redux-saga.js.org/docs/api#takeleadingpattern-saga-args
let listenerCalls = 0
let workPerformed = 0
startListening({
actionCreator: increment,
effect: async (action, listenerApi) => {
listenerCalls++
// Stop listening for this action
listenerApi.unsubscribe()
// Pretend we're doing expensive work
await listenerApi.delay(25)
workPerformed++
// Re-enable the listener
listenerApi.subscribe()
},
})
// First action starts the listener, which unsubscribes
store.dispatch(increment())
// Second action is ignored
store.dispatch(increment())
// One instance in progress, but not complete
expect(listenerCalls).toBe(1)
expect(workPerformed).toBe(0)
await delay(5)
// In-progress listener not done yet
store.dispatch(increment())
// No changes in status
expect(listenerCalls).toBe(1)
expect(workPerformed).toBe(0)
await delay(50)
// Work finished, should have resubscribed
expect(workPerformed).toBe(1)
// Listener is re-subscribed, will trigger again
store.dispatch(increment())
expect(listenerCalls).toBe(2)
expect(workPerformed).toBe(1)
await delay(50)
expect(workPerformed).toBe(2)
})
test('fork + join', async () => {
// fork starts a child job, join waits for the child to complete and return a value
// Ref: https://redux-saga.js.org/docs/api#forkfn-args
// Ref: https://redux-saga.js.org/docs/api#jointask
let childResult = 0
startListening({
actionCreator: increment,
effect: async (_, listenerApi) => {
const childOutput = 42
// Spawn a child job and start it immediately
const result = await listenerApi.fork(async () => {
// Artificially wait a bit inside the child
await listenerApi.delay(5)
// Complete the child by returning an Outcome-wrapped value
return childOutput
}).result
// Unwrap the child result in the listener
if (result.status === 'ok') {
childResult = result.value
}
},
})
store.dispatch(increment())
await delay(10)
expect(childResult).toBe(42)
})
test('fork + cancel', async () => {
// fork starts a child job, cancel will raise an exception if the
// child is paused in the middle of an effect
// Ref: https://redux-saga.js.org/docs/api#forkfn-args
let childResult = 0
let listenerCompleted = false
startListening({
actionCreator: increment,
effect: async (action, listenerApi) => {
// Spawn a child job and start it immediately
const forkedTask = listenerApi.fork(async () => {
// Artificially wait a bit inside the child
await listenerApi.delay(15)
// Complete the child by returning an Outcome-wrapped value
childResult = 42
return 0
})
await listenerApi.delay(5)
forkedTask.cancel()
listenerCompleted = true
},
})
// Starts listener, which starts child
store.dispatch(increment())
// Wait for child to have maybe completed
await delay(20)
// Listener finished, but the child was canceled and threw an exception, so it never finished
expect(listenerCompleted).toBe(true)
expect(childResult).toBe(0)
})
test('canceled', async () => {
// canceled allows checking if the current task was canceled
// Ref: https://redux-saga.js.org/docs/api#cancelled
let canceledAndCaught = false
let canceledCheck = false
startListening({
matcher: isAnyOf(increment, decrement, incrementByAmount),
effect: async (action, listenerApi) => {
if (increment.match(action)) {
// Have this branch wait around to be canceled by the other
try {
await listenerApi.delay(10)
} catch (err) {
// Can check cancelation based on the exception and its reason
if (err instanceof TaskAbortError) {
canceledAndCaught = true
}
}
} else if (incrementByAmount.match(action)) {
// do a non-cancelation-aware wait
await delay(15)
if (listenerApi.signal.aborted) {
canceledCheck = true
}
} else if (decrement.match(action)) {
listenerApi.cancelActiveListeners()
}
},
})
// Start first branch
store.dispatch(increment())
// Cancel first listener
store.dispatch(decrement())
// Have to wait for the delay to resolve
// TODO Can we make ``Job.delay()` be a race?
await delay(15)
expect(canceledAndCaught).toBe(true)
// Start second branch
store.dispatch(incrementByAmount(42))
// Cancel second listener, although it won't know about that until later
store.dispatch(decrement())
expect(canceledCheck).toBe(false)
await delay(20)
expect(canceledCheck).toBe(true)
})
})