@reduxjs/toolkit
Version:
The official, opinionated, batteries-included toolset for efficient Redux development
531 lines (450 loc) • 14.6 kB
text/typescript
import type { EnhancedStore } from '@reduxjs/toolkit'
import { configureStore, createSlice, createAction } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
import type {
AbortSignalWithReason,
ForkedTaskExecutor,
TaskResult,
} from '../types'
import { createListenerMiddleware, TaskAbortError } from '../index'
import {
listenerCancelled,
listenerCompleted,
taskCancelled,
taskCompleted,
} from '../exceptions'
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// @see https://deno.land/std@0.95.0/async/deferred.ts (MIT)
export interface Deferred<T> extends Promise<T> {
resolve(value?: T | PromiseLike<T>): void
reject(reason?: any): void
}
/** Creates a Promise with the `reject` and `resolve` functions
* placed as methods on the promise object itself. It allows you to do:
*
* const p = deferred<number>();
* // ...
* p.resolve(42);
*/
export function deferred<T>(): Deferred<T> {
let methods
const promise = new Promise<T>((resolve, reject): void => {
methods = { resolve, reject }
})
return Object.assign(promise, methods) as Deferred<T>
}
interface CounterSlice {
value: number
}
describe('fork', () => {
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 } as CounterSlice,
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 listenerMiddleware = createListenerMiddleware()
let { middleware, startListening, stopListening } = listenerMiddleware
let store = configureStore({
reducer: counterSlice.reducer,
middleware: (gDM) => gDM().prepend(middleware),
})
beforeEach(() => {
listenerMiddleware = createListenerMiddleware()
middleware = listenerMiddleware.middleware
startListening = listenerMiddleware.startListening
stopListening = listenerMiddleware.stopListening
store = configureStore({
reducer: counterSlice.reducer,
middleware: (gDM) => gDM().prepend(middleware),
})
})
it('runs executors in the next microtask', async () => {
let hasRunSyncExector = false
let hasRunAsyncExecutor = false
startListening({
actionCreator: increment,
effect: async (_, listenerApi) => {
listenerApi.fork(() => {
hasRunSyncExector = true
})
listenerApi.fork(async () => {
hasRunAsyncExecutor = true
})
},
})
store.dispatch(increment())
expect(hasRunSyncExector).toBe(false)
expect(hasRunAsyncExecutor).toBe(false)
await Promise.resolve()
expect(hasRunSyncExector).toBe(true)
expect(hasRunAsyncExecutor).toBe(true)
})
test('forkedTask.result rejects TaskAbortError if listener is cancelled', async () => {
const deferredForkedTaskError = deferred()
startListening({
actionCreator: increment,
async effect(_, listenerApi) {
listenerApi.cancelActiveListeners()
listenerApi
.fork(async () => {
await delay(10)
throw new Error('unreachable code')
})
.result.then(
deferredForkedTaskError.resolve,
deferredForkedTaskError.resolve,
)
},
})
store.dispatch(increment())
store.dispatch(increment())
expect(await deferredForkedTaskError).toEqual(
new TaskAbortError(listenerCancelled),
)
})
it('synchronously throws TypeError error if the provided executor is not a function', () => {
const invalidExecutors = [null, {}, undefined, 1]
startListening({
predicate: () => true,
effect: async (_, listenerApi) => {
invalidExecutors.forEach((invalidExecutor) => {
let caughtError
try {
listenerApi.fork(invalidExecutor as any)
} catch (err) {
caughtError = err
}
expect(caughtError).toBeInstanceOf(TypeError)
})
},
})
store.dispatch(increment())
expect.assertions(invalidExecutors.length)
})
it('does not run an executor if the task is synchronously cancelled', async () => {
const storeStateAfter = deferred()
startListening({
actionCreator: increment,
effect: async (action, listenerApi) => {
const forkedTask = listenerApi.fork(() => {
listenerApi.dispatch(decrement())
listenerApi.dispatch(decrement())
listenerApi.dispatch(decrement())
})
forkedTask.cancel()
const result = await forkedTask.result
storeStateAfter.resolve(listenerApi.getState())
},
})
store.dispatch(increment())
await expect(storeStateAfter).resolves.toEqual({ value: 1 })
})
it.each<{
desc: string
executor: ForkedTaskExecutor<any>
cancelAfterMs?: number
expected: TaskResult<any>
}>([
{
desc: 'sync exec - success',
executor: () => 42,
expected: { status: 'ok', value: 42 },
},
{
desc: 'sync exec - error',
executor: () => {
throw new Error('2020')
},
expected: { status: 'rejected', error: new Error('2020') },
},
{
desc: 'sync exec - sync cancel',
executor: () => 42,
cancelAfterMs: -1,
expected: {
status: 'cancelled',
error: new TaskAbortError(taskCancelled),
},
},
{
desc: 'sync exec - async cancel',
executor: () => 42,
cancelAfterMs: 0,
expected: { status: 'ok', value: 42 },
},
{
desc: 'async exec - async cancel',
executor: async (forkApi) => {
await forkApi.delay(100)
throw new Error('2020')
},
cancelAfterMs: 10,
expected: {
status: 'cancelled',
error: new TaskAbortError(taskCancelled),
},
},
{
desc: 'async exec - success',
executor: async () => {
await delay(20)
return Promise.resolve(21)
},
expected: { status: 'ok', value: 21 },
},
{
desc: 'async exec - error',
executor: async () => {
await Promise.resolve()
throw new Error('2020')
},
expected: { status: 'rejected', error: new Error('2020') },
},
{
desc: 'async exec - success with forkApi.pause',
executor: async (forkApi) => {
return forkApi.pause(Promise.resolve(2))
},
expected: { status: 'ok', value: 2 },
},
{
desc: 'async exec - error with forkApi.pause',
executor: async (forkApi) => {
return forkApi.pause(Promise.reject(22))
},
expected: { status: 'rejected', error: 22 },
},
{
desc: 'async exec - success with forkApi.delay',
executor: async (forkApi) => {
await forkApi.delay(10)
return 5
},
expected: { status: 'ok', value: 5 },
},
])('$desc', async ({ executor, expected, cancelAfterMs }) => {
let deferredResult = deferred()
let forkedTask: any = {}
startListening({
predicate: () => true,
effect: async (_, listenerApi) => {
forkedTask = listenerApi.fork(executor)
deferredResult.resolve(await forkedTask.result)
},
})
store.dispatch({ type: '' })
if (typeof cancelAfterMs === 'number') {
if (cancelAfterMs < 0) {
forkedTask.cancel()
} else {
await delay(cancelAfterMs)
forkedTask.cancel()
}
}
const result = await deferredResult
expect(result).toEqual(expected)
})
describe('forkAPI', () => {
test('forkApi.delay rejects as soon as the task is cancelled', async () => {
let deferredResult = deferred()
startListening({
actionCreator: increment,
effect: async (_, listenerApi) => {
const forkedTask = listenerApi.fork(async (forkApi) => {
await forkApi.delay(100)
return 4
})
await listenerApi.delay(10)
forkedTask.cancel()
deferredResult.resolve(await forkedTask.result)
},
})
store.dispatch(increment())
expect(await deferredResult).toEqual({
status: 'cancelled',
error: new TaskAbortError(taskCancelled),
})
})
test('forkApi.delay rejects as soon as the parent listener is cancelled', async () => {
let deferredResult = deferred()
startListening({
actionCreator: increment,
effect: async (_, listenerApi) => {
listenerApi.cancelActiveListeners()
await listenerApi.fork(async (forkApi) => {
await forkApi
.delay(100)
.then(deferredResult.resolve, deferredResult.resolve)
return 4
}).result
deferredResult.resolve(new Error('unreachable'))
},
})
store.dispatch(increment())
await Promise.resolve()
store.dispatch(increment())
expect(await deferredResult).toEqual(
new TaskAbortError(listenerCancelled),
)
})
it.each([
{
autoJoin: true,
expectedAbortReason: taskCompleted,
cancelListener: false,
},
{
autoJoin: false,
expectedAbortReason: listenerCompleted,
cancelListener: false,
},
{
autoJoin: true,
expectedAbortReason: listenerCancelled,
cancelListener: true,
},
{
autoJoin: false,
expectedAbortReason: listenerCancelled,
cancelListener: true,
},
])(
'signal is $expectedAbortReason when autoJoin: $autoJoin, cancelListener: $cancelListener',
async ({ autoJoin, cancelListener, expectedAbortReason }) => {
let deferredResult = deferred()
const unsubscribe = startListening({
actionCreator: increment,
async effect(_, listenerApi) {
listenerApi.fork(
async (forkApi) => {
forkApi.signal.addEventListener('abort', () => {
deferredResult.resolve(
(forkApi.signal as AbortSignalWithReason<unknown>).reason,
)
})
await forkApi.delay(10)
},
{ autoJoin },
)
},
})
store.dispatch(increment())
// let task start
await Promise.resolve()
if (cancelListener) unsubscribe({ cancelActive: true })
expect(await deferredResult).toBe(expectedAbortReason)
},
)
test('fork.delay does not trigger unhandledRejections for completed or cancelled tasks', async () => {
let deferredCompletedEvt = deferred()
let deferredCancelledEvt = deferred()
// Unfortunately we cannot test declaratively unhandleRejections in jest: https://github.com/facebook/jest/issues/5620
// This test just fails if an `unhandledRejection` occurs.
startListening({
actionCreator: increment,
effect: async (_, listenerApi) => {
const completedTask = listenerApi.fork(async (forkApi) => {
forkApi.signal.addEventListener(
'abort',
deferredCompletedEvt.resolve,
{ once: true },
)
forkApi.delay(100) // missing await
return 4
})
deferredCompletedEvt.resolve(await completedTask.result)
const godotPauseTrigger = deferred()
const cancelledTask = listenerApi.fork(async (forkApi) => {
forkApi.signal.addEventListener(
'abort',
deferredCompletedEvt.resolve,
{ once: true },
)
forkApi.delay(1_000) // missing await
await forkApi.pause(godotPauseTrigger)
return 4
})
await Promise.resolve()
cancelledTask.cancel()
deferredCancelledEvt.resolve(await cancelledTask.result)
},
})
store.dispatch(increment())
expect(await deferredCompletedEvt).toBeDefined()
expect(await deferredCancelledEvt).toBeDefined()
})
})
test('forkApi.pause rejects if task is cancelled', async () => {
let deferredResult = deferred()
startListening({
actionCreator: increment,
effect: async (_, listenerApi) => {
const forkedTask = listenerApi.fork(async (forkApi) => {
await forkApi.pause(delay(1_000))
return 4
})
await Promise.resolve()
forkedTask.cancel()
deferredResult.resolve(await forkedTask.result)
},
})
store.dispatch(increment())
expect(await deferredResult).toEqual({
status: 'cancelled',
error: new TaskAbortError(taskCancelled),
})
})
test('forkApi.pause rejects as soon as the parent listener is cancelled', async () => {
let deferredResult = deferred()
startListening({
actionCreator: increment,
effect: async (_, listenerApi) => {
listenerApi.cancelActiveListeners()
const forkedTask = listenerApi.fork(async (forkApi) => {
await forkApi
.pause(delay(100))
.then(deferredResult.resolve, deferredResult.resolve)
return 4
})
await forkedTask.result
deferredResult.resolve(new Error('unreachable'))
},
})
store.dispatch(increment())
await Promise.resolve()
store.dispatch(increment())
expect(await deferredResult).toEqual(new TaskAbortError(listenerCancelled))
})
test('forkApi.pause rejects if listener is cancelled', async () => {
const incrementByInListener = createAction<number>('incrementByInListener')
startListening({
actionCreator: incrementByInListener,
async effect({ payload: amountToIncrement }, listenerApi) {
listenerApi.cancelActiveListeners()
await listenerApi.fork(async (forkApi) => {
await forkApi.pause(delay(10))
listenerApi.dispatch(incrementByAmount(amountToIncrement))
}).result
listenerApi.dispatch(incrementByAmount(2 * amountToIncrement))
},
})
store.dispatch(incrementByInListener(10))
store.dispatch(incrementByInListener(100))
await delay(50)
expect(store.getState().value).toEqual(300)
})
})