531 lines
14 KiB
Plaintext
531 lines
14 KiB
Plaintext
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)
|
|
})
|
|
})
|