From 762f729c2a3a534a54fbccf0a2193bfefa7afdd6 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 5 Feb 2022 17:00:51 -0500 Subject: [PATCH 01/27] Rewrite gDM type inference for better extraction of `dispatch` `getDefaultMiddleware` defines an initial type that may or may not include `thunk`, depending on options. From there, `EnhancedStore` tries to extract any extensions to `dispatch` from "the type of all middleware as an array", to fully infer the correct type of `store.dispatch`. This was flaky. In particular, the `MiddlewareArray` type ended up with "a union of all middleware" type, and that meant the extensions weren't always getting read correctly. This commit rewrites the inference to be more correct: - `MiddlewareArray` now tracks an exact tuple type for its contents - `.concat/prepend` now update that middleware tuple type - We exactly extract the dispatch extensions from the tuple, and retain the exact order of the middleware as declared - All the extensions are intersected together with `Dispatch` This should now correctly represent cases like the listener middleware returning an `Unsubscribe` callback in response to `dispatch(addListenerAction())` This commit also drops support for passing `null` as the `extraArg` generic for `ThunkAction`, as that is an outdated pattern and we want to discourage it. --- packages/toolkit/src/configureStore.ts | 6 +- packages/toolkit/src/getDefaultMiddleware.ts | 11 +- .../src/tests/MiddlewareArray.typetest.ts | 6 +- .../src/tests/configureStore.typetest.ts | 109 ++++++++++++++---- .../src/tests/getDefaultMiddleware.test.ts | 52 ++++++++- packages/toolkit/src/tsHelpers.ts | 39 ++++--- packages/toolkit/src/utils.ts | 15 ++- 7 files changed, 181 insertions(+), 57 deletions(-) diff --git a/packages/toolkit/src/configureStore.ts b/packages/toolkit/src/configureStore.ts index 86a9190ae5..e0a7896ac2 100644 --- a/packages/toolkit/src/configureStore.ts +++ b/packages/toolkit/src/configureStore.ts @@ -20,7 +20,7 @@ import type { CurriedGetDefaultMiddleware, } from './getDefaultMiddleware' import { curryGetDefaultMiddleware } from './getDefaultMiddleware' -import type { DispatchForMiddlewares, NoInfer } from './tsHelpers' +import type { NoInfer, ExtractDispatchExtensions } from './tsHelpers' const IS_PRODUCTION = process.env.NODE_ENV === 'production' @@ -110,7 +110,7 @@ export interface EnhancedStore< * * @inheritdoc */ - dispatch: Dispatch & DispatchForMiddlewares + dispatch: ExtractDispatchExtensions & Dispatch } /** @@ -160,7 +160,7 @@ export function configureStore< } if ( !IS_PRODUCTION && - finalMiddleware.some((item) => typeof item !== 'function') + finalMiddleware.some((item: any) => typeof item !== 'function') ) { throw new Error( 'each middleware provided to configureStore must be a function' diff --git a/packages/toolkit/src/getDefaultMiddleware.ts b/packages/toolkit/src/getDefaultMiddleware.ts index c08da77b57..e54a667680 100644 --- a/packages/toolkit/src/getDefaultMiddleware.ts +++ b/packages/toolkit/src/getDefaultMiddleware.ts @@ -8,6 +8,7 @@ import { createImmutableStateInvariantMiddleware } from './immutableStateInvaria import type { SerializableStateInvariantMiddlewareOptions } from './serializableStateInvariantMiddleware' import { createSerializableStateInvariantMiddleware } from './serializableStateInvariantMiddleware' +import type { ExcludeFromTuple } from './tsHelpers' import { MiddlewareArray } from './utils' function isBoolean(x: any): x is boolean { @@ -33,9 +34,7 @@ export type ThunkMiddlewareFor< ? never : O extends { thunk: { extraArgument: infer E } } ? ThunkMiddleware - : - | ThunkMiddleware //The ThunkMiddleware with a `null` ExtraArgument is here to provide backwards-compatibility. - | ThunkMiddleware + : ThunkMiddleware export type CurriedGetDefaultMiddleware = < O extends Partial = { @@ -45,7 +44,7 @@ export type CurriedGetDefaultMiddleware = < } >( options?: O -) => MiddlewareArray | ThunkMiddlewareFor> +) => MiddlewareArray], never>> export function curryGetDefaultMiddleware< S = any @@ -76,14 +75,14 @@ export function getDefaultMiddleware< } >( options: O = {} as O -): MiddlewareArray | ThunkMiddlewareFor> { +): MiddlewareArray], never>> { const { thunk = true, immutableCheck = true, serializableCheck = true, } = options - let middlewareArray: Middleware<{}, S>[] = new MiddlewareArray() + let middlewareArray = new MiddlewareArray() if (thunk) { if (isBoolean(thunk)) { diff --git a/packages/toolkit/src/tests/MiddlewareArray.typetest.ts b/packages/toolkit/src/tests/MiddlewareArray.typetest.ts index d9cfa00f50..30fefad635 100644 --- a/packages/toolkit/src/tests/MiddlewareArray.typetest.ts +++ b/packages/toolkit/src/tests/MiddlewareArray.typetest.ts @@ -1,6 +1,8 @@ import { getDefaultMiddleware } from '@reduxjs/toolkit' import type { Middleware } from 'redux' -import type { DispatchForMiddlewares } from '@internal/tsHelpers' +import type { ExtractDispatchExtensions } from '@internal/tsHelpers' +import thunk from 'redux-thunk' +import { MiddlewareArray } from '../utils' declare const expectType: (t: T) => T @@ -14,7 +16,7 @@ declare const middleware2: Middleware<{ declare const getDispatch: >( m: M -) => DispatchForMiddlewares +) => ExtractDispatchExtensions type ThunkReturn = Promise<'thunk'> declare const thunkCreator: () => () => ThunkReturn diff --git a/packages/toolkit/src/tests/configureStore.typetest.ts b/packages/toolkit/src/tests/configureStore.typetest.ts index a956c9cbf1..2f9183095d 100644 --- a/packages/toolkit/src/tests/configureStore.typetest.ts +++ b/packages/toolkit/src/tests/configureStore.typetest.ts @@ -1,15 +1,23 @@ /* eslint-disable no-lone-blocks */ -import type { Dispatch, AnyAction, Middleware, Reducer, Store } from 'redux' +import type { + Dispatch, + AnyAction, + Middleware, + Reducer, + Store, + Action, +} from 'redux' import { applyMiddleware } from 'redux' -import type { PayloadAction } from '@reduxjs/toolkit' +import type { PayloadAction, MiddlewareArray } from '@reduxjs/toolkit' import { configureStore, getDefaultMiddleware, createSlice, } from '@reduxjs/toolkit' -import type { ThunkMiddleware, ThunkAction } from 'redux-thunk' -import thunk, { ThunkDispatch } from 'redux-thunk' +import type { ThunkMiddleware, ThunkAction, ThunkDispatch } from 'redux-thunk' +import thunk from 'redux-thunk' import { expectNotAny, expectType } from './helpers' +import type { IsAny, ExtractDispatchExtensions } from '../tsHelpers' const _anyMiddleware: any = () => () => () => {} @@ -300,14 +308,16 @@ const _anyMiddleware: any = () => () => () => {} * Test: multiple custom middleware */ { + const middleware = [] as any as [ + Middleware<(a: 'a') => 'A', StateA>, + Middleware<(b: 'b') => 'B', StateA>, + ThunkMiddleware + ] const store = configureStore({ reducer: reducerA, - middleware: [] as any as [ - Middleware<(a: 'a') => 'A', StateA>, - Middleware<(b: 'b') => 'B', StateA>, - ThunkMiddleware - ], + middleware, }) + const result: 'A' = store.dispatch('a') const result2: 'B' = store.dispatch('b') const result3: Promise<'A'> = store.dispatch(thunkA()) @@ -324,7 +334,9 @@ const _anyMiddleware: any = () => () => () => {} undefined, AnyAction >) - // null was previously documented in the redux docs + // `null` for the `extra` generic was previously documented in the RTK "Advanced Tutorial", but + // is a bad pattern and users should use `unknown` instead + // @ts-expect-error store.dispatch(function () {} as ThunkAction) // unknown is the best way to type a ThunkAction if you do not care // about the value of the extraArgument, as it will always work with every @@ -338,13 +350,14 @@ const _anyMiddleware: any = () => () => () => {} * Test: custom middleware and getDefaultMiddleware */ { + const middleware = getDefaultMiddleware().prepend( + (() => {}) as any as Middleware<(a: 'a') => 'A', StateA> + ) const store = configureStore({ reducer: reducerA, - middleware: [ - (() => {}) as any as Middleware<(a: 'a') => 'A', StateA>, - ...getDefaultMiddleware(), - ] as const, + middleware, }) + const result1: 'A' = store.dispatch('a') const result2: Promise<'A'> = store.dispatch(thunkA()) // @ts-expect-error @@ -400,10 +413,10 @@ const _anyMiddleware: any = () => () => () => {} const store = configureStore({ reducer: reducerA, middleware: (getDefaultMiddleware) => - [ - (() => {}) as any as Middleware<(a: 'a') => 'A', StateA>, - ...getDefaultMiddleware(), - ] as const, + getDefaultMiddleware().prepend((() => {}) as any as Middleware< + (a: 'a') => 'A', + StateA + >), }) const result1: 'A' = store.dispatch('a') const result2: Promise<'A'> = store.dispatch(thunkA()) @@ -438,10 +451,9 @@ const _anyMiddleware: any = () => () => () => {} const store = configureStore({ reducer: reducerA, middleware: (getDefaultMiddleware) => - [ - (() => {}) as any as Middleware<(a: 'a') => 'A', StateA>, - ...getDefaultMiddleware({ thunk: false }), - ] as const, + getDefaultMiddleware({ thunk: false }).prepend( + (() => {}) as any as Middleware<(a: 'a') => 'A', StateA> + ), }) const result1: 'A' = store.dispatch('a') // @ts-expect-error @@ -460,4 +472,57 @@ const _anyMiddleware: any = () => () => () => {} expectNotAny(store.dispatch) } + + { + 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) => { + state.value += action.payload + }, + }, + }) + + type Unsubscribe = () => void + + // A fake middleware that tells TS that an unsubscribe callback is being returned for a given action + // This is the same signature that the "listener" middleware uses + const dummyMiddleware: Middleware< + { + (action: Action<'actionListenerMiddleware/add'>): Unsubscribe + }, + CounterState + > = (storeApi) => (next) => (action) => {} + + const store = configureStore({ + reducer: counterSlice.reducer, + middleware: (gDM) => gDM().prepend(dummyMiddleware), + }) + + // Order matters here! We need the listener type to come first, otherwise + // the thunk middleware type kicks in and TS thinks a plain action is being returned + expectType< + ((action: Action<'actionListenerMiddleware/add'>) => Unsubscribe) & + ThunkDispatch & + Dispatch + >(store.dispatch) + + const unsubscribe = store.dispatch({ + type: 'actionListenerMiddleware/add', + } as const) + + expectType(unsubscribe) + } } diff --git a/packages/toolkit/src/tests/getDefaultMiddleware.test.ts b/packages/toolkit/src/tests/getDefaultMiddleware.test.ts index 06264cb281..2367ae4040 100644 --- a/packages/toolkit/src/tests/getDefaultMiddleware.test.ts +++ b/packages/toolkit/src/tests/getDefaultMiddleware.test.ts @@ -1,10 +1,20 @@ -import type { AnyAction, Middleware, ThunkAction } from '@reduxjs/toolkit' +import type { + AnyAction, + Middleware, + ThunkAction, + Action, + ThunkDispatch, + Dispatch, +} from '@reduxjs/toolkit' import { getDefaultMiddleware, MiddlewareArray, configureStore, } from '@reduxjs/toolkit' import thunk from 'redux-thunk' +import type { ThunkMiddleware } from 'redux-thunk' + +import { expectType } from './helpers' describe('getDefaultMiddleware', () => { const ORIGINAL_NODE_ENV = process.env.NODE_ENV @@ -27,6 +37,7 @@ describe('getDefaultMiddleware', () => { it('removes the thunk middleware if disabled', () => { const middleware = getDefaultMiddleware({ thunk: false }) + // @ts-ignore expect(middleware.includes(thunk)).toBe(false) expect(middleware.length).toBe(2) }) @@ -44,13 +55,46 @@ describe('getDefaultMiddleware', () => { }) it('allows passing options to thunk', () => { - const extraArgument = 42 + const extraArgument = 42 as const const middleware = getDefaultMiddleware({ thunk: { extraArgument }, immutableCheck: false, serializableCheck: false, }) + const m2 = getDefaultMiddleware({ + thunk: false, + }) + + expectType>(m2) + + const dummyMiddleware: Middleware< + { + (action: Action<'actionListenerMiddleware/add'>): () => void + }, + { counter: number } + > = (storeApi) => (next) => (action) => {} + + const dummyMiddleware2: Middleware = (storeApi) => (next) => (action) => {} + + const m3 = middleware.concat(dummyMiddleware, dummyMiddleware2) + + expectType< + MiddlewareArray< + [ + ThunkMiddleware, + Middleware< + (action: Action<'actionListenerMiddleware/add'>) => () => void, + { + counter: number + }, + Dispatch + >, + Middleware<{}, any, Dispatch> + ] + > + >(m3) + const testThunk: ThunkAction = ( dispatch, getState, @@ -66,6 +110,10 @@ describe('getDefaultMiddleware', () => { middleware, }) + expectType & Dispatch>( + store.dispatch + ) + store.dispatch(testThunk) }) diff --git a/packages/toolkit/src/tsHelpers.ts b/packages/toolkit/src/tsHelpers.ts index e91457326e..342f0b0c7b 100644 --- a/packages/toolkit/src/tsHelpers.ts +++ b/packages/toolkit/src/tsHelpers.ts @@ -1,4 +1,5 @@ -import type { Middleware } from 'redux' +import type { Middleware, Dispatch } from 'redux' +import type { MiddlewareArray } from './utils' /** * return True if T is `any`, otherwise return False @@ -65,20 +66,30 @@ export type IsUnknownOrNonInferrable = AtLeastTS35< IsEmptyObj> > -/** - * Combines all dispatch signatures of all middlewares in the array `M` into - * one intersected dispatch signature. - */ -export type DispatchForMiddlewares = M extends ReadonlyArray - ? UnionToIntersection< - M[number] extends infer MiddlewareValues - ? MiddlewareValues extends Middleware - ? DispatchExt extends Function - ? IsAny - : never - : never - : never +// Appears to have a convenient side effect of ignoring `never` even if that's not what you specified +export type ExcludeFromTuple = T extends [ + infer Head, + ...infer Tail +] + ? ExcludeFromTuple + : Acc + +type ExtractDispatchFromMiddlewareTuple< + MiddlewareTuple extends any[], + Acc extends {} +> = MiddlewareTuple extends [infer Head, ...infer Tail] + ? ExtractDispatchFromMiddlewareTuple< + Tail, + Acc & (Head extends Middleware ? IsAny : {}) > + : Acc + +export type ExtractDispatchExtensions = M extends MiddlewareArray< + infer MiddlewareTuple +> + ? ExtractDispatchFromMiddlewareTuple + : M extends Middleware[] + ? ExtractDispatchFromMiddlewareTuple<[...M], {}> : never /** diff --git a/packages/toolkit/src/utils.ts b/packages/toolkit/src/utils.ts index 037db322fd..5a3c77e1da 100644 --- a/packages/toolkit/src/utils.ts +++ b/packages/toolkit/src/utils.ts @@ -26,10 +26,9 @@ It is disabled in production builds, so you don't need to worry about that.`) * @public */ export class MiddlewareArray< - Middlewares extends Middleware -> extends Array { - constructor(arrayLength?: number) - constructor(...items: Middlewares[]) + Middlewares extends Middleware[] +> extends Array { + constructor(...items: Middlewares) constructor(...args: any[]) { super(...args) Object.setPrototypeOf(this, MiddlewareArray.prototype) @@ -41,22 +40,22 @@ export class MiddlewareArray< concat>>( items: AdditionalMiddlewares - ): MiddlewareArray + ): MiddlewareArray<[...Middlewares, ...AdditionalMiddlewares]> concat>>( ...items: AdditionalMiddlewares - ): MiddlewareArray + ): MiddlewareArray<[...Middlewares, ...AdditionalMiddlewares]> concat(...arr: any[]) { return super.concat.apply(this, arr) } prepend>>( items: AdditionalMiddlewares - ): MiddlewareArray + ): MiddlewareArray<[...AdditionalMiddlewares, ...Middlewares]> prepend>>( ...items: AdditionalMiddlewares - ): MiddlewareArray + ): MiddlewareArray<[...AdditionalMiddlewares, ...Middlewares]> prepend(...arr: any[]) { if (arr.length === 1 && Array.isArray(arr[0])) { From 2085614563a1577b0189f88598cad9b1d6a06ad1 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 5 Feb 2022 23:47:31 -0500 Subject: [PATCH 02/27] Rewrite MiddlewareArray typetest to use a real store to fix CI --- .../src/tests/MiddlewareArray.typetest.ts | 122 +++++++++--------- 1 file changed, 62 insertions(+), 60 deletions(-) diff --git a/packages/toolkit/src/tests/MiddlewareArray.typetest.ts b/packages/toolkit/src/tests/MiddlewareArray.typetest.ts index 30fefad635..25bb4cb21d 100644 --- a/packages/toolkit/src/tests/MiddlewareArray.typetest.ts +++ b/packages/toolkit/src/tests/MiddlewareArray.typetest.ts @@ -1,8 +1,5 @@ -import { getDefaultMiddleware } from '@reduxjs/toolkit' +import { getDefaultMiddleware, configureStore } from '@reduxjs/toolkit' import type { Middleware } from 'redux' -import type { ExtractDispatchExtensions } from '@internal/tsHelpers' -import thunk from 'redux-thunk' -import { MiddlewareArray } from '../utils' declare const expectType: (t: T) => T @@ -14,103 +11,108 @@ declare const middleware2: Middleware<{ (_: number): string }> -declare const getDispatch: >( - m: M -) => ExtractDispatchExtensions - type ThunkReturn = Promise<'thunk'> declare const thunkCreator: () => () => ThunkReturn { - const defaultMiddleware = getDefaultMiddleware() - // prepend single element { - const concatenated = defaultMiddleware.prepend(middleware1) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().prepend(middleware1), + }) + expectType(store.dispatch('foo')) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } - // prepepend multiple (rest) + // prepend multiple (rest) { - const concatenated = defaultMiddleware.prepend(middleware1, middleware2) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(5)) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().prepend(middleware1, middleware2), + }) + expectType(store.dispatch('foo')) + expectType(store.dispatch(5)) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } // prepend multiple (array notation) { - const concatenated = defaultMiddleware.prepend([ - middleware1, - middleware2, - ] as const) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(5)) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().prepend([middleware1, middleware2] as const), + }) + + expectType(store.dispatch('foo')) + expectType(store.dispatch(5)) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } // concat single element { - const concatenated = defaultMiddleware.concat(middleware1) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().concat(middleware1), + }) + + expectType(store.dispatch('foo')) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } - // prepepend multiple (rest) + // prepend multiple (rest) { - const concatenated = defaultMiddleware.concat(middleware1, middleware2) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(5)) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().concat(middleware1, middleware2), + }) + + expectType(store.dispatch('foo')) + expectType(store.dispatch(5)) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } // concat multiple (array notation) { - const concatenated = defaultMiddleware.concat([ - middleware1, - middleware2, - ] as const) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(5)) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().concat([middleware1, middleware2] as const), + }) + + expectType(store.dispatch('foo')) + expectType(store.dispatch(5)) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } // concat and prepend { - const concatenated = defaultMiddleware - .concat(middleware1) - .prepend(middleware2) - const dispatch = getDispatch(concatenated) - expectType(dispatch('foo')) - expectType(dispatch(5)) - expectType(dispatch(thunkCreator())) + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().concat(middleware1).prepend(middleware2), + }) + + expectType(store.dispatch('foo')) + expectType(store.dispatch(5)) + expectType(store.dispatch(thunkCreator())) // @ts-expect-error - expectType(dispatch('foo')) + expectType(store.dispatch('foo')) } } From 42607dad8f6db7f8e9f0378ccbe69ff59903e5fa Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 6 Feb 2022 12:14:03 -0500 Subject: [PATCH 03/27] Drop TS versions before 4.1 from the test matrix --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5cc462e99f..b6180d8874 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -96,7 +96,7 @@ jobs: fail-fast: false matrix: node: ['14.x'] - ts: ['3.9', '4.0', '4.1', '4.2', '4.3', '4.4', '4.5', 'next'] + ts: ['4.1', '4.2', '4.3', '4.4', '4.5', 'next'] steps: - name: Checkout repo uses: actions/checkout@v2 From 3ff71371aa11bc02ddadfaa8d83199c5d40f0bd8 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 6 Feb 2022 12:18:17 -0500 Subject: [PATCH 04/27] Fix type issues with listener fork test --- packages/action-listener-middleware/src/tests/fork.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/action-listener-middleware/src/tests/fork.test.ts b/packages/action-listener-middleware/src/tests/fork.test.ts index af4fd452a7..47c5b60d19 100644 --- a/packages/action-listener-middleware/src/tests/fork.test.ts +++ b/packages/action-listener-middleware/src/tests/fork.test.ts @@ -52,8 +52,11 @@ describe('fork', () => { }, }) const { increment, decrement, incrementByAmount } = counterSlice.actions - let middleware: ReturnType - let store: EnhancedStore + let middleware = createActionListenerMiddleware() + let store = configureStore({ + reducer: counterSlice.reducer, + middleware: (gDM) => gDM().prepend(middleware), + }) beforeEach(() => { middleware = createActionListenerMiddleware() From ac778141bfc348934449d0325300b33ef428cbc6 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 6 Feb 2022 12:38:19 -0500 Subject: [PATCH 05/27] Carry type of `extra` argument through the listener config --- .../action-listener-middleware/src/index.ts | 1 - .../src/tests/listenerMiddleware.test.ts | 16 ++++--- .../action-listener-middleware/src/types.ts | 43 +++++++++++++------ 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/packages/action-listener-middleware/src/index.ts b/packages/action-listener-middleware/src/index.ts index 2f7e47c601..d9a5e663a7 100644 --- a/packages/action-listener-middleware/src/index.ts +++ b/packages/action-listener-middleware/src/index.ts @@ -273,7 +273,6 @@ const defaultErrorHandler: ListenerErrorHandler = (...args: unknown[]) => { */ export function createActionListenerMiddleware< S = unknown, - // TODO Carry through the thunk extra arg somehow? D extends Dispatch = ThunkDispatch, ExtraArgument = unknown >(middlewareOptions: CreateListenerMiddlewareOptions = {}) { diff --git a/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts b/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts index 96ca25af63..100026044c 100644 --- a/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts +++ b/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts @@ -171,21 +171,27 @@ describe('createActionListenerMiddleware', () => { describe('Middleware setup', () => { test('Allows passing an extra argument on middleware creation', () => { const originalExtra = 42 - middleware = createActionListenerMiddleware({ + const middleware = createActionListenerMiddleware({ extra: originalExtra, }) - reducer = jest.fn(() => ({})) - store = configureStore({ - reducer, + const store = configureStore({ + reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) let foundExtra = null - middleware.addListener({ + const typedAddListener = middleware.addListener as TypedAddListener< + CounterState, + typeof store.dispatch, + typeof originalExtra + > + + typedAddListener({ matcher: (action: AnyAction): action is AnyAction => true, listener: (action, listenerApi) => { foundExtra = listenerApi.extra + expectType(listenerApi.extra) }, }) diff --git a/packages/action-listener-middleware/src/types.ts b/packages/action-listener-middleware/src/types.ts index aa3577d42e..96e1a25dda 100644 --- a/packages/action-listener-middleware/src/types.ts +++ b/packages/action-listener-middleware/src/types.ts @@ -116,8 +116,11 @@ export interface ForkedTask { /** * @alpha */ -export interface ActionListenerMiddlewareAPI> - extends MiddlewareAPI { +export interface ActionListenerMiddlewareAPI< + S, + D extends Dispatch, + ExtraArgument = unknown +> extends MiddlewareAPI { /** * Returns the store state as it existed when the action was originally dispatched, _before_ the reducers ran. * @@ -171,7 +174,7 @@ export interface ActionListenerMiddlewareAPI> */ pause(promise: Promise): Promise // TODO Figure out how to pass this through the other types correctly - extra: unknown + extra: ExtraArgument } /** @@ -180,8 +183,12 @@ export interface ActionListenerMiddlewareAPI> export type ActionListener< A extends AnyAction, S, - D extends Dispatch -> = (action: A, api: ActionListenerMiddlewareAPI) => void | Promise + D extends Dispatch, + ExtraArgument = unknown +> = ( + action: A, + api: ActionListenerMiddlewareAPI +) => void | Promise export interface ListenerErrorHandler { (error: unknown): void @@ -261,7 +268,8 @@ export interface TakePattern { export interface AddListenerOverloads< Return, S = unknown, - D extends Dispatch = ThunkDispatch + D extends Dispatch = ThunkDispatch, + ExtraArgument = unknown > { /** Accepts a "listener predicate" that is also a TS type predicate for the action*/ >( @@ -270,7 +278,12 @@ export interface AddListenerOverloads< type?: never matcher?: never predicate: LP - listener: ActionListener, S, D> + listener: ActionListener< + ListenerPredicateGuardedActionType, + S, + D, + ExtraArgument + > } & ActionListenerOptions ): Return @@ -281,7 +294,7 @@ export interface AddListenerOverloads< type?: never matcher?: never predicate?: never - listener: ActionListener, S, D> + listener: ActionListener, S, D, ExtraArgument> } & ActionListenerOptions ): Return @@ -292,7 +305,7 @@ export interface AddListenerOverloads< type: T matcher?: never predicate?: never - listener: ActionListener, S, D> + listener: ActionListener, S, D, ExtraArgument> } & ActionListenerOptions ): Return @@ -303,7 +316,7 @@ export interface AddListenerOverloads< type?: never matcher: M predicate?: never - listener: ActionListener, S, D> + listener: ActionListener, S, D, ExtraArgument> } & ActionListenerOptions ): Return @@ -314,7 +327,7 @@ export interface AddListenerOverloads< type?: never matcher?: never predicate: LP - listener: ActionListener + listener: ActionListener } & ActionListenerOptions ): Return } @@ -340,10 +353,11 @@ export interface RemoveListenerAction< export type TypedAddListenerAction< S, D extends Dispatch = ThunkDispatch, + ExtraArgument = unknown, Payload = ListenerEntry, T extends string = 'actionListenerMiddleware/add' > = BaseActionCreator & - AddListenerOverloads, S, D> + AddListenerOverloads, S, D, ExtraArgument> /** A "pre-typed" version of `removeListenerAction`, so the listener args are well-typed */ export type TypedRemoveListenerAction< @@ -357,8 +371,9 @@ export type TypedRemoveListenerAction< /** A "pre-typed" version of `middleware.addListener`, so the listener args are well-typed */ export type TypedAddListener< S, - D extends Dispatch = ThunkDispatch -> = AddListenerOverloads + D extends Dispatch = ThunkDispatch, + ExtraArgument = unknown +> = AddListenerOverloads /** A "pre-typed" version of `middleware.removeListener`, so the listener args are well-typed */ export type TypedRemoveListener< From faf5438ce6dd1ec751ddf108dc6c78bd77c180ec Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 6 Feb 2022 12:41:10 -0500 Subject: [PATCH 06/27] Rename API to createListenerMiddleware --- .../action-listener-middleware/src/index.ts | 2 +- .../src/tests/effectScenarios.test.ts | 10 ++++---- .../src/tests/fork.test.ts | 6 ++--- .../src/tests/listenerMiddleware.test.ts | 24 +++++++++---------- .../src/tests/useCases.test.ts | 8 +++---- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/action-listener-middleware/src/index.ts b/packages/action-listener-middleware/src/index.ts index d9a5e663a7..9aebcb791b 100644 --- a/packages/action-listener-middleware/src/index.ts +++ b/packages/action-listener-middleware/src/index.ts @@ -271,7 +271,7 @@ const defaultErrorHandler: ListenerErrorHandler = (...args: unknown[]) => { /** * @alpha */ -export function createActionListenerMiddleware< +export function createListenerMiddleware< S = unknown, D extends Dispatch = ThunkDispatch, ExtraArgument = unknown diff --git a/packages/action-listener-middleware/src/tests/effectScenarios.test.ts b/packages/action-listener-middleware/src/tests/effectScenarios.test.ts index 0e7a989cf3..7c1a892e87 100644 --- a/packages/action-listener-middleware/src/tests/effectScenarios.test.ts +++ b/packages/action-listener-middleware/src/tests/effectScenarios.test.ts @@ -7,7 +7,7 @@ import { import type { AnyAction, PayloadAction, Action } from '@reduxjs/toolkit' -import { createActionListenerMiddleware, TaskAbortError } from '../index' +import { createListenerMiddleware, TaskAbortError } from '../index' import type { TypedAddListener } from '../index' @@ -35,13 +35,13 @@ describe('Saga-style Effects Scenarios', () => { const { increment, decrement, incrementByAmount } = counterSlice.actions let { reducer } = counterSlice - let middleware: ReturnType + let middleware: ReturnType let store = configureStore({ reducer, - middleware: (gDM) => gDM().prepend(createActionListenerMiddleware()), + middleware: (gDM) => gDM().prepend(createListenerMiddleware()), }) - // let middleware: ActionListenerMiddleware //: ReturnType + // let middleware: ActionListenerMiddleware //: ReturnType const testAction1 = createAction('testAction1') type TestAction1 = ReturnType @@ -64,7 +64,7 @@ describe('Saga-style Effects Scenarios', () => { }) beforeEach(() => { - middleware = createActionListenerMiddleware() + middleware = createListenerMiddleware() addListener = middleware.addListener as TypedAddListener store = configureStore({ reducer, diff --git a/packages/action-listener-middleware/src/tests/fork.test.ts b/packages/action-listener-middleware/src/tests/fork.test.ts index 47c5b60d19..b665b017ca 100644 --- a/packages/action-listener-middleware/src/tests/fork.test.ts +++ b/packages/action-listener-middleware/src/tests/fork.test.ts @@ -3,7 +3,7 @@ import { configureStore, createSlice } from '@reduxjs/toolkit' import type { PayloadAction } from '@reduxjs/toolkit' import type { ForkedTaskExecutor, TaskResult } from '../types' -import { createActionListenerMiddleware, TaskAbortError } from '../index' +import { createListenerMiddleware, TaskAbortError } from '../index' function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -52,14 +52,14 @@ describe('fork', () => { }, }) const { increment, decrement, incrementByAmount } = counterSlice.actions - let middleware = createActionListenerMiddleware() + let middleware = createListenerMiddleware() let store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) beforeEach(() => { - middleware = createActionListenerMiddleware() + middleware = createListenerMiddleware() store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), diff --git a/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts b/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts index 100026044c..f6cb2c8fcd 100644 --- a/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts +++ b/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts @@ -9,7 +9,7 @@ import { import type { AnyAction, PayloadAction, Action } from '@reduxjs/toolkit' import { - createActionListenerMiddleware, + createListenerMiddleware, createListenerEntry, addListenerAction, removeListenerAction, @@ -108,10 +108,10 @@ export function expectNotAny>(t: T): T { return t } -describe('createActionListenerMiddleware', () => { +describe('createListenerMiddleware', () => { let store = configureStore({ reducer: () => 42, - middleware: (gDM) => gDM().prepend(createActionListenerMiddleware()), + middleware: (gDM) => gDM().prepend(createListenerMiddleware()), }) interface CounterState { @@ -141,12 +141,12 @@ describe('createActionListenerMiddleware', () => { } let reducer: jest.Mock - let middleware: ReturnType + let middleware: ReturnType let addTypedListenerAction = addListenerAction as TypedAddListenerAction let removeTypedListenerAction = removeListenerAction as TypedRemoveListenerAction - // let middleware: ActionListenerMiddleware //: ReturnType + // let middleware: ActionListenerMiddleware //: ReturnType const testAction1 = createAction('testAction1') type TestAction1 = ReturnType @@ -160,7 +160,7 @@ describe('createActionListenerMiddleware', () => { }) beforeEach(() => { - middleware = createActionListenerMiddleware() + middleware = createListenerMiddleware() reducer = jest.fn(() => ({})) store = configureStore({ reducer, @@ -171,7 +171,7 @@ describe('createActionListenerMiddleware', () => { describe('Middleware setup', () => { test('Allows passing an extra argument on middleware creation', () => { const originalExtra = 42 - const middleware = createActionListenerMiddleware({ + const middleware = createListenerMiddleware({ extra: originalExtra, }) const store = configureStore({ @@ -744,7 +744,7 @@ describe('createActionListenerMiddleware', () => { test('getOriginalState can only be invoked synchronously', async () => { const onError = jest.fn() - middleware = createActionListenerMiddleware({ onError }) + middleware = createListenerMiddleware({ onError }) const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), @@ -890,7 +890,7 @@ describe('createActionListenerMiddleware', () => { test('Notifies sync listener errors to `onError`, if provided', async () => { const onError = jest.fn() - middleware = createActionListenerMiddleware({ + middleware = createListenerMiddleware({ onError, }) reducer = jest.fn(() => ({})) @@ -920,7 +920,7 @@ describe('createActionListenerMiddleware', () => { test('Notifies async listeners errors to `onError`, if provided', async () => { const onError = jest.fn() - middleware = createActionListenerMiddleware({ + middleware = createListenerMiddleware({ onError, }) reducer = jest.fn(() => ({})) @@ -1175,7 +1175,7 @@ describe('createActionListenerMiddleware', () => { }) describe('Type tests', () => { - const middleware = createActionListenerMiddleware() + const middleware = createListenerMiddleware() const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), @@ -1339,7 +1339,7 @@ describe('createActionListenerMiddleware', () => { }) test('Can create a pre-typed middleware', () => { - const typedMiddleware = createActionListenerMiddleware() + const typedMiddleware = createListenerMiddleware() typedMiddleware.addListener({ predicate: ( diff --git a/packages/action-listener-middleware/src/tests/useCases.test.ts b/packages/action-listener-middleware/src/tests/useCases.test.ts index 961d164c10..0060db0146 100644 --- a/packages/action-listener-middleware/src/tests/useCases.test.ts +++ b/packages/action-listener-middleware/src/tests/useCases.test.ts @@ -7,7 +7,7 @@ import { import type { PayloadAction } from '@reduxjs/toolkit' -import { createActionListenerMiddleware } from '../index' +import { createListenerMiddleware } from '../index' import type { TypedAddListener } from '../index' import { TaskAbortError } from '../exceptions' @@ -35,11 +35,11 @@ const counterSlice = createSlice({ const { increment, decrement, incrementByAmount } = counterSlice.actions describe('Saga-style Effects Scenarios', () => { - let middleware: ReturnType + let middleware: ReturnType let store = configureStore({ reducer: counterSlice.reducer, - middleware: (gDM) => gDM().prepend(createActionListenerMiddleware()), + middleware: (gDM) => gDM().prepend(createListenerMiddleware()), }) const testAction1 = createAction('testAction1') @@ -58,7 +58,7 @@ describe('Saga-style Effects Scenarios', () => { } beforeEach(() => { - middleware = createActionListenerMiddleware() + middleware = createListenerMiddleware() addListener = middleware.addListener as TypedAddListener store = configureStore({ reducer: counterSlice.reducer, From 41f70e84aed9885e7c50e5f8f4416805721b25e6 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 12 Feb 2022 16:16:10 -0500 Subject: [PATCH 07/27] Rename listener middleware public APIs and rework return value This commit makes major breaking changes to the listener middleware. First, we've renamed all the public methods and action creators, so that we have a consistent naming scheme without clashes: - API: `createListenerMiddleware` - Returns: `{middleware, startListening, stopListening, clearListeners}` - Docs examples show the object as `listenerMiddleware` - One entry: "listener" - Field in that listener entry: `effect` - Action creators: `addListener`, `removeListener`, `removeAllListeners` Next, `createListenerMiddleware` now returns an object containing `{middleware, start/stop/clear}`, rather than the middleware itself with those methods attached. This works around the TS inference issues from attaching the add and remove functions directly to the middleware function itself, and allows `const unsub = dispatch(addListener())` to have correct TS types for the return value. --- packages/action-listener-middleware/README.md | 215 +++++---- .../action-listener-middleware/src/index.ts | 202 ++++---- .../src/tests/effectScenarios.test.ts | 41 +- .../src/tests/fork.test.ts | 44 +- .../src/tests/listenerMiddleware.test.ts | 445 +++++++++--------- .../src/tests/useCases.test.ts | 16 +- .../action-listener-middleware/src/types.ts | 172 ++++--- 7 files changed, 579 insertions(+), 556 deletions(-) diff --git a/packages/action-listener-middleware/README.md b/packages/action-listener-middleware/README.md index 96f9446b11..adc988c21b 100644 --- a/packages/action-listener-middleware/README.md +++ b/packages/action-listener-middleware/README.md @@ -1,8 +1,10 @@ # RTK Incubator - Action Listener Middleware -This package provides a callback-based Redux middleware that we plan to include in Redux Toolkit directly in a future release. We're publishing it as a standalone package to allow users to try it out separately and give us feedback on its API design. +This package provides a callback-based Redux middleware that we plan to include in Redux Toolkit directly in the next feature release. We're publishing it as a standalone package to allow users to try it out separately and give us feedback on its API design. -This middleware lets you define "listener" callbacks that will run in response to specific actions being dispatched. It's intended to be a lightweight alternative to more widely used Redux async middleware like sagas and observables. While similar to thunks in level of complexity and concept, it can be used to replicate some common saga usage patterns. +This middleware lets you define "listener" entries containing "effect" callbacks that will run in response to specific actions being dispatched. It's intended to be a lightweight alternative to more widely used Redux async middleware like sagas and observables. While similar to thunks in level of complexity and concept, it can be used to replicate some common saga usage patterns. + +Conceptually, you can think of this as being similar to React's `useEffect` hook, except that it runs logic in response to Redux store updates instead of component props/state updates. ## Installation @@ -16,7 +18,7 @@ yarn add @rtk-incubator/action-listener-middleware ```js import { configureStore } from '@reduxjs/toolkit' -import { createActionListenerMiddleware } from '@rtk-incubator/action-listener-middleware' +import { createListenerMiddleware } from '@rtk-incubator/action-listener-middleware' import todosReducer, { todoAdded, @@ -24,39 +26,42 @@ import todosReducer, { todoDeleted, } from '../features/todos/todosSlice' -// Create the middleware instance -const listenerMiddleware = createActionListenerMiddleware() +// Create the middleware instance and methods +const listenerMiddleware = createListenerMiddleware() -// Add one or more listener callbacks for specific actions. They may -// contain any sync or async logic, similar to thunks. -listenerMiddleware.addListener(todoAdded, async (action, listenerApi) => { - // Run whatever additional side-effect-y logic you want here - console.log('Todo added: ', action.payload.text) +// Add one or more listener entries that look for specific actions. +// They may contain any sync or async logic, similar to thunks. +listenerMiddleware.startListening({ + actionCreator: todoAdded, + effect: async (action, listenerApi) => { + // Run whatever additional side-effect-y logic you want here + console.log('Todo added: ', action.payload.text) - // Can cancel other running instances - listenerApi.cancelActiveListeners() + // Can cancel other running instances + listenerApi.cancelActiveListeners() - // Run async logic - const data = await fetchData() + // Run async logic + const data = await fetchData() - // Pause until action dispatched or state changed - if (await listenerApi.condition(matchSomeAction)) { - // Use the listener API methods to dispatch, get state, - // unsubscribe the listener, or cancel previous - listenerApi.dispatch(todoAdded('Buy pet food')) - listenerApi.unsubscribe() - } + // Pause until action dispatched or state changed + if (await listenerApi.condition(matchSomeAction)) { + // Use the listener API methods to dispatch, get state, + // unsubscribe the listener, or cancel previous + listenerApi.dispatch(todoAdded('Buy pet food')) + listenerApi.unsubscribe() + } + }, }) const store = configureStore({ reducer: { todos: todosReducer, }, - // Add the middleware to the store. - // NOTE Since this can receive actions with functions inside, + // Add the listener middleware to the store. + // NOTE: Since this can receive actions with functions inside, // it should go before the serializability check middleware middleware: (getDefaultMiddleware) => - getDefaultMiddleware().prepend(listenerMiddleware), + getDefaultMiddleware().prepend(listenerMiddleware.middleware), }) ``` @@ -88,7 +93,7 @@ const myMiddleware = (storeAPI) => (next) => (action) => { However, it would be nice to have a more structured API to help abstract this process. -The `createActionListenerMiddleware` API provides that structure. +The `createListenerMiddleware` API provides that structure. For more background and debate over the use cases and API design, see the original discussion issue and PR: @@ -98,13 +103,13 @@ For more background and debate over the use cases and API design, see the origin ## API Reference -`createActionListenerMiddleware` lets you add listeners by providing a "listener callback" containing additional logic, a way to specify when that callback should run based on dispatched actions or state changes, and whether your callback should run before or after the action is processed by the reducers. +`createListenerMiddleware` lets you add listeners by providing an "effect callback" containing additional logic, and a way to specify when that callback should run based on dispatched actions or state changes. -The middleware then gives you access to `dispatch` and `getState` for use in your listener callback's logic. Callbacks can also unsubscribe to stop from being run again in the future. +The middleware then gives you access to `dispatch` and `getState` for use in your effect callback's logic, similar to thunks. The listener also receives a set of async workflow functions like `take`, `condition`, `pause`, `fork`, and `unsubscribe`, which allow writing more complex async logic. -Listeners can be defined statically by calling `listenerMiddleware.addListener()` during setup, or added and removed dynamically at runtime with special `dispatch(addListenerAction())` and `dispatch(removeListenerAction())` actions. +Listeners can be defined statically by calling `listenerMiddleware.startListening()` during setup, or added and removed dynamically at runtime with special `dispatch(addListener())` and `dispatch(removeListener())` actions. -### `createActionListenerMiddleware: (options?: CreateMiddlewareOptions) => Middleware` +### `createListenerMiddleware: (options?: CreateMiddlewareOptions) => ListenerMiddlewareInstance` Creates an instance of the middleware, which should then be added to the store via `configureStore`'s `middleware` parameter. @@ -114,9 +119,16 @@ Current options are: - `onError`: an optional error handler that gets called with synchronous and async errors raised by `listener` and synchronous errors thrown by `predicate`. -### `listenerMiddleware.addListener(options: AddListenerOptions) : Unsubscribe` +`createListenerMiddleware` returns an object (similar to how `createSlice` does), with the following fields: + +- `middleware`: the actual listener middleware instance. Add this to `configureStore()` +- `startListening`: adds a single listener entry to this specific middleware instance +- `stopListening`: removes a single listener entry from this specific middleware instance +- `clearListeners`: removes all listener entries from this specific middleware instance + +### `startListening(options: AddListenerOptions) : Unsubscribe` -Statically adds a new listener callback to the middleware. +Statically adds a new listener entry to the middleware. The available options are: @@ -142,7 +154,8 @@ interface AddListenerOptions { // 4) Return true based on a combination of action + state predicate?: ListenerPredicate - listener: (action: Action, listenerApi: ListenerApi) => void | Promise + // The actual callback to run when the action is matched + effect: (action: Action, listenerApi: ListenerApi) => void | Promise } ``` @@ -152,13 +165,13 @@ These are all acceptable: ```ts // 1) Action type string -middleware.addListener({ type: 'todos/todoAdded', listener }) +startListening({ type: 'todos/todoAdded', listener }) // 2) RTK action creator -middleware.addListener({ actionCreator: todoAdded, listener }) +startListening({ actionCreator: todoAdded, listener }) // 3) RTK matcher function -middleware.addListener({ matcher: isAnyOf(todoAdded, todoToggled), listener }) +startListening({ matcher: isAnyOf(todoAdded, todoToggled), listener }) // 4) Listener predicate -middleware.addListener({ +startListening({ predicate: (action, currentState, previousState) => { // return true when the listener should run }, @@ -166,61 +179,71 @@ middleware.addListener({ }) ``` +Note that the `predicate` option actually allows matching solely against state-related checks, such as "did `state.x` change" or "the current value of `state.x` matches some criteria", regardless of the actual action. + The ["matcher" utility functions included in RTK](https://redux-toolkit.js.org/api/matching-utilities) are acceptable as predicates. The return value is a standard `unsubscribe()` callback that will remove this listener. If you try to add a listener entry but another entry with this exact function reference already exists, no new entry will be added, and the existing `unsubscribe` method will be returned. -The `listener` callback will receive the current action as its first argument, as well as a "listener API" object similar to the "thunk API" object in `createAsyncThunk`. +The `effect` callback will receive the current action as its first argument, as well as a "listener API" object similar to the "thunk API" object in `createAsyncThunk`. All listener predicates and callbacks are checked _after_ the root reducer has already processed the action and updated the state. The `listenerApi.getOriginalState()` method can be used to get the state value that existed before the action that triggered this listener was processed. -### `listenerMiddleware.removeListener(options: AddListenerOptions): boolean` +### `stopListening(options: AddListenerOptions): boolean` -Removes a given listener. It accepts the same arguments as `middleware.addListener()`. It checks for an existing listener entry by comparing the function references of `listener` and the provided `actionCreator/matcher/predicate` function or `type` string. +Removes a given listener. It accepts the same arguments as `startListening()`. It checks for an existing listener entry by comparing the function references of `listener` and the provided `actionCreator/matcher/predicate` function or `type` string. -Returns `true` if the `options.listener` listener has been removed, `false` if no subscription matching the input provided has been found. +Returns `true` if the `options.effect` listener has been removed, or `false` if no subscription matching the input provided has been found. ```ts // 1) Action type string -middleware.removeListener({ type: 'todos/todoAdded', listener }) +stopListening({ type: 'todos/todoAdded', listener }) // 2) RTK action creator -middleware.removeListener({ actionCreator: todoAdded, listener }) +stopListening({ actionCreator: todoAdded, listener }) // 3) RTK matcher function -middleware.removeListener({ matcher, listener }) +stopListening({ matcher, listener }) // 4) Listener predicate -middleware.removeListener({ predicate, listener }) +stopListening({ predicate, listener }) ``` -### `listenerMiddleware.clearListeners(): void` +### `clearListeners(): void` Removes all current listener entries. This is most likely useful for test scenarios where a single middleware or store instance might be used in multiple tests, as well as some app cleanup situations. -### `addListenerAction` +### `addListener` -A standard RTK action creator. Dispatching this action tells the middleware to dynamically add a new listener at runtime. It accepts exactly the same options as `middleware.addListener()` +A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically add a new listener at runtime. It accepts exactly the same options as `startListening()` Dispatching this action returns an `unsubscribe()` callback from `dispatch`. ```js // Per above, provide `predicate` or any of the other comparison options -const unsubscribe = store.dispatch(addListenerAction({ predicate, listener })) +const unsubscribe = store.dispatch(addListener({ predicate, listener })) ``` -### `removeListenerAction` +### `removeListener` -A standard RTK action creator. Dispatching this action tells the middleware to dynamically remove a listener at runtime. Accepts the same arguments as `middleware.removeListener()`. +A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically remove a listener at runtime. Accepts the same arguments as `stopListening()`. Returns `true` if the `options.listener` listener has been removed, `false` if no subscription matching the input provided has been found. ```js -store.dispatch(removeListenerAction({ predicate, listener })) +store.dispatch(removeListener({ predicate, listener })) +``` + +### `removeAllListeners` + +A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically remove all listeners at runtime. + +```js +store.dispatch(removeAllListeners()) ``` ### `listenerApi` The `listenerApi` object is the second argument to each listener callback. It contains several utility functions that may be called anywhere inside the listener's logic. These can be divided into several categories: -#### Store Methods +#### Store Interaction Methods - `dispatch: Dispatch`: the standard `store.dispatch` method - `getState: () => State`: the standard `store.getState` method @@ -245,7 +268,7 @@ Dynamically unsubscribing and re-subscribing this listener allows for more compl #### Conditional Workflow Execution -- `take: (predicate: ListenerPredicate, timeout?: number) => Promise<[Action, State, State] | null>`: returns a promise that will resolve when the `predicate` returns `true`. The return value is the `[action, currentState, previousState]` combination that the predicate saw as arguments. If a `timeout` is provided and expires if a `timeout` is provided and expires first. the promise resolves to `null`. +- `take: (predicate: ListenerPredicate, timeout?: number) => Promise<[Action, State, State] | null>`: returns a promise that will resolve when the `predicate` returns `true`. The return value is the `[action, currentState, previousState]` combination that the predicate saw as arguments. If a `timeout` is provided and expires first, the promise resolves to `null`. - `condition: (predicate: ListenerPredicate, timeout?: number) => Promise`: Similar to `take`, but resolves to `true` if the predicate succeeds, and `false` if a `timeout` is provided and expires first. This allows async logic to pause and wait for some condition to occur before continuing. See "Writing Async Workflows" below for details on usage. - `delay: (timeoutMs: number) => Promise`: returns a cancelation-aware promise that resolves after the timeout, or rejects if canceled before the expiration - `pause: (promise: Promise) => Promise`: accepts any promise, and returns a cancelation-aware promise that either resolves with the argument promise or rejects if canceled before the resolution @@ -316,9 +339,9 @@ As of v0.5.0, the middleware does include several async workflow primitives that The most common expected usage is "run some logic after a given action was dispatched". For example, you could set up a simple analytics tracker by looking for certain actions and sending extracted data to the server, including pulling user details from the store: ```js -middleware.addListener({ +listenerMiddleware.startListening({ matcher: isAnyOf(action1, action2, action3), - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const user = selectUserDetails(listenerApi.getState()) const { specialData } = action.meta @@ -328,12 +351,32 @@ middleware.addListener({ }) ``` +However, the `predicate` option also allows triggering logic when some state value has changed, or when the state matches a particular condition: + +```js +listenerMiddleware.startListening({ + predicate: (action, currentState, previousState) => { + // Trigger logic whenever this field changes + return currentState.counter.value !== previousState.counter.value + }, + effect, +}) + +listenerMiddleware.startListening({ + predicate: (action, currentState, previousState) => { + // Trigger logic after every action if this condition is true + return currentState.counter.value > 3 + }, + effect, +}) +``` + You could also implement a generic API fetching capability, where the UI dispatches a plain action describing the type of resource to be requested, and the middleware automatically fetches it and dispatches a result action: ```js -middleware.addListener({ +listenerMiddleware.startListening({ actionCreator: resourceRequested, - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { const { name, args } = action.payload listenerApi.dispatch(resourceLoading()) @@ -343,13 +386,13 @@ middleware.addListener({ }) ``` -The `listenerApi.unsubscribe` method may be used at any time, and will remove the listener from handling any future actions. As an example, you could create a one-shot listener by unconditionally calling `unsubscribe()` in the body - it would run the first time the relevant action is seen, and then immediately stop and not handle any future actions. +The `listenerApi.unsubscribe` method may be used at any time, and will remove the listener from handling any future actions. As an example, you could create a one-shot listener by unconditionally calling `unsubscribe()` in the body - it would run the first time the relevant action is seen, and then immediately stop and not handle any future actions. (The middleware actually uses this technique internally for the `take/condition` methods) ### Writing Async Workflows with Conditions One of the great strengths of both sagas and observables is their support for complex async workflows, including stopping and starting behavior based on specific dispatched actions. However, the weakness is that both require mastering a complex API with many unique operators (effects methods like `call()` and `fork()` for sagas, RxJS operators for observables), and both add a significant amount to application bundle size. -While this middleware is _not_ at all meant to fully replace those, it has some ability to implement long-running async workflows as well. +While the listener middleware is _not_ meant to fully replace sagas or observables, it does provide a carefully chosen set of APIs to implement long-running async workflows as well. Listeners can use the `condition` and `take` methods in `listenerApi` to wait until some action is dispatched or state check is met. The `condition` method is directly inspired by [the `condition` function in Temporal.io's workflow API](https://docs.temporal.io/docs/typescript/workflows/#condition) (credit to [@swyx](https://twitter.com/swyx) for the suggestion!), and `take` is inspired by [the `take` effect from Redux-Saga](https://redux-saga.js.org/docs/api#takepattern). @@ -380,16 +423,14 @@ test('condition method resolves promise when there is a timeout', async () => { let finalCount = 0 let listenerStarted = false - middleware.addListener( - // @ts-expect-error state declaration not yet working right - (action, currentState: CounterState) => { + listenerMiddleware.startListening({ + predicate: (action, currentState: CounterState) => { return increment.match(action) && currentState.value === 0 }, - async (action, listenerApi) => { + effect: async (action, listenerApi) => { listenerStarted = true // Wait for either the counter to hit 3, or 50ms to elapse const result = await listenerApi.condition( - // @ts-expect-error state declaration not yet working right (action, currentState: CounterState) => { return currentState.value === 3 }, @@ -399,11 +440,10 @@ test('condition method resolves promise when there is a timeout', async () => { // In this test, we expect the timeout to happen first expect(result).toBe(false) // Save the state for comparison outside the listener - const latestState = listenerApi.getState() as CounterState + const latestState = listenerApi.getState() finalCount = latestState.value }, - { when: 'beforeReducer' } - ) + }) store.dispatch(increment()) // The listener should have started right away @@ -423,16 +463,16 @@ test('condition method resolves promise when there is a timeout', async () => { ### Cancelation and Task Management -As of 0.5.0, the middleware now supports cancelation of running listener instances, `take/condition`/pause/delay` functions, and "child tasks", with an implementation based on [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). +As of 0.5.0, the middleware now supports cancelation of running listener instances, `take/condition/pause/delay` functions, and "child tasks", with an implementation based on [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). The `listenerApi.pause/delay()` functions provide a cancelation-aware way to have the current listener sleep. `pause()` accepts a promise, while `delay` accepts a timeout value. If the listener is canceled while waiting, a `TaskAbortError` will be thrown. In addition, both `take` and `condition` support cancelation interruption as well. `listenerApi.fork()` can used to launch "child tasks" that can do additional work. These can be waited on to collect their results. An example of this might look like: ```ts -middleware.addListener({ +listenerMiddleware.startListening({ actionCreator: increment, - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { // Spawn a child task and start it immediately const task = listenerApi.fork(async (forkApi) => { // Artificially wait a bit inside the child @@ -462,9 +502,9 @@ test('debounce / takeLatest', async () => { // Ref: https://redux-saga.js.org/docs/api#debouncems-pattern-saga-args // Ref: https://redux-saga.js.org/docs/api#takelatestpattern-saga-args - addListener({ + listenerMiddleware.startListening({ actionCreator: increment, - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { // Cancel any in-progress instances of this listener listenerApi.cancelActiveListeners() @@ -480,9 +520,9 @@ test('takeLeading', async () => { // Starts listener on first action, ignores others until task completes // Ref: https://redux-saga.js.org/docs/api#takeleadingpattern-saga-args - addListener({ + listenerMiddleware.startListening({ actionCreator: increment, - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { listenerCalls++ // Stop listening for this action @@ -504,9 +544,9 @@ test('canceled', async () => { let canceledCheck = false // Example of canceling prior instances conditionally and checking cancelation - addListener({ + listenerMiddleware.startListening({ matcher: isAnyOf(increment, decrement, incrementByAmount), - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { if (increment.match(action)) { // Have this branch wait around to be canceled by the other try { @@ -533,27 +573,28 @@ test('canceled', async () => { ### TypeScript Usage -The code is fully typed. However, the `middleware.addListener` and `addListenerAction` functions do not know what the store's `RootState` type looks like by default, so `getState()` will return `unknown`. +The code is fully typed. However, the `startListening` and `addListener` functions do not know what the store's `RootState` type looks like by default, so `getState()` will return `unknown`. To fix this, the middleware provides types for defining "pre-typed" versions of those methods, similar to the pattern used for defing pre-typed React-Redux hooks: ```ts -// middleware.ts +// listenerMiddleware.ts import { - createActionListener, - addListenerAction, + createListenerMiddleware, + addListener, +} from '@rtk-incubator/action-listener-middleware' +import type { + TypedStartListening, TypedAddListener, - TypedAddListenerAction, } from '@rtk-incubator/action-listener-middleware' -import { RootState } from './store' +import type { RootState } from './store' -export const listenerMiddleware = createActionListenerMiddleware() +export const listenerMiddleware = createListenerMiddleware() -export const addAppListener = - listenerMiddleware.addListener as TypedAddListener -export const addAppListenerAction = - addListenerAction as TypedAddListenerAction +export const startAppListening = + listenerMiddleware.startListening as TypedStartListening +export const addAppListener = addListener as TypedAddListenern ``` Then import and use those pre-typed versions in your components. diff --git a/packages/action-listener-middleware/src/index.ts b/packages/action-listener-middleware/src/index.ts index 9aebcb791b..0a1c56c943 100644 --- a/packages/action-listener-middleware/src/index.ts +++ b/packages/action-listener-middleware/src/index.ts @@ -9,25 +9,25 @@ import type { import { createAction, nanoid } from '@reduxjs/toolkit' import type { - ActionListenerMiddleware, + ListenerMiddleware, + ListenerMiddlewareInstance, AddListenerOverloads, - AnyActionListenerPredicate, + AnyListenerPredicate, CreateListenerMiddlewareOptions, TypedActionCreator, + TypedStartListening, TypedAddListener, - TypedAddListenerAction, TypedCreateListenerEntry, FallbackAddListenerOptions, ListenerEntry, ListenerErrorHandler, Unsubscribe, - WithMiddlewareType, TakePattern, ListenerErrorInfo, ForkedTaskExecutor, ForkedTask, - TypedRemoveListenerAction, TypedRemoveListener, + TypedStopListening, } from './types' import { assertFunction, catchRejection } from './utils' import { TaskAbortError } from './exceptions' @@ -40,16 +40,15 @@ import { } from './task' export { TaskAbortError } from './exceptions' export type { - ActionListener, - ActionListenerMiddleware, - ActionListenerMiddlewareAPI, - ActionListenerOptions, + ListenerEffect, + ListenerMiddleware, + ListenerEffectAPI, CreateListenerMiddlewareOptions, ListenerErrorHandler, + TypedStartListening, TypedAddListener, - TypedAddListenerAction, + TypedStopListening, TypedRemoveListener, - TypedRemoveListenerAction, Unsubscribe, ForkedTaskExecutor, ForkedTask, @@ -69,7 +68,7 @@ const { assign } = Object */ const INTERNAL_NIL_TOKEN = {} as const -const alm = 'actionListenerMiddleware' as const +const alm = 'listenerMiddleware' as const const createFork = (parentAbortSignal: AbortSignal) => { return (taskExecutor: ForkedTaskExecutor): ForkedTask => { @@ -100,17 +99,17 @@ const createFork = (parentAbortSignal: AbortSignal) => { } const createTakePattern = ( - addListener: AddListenerOverloads>, + startListening: AddListenerOverloads>, signal: AbortSignal ): TakePattern => { /** - * A function that takes an ActionListenerPredicate and an optional timeout, + * A function that takes a ListenerPredicate and an optional timeout, * and resolves when either the predicate returns `true` based on an action * state combination or when the timeout expires. * If the parent listener is canceled while waiting, this will throw a * TaskAbortError. */ - const take = async

>( + const take = async

>( predicate: P, timeout: number | undefined ) => { @@ -121,9 +120,9 @@ const createTakePattern = ( const tuplePromise = new Promise<[AnyAction, S, S]>((resolve) => { // Inside the Promise, we synchronously add the listener. - unsubscribe = addListener({ + unsubscribe = startListening({ predicate: predicate as any, - listener: (action, listenerApi): void => { + effect: (action, listenerApi): void => { // One-shot listener that cleans up as soon as the predicate passes listenerApi.unsubscribe() // Resolve the promise with the same arguments the predicate saw @@ -158,14 +157,12 @@ const createTakePattern = ( } } - return (( - predicate: AnyActionListenerPredicate, - timeout: number | undefined - ) => catchRejection(take(predicate, timeout))) as TakePattern + return ((predicate: AnyListenerPredicate, timeout: number | undefined) => + catchRejection(take(predicate, timeout))) as TakePattern } const getListenerEntryPropsFrom = (options: FallbackAddListenerOptions) => { - let { type, actionCreator, matcher, predicate, listener } = options + let { type, actionCreator, matcher, predicate, effect } = options if (type) { predicate = createAction(type).match @@ -182,21 +179,21 @@ const getListenerEntryPropsFrom = (options: FallbackAddListenerOptions) => { ) } - assertFunction(listener, 'options.listener') + assertFunction(effect, 'options.listener') - return { predicate, type, listener } + return { predicate, type, effect } } /** Accepts the possible options for creating a listener, and returns a formatted listener entry */ export const createListenerEntry: TypedCreateListenerEntry = ( options: FallbackAddListenerOptions ) => { - const { type, predicate, listener } = getListenerEntryPropsFrom(options) + const { type, predicate, effect } = getListenerEntryPropsFrom(options) const id = nanoid() const entry: ListenerEntry = { id, - listener, + effect, type, predicate, pending: new Set(), @@ -248,21 +245,21 @@ const safelyNotifyError = ( /** * @alpha */ -export const addListenerAction = createAction( +export const addListener = createAction( `${alm}/add` -) as TypedAddListenerAction +) as TypedAddListener /** * @alpha */ -export const clearListenerMiddlewareAction = createAction(`${alm}/clear`) +export const removeAllListeners = createAction(`${alm}/removeAll`) /** * @alpha */ -export const removeListenerAction = createAction( +export const removeListener = createAction( `${alm}/remove` -) as TypedRemoveListenerAction +) as TypedRemoveListener const defaultErrorHandler: ListenerErrorHandler = (...args: unknown[]) => { console.error(`${alm}/error`, ...args) @@ -300,9 +297,9 @@ export function createListenerMiddleware< return undefined } - const addListener = (options: FallbackAddListenerOptions) => { + const startListening = (options: FallbackAddListenerOptions) => { let entry = findListenerEntry( - (existingEntry) => existingEntry.listener === options.listener + (existingEntry) => existingEntry.effect === options.effect ) if (!entry) { @@ -312,8 +309,8 @@ export function createListenerMiddleware< return insertEntry(entry) } - const removeListener = (options: FallbackAddListenerOptions): boolean => { - const { type, listener, predicate } = getListenerEntryPropsFrom(options) + const stopListening = (options: FallbackAddListenerOptions): boolean => { + const { type, effect, predicate } = getListenerEntryPropsFrom(options) const entry = findListenerEntry((entry) => { const matchPredicateOrType = @@ -321,7 +318,7 @@ export function createListenerMiddleware< ? entry.type === type : entry.predicate === predicate - return matchPredicateOrType && entry.listener === listener + return matchPredicateOrType && entry.effect === effect }) entry?.unsubscribe() @@ -336,18 +333,21 @@ export function createListenerMiddleware< getOriginalState: () => S ) => { const internalTaskController = new AbortController() - const take = createTakePattern(addListener, internalTaskController.signal) + const take = createTakePattern( + startListening, + internalTaskController.signal + ) try { entry.pending.add(internalTaskController) await Promise.resolve( - entry.listener( + entry.effect( action, // Use assign() rather than ... to avoid extra helper functions added to bundle assign({}, api, { getOriginalState, condition: ( - predicate: AnyActionListenerPredicate, + predicate: AnyListenerPredicate, timeout?: number ) => take(predicate, timeout).then(Boolean), take, @@ -374,7 +374,7 @@ export function createListenerMiddleware< } catch (listenerError) { if (!(listenerError instanceof TaskAbortError)) { safelyNotifyError(onError, listenerError, { - raisedBy: 'listener', + raisedBy: 'effect', }) } } finally { @@ -385,84 +385,76 @@ export function createListenerMiddleware< const clearListenerMiddleware = createClearListenerMiddleware(listenerMap) - const middleware: Middleware< - { - (action: Action<`${typeof alm}/add`>): Unsubscribe - }, - S, - D - > = (api) => (next) => (action) => { - if (addListenerAction.match(action)) { - return addListener(action.payload) - } + const middleware: ListenerMiddleware = + (api) => (next) => (action) => { + if (addListener.match(action)) { + return startListening(action.payload) + } - if (clearListenerMiddlewareAction.match(action)) { - clearListenerMiddleware() - return - } + if (removeAllListeners.match(action)) { + clearListenerMiddleware() + return + } - if (removeListenerAction.match(action)) { - return removeListener(action.payload) - } + if (removeListener.match(action)) { + return stopListening(action.payload) + } - // Need to get this state _before_ the reducer processes the action - let originalState: S | typeof INTERNAL_NIL_TOKEN = api.getState() + // Need to get this state _before_ the reducer processes the action + let originalState: S | typeof INTERNAL_NIL_TOKEN = api.getState() - // `getOriginalState` can only be called synchronously. - // @see https://github.com/reduxjs/redux-toolkit/discussions/1648#discussioncomment-1932820 - const getOriginalState = (): S => { - if (originalState === INTERNAL_NIL_TOKEN) { - throw new Error( - `${alm}: getOriginalState can only be called synchronously` - ) + // `getOriginalState` can only be called synchronously. + // @see https://github.com/reduxjs/redux-toolkit/discussions/1648#discussioncomment-1932820 + const getOriginalState = (): S => { + if (originalState === INTERNAL_NIL_TOKEN) { + throw new Error( + `${alm}: getOriginalState can only be called synchronously` + ) + } + + return originalState as S } - return originalState as S - } + let result: unknown - let result: unknown + try { + // Actually forward the action to the reducer before we handle listeners + result = next(action) - try { - // Actually forward the action to the reducer before we handle listeners - result = next(action) - - if (listenerMap.size > 0) { - let currentState = api.getState() - for (let entry of listenerMap.values()) { - let runListener = false - - try { - runListener = entry.predicate(action, currentState, originalState) - } catch (predicateError) { - runListener = false - - safelyNotifyError(onError, predicateError, { - raisedBy: 'predicate', - }) - } + if (listenerMap.size > 0) { + let currentState = api.getState() + for (let entry of listenerMap.values()) { + let runListener = false - if (!runListener) { - continue - } + try { + runListener = entry.predicate(action, currentState, originalState) + } catch (predicateError) { + runListener = false - notifyListener(entry, action, api, getOriginalState) + safelyNotifyError(onError, predicateError, { + raisedBy: 'predicate', + }) + } + + if (!runListener) { + continue + } + + notifyListener(entry, action, api, getOriginalState) + } } + } finally { + // Remove `originalState` store from this scope. + originalState = INTERNAL_NIL_TOKEN } - } finally { - // Remove `originalState` store from this scope. - originalState = INTERNAL_NIL_TOKEN - } - return result - } + return result + } - return assign( + return { middleware, - { - addListener: addListener as TypedAddListener, - removeListener: removeListener as TypedRemoveListener, - clearListeners: clearListenerMiddleware, - }, - {} as WithMiddlewareType - ) + startListening, + stopListening, + clearListeners: clearListenerMiddleware, + } as ListenerMiddlewareInstance } diff --git a/packages/action-listener-middleware/src/tests/effectScenarios.test.ts b/packages/action-listener-middleware/src/tests/effectScenarios.test.ts index 7c1a892e87..87ed4b1eef 100644 --- a/packages/action-listener-middleware/src/tests/effectScenarios.test.ts +++ b/packages/action-listener-middleware/src/tests/effectScenarios.test.ts @@ -35,13 +35,13 @@ describe('Saga-style Effects Scenarios', () => { const { increment, decrement, incrementByAmount } = counterSlice.actions let { reducer } = counterSlice - let middleware: ReturnType + let listenerMiddleware = createListenerMiddleware() + let { middleware, startListening, stopListening } = listenerMiddleware let store = configureStore({ reducer, - middleware: (gDM) => gDM().prepend(createListenerMiddleware()), + middleware: (gDM) => gDM().prepend(middleware), }) - // let middleware: ActionListenerMiddleware //: ReturnType const testAction1 = createAction('testAction1') type TestAction1 = ReturnType @@ -52,8 +52,6 @@ describe('Saga-style Effects Scenarios', () => { type RootState = ReturnType - let addListener: TypedAddListener - function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } @@ -64,8 +62,9 @@ describe('Saga-style Effects Scenarios', () => { }) beforeEach(() => { - middleware = createListenerMiddleware() - addListener = middleware.addListener as TypedAddListener + listenerMiddleware = createListenerMiddleware() + middleware = listenerMiddleware.middleware + startListening = listenerMiddleware.startListening store = configureStore({ reducer, middleware: (gDM) => gDM().prepend(middleware), @@ -79,9 +78,9 @@ describe('Saga-style Effects Scenarios', () => { let listenerCalls = 0 let workPerformed = 0 - addListener({ + startListening({ actionCreator: increment, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { listenerCalls++ // Stop listening until further notice @@ -123,9 +122,9 @@ describe('Saga-style Effects Scenarios', () => { let listenerCalls = 0 let workPerformed = 0 - addListener({ + startListening({ actionCreator: increment, - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { listenerCalls++ // Cancel any in-progress instances of this listener @@ -165,9 +164,9 @@ describe('Saga-style Effects Scenarios', () => { // NOTE: This is already the default behavior - nothing special here! let listenerCalls = 0 - addListener({ + startListening({ actionCreator: increment, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { listenerCalls++ }, }) @@ -186,9 +185,9 @@ describe('Saga-style Effects Scenarios', () => { let listenerCalls = 0 let workPerformed = 0 - addListener({ + startListening({ actionCreator: increment, - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { listenerCalls++ // Stop listening for this action @@ -245,9 +244,9 @@ describe('Saga-style Effects Scenarios', () => { let childResult = 0 - addListener({ + startListening({ actionCreator: increment, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { const childOutput = 42 // Spawn a child job and start it immediately const result = await listenerApi.fork(async () => { @@ -278,9 +277,9 @@ describe('Saga-style Effects Scenarios', () => { let childResult = 0 let listenerCompleted = false - addListener({ + startListening({ actionCreator: increment, - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { // Spawn a child job and start it immediately const forkedTask = listenerApi.fork(async () => { // Artificially wait a bit inside the child @@ -315,9 +314,9 @@ describe('Saga-style Effects Scenarios', () => { let canceledAndCaught = false let canceledCheck = false - addListener({ + startListening({ matcher: isAnyOf(increment, decrement, incrementByAmount), - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { if (increment.match(action)) { // Have this branch wait around to be canceled by the other try { diff --git a/packages/action-listener-middleware/src/tests/fork.test.ts b/packages/action-listener-middleware/src/tests/fork.test.ts index b665b017ca..fa6cae9ea4 100644 --- a/packages/action-listener-middleware/src/tests/fork.test.ts +++ b/packages/action-listener-middleware/src/tests/fork.test.ts @@ -52,14 +52,18 @@ describe('fork', () => { }, }) const { increment, decrement, incrementByAmount } = counterSlice.actions - let middleware = createListenerMiddleware() + let listenerMiddleware = createListenerMiddleware() + let { middleware, startListening, stopListening } = listenerMiddleware let store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) beforeEach(() => { - middleware = createListenerMiddleware() + listenerMiddleware = createListenerMiddleware() + middleware = listenerMiddleware.middleware + startListening = listenerMiddleware.startListening + stopListening = listenerMiddleware.stopListening store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), @@ -70,9 +74,9 @@ describe('fork', () => { let hasRunSyncExector = false let hasRunAsyncExecutor = false - middleware.addListener({ + startListening({ actionCreator: increment, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { listenerApi.fork(() => { hasRunSyncExector = true }) @@ -97,9 +101,9 @@ describe('fork', () => { it('runs forked tasks that are cancelled if parent listener is cancelled', async () => { const deferredForkedTaskError = deferred() - middleware.addListener({ + startListening({ actionCreator: increment, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { listenerApi.cancelActiveListeners() const result = await listenerApi.fork(async () => { await delay(20) @@ -124,9 +128,9 @@ describe('fork', () => { it('synchronously throws TypeError error if the provided executor is not a function', () => { const invalidExecutors = [null, {}, undefined, 1] - middleware.addListener({ + startListening({ predicate: () => true, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { invalidExecutors.forEach((invalidExecutor) => { let caughtError try { @@ -148,9 +152,9 @@ describe('fork', () => { it('does not run an executor if the task is synchronously cancelled', async () => { const storeStateAfter = deferred() - middleware.addListener({ + startListening({ actionCreator: increment, - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { const forkedTask = listenerApi.fork(() => { listenerApi.dispatch(decrement()) listenerApi.dispatch(decrement()) @@ -248,9 +252,9 @@ describe('fork', () => { let deferredResult = deferred() let forkedTask: any = {} - middleware.addListener({ + startListening({ predicate: () => true, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { forkedTask = listenerApi.fork(executor) deferredResult.resolve(await forkedTask.result) @@ -277,9 +281,9 @@ describe('fork', () => { test('forkApi.delay rejects as soon as the task is cancelled', async () => { let deferredResult = deferred() - middleware.addListener({ + startListening({ actionCreator: increment, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { const forkedTask = listenerApi.fork(async (forkApi) => { await forkApi.delay(100) @@ -306,9 +310,9 @@ describe('fork', () => { // Unfortunately we cannot test declaratively unhandleRejections in jest: https://github.com/facebook/jest/issues/5620 // This test just fails if an `unhandledRejection` occurs. - middleware.addListener({ + startListening({ actionCreator: increment, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { const completedTask = listenerApi.fork(async (forkApi) => { forkApi.signal.addEventListener( 'abort', @@ -349,9 +353,9 @@ describe('fork', () => { test('forkApi.pause rejects if task is cancelled', async () => { let deferredResult = deferred() - middleware.addListener({ + startListening({ actionCreator: increment, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { const forkedTask = listenerApi.fork(async (forkApi) => { await forkApi.pause(delay(30)) @@ -374,9 +378,9 @@ describe('fork', () => { test('forkApi.pause rejects if listener is cancelled', async () => { let deferredResult = deferred() - middleware.addListener({ + startListening({ actionCreator: increment, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { listenerApi.cancelActiveListeners() const forkedTask = listenerApi.fork(async (forkApi) => { await forkApi.pause(delay(30)) diff --git a/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts b/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts index f6cb2c8fcd..11af019fac 100644 --- a/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts +++ b/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts @@ -11,24 +11,21 @@ import type { AnyAction, PayloadAction, Action } from '@reduxjs/toolkit' import { createListenerMiddleware, createListenerEntry, - addListenerAction, - removeListenerAction, + addListener, + removeListener, TaskAbortError, - clearListenerMiddlewareAction, + removeAllListeners, } from '../index' import type { - ActionListenerMiddlewareAPI, - TypedAddListenerAction, + ListenerEffect, + ListenerEffectAPI, TypedAddListener, + TypedStartListening, Unsubscribe, - ActionListenerMiddleware, + ListenerMiddleware, } from '../index' -import type { - ActionListener, - AddListenerOverloads, - TypedRemoveListenerAction, -} from '../types' +import type { AddListenerOverloads, TypedRemoveListener } from '../types' const middlewareApi = { getState: expect.any(Function), @@ -111,7 +108,7 @@ export function expectNotAny>(t: T): T { describe('createListenerMiddleware', () => { let store = configureStore({ reducer: () => 42, - middleware: (gDM) => gDM().prepend(createListenerMiddleware()), + middleware: (gDM) => gDM().prepend(createListenerMiddleware().middleware), }) interface CounterState { @@ -141,12 +138,12 @@ describe('createListenerMiddleware', () => { } let reducer: jest.Mock - let middleware: ReturnType - let addTypedListenerAction = - addListenerAction as TypedAddListenerAction + let listenerMiddleware = createListenerMiddleware() + let { middleware, startListening, stopListening, clearListeners } = + listenerMiddleware + let addTypedListenerAction = addListener as TypedAddListener let removeTypedListenerAction = - removeListenerAction as TypedRemoveListenerAction - // let middleware: ActionListenerMiddleware //: ReturnType + removeListener as TypedRemoveListener const testAction1 = createAction('testAction1') type TestAction1 = ReturnType @@ -160,7 +157,11 @@ describe('createListenerMiddleware', () => { }) beforeEach(() => { - middleware = createListenerMiddleware() + listenerMiddleware = createListenerMiddleware() + middleware = listenerMiddleware.middleware + startListening = listenerMiddleware.startListening + stopListening = listenerMiddleware.stopListening + clearListeners = listenerMiddleware.clearListeners reducer = jest.fn(() => ({})) store = configureStore({ reducer, @@ -171,25 +172,26 @@ describe('createListenerMiddleware', () => { describe('Middleware setup', () => { test('Allows passing an extra argument on middleware creation', () => { const originalExtra = 42 - const middleware = createListenerMiddleware({ + const listenerMiddleware = createListenerMiddleware({ extra: originalExtra, }) const store = configureStore({ reducer: counterSlice.reducer, - middleware: (gDM) => gDM().prepend(middleware), + middleware: (gDM) => gDM().prepend(listenerMiddleware.middleware), }) let foundExtra = null - const typedAddListener = middleware.addListener as TypedAddListener< - CounterState, - typeof store.dispatch, - typeof originalExtra - > + const typedAddListener = + listenerMiddleware.startListening as TypedStartListening< + CounterState, + typeof store.dispatch, + typeof originalExtra + > typedAddListener({ matcher: (action: AnyAction): action is AnyAction => true, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { foundExtra = listenerApi.extra expectType(listenerApi.extra) }, @@ -208,52 +210,48 @@ describe('createListenerMiddleware', () => { describe('Subscription and unsubscription', () => { test('directly subscribing', () => { - const listener = jest.fn((_: TestAction1) => {}) + const effect = jest.fn((_: TestAction1) => {}) - middleware.addListener({ + startListening({ actionCreator: testAction1, - listener: listener, + effect: effect, }) store.dispatch(testAction1('a')) store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) - expect(listener.mock.calls).toEqual([ + expect(effect.mock.calls).toEqual([ [testAction1('a'), middlewareApi], [testAction1('c'), middlewareApi], ]) }) - test('removeListener returns true if an entry has been unsubscribed, false otherwise', () => { - const listener = jest.fn((_: TestAction1) => {}) + test('stopListening returns true if an entry has been unsubscribed, false otherwise', () => { + const effect = jest.fn((_: TestAction1) => {}) - middleware.addListener({ + startListening({ actionCreator: testAction1, - listener, + effect, }) - expect( - middleware.removeListener({ actionCreator: testAction2, listener }) - ).toBe(false) - expect( - middleware.removeListener({ actionCreator: testAction1, listener }) - ).toBe(true) + expect(stopListening({ actionCreator: testAction2, effect })).toBe(false) + expect(stopListening({ actionCreator: testAction1, effect })).toBe(true) }) - test('dispatch(removeListenerAction({...})) returns true if an entry has been unsubscribed, false otherwise', () => { - const listener = jest.fn((_: TestAction1) => {}) + test('dispatch(removeListener({...})) returns true if an entry has been unsubscribed, false otherwise', () => { + const effect = jest.fn((_: TestAction1) => {}) - middleware.addListener({ + startListening({ actionCreator: testAction1, - listener, + effect, }) expect( store.dispatch( removeTypedListenerAction({ actionCreator: testAction2, - listener, + effect, }) ) ).toBe(false) @@ -261,45 +259,45 @@ describe('createListenerMiddleware', () => { store.dispatch( removeTypedListenerAction({ actionCreator: testAction1, - listener, + effect, }) ) ).toBe(true) }) test('can subscribe with a string action type', () => { - const listener = jest.fn((_: AnyAction) => {}) + const effect = jest.fn((_: AnyAction) => {}) store.dispatch( - addListenerAction({ + addListener({ type: testAction2.type, - listener, + effect, }) ) store.dispatch(testAction2('b')) - expect(listener.mock.calls).toEqual([[testAction2('b'), middlewareApi]]) + expect(effect.mock.calls).toEqual([[testAction2('b'), middlewareApi]]) - store.dispatch(removeListenerAction({ type: testAction2.type, listener })) + store.dispatch(removeListener({ type: testAction2.type, effect })) store.dispatch(testAction2('b')) - expect(listener.mock.calls).toEqual([[testAction2('b'), middlewareApi]]) + expect(effect.mock.calls).toEqual([[testAction2('b'), middlewareApi]]) }) test('can subscribe with a matcher function', () => { - const listener = jest.fn((_: AnyAction) => {}) + const effect = jest.fn((_: AnyAction) => {}) const isAction1Or2 = isAnyOf(testAction1, testAction2) - const unsubscribe = middleware.addListener({ + const unsubscribe = startListening({ matcher: isAction1Or2, - listener: listener, + effect: effect, }) store.dispatch(testAction1('a')) store.dispatch(testAction2('b')) store.dispatch(testAction3('c')) - expect(listener.mock.calls).toEqual([ + expect(effect.mock.calls).toEqual([ [testAction1('a'), middlewareApi], [testAction2('b'), middlewareApi], ]) @@ -307,7 +305,7 @@ describe('createListenerMiddleware', () => { unsubscribe() store.dispatch(testAction2('b')) - expect(listener.mock.calls).toEqual([ + expect(effect.mock.calls).toEqual([ [testAction1('a'), middlewareApi], [testAction2('b'), middlewareApi], ]) @@ -321,25 +319,25 @@ describe('createListenerMiddleware', () => { let listener1Calls = 0 - middleware.addListener({ + startListening({ predicate: (action, state) => { return (state as CounterState).value > 1 }, - listener: () => { + effect: () => { listener1Calls++ }, }) let listener2Calls = 0 - middleware.addListener({ + startListening({ predicate: (action, state, prevState) => { return ( (state as CounterState).value > 1 && (prevState as CounterState).value % 2 === 0 ) }, - listener: () => { + effect: () => { listener2Calls++ }, }) @@ -354,33 +352,33 @@ describe('createListenerMiddleware', () => { }) test('subscribing with the same listener will not make it trigger twice (like EventTarget.addEventListener())', () => { - const listener = jest.fn((_: TestAction1) => {}) + const effect = jest.fn((_: TestAction1) => {}) - middleware.addListener({ + startListening({ actionCreator: testAction1, - listener, + effect, }) - middleware.addListener({ + startListening({ actionCreator: testAction1, - listener, + effect, }) store.dispatch(testAction1('a')) store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) - expect(listener.mock.calls).toEqual([ + expect(effect.mock.calls).toEqual([ [testAction1('a'), middlewareApi], [testAction1('c'), middlewareApi], ]) }) test('unsubscribing via callback', () => { - const listener = jest.fn((_: TestAction1) => {}) + const effect = jest.fn((_: TestAction1) => {}) - const unsubscribe = middleware.addListener({ + const unsubscribe = startListening({ actionCreator: testAction1, - listener, + effect, }) store.dispatch(testAction1('a')) @@ -388,37 +386,37 @@ describe('createListenerMiddleware', () => { store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) - expect(listener.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) + expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) }) test('directly unsubscribing', () => { - const listener = jest.fn((_: TestAction1) => {}) + const effect = jest.fn((_: TestAction1) => {}) - middleware.addListener({ + startListening({ actionCreator: testAction1, - listener, + effect, }) store.dispatch(testAction1('a')) - middleware.removeListener({ actionCreator: testAction1, listener }) + stopListening({ actionCreator: testAction1, effect }) store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) - expect(listener.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) + expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) }) test('unsubscribing without any subscriptions does not trigger an error', () => { - middleware.removeListener({ matcher: testAction1.match, listener: noop }) + stopListening({ matcher: testAction1.match, effect: noop }) }) test('subscribing via action', () => { - const listener = jest.fn((_: TestAction1) => {}) + const effect = jest.fn((_: TestAction1) => {}) store.dispatch( - addListenerAction({ + addListener({ actionCreator: testAction1, - listener, + effect, }) ) @@ -426,57 +424,53 @@ describe('createListenerMiddleware', () => { store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) - expect(listener.mock.calls).toEqual([ + expect(effect.mock.calls).toEqual([ [testAction1('a'), middlewareApi], [testAction1('c'), middlewareApi], ]) }) test('unsubscribing via callback from dispatch', () => { - const listener = jest.fn((_: TestAction1) => {}) + const effect = jest.fn((_: TestAction1) => {}) const unsubscribe = store.dispatch( - addListenerAction({ + addListener({ actionCreator: testAction1, - listener, + effect, }) ) - // TODO Fix this type error - return type isn't getting picked up right - // @ts-expect-error + expectType(unsubscribe) store.dispatch(testAction1('a')) - // TODO This return type isn't correct - // @ts-expect-error + unsubscribe() store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) - expect(listener.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) + expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) }) test('unsubscribing via action', () => { - const listener = jest.fn((_: TestAction1) => {}) + const effect = jest.fn((_: TestAction1) => {}) - middleware.addListener({ + startListening({ actionCreator: testAction1, - listener, + effect, }) - middleware.addListener({ + startListening({ actionCreator: testAction1, - listener, + effect, }) store.dispatch(testAction1('a')) - store.dispatch( - removeListenerAction({ actionCreator: testAction1, listener }) - ) + store.dispatch(removeListener({ actionCreator: testAction1, effect })) store.dispatch(testAction2('b')) store.dispatch(testAction1('c')) - expect(listener.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) + expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) }) const addListenerOptions: [ @@ -487,7 +481,7 @@ describe('createListenerMiddleware', () => { typeof store.getState, typeof store.dispatch >, - 'listener' + 'effect' > ][] = [ ['predicate', { predicate: () => true }], @@ -499,32 +493,32 @@ describe('createListenerMiddleware', () => { test.each(addListenerOptions)( 'add and remove listener with "%s" param correctly', (_, params) => { - const listener: ActionListener< + const effect: ListenerEffect< AnyAction, typeof store.getState, typeof store.dispatch > = jest.fn() - middleware.addListener({ ...params, listener } as any) + startListening({ ...params, effect } as any) store.dispatch(testAction1('a')) - expect(listener).toBeCalledTimes(1) + expect(effect).toBeCalledTimes(1) - middleware.removeListener({ ...params, listener } as any) + stopListening({ ...params, effect } as any) store.dispatch(testAction1('b')) - expect(listener).toBeCalledTimes(1) + expect(effect).toBeCalledTimes(1) } ) const unforwardedActions: [string, AnyAction][] = [ [ - 'addListenerAction', - addListenerAction({ actionCreator: testAction1, listener: noop }), + 'addListener', + addListener({ actionCreator: testAction1, effect: noop }), ], [ - 'removeListenerAction', - removeListenerAction({ actionCreator: testAction1, listener: noop }), + 'removeListener', + removeListener({ actionCreator: testAction1, effect: noop }), ], ] test.each(unforwardedActions)( @@ -544,24 +538,24 @@ describe('createListenerMiddleware', () => { ) test('"can unsubscribe via middleware api', () => { - const listener = jest.fn( - (action: TestAction1, api: ActionListenerMiddlewareAPI) => { + const effect = jest.fn( + (action: TestAction1, api: ListenerEffectAPI) => { if (action.payload === 'b') { api.unsubscribe() } } ) - middleware.addListener({ + startListening({ actionCreator: testAction1, - listener, + effect, }) store.dispatch(testAction1('a')) store.dispatch(testAction1('b')) store.dispatch(testAction1('c')) - expect(listener.mock.calls).toEqual([ + expect(effect.mock.calls).toEqual([ [testAction1('a'), middlewareApi], [testAction1('b'), middlewareApi], ]) @@ -569,9 +563,9 @@ describe('createListenerMiddleware', () => { test('Can re-subscribe via middleware api', async () => { let numListenerRuns = 0 - middleware.addListener({ + startListening({ actionCreator: testAction1, - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { numListenerRuns++ listenerApi.unsubscribe() @@ -605,9 +599,9 @@ describe('createListenerMiddleware', () => { let listener2Calls = 0 let listener3Calls = 0 - middleware.addListener({ + startListening({ actionCreator: testAction1, - async listener(_, listenerApi) { + async effect(_, listenerApi) { listener1Calls++ listenerApi.signal.addEventListener( 'abort', @@ -619,22 +613,22 @@ describe('createListenerMiddleware', () => { }, }) - middleware.addListener({ - actionCreator: clearListenerMiddlewareAction, - listener() { + startListening({ + actionCreator: removeAllListeners, + effect() { listener2Calls++ }, }) - middleware.addListener({ + startListening({ predicate: () => true, - listener() { + effect() { listener3Calls++ }, }) store.dispatch(testAction1('a')) - store.dispatch(clearListenerMiddlewareAction()) + store.dispatch(removeAllListeners()) store.dispatch(testAction1('b')) expect(await listener1Test).toBe(1) expect(listener1Calls).toBe(1) @@ -648,9 +642,9 @@ describe('createListenerMiddleware', () => { let listener1Calls = 0 let listener2Calls = 0 - middleware.addListener({ + startListening({ actionCreator: testAction1, - async listener(_, listenerApi) { + async effect(_, listenerApi) { listener1Calls++ listenerApi.signal.addEventListener( 'abort', @@ -662,16 +656,16 @@ describe('createListenerMiddleware', () => { }, }) - middleware.addListener({ + startListening({ actionCreator: testAction2, - listener() { + effect() { listener2Calls++ }, }) store.dispatch(testAction1('a')) - middleware.clearListeners() + clearListeners() store.dispatch(testAction1('b')) store.dispatch(testAction2('c')) @@ -682,9 +676,9 @@ describe('createListenerMiddleware', () => { test('clear() cancels all running forked tasks', async () => { const fork1Test = deferred() - middleware.addListener({ + startListening({ actionCreator: testAction1, - async listener(_, { fork }) { + async effect(_, { fork }) { const taskResult = await fork(() => { return 3 }).result @@ -694,7 +688,7 @@ describe('createListenerMiddleware', () => { store.dispatch(testAction1('a')) - middleware.clearListeners() + clearListeners() store.dispatch(testAction1('b')) expect(await fork1Test).toHaveProperty('status', 'cancelled') @@ -709,9 +703,9 @@ describe('createListenerMiddleware', () => { }) let listener1Calls = 0 - middleware.addListener({ + startListening({ actionCreator: increment, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const stateBefore = listenerApi.getOriginalState() as CounterState const currentState = listenerApi.getOriginalState() as CounterState @@ -722,9 +716,9 @@ describe('createListenerMiddleware', () => { }) let listener2Calls = 0 - middleware.addListener({ + startListening({ actionCreator: increment, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { // TODO getState functions aren't typed right here const stateBefore = listenerApi.getOriginalState() as CounterState const currentState = listenerApi.getOriginalState() as CounterState @@ -744,20 +738,18 @@ describe('createListenerMiddleware', () => { test('getOriginalState can only be invoked synchronously', async () => { const onError = jest.fn() - middleware = createListenerMiddleware({ onError }) + const listenerMiddleware = createListenerMiddleware({ + onError, + }) + const { middleware, startListening } = listenerMiddleware const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), }) - let appMidleware = middleware as ActionListenerMiddleware< - CounterState, - typeof store.dispatch - > - - appMidleware.addListener({ + startListening({ actionCreator: increment, - async listener(_, listenerApi) { + async effect(_, listenerApi) { const runIncrementBy = () => { listenerApi.dispatch( counterSlice.actions.incrementByAmount( @@ -784,9 +776,9 @@ describe('createListenerMiddleware', () => { expect(onError).toBeCalledWith( new Error( - 'actionListenerMiddleware: getOriginalState can only be called synchronously' + 'listenerMiddleware: getOriginalState can only be called synchronously' ), - { raisedBy: 'listener' } + { raisedBy: 'effect' } ) expect(store.getState()).toEqual({ value: 3 }) }) @@ -794,11 +786,11 @@ describe('createListenerMiddleware', () => { test('by default, actions are forwarded to the store', () => { reducer.mockClear() - const listener = jest.fn((_: TestAction1) => {}) + const effect = jest.fn((_: TestAction1) => {}) - middleware.addListener({ + startListening({ actionCreator: testAction1, - listener, + effect, }) store.dispatch(testAction1('a')) @@ -813,9 +805,9 @@ describe('createListenerMiddleware', () => { // Unfortunately we cannot test declaratively unhandleRejections in jest: https://github.com/facebook/jest/issues/5620 // This test just fails if an `unhandledRejection` occurs. - middleware.addListener({ + startListening({ actionCreator: increment, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { listenerApi.unsubscribe() listenerApi.signal.addEventListener( 'abort', @@ -826,9 +818,9 @@ describe('createListenerMiddleware', () => { }, }) - middleware.addListener({ + startListening({ actionCreator: increment, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { listenerApi.cancelActiveListeners() listenerApi.signal.addEventListener( 'abort', @@ -852,18 +844,18 @@ describe('createListenerMiddleware', () => { test('Continues running other listeners if one of them raises an error', () => { const matcher = (action: any): action is any => true - middleware.addListener({ + startListening({ matcher, - listener: () => { + effect: () => { throw new Error('Panic!') }, }) - const listener = jest.fn(() => {}) - middleware.addListener({ matcher, listener }) + const effect = jest.fn(() => {}) + startListening({ matcher, effect }) store.dispatch(testAction1('a')) - expect(listener.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) + expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) }) test('Continues running other listeners if a predicate raises an error', () => { @@ -871,15 +863,15 @@ describe('createListenerMiddleware', () => { const firstListener = jest.fn(() => {}) const secondListener = jest.fn(() => {}) - middleware.addListener({ + startListening({ // @ts-expect-error matcher: (arg: unknown): arg is unknown => { throw new Error('Predicate Panic!') }, - listener: firstListener, + effect: firstListener, }) - middleware.addListener({ matcher, listener: secondListener }) + startListening({ matcher, effect: secondListener }) store.dispatch(testAction1('a')) expect(firstListener).not.toHaveBeenCalled() @@ -890,9 +882,10 @@ describe('createListenerMiddleware', () => { test('Notifies sync listener errors to `onError`, if provided', async () => { const onError = jest.fn() - middleware = createListenerMiddleware({ + const listenerMiddleware = createListenerMiddleware({ onError, }) + const { middleware, startListening } = listenerMiddleware reducer = jest.fn(() => ({})) store = configureStore({ reducer, @@ -903,9 +896,9 @@ describe('createListenerMiddleware', () => { const matcher = (action: any): action is any => true - middleware.addListener({ + startListening({ matcher, - listener: () => { + effect: () => { throw listenerError }, }) @@ -914,15 +907,16 @@ describe('createListenerMiddleware', () => { await delay(100) expect(onError).toBeCalledWith(listenerError, { - raisedBy: 'listener', + raisedBy: 'effect', }) }) test('Notifies async listeners errors to `onError`, if provided', async () => { const onError = jest.fn() - middleware = createListenerMiddleware({ + const listenerMiddleware = createListenerMiddleware({ onError, }) + const { middleware, startListening } = listenerMiddleware reducer = jest.fn(() => ({})) store = configureStore({ reducer, @@ -932,9 +926,9 @@ describe('createListenerMiddleware', () => { const listenerError = new Error('Boom!') const matcher = (action: any): action is any => true - middleware.addListener({ + startListening({ matcher, - listener: async () => { + effect: async () => { throw listenerError }, }) @@ -944,7 +938,7 @@ describe('createListenerMiddleware', () => { await delay(100) expect(onError).toBeCalledWith(listenerError, { - raisedBy: 'listener', + raisedBy: 'effect', }) }) }) @@ -958,9 +952,9 @@ describe('createListenerMiddleware', () => { let result = null - middleware.addListener({ + startListening({ predicate: incrementByAmount.match, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { result = await listenerApi.take(increment.match) }, }) @@ -980,9 +974,9 @@ describe('createListenerMiddleware', () => { let takeResult: any = undefined - middleware.addListener({ + startListening({ predicate: incrementByAmount.match, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { takeResult = await listenerApi.take(increment.match, 15) }, }) @@ -1001,9 +995,9 @@ describe('createListenerMiddleware', () => { let stateBefore: any = undefined let stateCurrent: any = undefined - middleware.addListener({ + startListening({ predicate: incrementByAmount.match, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { stateBefore = listenerApi.getState() takeResult = await listenerApi.take(increment.match, 50) stateCurrent = listenerApi.getState() @@ -1025,14 +1019,14 @@ describe('createListenerMiddleware', () => { let finalCount = 0 let listenerStarted = false - middleware.addListener({ + startListening({ predicate: (action, _, previousState) => { return ( increment.match(action) && (previousState as CounterState).value === 0 ) }, - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { listenerStarted = true const result = await listenerApi.condition((action, currentState) => { return (currentState as CounterState).value === 3 @@ -1065,14 +1059,14 @@ describe('createListenerMiddleware', () => { let finalCount = 0 let listenerStarted = false - middleware.addListener({ + startListening({ predicate: (action, currentState) => { return ( increment.match(action) && (currentState as CounterState).value === 1 ) }, - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { listenerStarted = true const result = await listenerApi.condition((action, currentState) => { return (currentState as CounterState).value === 3 @@ -1104,9 +1098,9 @@ describe('createListenerMiddleware', () => { }) const godotPauseTrigger = deferred() - middleware.addListener({ + startListening({ predicate: () => true, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { listenerApi.unsubscribe() // run once listenerApi.signal.addEventListener( 'abort', @@ -1116,9 +1110,9 @@ describe('createListenerMiddleware', () => { }, }) - middleware.addListener({ + startListening({ predicate: () => true, - listener: async (_, listenerApi) => { + effect: async (_, listenerApi) => { listenerApi.cancelActiveListeners() listenerApi.signal.addEventListener( 'abort', @@ -1141,9 +1135,9 @@ describe('createListenerMiddleware', () => { let jobsContinued = 0 let jobsCanceled = 0 - middleware.addListener({ + startListening({ actionCreator: increment, - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { jobsStarted++ if (jobsStarted < 3) { @@ -1175,7 +1169,8 @@ describe('createListenerMiddleware', () => { }) describe('Type tests', () => { - const middleware = createListenerMiddleware() + const listenerMiddleware = createListenerMiddleware() + const { middleware, startListening } = listenerMiddleware const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), @@ -1192,7 +1187,7 @@ describe('createListenerMiddleware', () => { expectUnknown(previousState) return true }, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectUnknown(listenerState) listenerApi.dispatch((dispatch, getState) => { @@ -1202,7 +1197,7 @@ describe('createListenerMiddleware', () => { }, }) - middleware.addListener({ + startListening({ predicate: ( action, currentState, @@ -1212,12 +1207,12 @@ describe('createListenerMiddleware', () => { expectUnknown(previousState) return true }, - listener: (action, listenerApi) => {}, + effect: (action, listenerApi) => {}, }) - middleware.addListener({ + startListening({ matcher: increment.match, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectUnknown(listenerState) listenerApi.dispatch((dispatch, getState) => { @@ -1228,7 +1223,7 @@ describe('createListenerMiddleware', () => { }) store.dispatch( - addListenerAction({ + addListener({ predicate: ( action, currentState, @@ -1238,7 +1233,7 @@ describe('createListenerMiddleware', () => { expectUnknown(previousState) return true }, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectUnknown(listenerState) listenerApi.dispatch((dispatch, getState) => { @@ -1250,9 +1245,9 @@ describe('createListenerMiddleware', () => { ) store.dispatch( - addListenerAction({ + addListener({ matcher: increment.match, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectUnknown(listenerState) // TODO Can't get the thunk dispatch types to carry through @@ -1266,28 +1261,28 @@ describe('createListenerMiddleware', () => { }) test('Action type is inferred from args', () => { - middleware.addListener({ + startListening({ type: 'abcd', - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { expectType<{ type: 'abcd' }>(action) }, }) - middleware.addListener({ + startListening({ actionCreator: incrementByAmount, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { expectType>(action) }, }) - middleware.addListener({ + startListening({ matcher: incrementByAmount.match, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { expectType>(action) }, }) - middleware.addListener({ + startListening({ predicate: ( action, currentState, @@ -1295,43 +1290,43 @@ describe('createListenerMiddleware', () => { ): action is PayloadAction => { return typeof action.payload === 'boolean' }, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { // @ts-expect-error expectExactType>(action) }, }) - middleware.addListener({ + startListening({ predicate: (action, currentState) => { return typeof action.payload === 'number' }, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { expectExactType(action) }, }) store.dispatch( - addListenerAction({ + addListener({ type: 'abcd', - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { expectType<{ type: 'abcd' }>(action) }, }) ) store.dispatch( - addListenerAction({ + addListener({ actionCreator: incrementByAmount, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { expectType>(action) }, }) ) store.dispatch( - addListenerAction({ + addListener({ matcher: incrementByAmount.match, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { expectType>(action) }, }) @@ -1341,7 +1336,7 @@ describe('createListenerMiddleware', () => { test('Can create a pre-typed middleware', () => { const typedMiddleware = createListenerMiddleware() - typedMiddleware.addListener({ + typedMiddleware.startListening({ predicate: ( action, currentState, @@ -1353,7 +1348,7 @@ describe('createListenerMiddleware', () => { expectExactType(previousState) return true }, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType(listenerState) listenerApi.dispatch((dispatch, getState) => { @@ -1364,7 +1359,7 @@ describe('createListenerMiddleware', () => { }) // Can pass a predicate function with fewer args - typedMiddleware.addListener({ + typedMiddleware.startListening({ // TODO Why won't this infer the listener's `action` with implicit argument types? predicate: ( action: AnyAction, @@ -1374,7 +1369,7 @@ describe('createListenerMiddleware', () => { expectExactType(currentState) return true }, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { expectType>(action) const listenerState = listenerApi.getState() @@ -1386,9 +1381,9 @@ describe('createListenerMiddleware', () => { }, }) - typedMiddleware.addListener({ + typedMiddleware.startListening({ actionCreator: incrementByAmount, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType(listenerState) listenerApi.dispatch((dispatch, getState) => { @@ -1411,7 +1406,7 @@ describe('createListenerMiddleware', () => { expectExactType(previousState) return true }, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType(listenerState) listenerApi.dispatch((dispatch, getState) => { @@ -1435,7 +1430,7 @@ describe('createListenerMiddleware', () => { expectExactType(previousState) return true }, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType(listenerState) listenerApi.dispatch((dispatch, getState) => { @@ -1447,11 +1442,11 @@ describe('createListenerMiddleware', () => { ) }) - test('Can create pre-typed versions of addListener and addListenerAction', () => { + test('Can create pre-typed versions of startListening and addListener', () => { const typedAddListener = - middleware.addListener as TypedAddListener + startListening as TypedStartListening const typedAddListenerAction = - addListenerAction as TypedAddListenerAction + addListener as TypedAddListener typedAddListener({ predicate: ( @@ -1465,7 +1460,7 @@ describe('createListenerMiddleware', () => { expectExactType(previousState) return true }, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType(listenerState) // TODO Can't get the thunk dispatch types to carry through @@ -1478,7 +1473,7 @@ describe('createListenerMiddleware', () => { typedAddListener({ matcher: incrementByAmount.match, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType(listenerState) // TODO Can't get the thunk dispatch types to carry through @@ -1502,7 +1497,7 @@ describe('createListenerMiddleware', () => { expectExactType(previousState) return true }, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType(listenerState) listenerApi.dispatch((dispatch, getState) => { @@ -1516,7 +1511,7 @@ describe('createListenerMiddleware', () => { store.dispatch( typedAddListenerAction({ matcher: incrementByAmount.match, - listener: (action, listenerApi) => { + effect: (action, listenerApi) => { const listenerState = listenerApi.getState() expectExactType(listenerState) listenerApi.dispatch((dispatch, getState) => { diff --git a/packages/action-listener-middleware/src/tests/useCases.test.ts b/packages/action-listener-middleware/src/tests/useCases.test.ts index 0060db0146..0bd4a13a7d 100644 --- a/packages/action-listener-middleware/src/tests/useCases.test.ts +++ b/packages/action-listener-middleware/src/tests/useCases.test.ts @@ -35,11 +35,12 @@ const counterSlice = createSlice({ const { increment, decrement, incrementByAmount } = counterSlice.actions describe('Saga-style Effects Scenarios', () => { - let middleware: ReturnType + let listenerMiddleware = createListenerMiddleware() + let { middleware, startListening, stopListening } = listenerMiddleware let store = configureStore({ reducer: counterSlice.reducer, - middleware: (gDM) => gDM().prepend(createListenerMiddleware()), + middleware: (gDM) => gDM().prepend(middleware), }) const testAction1 = createAction('testAction1') @@ -51,15 +52,14 @@ describe('Saga-style Effects Scenarios', () => { type RootState = ReturnType - let addListener: TypedAddListener - function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } beforeEach(() => { - middleware = createListenerMiddleware() - addListener = middleware.addListener as TypedAddListener + listenerMiddleware = createListenerMiddleware() + middleware = listenerMiddleware.middleware + startListening = listenerMiddleware.startListening store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), @@ -113,9 +113,9 @@ describe('Saga-style Effects Scenarios', () => { let pollingTaskStarted = false let pollingTaskCanceled = false - addListener({ + startListening({ actionCreator: eventPollingStarted, - listener: async (action, listenerApi) => { + effect: async (action, listenerApi) => { listenerApi.unsubscribe() // Start a child job that will infinitely loop receiving messages diff --git a/packages/action-listener-middleware/src/types.ts b/packages/action-listener-middleware/src/types.ts index 96e1a25dda..19d343a961 100644 --- a/packages/action-listener-middleware/src/types.ts +++ b/packages/action-listener-middleware/src/types.ts @@ -24,7 +24,7 @@ export interface TypedActionCreator { match: MatchFunction } -export type AnyActionListenerPredicate = ( +export type AnyListenerPredicate = ( action: AnyAction, currentState: State, originalState: State @@ -37,14 +37,8 @@ export type ListenerPredicate = ( ) => action is Action export interface ConditionFunction { - ( - predicate: AnyActionListenerPredicate, - timeout?: number - ): Promise - ( - predicate: AnyActionListenerPredicate, - timeout?: number - ): Promise + (predicate: AnyListenerPredicate, timeout?: number): Promise + (predicate: AnyListenerPredicate, timeout?: number): Promise (predicate: () => boolean, timeout?: number): Promise } @@ -116,7 +110,7 @@ export interface ForkedTask { /** * @alpha */ -export interface ActionListenerMiddlewareAPI< +export interface ListenerEffectAPI< S, D extends Dispatch, ExtraArgument = unknown @@ -131,7 +125,7 @@ export interface ActionListenerMiddlewareAPI< * @example * * ```ts - * middleware.addListener({ + * middleware.startListening({ * predicate: () => true, * async listener(_, { getOriginalState }) { * getOriginalState(); // sync: OK! @@ -180,24 +174,20 @@ export interface ActionListenerMiddlewareAPI< /** * @alpha */ -export type ActionListener< +export type ListenerEffect< A extends AnyAction, S, D extends Dispatch, ExtraArgument = unknown > = ( action: A, - api: ActionListenerMiddlewareAPI + api: ListenerEffectAPI ) => void | Promise export interface ListenerErrorHandler { (error: unknown): void } -export interface ActionListenerOptions { - /** TODO Empty after removing `when` - will leave here for now */ -} - export interface CreateListenerMiddlewareOptions { extra?: ExtraArgument /** @@ -206,7 +196,7 @@ export interface CreateListenerMiddlewareOptions { onError?: ListenerErrorHandler } -export type ActionListenerMiddleware< +export type ListenerMiddleware< S = unknown, // TODO Carry through the thunk extra arg somehow? D extends ThunkDispatch = ThunkDispatch< @@ -217,13 +207,25 @@ export type ActionListenerMiddleware< ExtraArgument = unknown > = Middleware< { - (action: Action<'actionListenerMiddleware/add'>): Unsubscribe + (action: Action<'listenerMiddleware/add'>): Unsubscribe }, S, D -> & { - addListener: AddListenerOverloads - removeListener: RemoveListenerOverloads +> + +export interface ListenerMiddlewareInstance< + S = unknown, + // TODO Carry through the thunk extra arg somehow? + D extends ThunkDispatch = ThunkDispatch< + S, + unknown, + AnyAction + >, + ExtraArgument = unknown +> { + middleware: ListenerMiddleware + startListening: AddListenerOverloads + stopListening: RemoveListenerOverloads /** * Unsubscribes all listeners, cancels running listeners and tasks. */ @@ -236,27 +238,27 @@ export type ActionListenerMiddleware< export type TakePatternOutputWithoutTimeout< State, - Predicate extends AnyActionListenerPredicate + Predicate extends AnyListenerPredicate > = Predicate extends MatchFunction ? Promise<[Action, State, State]> : Promise<[AnyAction, State, State]> export type TakePatternOutputWithTimeout< State, - Predicate extends AnyActionListenerPredicate + Predicate extends AnyListenerPredicate > = Predicate extends MatchFunction ? Promise<[Action, State, State] | null> : Promise<[AnyAction, State, State] | null> export interface TakePattern { - >( + >( predicate: Predicate ): TakePatternOutputWithoutTimeout - >( + >( predicate: Predicate, timeout: number ): TakePatternOutputWithTimeout - >( + >( predicate: Predicate, timeout?: number | undefined ): Promise<[AnyAction, State, State] | null> @@ -272,64 +274,54 @@ export interface AddListenerOverloads< ExtraArgument = unknown > { /** Accepts a "listener predicate" that is also a TS type predicate for the action*/ - >( - options: { - actionCreator?: never - type?: never - matcher?: never - predicate: LP - listener: ActionListener< - ListenerPredicateGuardedActionType, - S, - D, - ExtraArgument - > - } & ActionListenerOptions - ): Return + >(options: { + actionCreator?: never + type?: never + matcher?: never + predicate: LP + effect: ListenerEffect< + ListenerPredicateGuardedActionType, + S, + D, + ExtraArgument + > + }): Return /** Accepts an RTK action creator, like `incrementByAmount` */ - >( - options: { - actionCreator: C - type?: never - matcher?: never - predicate?: never - listener: ActionListener, S, D, ExtraArgument> - } & ActionListenerOptions - ): Return + >(options: { + actionCreator: C + type?: never + matcher?: never + predicate?: never + effect: ListenerEffect, S, D, ExtraArgument> + }): Return /** Accepts a specific action type string */ - ( - options: { - actionCreator?: never - type: T - matcher?: never - predicate?: never - listener: ActionListener, S, D, ExtraArgument> - } & ActionListenerOptions - ): Return + (options: { + actionCreator?: never + type: T + matcher?: never + predicate?: never + effect: ListenerEffect, S, D, ExtraArgument> + }): Return /** Accepts an RTK matcher function, such as `incrementByAmount.match` */ - >( - options: { - actionCreator?: never - type?: never - matcher: M - predicate?: never - listener: ActionListener, S, D, ExtraArgument> - } & ActionListenerOptions - ): Return + >(options: { + actionCreator?: never + type?: never + matcher: M + predicate?: never + effect: ListenerEffect, S, D, ExtraArgument> + }): Return /** Accepts a "listener predicate" that just returns a boolean, no type assertion */ - >( - options: { - actionCreator?: never - type?: never - matcher?: never - predicate: LP - listener: ActionListener - } & ActionListenerOptions - ): Return + >(options: { + actionCreator?: never + type?: never + matcher?: never + predicate: LP + effect: ListenerEffect + }): Return } export type RemoveListenerOverloads< @@ -342,41 +334,41 @@ export interface RemoveListenerAction< S, D extends Dispatch > { - type: 'actionListenerMiddleware/remove' + type: 'listenerMiddleware/remove' payload: { type: string - listener: ActionListener + listener: ListenerEffect } } /** A "pre-typed" version of `addListenerAction`, so the listener args are well-typed */ -export type TypedAddListenerAction< +export type TypedAddListener< S, D extends Dispatch = ThunkDispatch, ExtraArgument = unknown, Payload = ListenerEntry, - T extends string = 'actionListenerMiddleware/add' + T extends string = 'listenerMiddleware/add' > = BaseActionCreator & AddListenerOverloads, S, D, ExtraArgument> /** A "pre-typed" version of `removeListenerAction`, so the listener args are well-typed */ -export type TypedRemoveListenerAction< +export type TypedRemoveListener< S, D extends Dispatch = ThunkDispatch, Payload = ListenerEntry, - T extends string = 'actionListenerMiddleware/remove' + T extends string = 'listenerMiddleware/remove' > = BaseActionCreator & AddListenerOverloads, S, D> -/** A "pre-typed" version of `middleware.addListener`, so the listener args are well-typed */ -export type TypedAddListener< +/** A "pre-typed" version of `middleware.startListening`, so the listener args are well-typed */ +export type TypedStartListening< S, D extends Dispatch = ThunkDispatch, ExtraArgument = unknown > = AddListenerOverloads -/** A "pre-typed" version of `middleware.removeListener`, so the listener args are well-typed */ -export type TypedRemoveListener< +/** A "pre-typed" version of `middleware.stopListening`, so the listener args are well-typed */ +export type TypedStopListening< S, D extends Dispatch = ThunkDispatch > = RemoveListenerOverloads @@ -397,7 +389,7 @@ export type ListenerEntry< D extends Dispatch = Dispatch > = { id: string - listener: ActionListener + effect: ListenerEffect unsubscribe: () => void pending: Set type?: string @@ -415,7 +407,7 @@ export type FallbackAddListenerOptions = { type?: string matcher?: MatchFunction predicate?: ListenerPredicate -} & ActionListenerOptions & { listener: ActionListener } +} & { effect: ListenerEffect } /** * Utility Types @@ -444,7 +436,7 @@ export interface ListenerErrorInfo { /** * Which function has generated the exception. */ - raisedBy: 'listener' | 'predicate' + raisedBy: 'effect' | 'predicate' } /** From 75e8275c0629198555b52aa045a01ec49d30fd6b Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Sat, 12 Feb 2022 23:29:30 +0100 Subject: [PATCH 08/27] feat(alm): add meaningful cancellation reasons to TaskAbortError and listenerApi.signal listenerApi.signal.reason can be one of - 'listener-cancelled' - 'listener-completed' forkApi.signal.reason can be one of - 'listener-cancelled' - 'listener-completed' - 'task-cancelled' - 'task-completed' BREAKING CHANGE: renamed TaskAbortError.reason -> TaskAbortError.code --- - Build log ```bash $ yarn workspace @rtk-incubator/action-listener-middleware run build Build "actionListenerMiddleware" to dist/esm: 1739 B: index.modern.js.gz 1566 B: index.modern.js.br Build "actionListenerMiddleware" to dist/module: 2.41 kB: index.js.gz 2.15 kB: index.js.br Build "actionListenerMiddleware" to dist/cjs: 2.4 kB: index.js.gz 2.15 kB: index.js.br ``` - Test log ```bash $ yarn workspace @rtk-incubator/action-listener-middleware run test --coverage PASS src/tests/listenerMiddleware.test.ts (5.738 s) PASS src/tests/effectScenarios.test.ts PASS src/tests/fork.test.ts PASS src/tests/useCases.test.ts ---------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s ---------------|---------|----------|---------|---------|------------------- All files | 97.72 | 91.38 | 94.64 | 97.55 | exceptions.ts | 100 | 0 | 100 | 100 | 17 index.ts | 97.4 | 97.5 | 92.5 | 97.32 | 190,214,252-253 task.ts | 97.06 | 80 | 100 | 96.3 | 30 utils.ts | 100 | 85.71 | 100 | 100 | 52 ---------------|---------|----------|---------|---------|------------------- Test Suites: 4 passed, 4 total Tests: 72 passed, 72 total Snapshots: 0 total Time: 6.796 s Ran all test suites. ``` --- .../src/exceptions.ts | 19 +++++-- .../action-listener-middleware/src/index.ts | 49 ++++++++++++------- .../action-listener-middleware/src/task.ts | 13 +++-- .../src/tests/fork.test.ts | 25 +++++++--- .../src/tests/listenerMiddleware.test.ts | 37 +++++++++++++- .../action-listener-middleware/src/types.ts | 6 +++ .../action-listener-middleware/src/utils.ts | 40 +++++++++++++++ 7 files changed, 152 insertions(+), 37 deletions(-) diff --git a/packages/action-listener-middleware/src/exceptions.ts b/packages/action-listener-middleware/src/exceptions.ts index 0e6bdf095a..8374085619 100644 --- a/packages/action-listener-middleware/src/exceptions.ts +++ b/packages/action-listener-middleware/src/exceptions.ts @@ -1,7 +1,20 @@ -export class TaskAbortError implements Error { +import type { SerializedError } from '@reduxjs/toolkit' + +const task = 'task' +const listener = 'listener' +const completed = 'completed' +const cancelled = 'cancelled' + +/* TaskAbortError error codes */ +export const taskCancelled = `${task}-${cancelled}` as const +export const taskCompleted = `${task}-${completed}` as const +export const listenerCancelled = `${listener}-${cancelled}` as const +export const listenerCompleted = `${listener}-${completed}` as const + +export class TaskAbortError implements SerializedError { name = 'TaskAbortError' message = '' - constructor(public reason = 'unknown') { - this.message = `task cancelled (reason: ${reason})` + constructor(public code = 'unknown') { + this.message = `task cancelled (reason: ${code})` } } diff --git a/packages/action-listener-middleware/src/index.ts b/packages/action-listener-middleware/src/index.ts index 0a1c56c943..24a5af3ee4 100644 --- a/packages/action-listener-middleware/src/index.ts +++ b/packages/action-listener-middleware/src/index.ts @@ -29,8 +29,18 @@ import type { TypedRemoveListener, TypedStopListening, } from './types' -import { assertFunction, catchRejection } from './utils' -import { TaskAbortError } from './exceptions' +import { + abortControllerWithReason, + assertFunction, + catchRejection, +} from './utils' +import { + listenerCancelled, + listenerCompleted, + TaskAbortError, + taskCancelled, + taskCompleted, +} from './exceptions' import { runTask, promisifyAbortSignal, @@ -75,21 +85,24 @@ const createFork = (parentAbortSignal: AbortSignal) => { assertFunction(taskExecutor, 'taskExecutor') const childAbortController = new AbortController() const cancel = () => { - childAbortController.abort() + abortControllerWithReason(childAbortController, taskCancelled) } - const result = runTask(async (): Promise => { - validateActive(parentAbortSignal) - validateActive(childAbortController.signal) - const result = (await taskExecutor({ - pause: createPause(childAbortController.signal), - delay: createDelay(childAbortController.signal), - signal: childAbortController.signal, - })) as T - validateActive(parentAbortSignal) - validateActive(childAbortController.signal) - return result - }, cancel) + const result = runTask( + async (): Promise => { + validateActive(parentAbortSignal) + validateActive(childAbortController.signal) + const result = (await taskExecutor({ + pause: createPause(childAbortController.signal), + delay: createDelay(childAbortController.signal), + signal: childAbortController.signal, + })) as T + validateActive(parentAbortSignal) + validateActive(childAbortController.signal) + return result + }, + () => abortControllerWithReason(childAbortController, taskCompleted) + ) return { result, @@ -211,7 +224,7 @@ const createClearListenerMiddleware = ( return () => { listenerMap.forEach((entry) => { entry.pending.forEach((controller) => { - controller.abort() + abortControllerWithReason(controller, listenerCancelled) }) }) @@ -363,7 +376,7 @@ export function createListenerMiddleware< cancelActiveListeners: () => { entry.pending.forEach((controller, _, set) => { if (controller !== internalTaskController) { - controller.abort() + abortControllerWithReason(controller, listenerCancelled) set.delete(controller) } }) @@ -378,7 +391,7 @@ export function createListenerMiddleware< }) } } finally { - internalTaskController.abort() // Notify that the task has completed + abortControllerWithReason(internalTaskController, listenerCompleted) // Notify that the task has completed entry.pending.delete(internalTaskController) } } diff --git a/packages/action-listener-middleware/src/task.ts b/packages/action-listener-middleware/src/task.ts index dbf92a0d5b..8644223b55 100644 --- a/packages/action-listener-middleware/src/task.ts +++ b/packages/action-listener-middleware/src/task.ts @@ -1,6 +1,6 @@ import { TaskAbortError } from './exceptions' -import type { TaskResult } from './types' -import { noop, catchRejection } from './utils' +import type { AbortSignalWithReason, TaskResult } from './types' +import { catchRejection } from './utils' /** * Synchronously raises {@link TaskAbortError} if the task tied to the input `signal` has been cancelled. @@ -8,9 +8,9 @@ import { noop, catchRejection } from './utils' * @param reason * @see {TaskAbortError} */ -export const validateActive = (signal: AbortSignal, reason?: string): void => { +export const validateActive = (signal: AbortSignal): void => { if (signal.aborted) { - throw new TaskAbortError(reason) + throw new TaskAbortError((signal as AbortSignalWithReason).reason) } } @@ -20,12 +20,11 @@ export const validateActive = (signal: AbortSignal, reason?: string): void => { * @returns */ export const promisifyAbortSignal = ( - signal: AbortSignal, - reason?: string + signal: AbortSignalWithReason ): Promise => { return catchRejection( new Promise((_, reject) => { - const notifyRejection = () => reject(new TaskAbortError(reason)) + const notifyRejection = () => reject(new TaskAbortError(signal.reason)) if (signal.aborted) { notifyRejection() diff --git a/packages/action-listener-middleware/src/tests/fork.test.ts b/packages/action-listener-middleware/src/tests/fork.test.ts index fa6cae9ea4..813d4e7827 100644 --- a/packages/action-listener-middleware/src/tests/fork.test.ts +++ b/packages/action-listener-middleware/src/tests/fork.test.ts @@ -4,6 +4,7 @@ import { configureStore, createSlice } from '@reduxjs/toolkit' import type { PayloadAction } from '@reduxjs/toolkit' import type { ForkedTaskExecutor, TaskResult } from '../types' import { createListenerMiddleware, TaskAbortError } from '../index' +import { listenerCancelled, taskCancelled } from '../exceptions' function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -122,7 +123,9 @@ describe('fork', () => { store.dispatch(increment()) store.dispatch(increment()) - expect(await deferredForkedTaskError).toEqual(new TaskAbortError()) + expect(await deferredForkedTaskError).toEqual( + new TaskAbortError(listenerCancelled) + ) }) it('synchronously throws TypeError error if the provided executor is not a function', () => { @@ -193,7 +196,10 @@ describe('fork', () => { desc: 'sync exec - sync cancel', executor: () => 42, cancelAfterMs: -1, - expected: { status: 'cancelled', error: new TaskAbortError() }, + expected: { + status: 'cancelled', + error: new TaskAbortError(taskCancelled), + }, }, { desc: 'sync exec - async cancel', @@ -208,7 +214,10 @@ describe('fork', () => { throw new Error('2020') }, cancelAfterMs: 10, - expected: { status: 'cancelled', error: new TaskAbortError() }, + expected: { + status: 'cancelled', + error: new TaskAbortError(taskCancelled), + }, }, { desc: 'async exec - success', @@ -300,7 +309,7 @@ describe('fork', () => { expect(await deferredResult).toEqual({ status: 'cancelled', - error: new TaskAbortError(), + error: new TaskAbortError(taskCancelled), }) }) @@ -357,12 +366,12 @@ describe('fork', () => { actionCreator: increment, effect: async (_, listenerApi) => { const forkedTask = listenerApi.fork(async (forkApi) => { - await forkApi.pause(delay(30)) + await forkApi.pause(delay(1_000)) return 4 }) - await listenerApi.delay(10) + await Promise.resolve() forkedTask.cancel() deferredResult.resolve(await forkedTask.result) }, @@ -372,7 +381,7 @@ describe('fork', () => { expect(await deferredResult).toEqual({ status: 'cancelled', - error: new TaskAbortError(), + error: new TaskAbortError(taskCancelled), }) }) @@ -396,7 +405,7 @@ describe('fork', () => { expect(await deferredResult).toEqual({ status: 'cancelled', - error: new TaskAbortError(), + error: new TaskAbortError(listenerCancelled), }) }) }) diff --git a/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts b/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts index 11af019fac..97d545b084 100644 --- a/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts +++ b/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts @@ -25,7 +25,12 @@ import type { Unsubscribe, ListenerMiddleware, } from '../index' -import type { AddListenerOverloads, TypedRemoveListener } from '../types' +import type { + AbortSignalWithReason, + AddListenerOverloads, + TypedRemoveListener, +} from '../types' +import { listenerCancelled, listenerCompleted } from '../exceptions' const middlewareApi = { getState: expect.any(Function), @@ -537,6 +542,36 @@ describe('createListenerMiddleware', () => { } ) + test('listenerApi.signal has correct reason when listener is cancelled or completes', async () => { + const notifyDeferred = createAction>('notify-deferred') + + startListening({ + actionCreator: notifyDeferred, + async effect({ payload }, { signal, cancelActiveListeners, delay }) { + signal.addEventListener( + 'abort', + () => { + payload.resolve((signal as AbortSignalWithReason).reason) + }, + { once: true } + ) + + cancelActiveListeners() + delay(10) + }, + }) + + const deferredCancelledSignalReason = store.dispatch( + notifyDeferred(deferred()) + ).payload + const deferredCompletedSignalReason = store.dispatch( + notifyDeferred(deferred()) + ).payload + + expect(await deferredCancelledSignalReason).toBe(listenerCancelled) + expect(await deferredCompletedSignalReason).toBe(listenerCompleted) + }) + test('"can unsubscribe via middleware api', () => { const effect = jest.fn( (action: TestAction1, api: ListenerEffectAPI) => { diff --git a/packages/action-listener-middleware/src/types.ts b/packages/action-listener-middleware/src/types.ts index 19d343a961..312bfe197d 100644 --- a/packages/action-listener-middleware/src/types.ts +++ b/packages/action-listener-middleware/src/types.ts @@ -9,6 +9,12 @@ import type { } from '@reduxjs/toolkit' import type { TaskAbortError } from './exceptions' +/** + * @internal + * At the time of writing `lib.dom.ts` does not provide `abortSignal.reason`. + */ +export type AbortSignalWithReason = AbortSignal & { reason?: T } + /** * Types copied from RTK */ diff --git a/packages/action-listener-middleware/src/utils.ts b/packages/action-listener-middleware/src/utils.ts index cc8044fecc..0d12eb8e90 100644 --- a/packages/action-listener-middleware/src/utils.ts +++ b/packages/action-listener-middleware/src/utils.ts @@ -1,3 +1,5 @@ +import type { AbortSignalWithReason } from './types' + export const assertFunction: ( func: unknown, expected: string @@ -20,3 +22,41 @@ export const catchRejection = ( return promise } + +/** + * Calls `abortController.abort(reason)` and patches `signal.reason`. + * if it is not supported. + * + * At the time of writing `signal.reason` is available in FF chrome, edge node 17 and deno. + * @param abortController + * @param reason + * @returns + * @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/reason + */ +export const abortControllerWithReason = ( + abortController: AbortController, + reason: T +): void => { + type Consumer = (val: T) => void + + const signal = abortController.signal as AbortSignalWithReason + + if (signal.aborted) { + return + } + + // Patch `reason` if necessary. + // - We use defineProperty here because reason is a getter of `AbortSignal.__proto__`. + // - We need to patch 'reason' before calling `.abort()` because listeners to the 'abort' + // event are are notified immediately. + if (!('reason' in signal)) { + Object.defineProperty(signal, 'reason', { + enumerable: true, + value: reason, + configurable: true, + writable: true, + }) + } + + ;(abortController.abort as Consumer)(reason) +} From 8441ec5707ca1ea9f3562e06bbeb2fb4a0f0b9ca Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 12 Feb 2022 18:17:59 -0500 Subject: [PATCH 09/27] Release 0.8.0 --- packages/action-listener-middleware/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/action-listener-middleware/package.json b/packages/action-listener-middleware/package.json index 2032bede30..a17900d43e 100644 --- a/packages/action-listener-middleware/package.json +++ b/packages/action-listener-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@rtk-incubator/action-listener-middleware", - "version": "0.7.0", + "version": "0.8.0", "author": { "name": "Lenz Weber", "email": "mail@phryneas.de", From 010d824f5d89aaeecffd7803d494c325b4cf1780 Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Sun, 13 Feb 2022 10:55:27 +0100 Subject: [PATCH 10/27] fix(alm): apply alm breaking API changes to counter-example --- examples/action-listener/counter/package.json | 2 +- .../counter/src/components/App/App.tsx | 36 +++++++++++++++++++ .../action-listener/counter/src/index.tsx | 22 ++---------- .../counter/src/services/counter/listeners.ts | 20 +++++------ .../counter/src/services/theme/listeners.ts | 25 ++++++------- examples/action-listener/counter/src/store.ts | 32 ++++++++--------- yarn.lock | 10 +++--- 7 files changed, 80 insertions(+), 67 deletions(-) create mode 100644 examples/action-listener/counter/src/components/App/App.tsx diff --git a/examples/action-listener/counter/package.json b/examples/action-listener/counter/package.json index 566062a4f6..f5e5e5b068 100644 --- a/examples/action-listener/counter/package.json +++ b/examples/action-listener/counter/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@reduxjs/toolkit": "^1.6.0-rc.1", - "@rtk-incubator/action-listener-middleware": "^0.6.0", + "@rtk-incubator/action-listener-middleware": "^0.8.0", "@types/node": "^12.0.0", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", diff --git a/examples/action-listener/counter/src/components/App/App.tsx b/examples/action-listener/counter/src/components/App/App.tsx new file mode 100644 index 0000000000..5779032d6d --- /dev/null +++ b/examples/action-listener/counter/src/components/App/App.tsx @@ -0,0 +1,36 @@ +import React, { useEffect } from 'react' +import { Provider } from 'react-redux' +import type { Unsubscribe } from '@reduxjs/toolkit' +import { setupThemeListeners } from '../../services/theme/listeners' +import { setupCounterListeners } from '../../services/counter/listeners' +import { ChangeThemeForm } from '../ChangeThemeForm/ChangeThemeForm' +import { CounterList } from '../CounterList/CounterList' +import { CreateCounterForm } from '../CreateCounterForm/CreateCounterForm' +import { store, startAppListening } from '../../store' + + +export function App() { + useEffect(() => { + const subscriptions: Unsubscribe[] = [ + setupCounterListeners(startAppListening), + setupThemeListeners(startAppListening), + ] + + return () => subscriptions.forEach((unsubscribe) => unsubscribe()) + }, []) + + return ( + + +

+
+

Counter example

+
+ + + +
+ + + ) +} diff --git a/examples/action-listener/counter/src/index.tsx b/examples/action-listener/counter/src/index.tsx index 28724d1316..ededcf300a 100644 --- a/examples/action-listener/counter/src/index.tsx +++ b/examples/action-listener/counter/src/index.tsx @@ -1,29 +1,11 @@ -import React from 'react' import ReactDOM from 'react-dom' import './index.css' -import { Provider } from 'react-redux' import { store } from './store' import { themeActions } from './services/theme/slice' -import { ChangeThemeForm } from './components/ChangeThemeForm/ChangeThemeForm' -import { CounterList } from './components/CounterList/CounterList' -import { CreateCounterForm } from './components/CreateCounterForm/CreateCounterForm' +import { App } from './components/App/App' if (window.matchMedia('(prefers-color-scheme: dark)').matches) { store.dispatch(themeActions.changeColorScheme('dark')) } -ReactDOM.render( - - -
-
-

Counter example

-
- - - -
-
-
, - document.getElementById('root') -) +ReactDOM.render(, document.getElementById('root')) diff --git a/examples/action-listener/counter/src/services/counter/listeners.ts b/examples/action-listener/counter/src/services/counter/listeners.ts index 7d49cc583e..3aa6113493 100644 --- a/examples/action-listener/counter/src/services/counter/listeners.ts +++ b/examples/action-listener/counter/src/services/counter/listeners.ts @@ -1,6 +1,6 @@ import { counterActions, counterSelectors } from './slice' -import { AnyAction, isAllOf, isAnyOf, PayloadAction } from '@reduxjs/toolkit' -import type { AppListenerApi, AppActionListenerMiddleware } from '../../store' +import { AnyAction, isAllOf, isAnyOf, PayloadAction, Unsubscribe } from '@reduxjs/toolkit' +import type { AppListenerEffectAPI, AppStartListening } from '../../store' function shouldStopAsyncTasksOf(id: string) { return isAllOf( @@ -14,7 +14,7 @@ async function onUpdateByPeriodically( { payload: { id, delta }, }: ReturnType, - { dispatch, getState, getOriginalState, condition }: AppListenerApi + { dispatch, getState, getOriginalState, condition }: AppListenerEffectAPI ) { const counter = counterSelectors.selectById(getState(), id) @@ -44,7 +44,7 @@ async function onUpdateAsync( { payload: { id, delta, delayMs }, }: ReturnType, - { condition, dispatch, getState }: AppListenerApi + { condition, dispatch, getState }: AppListenerEffectAPI ) { const counter = counterSelectors.selectById(getState(), id) @@ -70,17 +70,15 @@ async function onUpdateAsync( * }, []); * ``` */ -export function setupCounterListeners( - actionListener: AppActionListenerMiddleware -) { +export function setupCounterListeners(startListening: AppStartListening): Unsubscribe { const subscriptions = [ - actionListener.addListener({ + startListening({ actionCreator: counterActions.updateByPeriodically, - listener: onUpdateByPeriodically, + effect: onUpdateByPeriodically, }), - actionListener.addListener({ + startListening({ actionCreator: counterActions.updateByAsync, - listener: onUpdateAsync, + effect: onUpdateAsync, }), ] diff --git a/examples/action-listener/counter/src/services/theme/listeners.ts b/examples/action-listener/counter/src/services/theme/listeners.ts index 93864d3e6c..b9906f69d4 100644 --- a/examples/action-listener/counter/src/services/theme/listeners.ts +++ b/examples/action-listener/counter/src/services/theme/listeners.ts @@ -1,21 +1,22 @@ import { themeActions } from './slice' -import type { AppActionListenerMiddleware } from '../../store' +import type { AppStartListening } from '../../store' +import { Unsubscribe } from '@reduxjs/toolkit' function onChangeColorScheme( action: ReturnType ) { - if (action.payload === 'light') { - document.documentElement.classList.remove('dark') - } else { - document.documentElement.classList.add('dark') - } + document.documentElement.classList.toggle('dark', action.payload !== 'light') } export function setupThemeListeners( - actionListener: AppActionListenerMiddleware -) { - return actionListener.addListener({ - actionCreator: themeActions.changeColorScheme, - listener: onChangeColorScheme, - }) + startListening: AppStartListening +): Unsubscribe { + const listeners = [ + startListening({ + actionCreator: themeActions.changeColorScheme, + effect: onChangeColorScheme, + }), + ] + + return () => listeners.forEach((unsubscribe) => unsubscribe()) } diff --git a/examples/action-listener/counter/src/store.ts b/examples/action-listener/counter/src/store.ts index dd1a7bd6ab..4c7328299f 100644 --- a/examples/action-listener/counter/src/store.ts +++ b/examples/action-listener/counter/src/store.ts @@ -2,15 +2,15 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' import { counterSlice } from './services/counter/slice' import { - createActionListenerMiddleware, - ActionListenerMiddlewareAPI, - ActionListenerMiddleware, + createListenerMiddleware, + TypedStartListening, + TypedAddListener, + ListenerEffectAPI, + addListener, } from '@rtk-incubator/action-listener-middleware' import { themeSlice } from './services/theme/slice' -import { setupCounterListeners } from './services/counter/listeners' -import { setupThemeListeners } from './services/theme/listeners' -const actionListenerMiddleware = createActionListenerMiddleware({ +const listenerMiddlewareInstance = createListenerMiddleware({ onError: () => console.error, }) @@ -19,7 +19,7 @@ const store = configureStore({ [counterSlice.name]: counterSlice.reducer, [themeSlice.name]: themeSlice.reducer, }, - middleware: (gDM) => gDM().prepend(actionListenerMiddleware), + middleware: (gDM) => gDM().prepend(listenerMiddlewareInstance.middleware), }) export { store } @@ -29,19 +29,15 @@ export type RootState = ReturnType // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} export type AppDispatch = typeof store.dispatch -export type AppListenerApi = ActionListenerMiddlewareAPI -export type AppActionListenerMiddleware = ActionListenerMiddleware< - RootState, - AppDispatch -> +export type AppListenerEffectAPI = ListenerEffectAPI -// Typed version of `actionListenerMiddleware` -export const appActionListener = - actionListenerMiddleware as AppActionListenerMiddleware +export type AppStartListening = TypedStartListening +export type AppAddListener = TypedAddListener + +export const startAppListening = + listenerMiddlewareInstance.startListening as AppStartListening +export const addAppListener = addListener as AppAddListener // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch = () => useDispatch() export const useAppSelector: TypedUseSelectorHook = useSelector - -setupCounterListeners(appActionListener) -setupThemeListeners(appActionListener) diff --git a/yarn.lock b/yarn.lock index d1cc443d13..f6d43f7036 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3673,7 +3673,7 @@ __metadata: resolution: "@examples-action-listener/counter@workspace:examples/action-listener/counter" dependencies: "@reduxjs/toolkit": ^1.6.0-rc.1 - "@rtk-incubator/action-listener-middleware": ^0.6.0 + "@rtk-incubator/action-listener-middleware": ^0.8.0 "@types/node": ^12.0.0 "@types/react": ^17.0.0 "@types/react-dom": ^17.0.0 @@ -5513,12 +5513,12 @@ __metadata: languageName: node linkType: hard -"@rtk-incubator/action-listener-middleware@npm:^0.6.0": - version: 0.6.0 - resolution: "@rtk-incubator/action-listener-middleware@npm:0.6.0" +"@rtk-incubator/action-listener-middleware@npm:^0.8.0": + version: 0.8.0 + resolution: "@rtk-incubator/action-listener-middleware@npm:0.8.0" peerDependencies: "@reduxjs/toolkit": ^1.6.0 - checksum: 01e600a9e513f883e4c6d02cbe4565b9691d6b43ebff432a9ad7f4f96d07c3164c3a0c14fde4391e3d3f65e18753e567b67d9645a2af27daba6b0aadd5fa2066 + checksum: 2e9c0a235758bf2e7915c708ad641a7d3c25b0f7b0da787dca0fbdf73b0f530f3e9923f251232d1650c847e3447e96377ac592d5b167b7b63f216dccbb7c4d61 languageName: node linkType: hard From 8e8676d2189ac8128b1f3b53087f5c4d7f0b6038 Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Sun, 13 Feb 2022 11:02:25 +0100 Subject: [PATCH 11/27] fix(CI): update lockfile --- yarn.lock | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/yarn.lock b/yarn.lock index f6d43f7036..563ce0f517 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5513,16 +5513,7 @@ __metadata: languageName: node linkType: hard -"@rtk-incubator/action-listener-middleware@npm:^0.8.0": - version: 0.8.0 - resolution: "@rtk-incubator/action-listener-middleware@npm:0.8.0" - peerDependencies: - "@reduxjs/toolkit": ^1.6.0 - checksum: 2e9c0a235758bf2e7915c708ad641a7d3c25b0f7b0da787dca0fbdf73b0f530f3e9923f251232d1650c847e3447e96377ac592d5b167b7b63f216dccbb7c4d61 - languageName: node - linkType: hard - -"@rtk-incubator/action-listener-middleware@workspace:packages/action-listener-middleware": +"@rtk-incubator/action-listener-middleware@^0.8.0, @rtk-incubator/action-listener-middleware@workspace:packages/action-listener-middleware": version: 0.0.0-use.local resolution: "@rtk-incubator/action-listener-middleware@workspace:packages/action-listener-middleware" dependencies: From 519ed40202a63a1d05ee528d466fb150bc177d9e Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Sun, 13 Feb 2022 11:10:53 +0100 Subject: [PATCH 12/27] fix(alm): ListenerMiddlewareInstance not exported --- packages/action-listener-middleware/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/action-listener-middleware/src/index.ts b/packages/action-listener-middleware/src/index.ts index 24a5af3ee4..090cfe61b2 100644 --- a/packages/action-listener-middleware/src/index.ts +++ b/packages/action-listener-middleware/src/index.ts @@ -53,6 +53,7 @@ export type { ListenerEffect, ListenerMiddleware, ListenerEffectAPI, + ListenerMiddlewareInstance, CreateListenerMiddlewareOptions, ListenerErrorHandler, TypedStartListening, From 840bc47ca05663c1172cc0970c9169e3c13988ad Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Sat, 26 Feb 2022 13:02:56 +0100 Subject: [PATCH 13/27] fix(alm): prevent zombie listeners caused by forked tasks BREAKING CHANGE: forkedTask.result now rejects if parent listener is either completed or cancelled. --- packages/action-listener-middleware/README.md | 2 +- .../action-listener-middleware/src/index.ts | 11 ++-- .../action-listener-middleware/src/task.ts | 3 +- .../src/tests/fork.test.ts | 54 +++++++++---------- .../src/tests/listenerMiddleware.test.ts | 19 ++++--- .../action-listener-middleware/src/types.ts | 13 +++++ 6 files changed, 58 insertions(+), 44 deletions(-) diff --git a/packages/action-listener-middleware/README.md b/packages/action-listener-middleware/README.md index adc988c21b..17ae551670 100644 --- a/packages/action-listener-middleware/README.md +++ b/packages/action-listener-middleware/README.md @@ -285,7 +285,7 @@ Both these methods are cancelation-aware, and will throw a `TaskAbortError` if t - `fork: (executor: (forkApi: ForkApi) => T | Promise) => ForkedTask`: Launches a "child task" that may be used to accomplish additional work. Accepts any sync or async function as its argument, and returns a `{result, cancel}` object that can be used to check the final status and return value of the child task, or cancel it while in-progress. -Child tasks can be launched, and waited on to collect their return values. The provided `executor` function will be called with a `forkApi` object containing `{pause, delay, signal}`, allowing it to pause or check cancelation status. It can also make use of the `listenerApi` from the listener's scope. +Child tasks can be launched, and waited on to collect their return values. The provided `executor` function will be called asynchronously with a `forkApi` object containing `{pause, delay, signal}`, allowing it to pause or check cancelation status. It can also make use of the `listenerApi` from the listener's scope. An example of this might be a listener that forks a child task containing an infinite loop that listens for events from a server. The parent then uses `listenerApi.condition()` to wait for a "stop" action, and cancels the child task. diff --git a/packages/action-listener-middleware/src/index.ts b/packages/action-listener-middleware/src/index.ts index 090cfe61b2..65541989b2 100644 --- a/packages/action-listener-middleware/src/index.ts +++ b/packages/action-listener-middleware/src/index.ts @@ -28,6 +28,7 @@ import type { ForkedTask, TypedRemoveListener, TypedStopListening, + TaskResult, } from './types' import { abortControllerWithReason, @@ -85,9 +86,6 @@ const createFork = (parentAbortSignal: AbortSignal) => { return (taskExecutor: ForkedTaskExecutor): ForkedTask => { assertFunction(taskExecutor, 'taskExecutor') const childAbortController = new AbortController() - const cancel = () => { - abortControllerWithReason(childAbortController, taskCancelled) - } const result = runTask( async (): Promise => { @@ -98,7 +96,6 @@ const createFork = (parentAbortSignal: AbortSignal) => { delay: createDelay(childAbortController.signal), signal: childAbortController.signal, })) as T - validateActive(parentAbortSignal) validateActive(childAbortController.signal) return result }, @@ -106,8 +103,10 @@ const createFork = (parentAbortSignal: AbortSignal) => { ) return { - result, - cancel, + result: createPause>(parentAbortSignal)(result), + cancel() { + abortControllerWithReason(childAbortController, taskCancelled) + }, } } } diff --git a/packages/action-listener-middleware/src/task.ts b/packages/action-listener-middleware/src/task.ts index 8644223b55..21839abad8 100644 --- a/packages/action-listener-middleware/src/task.ts +++ b/packages/action-listener-middleware/src/task.ts @@ -37,8 +37,9 @@ export const promisifyAbortSignal = ( /** * Runs a task and returns promise that resolves to {@link TaskResult}. - * * Second argument is an optional `cleanUp` function that always runs after task. + * + * **Note:** `runTask` runs the executor in the next microtask. * @returns */ export const runTask = async ( diff --git a/packages/action-listener-middleware/src/tests/fork.test.ts b/packages/action-listener-middleware/src/tests/fork.test.ts index 813d4e7827..c54285d12c 100644 --- a/packages/action-listener-middleware/src/tests/fork.test.ts +++ b/packages/action-listener-middleware/src/tests/fork.test.ts @@ -1,5 +1,5 @@ import type { EnhancedStore } from '@reduxjs/toolkit' -import { configureStore, createSlice } from '@reduxjs/toolkit' +import { configureStore, createSlice, createAction } from '@reduxjs/toolkit' import type { PayloadAction } from '@reduxjs/toolkit' import type { ForkedTaskExecutor, TaskResult } from '../types' @@ -99,24 +99,23 @@ describe('fork', () => { expect(hasRunAsyncExecutor).toBe(true) }) - it('runs forked tasks that are cancelled if parent listener is cancelled', async () => { + test('forkedTask.result rejects TaskAbortError if listener is cancelled', async () => { const deferredForkedTaskError = deferred() startListening({ actionCreator: increment, - effect: async (_, listenerApi) => { + async effect(_, listenerApi) { listenerApi.cancelActiveListeners() - const result = await listenerApi.fork(async () => { - await delay(20) - - throw new Error('unreachable code') - }).result + listenerApi + .fork(async () => { + await delay(10) - if (result.status !== 'ok') { - deferredForkedTaskError.resolve(result.error) - } else { - deferredForkedTaskError.reject(new Error('unreachable code')) - } + throw new Error('unreachable code') + }) + .result.then( + deferredForkedTaskError.resolve, + deferredForkedTaskError.resolve + ) }, }) @@ -386,26 +385,25 @@ describe('fork', () => { }) test('forkApi.pause rejects if listener is cancelled', async () => { - let deferredResult = deferred() + const incrementByInListener = createAction('incrementByInListener') + startListening({ - actionCreator: increment, - effect: async (_, listenerApi) => { + actionCreator: incrementByInListener, + async effect({ payload: amountToIncrement }, listenerApi) { listenerApi.cancelActiveListeners() - const forkedTask = listenerApi.fork(async (forkApi) => { - await forkApi.pause(delay(30)) - - return 4 - }) - deferredResult.resolve(await forkedTask.result) + await listenerApi.fork(async (forkApi) => { + await forkApi.pause(delay(10)) + listenerApi.dispatch(incrementByAmount(amountToIncrement)) + }).result + listenerApi.dispatch(incrementByAmount(2 * amountToIncrement)) }, }) - store.dispatch(increment()) - store.dispatch(increment()) + store.dispatch(incrementByInListener(10)) + store.dispatch(incrementByInListener(100)) - expect(await deferredResult).toEqual({ - status: 'cancelled', - error: new TaskAbortError(listenerCancelled), - }) + await delay(50) + + expect(store.getState().value).toEqual(300) }) }) diff --git a/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts b/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts index 97d545b084..5b600f5e60 100644 --- a/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts +++ b/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts @@ -709,24 +709,27 @@ describe('createListenerMiddleware', () => { }) test('clear() cancels all running forked tasks', async () => { - const fork1Test = deferred() + const store = configureStore({ + reducer: counterSlice.reducer, + middleware: (gDM) => gDM().prepend(middleware), + }) startListening({ actionCreator: testAction1, - async effect(_, { fork }) { - const taskResult = await fork(() => { - return 3 - }).result - fork1Test.resolve(taskResult) + async effect(_, { fork, dispatch }) { + await fork(() => dispatch(incrementByAmount(3))).result + dispatch(incrementByAmount(4)) }, }) + expect(store.getState().value).toBe(0) store.dispatch(testAction1('a')) clearListeners() - store.dispatch(testAction1('b')) - expect(await fork1Test).toHaveProperty('status', 'cancelled') + await Promise.resolve() // Forked tasks run on the next microtask. + + expect(store.getState().value).toBe(0) }) }) diff --git a/packages/action-listener-middleware/src/types.ts b/packages/action-listener-middleware/src/types.ts index 312bfe197d..d2d97e66e5 100644 --- a/packages/action-listener-middleware/src/types.ts +++ b/packages/action-listener-middleware/src/types.ts @@ -105,6 +105,19 @@ export type TaskResult = | TaskCancelled export interface ForkedTask { + /** + * A promise that resolves when the task is either completed or cancelled or rejects + * if parent listener execution is cancelled or completed. + * + * ### Example + * ```ts + * const result = await fork(async (forkApi) => Promise.resolve(4)).result + * + * if(result.status === 'ok') { + * console.log(result.value) // logs 4 + * }} + * ``` + */ result: Promise> /** * Cancel task if it is in progress or not yet started, From 91f4c085cce32ae7173e3b85d1dfdd86bb24cb51 Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Sat, 26 Feb 2022 13:05:43 +0100 Subject: [PATCH 14/27] chore: build '@examples-query-react/*' examples when running "build:examples" --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6f35c2a8c0..02a6bbaa97 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "scripts": { "build": "yarn build:packages", "test": "yarn test:packages", - "build:examples": "yarn workspaces foreach --include '@reduxjs/*' --include '@examples-query-react/*' -vtp run build", + "build:examples": "yarn workspaces foreach --include '@reduxjs/*' --include '@examples-query-react/*' --include '@examples-action-listener/*' -vtp run build", "build:docs": "yarn workspace website run build", "build:packages": "yarn workspaces foreach --include '@reduxjs/*' --include '@rtk-query/*' --include '@rtk-incubator/*' --topological-dev run build", "test:packages": "yarn workspaces foreach --include '@reduxjs/*' --include '@rtk-query/*' --include '@rtk-incubator/*' run test", From 8a13bb03675d052bed628485908e70327c0ea122 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 26 Feb 2022 14:55:08 -0500 Subject: [PATCH 15/27] Move the listener middleware source into the RTK package --- packages/toolkit/src/index.ts | 32 +++++++++++++++++++ .../src/listenerMiddleware}/exceptions.ts | 0 .../src/listenerMiddleware}/index.ts | 16 +++------- .../src/listenerMiddleware}/task.ts | 0 .../tests/effectScenarios.test.ts | 0 .../listenerMiddleware}/tests/fork.test.ts | 0 .../tests/listenerMiddleware.test.ts | 0 .../tests/useCases.test.ts | 0 .../src/listenerMiddleware}/types.ts | 8 ++--- .../src/listenerMiddleware}/utils.ts | 0 10 files changed, 40 insertions(+), 16 deletions(-) rename packages/{action-listener-middleware/src => toolkit/src/listenerMiddleware}/exceptions.ts (100%) rename packages/{action-listener-middleware/src => toolkit/src/listenerMiddleware}/index.ts (98%) rename packages/{action-listener-middleware/src => toolkit/src/listenerMiddleware}/task.ts (100%) rename packages/{action-listener-middleware/src => toolkit/src/listenerMiddleware}/tests/effectScenarios.test.ts (100%) rename packages/{action-listener-middleware/src => toolkit/src/listenerMiddleware}/tests/fork.test.ts (100%) rename packages/{action-listener-middleware/src => toolkit/src/listenerMiddleware}/tests/listenerMiddleware.test.ts (100%) rename packages/{action-listener-middleware/src => toolkit/src/listenerMiddleware}/tests/useCases.test.ts (100%) rename packages/{action-listener-middleware/src => toolkit/src/listenerMiddleware}/types.ts (99%) rename packages/{action-listener-middleware/src => toolkit/src/listenerMiddleware}/utils.ts (100%) diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index 63a04c7c82..f1d6eb51ce 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -150,3 +150,35 @@ export type { export { nanoid } from './nanoid' export { default as isPlainObject } from './isPlainObject' + +export type { + ListenerEffect, + ListenerMiddleware, + ListenerEffectAPI, + ListenerMiddlewareInstance, + CreateListenerMiddlewareOptions, + ListenerErrorHandler, + TypedStartListening, + TypedAddListener, + TypedStopListening, + TypedRemoveListener, + Unsubscribe, + ForkedTaskExecutor, + ForkedTask, + ForkedTaskAPI, + AsyncTaskExecutor, + SyncTaskExecutor, + TaskCancelled, + TaskRejected, + TaskResolved, + TaskResult, +} from './listenerMiddleware/index' + +export { + createListenerMiddleware, + createListenerEntry, + addListener, + removeListener, + removeAllListeners, + TaskAbortError, +} from './listenerMiddleware/index' diff --git a/packages/action-listener-middleware/src/exceptions.ts b/packages/toolkit/src/listenerMiddleware/exceptions.ts similarity index 100% rename from packages/action-listener-middleware/src/exceptions.ts rename to packages/toolkit/src/listenerMiddleware/exceptions.ts diff --git a/packages/action-listener-middleware/src/index.ts b/packages/toolkit/src/listenerMiddleware/index.ts similarity index 98% rename from packages/action-listener-middleware/src/index.ts rename to packages/toolkit/src/listenerMiddleware/index.ts index 65541989b2..47c7024f79 100644 --- a/packages/action-listener-middleware/src/index.ts +++ b/packages/toolkit/src/listenerMiddleware/index.ts @@ -1,12 +1,7 @@ -import type { - Middleware, - Dispatch, - AnyAction, - Action, - ThunkDispatch, - MiddlewareAPI, -} from '@reduxjs/toolkit' -import { createAction, nanoid } from '@reduxjs/toolkit' +import type { Dispatch, AnyAction, MiddlewareAPI } from 'redux' +import type { ThunkDispatch } from 'redux-thunk' +import { createAction } from '../createAction' +import { nanoid } from '../nanoid' import type { ListenerMiddleware, @@ -14,8 +9,6 @@ import type { AddListenerOverloads, AnyListenerPredicate, CreateListenerMiddlewareOptions, - TypedActionCreator, - TypedStartListening, TypedAddListener, TypedCreateListenerEntry, FallbackAddListenerOptions, @@ -27,7 +20,6 @@ import type { ForkedTaskExecutor, ForkedTask, TypedRemoveListener, - TypedStopListening, TaskResult, } from './types' import { diff --git a/packages/action-listener-middleware/src/task.ts b/packages/toolkit/src/listenerMiddleware/task.ts similarity index 100% rename from packages/action-listener-middleware/src/task.ts rename to packages/toolkit/src/listenerMiddleware/task.ts diff --git a/packages/action-listener-middleware/src/tests/effectScenarios.test.ts b/packages/toolkit/src/listenerMiddleware/tests/effectScenarios.test.ts similarity index 100% rename from packages/action-listener-middleware/src/tests/effectScenarios.test.ts rename to packages/toolkit/src/listenerMiddleware/tests/effectScenarios.test.ts diff --git a/packages/action-listener-middleware/src/tests/fork.test.ts b/packages/toolkit/src/listenerMiddleware/tests/fork.test.ts similarity index 100% rename from packages/action-listener-middleware/src/tests/fork.test.ts rename to packages/toolkit/src/listenerMiddleware/tests/fork.test.ts diff --git a/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts similarity index 100% rename from packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts rename to packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts diff --git a/packages/action-listener-middleware/src/tests/useCases.test.ts b/packages/toolkit/src/listenerMiddleware/tests/useCases.test.ts similarity index 100% rename from packages/action-listener-middleware/src/tests/useCases.test.ts rename to packages/toolkit/src/listenerMiddleware/tests/useCases.test.ts diff --git a/packages/action-listener-middleware/src/types.ts b/packages/toolkit/src/listenerMiddleware/types.ts similarity index 99% rename from packages/action-listener-middleware/src/types.ts rename to packages/toolkit/src/listenerMiddleware/types.ts index d2d97e66e5..ce19016148 100644 --- a/packages/action-listener-middleware/src/types.ts +++ b/packages/toolkit/src/listenerMiddleware/types.ts @@ -1,12 +1,12 @@ +import type { PayloadAction } from '../createAction' import type { - PayloadAction, - Middleware, Dispatch, AnyAction, MiddlewareAPI, + Middleware, Action, - ThunkDispatch, -} from '@reduxjs/toolkit' +} from 'redux' +import type { ThunkDispatch } from 'redux-thunk' import type { TaskAbortError } from './exceptions' /** diff --git a/packages/action-listener-middleware/src/utils.ts b/packages/toolkit/src/listenerMiddleware/utils.ts similarity index 100% rename from packages/action-listener-middleware/src/utils.ts rename to packages/toolkit/src/listenerMiddleware/utils.ts From 0cbe6a28227ed238f6fd59536e37bed47aa4c50e Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 26 Feb 2022 17:05:44 -0500 Subject: [PATCH 16/27] Clean up listener middleware TS types - Added public/internal labels on all types - Removed export of `createListenerEntry` - Renamed 'S/D' generic args to 'State/Dispatch' for clarity --- packages/toolkit/src/createAction.ts | 2 +- packages/toolkit/src/index.ts | 1 - .../toolkit/src/listenerMiddleware/types.ts | 279 ++++++++++-------- 3 files changed, 159 insertions(+), 123 deletions(-) diff --git a/packages/toolkit/src/createAction.ts b/packages/toolkit/src/createAction.ts index 3bfaadd19d..8bb88266ef 100644 --- a/packages/toolkit/src/createAction.ts +++ b/packages/toolkit/src/createAction.ts @@ -81,7 +81,7 @@ export type _ActionCreatorWithPreparedPayload< * * @inheritdoc {redux#ActionCreator} */ -interface BaseActionCreator { +export interface BaseActionCreator { type: T match: (action: Action) => action is PayloadAction } diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index f1d6eb51ce..e4d6f5745c 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -176,7 +176,6 @@ export type { export { createListenerMiddleware, - createListenerEntry, addListener, removeListener, removeAllListeners, diff --git a/packages/toolkit/src/listenerMiddleware/types.ts b/packages/toolkit/src/listenerMiddleware/types.ts index ce19016148..25fca90270 100644 --- a/packages/toolkit/src/listenerMiddleware/types.ts +++ b/packages/toolkit/src/listenerMiddleware/types.ts @@ -1,10 +1,10 @@ -import type { PayloadAction } from '../createAction' +import type { PayloadAction, BaseActionCreator } from '../createAction' import type { - Dispatch, + Dispatch as ReduxDispatch, AnyAction, MiddlewareAPI, Middleware, - Action, + Action as ReduxAction, } from 'redux' import type { ThunkDispatch } from 'redux-thunk' import type { TaskAbortError } from './exceptions' @@ -19,41 +19,38 @@ export type AbortSignalWithReason = AbortSignal & { reason?: T } * Types copied from RTK */ -export interface BaseActionCreator { - type: T - match(action: Action): action is PayloadAction -} - +/** @internal */ export interface TypedActionCreator { - (...args: any[]): Action + (...args: any[]): ReduxAction type: Type match: MatchFunction } +/** @internal */ export type AnyListenerPredicate = ( action: AnyAction, currentState: State, originalState: State ) => boolean +/** @public */ export type ListenerPredicate = ( action: AnyAction, currentState: State, originalState: State ) => action is Action +/** @public */ export interface ConditionFunction { (predicate: AnyListenerPredicate, timeout?: number): Promise (predicate: AnyListenerPredicate, timeout?: number): Promise (predicate: () => boolean, timeout?: number): Promise } +/** @internal */ export type MatchFunction = (v: any) => v is T -export interface HasMatchFunction { - match: MatchFunction -} - +/** @public */ export interface ForkedTaskAPI { /** * Returns a promise that resolves when `waitFor` resolves or @@ -74,36 +71,44 @@ export interface ForkedTaskAPI { signal: AbortSignal } +/** @public */ export interface AsyncTaskExecutor { (forkApi: ForkedTaskAPI): Promise } +/** @public */ export interface SyncTaskExecutor { (forkApi: ForkedTaskAPI): T } +/** @public */ export type ForkedTaskExecutor = AsyncTaskExecutor | SyncTaskExecutor +/** @public */ export type TaskResolved = { readonly status: 'ok' readonly value: T } +/** @public */ export type TaskRejected = { readonly status: 'rejected' readonly error: unknown } +/** @public */ export type TaskCancelled = { readonly status: 'cancelled' readonly error: TaskAbortError } +/** @public */ export type TaskResult = | TaskResolved | TaskRejected | TaskCancelled +/** @public */ export interface ForkedTask { /** * A promise that resolves when the task is either completed or cancelled or rejects @@ -126,14 +131,12 @@ export interface ForkedTask { cancel(): void } -/** - * @alpha - */ +/** @public */ export interface ListenerEffectAPI< - S, - D extends Dispatch, + State, + Dispatch extends ReduxDispatch, ExtraArgument = unknown -> extends MiddlewareAPI { +> extends MiddlewareAPI { /** * Returns the store state as it existed when the action was originally dispatched, _before_ the reducers ran. * @@ -158,11 +161,11 @@ export interface ListenerEffectAPI< * }) * ``` */ - getOriginalState: () => S + getOriginalState: () => State unsubscribe(): void subscribe(): void - condition: ConditionFunction - take: TakePattern + condition: ConditionFunction + take: TakePattern cancelActiveListeners: () => void /** * An abort signal whose `aborted` property is set to `true` @@ -190,23 +193,39 @@ export interface ListenerEffectAPI< extra: ExtraArgument } -/** - * @alpha - */ +/** @public */ export type ListenerEffect< - A extends AnyAction, - S, - D extends Dispatch, + Action extends AnyAction, + State, + Dispatch extends ReduxDispatch, ExtraArgument = unknown > = ( - action: A, - api: ListenerEffectAPI + action: Action, + api: ListenerEffectAPI ) => void | Promise +/** + * @public + * Additional infos regarding the error raised. + */ +export interface ListenerErrorInfo { + /** + * Which function has generated the exception. + */ + raisedBy: 'effect' | 'predicate' +} + +/** + * @public + * Gets notified with synchronous and asynchronous errors raised by `listeners` or `predicates`. + * @param error The thrown error. + * @param errorInfo Additional information regarding the thrown error. + */ export interface ListenerErrorHandler { - (error: unknown): void + (error: unknown, errorInfo: ListenerErrorInfo): void } +/** @public */ export interface CreateListenerMiddlewareOptions { extra?: ExtraArgument /** @@ -215,36 +234,41 @@ export interface CreateListenerMiddlewareOptions { onError?: ListenerErrorHandler } +/** @public */ export type ListenerMiddleware< - S = unknown, - // TODO Carry through the thunk extra arg somehow? - D extends ThunkDispatch = ThunkDispatch< - S, + State = unknown, + Dispatch extends ThunkDispatch = ThunkDispatch< + State, unknown, AnyAction >, ExtraArgument = unknown > = Middleware< { - (action: Action<'listenerMiddleware/add'>): Unsubscribe + (action: ReduxAction<'listenerMiddleware/add'>): Unsubscribe }, - S, - D + State, + Dispatch > +/** @public */ export interface ListenerMiddlewareInstance< - S = unknown, - // TODO Carry through the thunk extra arg somehow? - D extends ThunkDispatch = ThunkDispatch< - S, + State = unknown, + Dispatch extends ThunkDispatch = ThunkDispatch< + State, unknown, AnyAction >, ExtraArgument = unknown > { - middleware: ListenerMiddleware - startListening: AddListenerOverloads - stopListening: RemoveListenerOverloads + middleware: ListenerMiddleware + startListening: AddListenerOverloads< + Unsubscribe, + State, + Dispatch, + ExtraArgument + > + stopListening: RemoveListenerOverloads /** * Unsubscribes all listeners, cancels running listeners and tasks. */ @@ -255,6 +279,7 @@ export interface ListenerMiddlewareInstance< * API Function Overloads */ +/** @public */ export type TakePatternOutputWithoutTimeout< State, Predicate extends AnyListenerPredicate @@ -262,6 +287,7 @@ export type TakePatternOutputWithoutTimeout< ? Promise<[Action, State, State]> : Promise<[AnyAction, State, State]> +/** @public */ export type TakePatternOutputWithTimeout< State, Predicate extends AnyListenerPredicate @@ -269,6 +295,7 @@ export type TakePatternOutputWithTimeout< ? Promise<[Action, State, State] | null> : Promise<[AnyAction, State, State] | null> +/** @public */ export interface TakePattern { >( predicate: Predicate @@ -284,24 +311,25 @@ export interface TakePattern { } /** + * @public * The possible overloads and options for defining a listener. The return type of each function is specified as a generic arg, so the overloads can be reused for multiple different functions */ export interface AddListenerOverloads< Return, - S = unknown, - D extends Dispatch = ThunkDispatch, + State = unknown, + Dispatch extends ReduxDispatch = ThunkDispatch, ExtraArgument = unknown > { /** Accepts a "listener predicate" that is also a TS type predicate for the action*/ - >(options: { + >(options: { actionCreator?: never type?: never matcher?: never predicate: LP effect: ListenerEffect< ListenerPredicateGuardedActionType, - S, - D, + State, + Dispatch, ExtraArgument > }): Return @@ -312,7 +340,7 @@ export interface AddListenerOverloads< type?: never matcher?: never predicate?: never - effect: ListenerEffect, S, D, ExtraArgument> + effect: ListenerEffect, State, Dispatch, ExtraArgument> }): Return /** Accepts a specific action type string */ @@ -321,7 +349,7 @@ export interface AddListenerOverloads< type: T matcher?: never predicate?: never - effect: ListenerEffect, S, D, ExtraArgument> + effect: ListenerEffect, State, Dispatch, ExtraArgument> }): Return /** Accepts an RTK matcher function, such as `incrementByAmount.match` */ @@ -330,73 +358,108 @@ export interface AddListenerOverloads< type?: never matcher: M predicate?: never - effect: ListenerEffect, S, D, ExtraArgument> + effect: ListenerEffect, State, Dispatch, ExtraArgument> }): Return /** Accepts a "listener predicate" that just returns a boolean, no type assertion */ - >(options: { + >(options: { actionCreator?: never type?: never matcher?: never predicate: LP - effect: ListenerEffect + effect: ListenerEffect }): Return } +/** @public */ export type RemoveListenerOverloads< - S = unknown, - D extends Dispatch = ThunkDispatch -> = AddListenerOverloads + State = unknown, + Dispatch extends ReduxDispatch = ThunkDispatch +> = AddListenerOverloads +/** @public */ export interface RemoveListenerAction< - A extends AnyAction, - S, - D extends Dispatch + Action extends AnyAction, + State, + Dispatch extends ReduxDispatch > { type: 'listenerMiddleware/remove' payload: { type: string - listener: ListenerEffect + listener: ListenerEffect } } -/** A "pre-typed" version of `addListenerAction`, so the listener args are well-typed */ +/** + * @public + * A "pre-typed" version of `addListenerAction`, so the listener args are well-typed */ export type TypedAddListener< - S, - D extends Dispatch = ThunkDispatch, + State, + Dispatch extends ReduxDispatch = ThunkDispatch< + State, + unknown, + AnyAction + >, ExtraArgument = unknown, - Payload = ListenerEntry, + Payload = ListenerEntry, T extends string = 'listenerMiddleware/add' > = BaseActionCreator & - AddListenerOverloads, S, D, ExtraArgument> + AddListenerOverloads< + PayloadAction, + State, + Dispatch, + ExtraArgument + > -/** A "pre-typed" version of `removeListenerAction`, so the listener args are well-typed */ +/** + * @public + * A "pre-typed" version of `removeListenerAction`, so the listener args are well-typed */ export type TypedRemoveListener< - S, - D extends Dispatch = ThunkDispatch, - Payload = ListenerEntry, + State, + Dispatch extends ReduxDispatch = ThunkDispatch< + State, + unknown, + AnyAction + >, + Payload = ListenerEntry, T extends string = 'listenerMiddleware/remove' > = BaseActionCreator & - AddListenerOverloads, S, D> + AddListenerOverloads, State, Dispatch> -/** A "pre-typed" version of `middleware.startListening`, so the listener args are well-typed */ +/** + * @public + * A "pre-typed" version of `middleware.startListening`, so the listener args are well-typed */ export type TypedStartListening< - S, - D extends Dispatch = ThunkDispatch, + State, + Dispatch extends ReduxDispatch = ThunkDispatch< + State, + unknown, + AnyAction + >, ExtraArgument = unknown -> = AddListenerOverloads +> = AddListenerOverloads -/** A "pre-typed" version of `middleware.stopListening`, so the listener args are well-typed */ +/** @public + * A "pre-typed" version of `middleware.stopListening`, so the listener args are well-typed */ export type TypedStopListening< - S, - D extends Dispatch = ThunkDispatch -> = RemoveListenerOverloads + State, + Dispatch extends ReduxDispatch = ThunkDispatch< + State, + unknown, + AnyAction + > +> = RemoveListenerOverloads -/** A "pre-typed" version of `createListenerEntry`, so the listener args are well-typed */ +/** @public + * A "pre-typed" version of `createListenerEntry`, so the listener args are well-typed */ export type TypedCreateListenerEntry< - S, - D extends Dispatch = ThunkDispatch -> = AddListenerOverloads, S, D> + State, + Dispatch extends ReduxDispatch = ThunkDispatch< + State, + unknown, + AnyAction + > +> = AddListenerOverloads, State, Dispatch> /** * Internal Types @@ -404,23 +467,21 @@ export type TypedCreateListenerEntry< /** @internal An single listener entry */ export type ListenerEntry< - S = unknown, - D extends Dispatch = Dispatch + State = unknown, + Dispatch extends ReduxDispatch = ReduxDispatch > = { id: string - effect: ListenerEffect + effect: ListenerEffect unsubscribe: () => void pending: Set type?: string - predicate: ListenerPredicate -} - -const declaredMiddlewareType: unique symbol = undefined as any -export type WithMiddlewareType> = { - [declaredMiddlewareType]: T + predicate: ListenerPredicate } -// A shorthand form of the accepted args, solely so that `createListenerEntry` has validly-typed conditional logic when checking the options contents +/** + * @internal + * A shorthand form of the accepted args, solely so that `createListenerEntry` has validly-typed conditional logic when checking the options contents + */ export type FallbackAddListenerOptions = { actionCreator?: TypedActionCreator type?: string @@ -432,8 +493,10 @@ export type FallbackAddListenerOptions = { * Utility Types */ +/** @public */ export type Unsubscribe = () => void +/** @public */ export type GuardedType = T extends ( x: any, ...args: unknown[] @@ -441,36 +504,10 @@ export type GuardedType = T extends ( ? T : never +/** @public */ export type ListenerPredicateGuardedActionType = T extends ListenerPredicate< infer Action, any > ? Action : never - -/** - * Additional infos regarding the error raised. - */ -export interface ListenerErrorInfo { - /** - * Which function has generated the exception. - */ - raisedBy: 'effect' | 'predicate' -} - -/** - * Gets notified with synchronous and asynchronous errors raised by `listeners` or `predicates`. - * @param error The thrown error. - * @param errorInfo Additional information regarding the thrown error. - */ -export interface ListenerErrorHandler { - (error: unknown, errorInfo: ListenerErrorInfo): void -} - -export interface CreateListenerMiddlewareOptions { - extra?: ExtraArgument - /** - * Receives synchronous and asynchronous errors that are raised by `listener` and `listenerOption.predicate`. - */ - onError?: ListenerErrorHandler -} From f1f1fb29fad68997df1e5bd31374e514ce3b4658 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 26 Feb 2022 16:29:06 -0500 Subject: [PATCH 17/27] Add listener middleware API ref / usage guide --- docs/api/createListenerMiddleware.mdx | 670 ++++++++++++++++++++++++++ website/sidebars.json | 3 +- 2 files changed, 672 insertions(+), 1 deletion(-) create mode 100644 docs/api/createListenerMiddleware.mdx diff --git a/docs/api/createListenerMiddleware.mdx b/docs/api/createListenerMiddleware.mdx new file mode 100644 index 0000000000..879ae01161 --- /dev/null +++ b/docs/api/createListenerMiddleware.mdx @@ -0,0 +1,670 @@ +--- +id: createListenerMiddleware +title: createListenerMiddleware +sidebar_label: createListenerMiddleware +hide_title: true +--- + +  + +# `createListenerMiddleware` + +## Overview + +A Redux middleware that lets you define "listener" entries that contain an "effect" callback with additional logic, and a way to specify when that callback should run based on dispatched actions or state changes. + +It's intended to be a lightweight alternative to more widely used Redux async middleware like sagas and observables. While similar to thunks in level of complexity and concept, it can be used to replicate some common saga usage patterns. + +Conceptually, you can think of this as being similar to React's `useEffect` hook, except that it runs logic in response to Redux store updates instead of component props/state updates. + +Listener effect callbacks have access to `dispatch` and `getState`, similar to thunks. The listener also receives a set of async workflow functions like `take`, `condition`, `pause`, `fork`, and `unsubscribe`, which allow writing more complex async logic. + +Listeners can be defined statically by calling `listenerMiddleware.startListening()` during setup, or added and removed dynamically at runtime with special `dispatch(addListener())` and `dispatch(removeListener())` actions. + +### Basic Usage + +```js +import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit' + +import todosReducer, { + todoAdded, + todoToggled, + todoDeleted, +} from '../features/todos/todosSlice' + +// Create the middleware instance and methods +const listenerMiddleware = createListenerMiddleware() + +// Add one or more listener entries that look for specific actions. +// They may contain any sync or async logic, similar to thunks. +listenerMiddleware.startListening({ + actionCreator: todoAdded, + effect: async (action, listenerApi) => { + // Run whatever additional side-effect-y logic you want here + console.log('Todo added: ', action.payload.text) + + // Can cancel other running instances + listenerApi.cancelActiveListeners() + + // Run async logic + const data = await fetchData() + + // Pause until action dispatched or state changed + if (await listenerApi.condition(matchSomeAction)) { + // Use the listener API methods to dispatch, get state, + // unsubscribe the listener, start child tasks, and more + listenerApi.dispatch(todoAdded('Buy pet food')) + listenerApi.unsubscribe() + } + }, +}) + +const store = configureStore({ + reducer: { + todos: todosReducer, + }, + // Add the listener middleware to the store. + // NOTE: Since this can receive actions with functions inside, + // it should go before the serializability check middleware + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().prepend(listenerMiddleware.middleware), +}) +``` + +## `createListenerMiddleware` + +Creates an instance of the middleware, which should then be added to the store via `configureStore`'s `middleware` parameter. + +```ts no-transpile +const createListenerMiddleware = (options?: CreateMiddlewareOptions) => + ListenerMiddlewareInstance + +interface CreateListenerMiddlewareOptions { + extra?: ExtraArgument + onError?: ListenerErrorHandler +} + +type ListenerErrorHandler = ( + error: unknown, + errorInfo: ListenerErrorInfo +) => void + +interface ListenerErrorInfo { + raisedBy: 'effect' | 'predicate' +} +``` + +### Middleware Options + +- `extra`: an optional "extra argument" that will be injected into the `listenerApi` parameter of each listener. Equivalent to [the "extra argument" in the Redux Thunk middleware](https://redux.js.org/usage/writing-logic-thunks#injecting-config-values-into-thunks) +- `onError`: an optional error handler that gets called with synchronous and async errors raised by `listener` and synchronous errors thrown by `predicate`. + +## Listener Middleware Instance + +The "listener middleware instance" returned from `createListenerMiddleware` is an object similar to the "slice" objects generated by `createSlice`. The instance object is _not_ the actual Redux middleware itself. Rather, it contains the middleware and some instance methods used to add and remove listener entries within the middleware. + +```ts no-transpile +interface ListenerMiddlewareInstance< + State = unknown, + Dispatch extends ThunkDispatch = ThunkDispatch< + State, + unknown, + AnyAction + >, + ExtraArgument = unknown +> { + middleware: ListenerMiddleware + startListening: (options: AddListenerOptions) => Unsubscribe + stopListening: (options: AddListenerOptions) => boolean + clearListeners: () => void +} +``` + +### `middleware` + +The actual Redux middleware. Add this to the Redux store via [the `configureStore.middleware` option](./configureStore.mdx#middleware). + +Since the listener middleware can receive "add" and "remove" actions containing functions, this should normally be added as the first middleware in the chain so that it is before the serializability check middleware. + +```js +const store = configureStore({ + reducer: { + todos: todosReducer, + }, + // Add the listener middleware to the store. + // NOTE: Since this can receive actions with functions inside, + // it should go before the serializability check middleware + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().prepend(listenerMiddleware.middleware), +}) +``` + +### `startListening` + +Adds a new listener entry to the middleware. Typically used to "statically" add new listeners during application setup. + +```ts no-transpile +const startListening = (options: AddListenerOptions) => Unsubscribe + +interface AddListenerOptions { + // Four options for deciding when the listener will run: + + // 1) Exact action type string match + type?: string + + // 2) Exact action type match based on the RTK action creator + actionCreator?: ActionCreator + + // 3) Match one of many actions using an RTK matcher + matcher?: Matcher + + // 4) Return true based on a combination of action + state + predicate?: ListenerPredicate + + // The actual callback to run when the action is matched + effect: (action: Action, listenerApi: ListenerApi) => void | Promise +} + +type ListenerPredicate = ( + action: Action, + currentState?: State, + originalState?: State +) => boolean +``` + +**You must provide exactly _one_ of the four options for deciding when the listener will run: `type`, `actionCreator`, `matcher`, or `predicate`**. Every time an action is dispatched, each listener will be checked to see if it should run based on the current action vs the comparison option provided. + +These are all acceptable: + +```js +// 1) Action type string +listenerMiddleware.startListening({ type: 'todos/todoAdded', listener }) +// 2) RTK action creator +listenerMiddleware.startListening({ actionCreator: todoAdded, listener }) +// 3) RTK matcher function +listenerMiddleware.startListening({ + matcher: isAnyOf(todoAdded, todoToggled), + listener, +}) +// 4) Listener predicate +listenerMiddleware.startListening({ + predicate: (action, currentState, previousState) => { + // return true when the listener should run + }, + listener, +}) +``` + +Note that the `predicate` option actually allows matching solely against state-related checks, such as "did `state.x` change" or "the current value of `state.x` matches some criteria", regardless of the actual action. + +The ["matcher" utility functions included in RTK](./matching-utilities.mdx) are acceptable as either the `matcher` or `predicate` option. + +The return value is a standard `unsubscribe()` callback that will remove this listener. If you try to add a listener entry but another entry with this exact function reference already exists, no new entry will be added, and the existing `unsubscribe` method will be returned. + +The `effect` callback will receive the current action as its first argument, as well as a "listener API" object similar to the "thunk API" object in `createAsyncThunk`. + +All listener predicates and callbacks are checked _after_ the root reducer has already processed the action and updated the state. The `listenerApi.getOriginalState()` method can be used to get the state value that existed before the action that triggered this listener was processed. + +### `stopListening` + +Removes a given listener. It accepts the same arguments as `startListening()`. It checks for an existing listener entry by comparing the function references of `listener` and the provided `actionCreator/matcher/predicate` function or `type` string. + +```ts no-transpile +const stopListening = (options: AddListenerOptions) => boolean +``` + +Returns `true` if the `options.effect` listener has been removed, or `false` if no subscription matching the input provided has been found. + +```js +// 1) Action type string +listenerMiddleware.stopListening({ type: 'todos/todoAdded', listener }) +// 2) RTK action creator +listenerMiddleware.stopListening({ actionCreator: todoAdded, listener }) +// 3) RTK matcher function +listenerMiddleware.stopListening({ matcher, listener }) +// 4) Listener predicate +listenerMiddleware.stopListening({ predicate, listener }) +``` + +### `clearListeners` + +Removes all current listener entries. This is most likely useful for test scenarios where a single middleware or store instance might be used in multiple tests, as well as some app cleanup situations. + +```ts no-transpile +const clearListeners = () => void; +``` + +## Action Creators + +In addition to adding and removing listeners by directly calling methods on the listener instance, you can dynamically add and remove listeners at runtime by dispatching special "add" and "remove" actions. These are exported from the main RTK package as standard RTK-generated action creators. + +### `addListener` + +A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically add a new listener at runtime. It accepts exactly the same options as `startListening()` + +Dispatching this action returns an `unsubscribe()` callback from `dispatch`. + +```js +// Per above, provide `predicate` or any of the other comparison options +const unsubscribe = store.dispatch(addListener({ predicate, listener })) +``` + +### `removeListener` + +A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically remove a listener at runtime. Accepts the same arguments as `stopListening()`. + +Returns `true` if the `options.listener` listener has been removed, `false` if no subscription matching the input provided has been found. + +```js +store.dispatch(removeListener({ predicate, listener })) +``` + +### `removeAllListeners` + +A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically remove all listeners at runtime. + +```js +store.dispatch(removeAllListeners()) +``` + +## Listener API + +The `listenerApi` object is the second argument to each listener callback. It contains several utility functions that may be called anywhere inside the listener's logic. These can be divided into several categories. + +### Store Interaction Methods + +- `dispatch: Dispatch`: the standard `store.dispatch` method +- `getState: () => State`: the standard `store.getState` method +- `getOriginalState: () => State`: returns the store state as it existed when the action was originally dispatched, _before_ the reducers ran. (**Note**: this method can only be called synchronously, during the initial dispatch call stack, to avoid memory leaks. Calling it asynchronously will throw an error.) +- `extra: unknown`: the "extra argument" that was provided as part of the middleware setup, if any + +`dispatch` and `getState` are exactly the same as in a thunk. `getOriginalState` can be used to compare the original state before the listener was started. + +`extra` can be used to inject a value such as an API service layer into the middleware at creation time, and is accessible here. + +### Listener Subscription Management + +- `unsubscribe: () => void`: removes the listener entry from the middleware, and prevent future instances of the listener from running. +- `subscribe: () => void`: will re-subscribe the listener entry if it was previously removed, or no-op if currently subscribed +- `cancelActiveListeners: () => void`: cancels all other running instances of this same listener _except_ for the one that made this call. (The cancellation will only have a meaningful effect if the other instances are paused using one of the cancellation-aware APIs like `take/cancel/pause/delay` - see "Cancelation and Task Management" in the "Usage" section for more details) +- `signal: AbortSignal`: An [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) whose `aborted` property will be set to `true` if the listener execution is aborted or completed. + +Dynamically unsubscribing and re-subscribing this listener allows for more complex async workflows, such as avoiding duplicate running instances by calling `listenerApi.unsubscribe()` at the start of a listener, or calling `listenerApi.cancelActiveListeners()` to ensure that only the most recent instance is allowed to complete. + +### Conditional Workflow Execution + +- `take: (predicate: ListenerPredicate, timeout?: number) => Promise<[Action, State, State] | null>`: returns a promise that will resolve when the `predicate` returns `true`. The return value is the `[action, currentState, previousState]` combination that the predicate saw as arguments. If a `timeout` is provided and expires first, the promise resolves to `null`. +- `condition: (predicate: ListenerPredicate, timeout?: number) => Promise`: Similar to `take`, but resolves to `true` if the predicate succeeds, and `false` if a `timeout` is provided and expires first. This allows async logic to pause and wait for some condition to occur before continuing. See "Writing Async Workflows" below for details on usage. +- `delay: (timeoutMs: number) => Promise`: returns a cancellation-aware promise that resolves after the timeout, or rejects if cancelled before the expiration +- `pause: (promise: Promise) => Promise`: accepts any promise, and returns a cancellation-aware promise that either resolves with the argument promise or rejects if cancelled before the resolution + +These methods provide the ability to write conditional logic based on future dispatched actions and state changes. Both also accept an optional `timeout` in milliseconds. + +`take` resolves to a `[action, currentState, previousState]` tuple or `null` if it timed out, whereas `condition` resolves to `true` if it succeeded or `false` if timed out. + +`take` is meant for "wait for an action and get its contents", while `condition` is meant for checks like `if (await condition(predicate))`. + +Both these methods are cancellation-aware, and will throw a `TaskAbortError` if the listener instance is cancelled while paused. + +### Child Tasks + +- `fork: (executor: (forkApi: ForkApi) => T | Promise) => ForkedTask`: Launches a "child task" that may be used to accomplish additional work. Accepts any sync or async function as its argument, and returns a `{result, cancel}` object that can be used to check the final status and return value of the child task, or cancel it while in-progress. + +Child tasks can be launched, and waited on to collect their return values. The provided `executor` function will be called asynchronously with a `forkApi` object containing `{pause, delay, signal}`, allowing it to pause or check cancellation status. It can also make use of the `listenerApi` from the listener's scope. + +An example of this might be a listener that forks a child task containing an infinite loop that listens for events from a server. The parent then uses `listenerApi.condition()` to wait for a "stop" action, and cancels the child task. + +The task and result types are: + +```ts no-transpile +interface ForkedTaskAPI { + pause(waitFor: Promise): Promise + delay(timeoutMs: number): Promise + signal: AbortSignal +} + +export type TaskResolved = { + readonly status: 'ok' + readonly value: T +} + +export type TaskRejected = { + readonly status: 'rejected' + readonly error: unknown +} + +export type TaskCancelled = { + readonly status: 'cancelled' + readonly error: TaskAbortError +} + +export type TaskResult = + | TaskResolved + | TaskRejected + | TaskCancelled + +export interface ForkedTask { + result: Promise> + cancel(): void +} +``` + +## TypeScript Usage + +The middleware code is fully TS-typed. However, the `startListening` and `addListener` functions do not know what the store's `RootState` type looks like by default, so `getState()` will return `unknown`. + +To fix this, the middleware provides types for defining "pre-typed" versions of those methods, similar to the pattern used for defing pre-typed React-Redux hooks. We specifically recommend creating the middleware instance in a separate file from the actual `configureStore()` call: + +```ts no-transpile +// listenerMiddleware.ts +import { createListenerMiddleware, addListener } from '@reduxjs/toolkit' +import type { TypedStartListening, TypedAddListener } from '@reduxjs/toolkit' + +import type { RootState } from './store' + +export const listenerMiddleware = createListenerMiddleware() + +export const startAppListening = + listenerMiddleware.startListening as TypedStartListening +export const addAppListener = addListener as TypedAddListenern +``` + +Then import and use those pre-typed methods in your components. + +## Usage Guide + +### Overall Purpose + +This middleware lets you run additional logic when some action is dispatched, as a lighter-weight alternative to middleware like sagas and observables that have both a heavy runtime bundle cost and a large conceptual overhead. + +This middleware is not intended to handle all possible use cases. Like thunks, it provides you with a basic set of primitives (including access to `dispatch` and `getState`), and gives you freedom to write any sync or async logic you want. This is both a strength (you can do anything!) and a weakness (you can do anything, with no guard rails!). + +The middleware includes several async workflow primitives that are sufficient to write equivalents to many Redux-Saga effects operators like `takeLatest`, `takeLeading`, and `debounce`. + +### Standard Usage Patterns + +The most common expected usage is "run some logic after a given action was dispatched". For example, you could set up a simple analytics tracker by looking for certain actions and sending extracted data to the server, including pulling user details from the store: + +```js +listenerMiddleware.startListening({ + matcher: isAnyOf(action1, action2, action3), + effect: (action, listenerApi) => { + const user = selectUserDetails(listenerApi.getState()) + + const { specialData } = action.meta + + analyticsApi.trackUsage(action.type, user, specialData) + }, +}) +``` + +However, the `predicate` option also allows triggering logic when some state value has changed, or when the state matches a particular condition: + +```js +listenerMiddleware.startListening({ + predicate: (action, currentState, previousState) => { + // Trigger logic whenever this field changes + return currentState.counter.value !== previousState.counter.value + }, + effect, +}) + +listenerMiddleware.startListening({ + predicate: (action, currentState, previousState) => { + // Trigger logic after every action if this condition is true + return currentState.counter.value > 3 + }, + effect, +}) +``` + +You could also implement a generic API fetching capability, where the UI dispatches a plain action describing the type of resource to be requested, and the middleware automatically fetches it and dispatches a result action: + +```js +listenerMiddleware.startListening({ + actionCreator: resourceRequested, + effect: async (action, listenerApi) => { + const { name, args } = action.payload + listenerApi.dispatch(resourceLoading()) + + const res = await serverApi.fetch(`/api/${name}`, ...args) + listenerApi.dispatch(resourceLoaded(res.data)) + }, +}) +``` + +(That said, we would recommend use of RTK Query for any meaningful data fetching behavior - this is primarily an example of what you _could_ do in a listener.) + +The `listenerApi.unsubscribe` method may be used at any time, and will remove the listener from handling any future actions. As an example, you could create a one-shot listener by unconditionally calling `unsubscribe()` in the body - the effect callback would run the first time the relevant action is seen, then immediately unsubscribe and never run again. (The middleware actually uses this technique internally for the `take/condition` methods) + +### Writing Async Workflows with Conditions + +One of the great strengths of both sagas and observables is their support for complex async workflows, including stopping and starting behavior based on specific dispatched actions. However, the weakness is that both require mastering a complex API with many unique operators (effects methods like `call()` and `fork()` for sagas, RxJS operators for observables), and both add a significant amount to application bundle size. + +While the listener middleware is _not_ meant to fully replace sagas or observables, it does provide a carefully chosen set of APIs to implement long-running async workflows as well. + +Listeners can use the `condition` and `take` methods in `listenerApi` to wait until some action is dispatched or state check is met. The `condition` method is directly inspired by [the `condition` function in Temporal.io's workflow API](https://docs.temporal.io/docs/typescript/workflows/#condition) (credit to [@swyx](https://twitter.com/swyx) for the suggestion!), and `take` is inspired by [the `take` effect from Redux-Saga](https://redux-saga.js.org/docs/api#takepattern). + +The signatures are: + +```ts no-transpile +type ConditionFunction = ( + predicate: ListenerPredicate | (() => boolean), + timeout?: number +) => Promise + +type TakeFunction = ( + predicate: ListenerPredicate | (() => boolean), + timeout?: number +) => Promise<[Action, State, State] | null> +``` + +You can use `await condition(somePredicate)` as a way to pause execution of your listener callback until some criteria is met. + +The `predicate` will be called after every action is processed by the reducers, and should return `true` when the condition should resolve. (It is effectively a one-shot listener itself.) If a `timeout` number (in ms) is provided, the promise will resolve `true` if the `predicate` returns first, or `false` if the timeout expires. This allows you to write comparisons like `if (await condition(predicate, timeout))`. + +This should enable writing longer-running workflows with more complex async logic, such as [the "cancellable counter" example from Redux-Saga](https://github.com/redux-saga/redux-saga/blob/1ecb1bed867eeafc69757df8acf1024b438a79e0/examples/cancellable-counter/src/sagas/index.js). + +An example of `condition` usage, from the test suite: + +```ts no-transpile +test('condition method resolves promise when there is a timeout', async () => { + let finalCount = 0 + let listenerStarted = false + + listenerMiddleware.startListening({ + predicate: (action, currentState: CounterState) => { + return increment.match(action) && currentState.value === 0 + }, + effect: async (action, listenerApi) => { + listenerStarted = true + // Wait for either the counter to hit 3, or 50ms to elapse + const result = await listenerApi.condition( + (action, currentState: CounterState) => { + return currentState.value === 3 + }, + 50 + ) + + // In this test, we expect the timeout to happen first + expect(result).toBe(false) + // Save the state for comparison outside the listener + const latestState = listenerApi.getState() + finalCount = latestState.value + }, + }) + + store.dispatch(increment()) + // The listener should have started right away + expect(listenerStarted).toBe(true) + + store.dispatch(increment()) + + // If we wait 150ms, the condition timeout will expire first + await delay(150) + // Update the state one more time to confirm the listener isn't checking it + store.dispatch(increment()) + + // Handled the state update before the delay, but not after + expect(finalCount).toBe(2) +}) +``` + +### Cancellation and Task Management + +The listener middleware supports cancellation of running listener instances, `take/condition/pause/delay` functions, and "child tasks", with an implementation based on [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). + +The `listenerApi.pause/delay()` functions provide a cancellation-aware way to have the current listener sleep. `pause()` accepts a promise, while `delay` accepts a timeout value. If the listener is cancelled while waiting, a `TaskAbortError` will be thrown. In addition, both `take` and `condition` support cancellation interruption as well. + +`listenerApi.fork()` can used to launch "child tasks" that can do additional work. These can be waited on to collect their results. An example of this might look like: + +```ts no-transpile +listenerMiddleware.startListening({ + actionCreator: increment, + effect: async (action, listenerApi) => { + // Spawn a child task and start it immediately + const task = listenerApi.fork(async (forkApi) => { + // Artificially wait a bit inside the child + await forkApi.delay(5) + // Complete the child by returning a value + return 42 + }) + + const result = await task.result + // Unwrap the child result in the listener + if (result.status === 'ok') { + // Logs the `42` result value that was returned + console.log('Child succeeded: ', result.value) + } + }, +}) +``` + +### Complex Async Workflows + +The provided async workflow primitives (`cancelActiveListeners`, `unsuscribe`, `subscribe`, `take`, `condition`, `pause`, `delay`) can be used to implement many of the more complex async workflow capabilities found in the Redux-Saga library. This includes effects such as `throttle`, `debounce`, `takeLatest`, `takeLeading`, and `fork/join`. Some examples from the test suite: + +```js +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 + + listenerMiddleware.startListening({ + actionCreator: increment, + effect: async (action, listenerApi) => { + // Cancel any in-progress instances of this listener + listenerApi.cancelActiveListeners() + + // Delay before starting actual work + await listenerApi.delay(15) + + // do work here + }, + }) +} + +test('takeLeading', async () => { + // Starts listener on first action, ignores others until task completes + // Ref: https://redux-saga.js.org/docs/api#takeleadingpattern-saga-args + + listenerMiddleware.startListening({ + actionCreator: increment, + effect: async (action, listenerApi) => { + listenerCalls++ + + // Stop listening for this action + listenerApi.unsubscribe() + + // Pretend we're doing expensive work + + // Re-enable the listener + listenerApi.subscribe() + }, + }) +}) + +test('cancelled', async () => { + // cancelled allows checking if the current task was cancelled + // Ref: https://redux-saga.js.org/docs/api#cancelled + + let canceledAndCaught = false + let canceledCheck = false + + // Example of canceling prior instances conditionally and checking cancellation + listenerMiddleware.startListening({ + matcher: isAnyOf(increment, decrement, incrementByAmount), + effect: async (action, listenerApi) => { + if (increment.match(action)) { + // Have this branch wait around to be cancelled by the other + try { + await listenerApi.delay(10) + } catch (err) { + // Can check cancellation based on the exception and its reason + if (err instanceof TaskAbortError) { + canceledAndCaught = true + } + } + } else if (incrementByAmount.match(action)) { + // do a non-cancellation-aware wait + await delay(15) + if (listenerApi.signal.aborted) { + canceledCheck = true + } + } else if (decrement.match(action)) { + listenerApi.cancelActiveListeners() + } + }, + }) +}) +``` + +As a more practical example: [this saga-based "long polling" loop](https://gist.github.com/markerikson/5203e71a69fa9dff203c9e27c3d84154) repeatedly asks the server for a message and then processes each response. The child loop is started on demand when a "start polling" action is dispatched, and the loop is cancelled when a "stop polling" action is dispatched. + +That approach can be implemented via the listener middleware: + +```ts no-transpile +// Track how many times each message was processed by the loop +const receivedMessages = { + a: 0, + b: 0, + c: 0, +} + +const eventPollingStarted = createAction('serverPolling/started') +const eventPollingStopped = createAction('serverPolling/stopped') + +listenerMiddleware.startListening({ + actionCreator: eventPollingStarted, + effect: async (action, listenerApi) => { + // Only allow one instance of this listener to run at a time + listenerApi.unsubscribe() + + // Start a child job that will infinitely loop receiving messages + const pollingTask = listenerApi.fork(async (forkApi) => { + try { + while (true) { + // Cancellation-aware pause for a new server message + const serverEvent = await forkApi.pause(pollForEvent()) + // Process the message. In this case, just count the times we've seen this message. + if (serverEvent.type in receivedMessages) { + receivedMessages[ + serverEvent.type as keyof typeof receivedMessages + ]++ + } + } + } catch (err) { + if (err instanceof TaskAbortError) { + // could do something here to track that the task was cancelled + } + } + }) + + // Wait for the "stop polling" action + await listenerApi.condition(eventPollingStopped.match) + pollingTask.cancel() + }, +}) +``` diff --git a/website/sidebars.json b/website/sidebars.json index 5b2e077863..402853dda0 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -40,7 +40,8 @@ "api/configureStore", "api/getDefaultMiddleware", "api/immutabilityMiddleware", - "api/serializabilityMiddleware" + "api/serializabilityMiddleware", + "api/createListenerMiddleware" ] }, { From 9167d3c8c7779649291767cbf34bc1fe68412931 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 26 Feb 2022 18:34:31 -0500 Subject: [PATCH 18/27] Update TS test matrix to cover 4.6 --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b6180d8874..8586ae1757 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -96,7 +96,7 @@ jobs: fail-fast: false matrix: node: ['14.x'] - ts: ['4.1', '4.2', '4.3', '4.4', '4.5', 'next'] + ts: ['4.1', '4.2', '4.3', '4.4', '4.5', '4.6.1-rc', 'next'] steps: - name: Checkout repo uses: actions/checkout@v2 From 1ddf6ccf219ee277e7d85cd49a9db25097c63e1c Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 26 Feb 2022 18:34:50 -0500 Subject: [PATCH 19/27] Fix potential TS 4.7-related syntax issues --- packages/toolkit/src/devtoolsExtension.ts | 12 ++++++++---- packages/toolkit/src/query/tests/buildHooks.test.tsx | 12 ++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/toolkit/src/devtoolsExtension.ts b/packages/toolkit/src/devtoolsExtension.ts index 2d803a3bb8..acd01a6bcd 100644 --- a/packages/toolkit/src/devtoolsExtension.ts +++ b/packages/toolkit/src/devtoolsExtension.ts @@ -173,13 +173,17 @@ export interface EnhancerOptions { traceLimit?: number } +type Compose = typeof compose + +interface ComposeWithDevTools { + (options: EnhancerOptions): Compose + (...funcs: StoreEnhancer[]): StoreEnhancer +} + /** * @public */ -export const composeWithDevTools: { - (options: EnhancerOptions): typeof compose - (...funcs: Array>): StoreEnhancer -} = +export const composeWithDevTools: ComposeWithDevTools = typeof window !== 'undefined' && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index acfc6a99fa..0adf60eb10 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -963,15 +963,15 @@ describe('hooks tests', () => { const unwrappedErrorResult = screen.getByTestId('unwrappedError')?.textContent - errorResult && - unwrappedErrorResult && + if (errorResult && unwrappedErrorResult) { expect(JSON.parse(errorResult)).toMatchObject({ status: 500, data: null, - }) && + }) expect(JSON.parse(unwrappedErrorResult)).toMatchObject( JSON.parse(errorResult) ) + } }) expect(screen.getByTestId('result').textContent).toBe('') @@ -1015,14 +1015,14 @@ describe('hooks tests', () => { const unwrappedDataResult = screen.getByTestId('unwrappedResult')?.textContent - dataResult && - unwrappedDataResult && + if (dataResult && unwrappedDataResult) { expect(JSON.parse(dataResult)).toMatchObject({ name: 'Timmy', - }) && + }) expect(JSON.parse(unwrappedDataResult)).toMatchObject( JSON.parse(dataResult) ) + } }) expect(screen.getByTestId('error').textContent).toBe('') From 2af92f81189b19761e7b839026ca4a1665292128 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 26 Feb 2022 18:35:00 -0500 Subject: [PATCH 20/27] Remove standalone listener package folder entirely --- .codesandbox/ci.json | 2 - .github/workflows/listenerTests.yml | 31 - package.json | 2 +- .../action-listener-middleware/.gitignore | 4 - packages/action-listener-middleware/LICENSE | 21 - packages/action-listener-middleware/README.md | 604 ------------------ packages/action-listener-middleware/cjs.json | 1 - .../action-listener-middleware/jest.config.js | 15 - .../action-listener-middleware/package.json | 44 -- .../tsconfig.base.json | 35 - .../action-listener-middleware/tsconfig.json | 8 - .../tsconfig.test.json | 17 - yarn.lock | 20 +- 13 files changed, 7 insertions(+), 797 deletions(-) delete mode 100644 .github/workflows/listenerTests.yml delete mode 100644 packages/action-listener-middleware/.gitignore delete mode 100644 packages/action-listener-middleware/LICENSE delete mode 100644 packages/action-listener-middleware/README.md delete mode 100644 packages/action-listener-middleware/cjs.json delete mode 100644 packages/action-listener-middleware/jest.config.js delete mode 100644 packages/action-listener-middleware/package.json delete mode 100644 packages/action-listener-middleware/tsconfig.base.json delete mode 100644 packages/action-listener-middleware/tsconfig.json delete mode 100644 packages/action-listener-middleware/tsconfig.test.json diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index 96cdb9fde0..69146d63ba 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -12,13 +12,11 @@ "packages": [ "packages/toolkit", "packages/rtk-query-graphql-request-base-query", - "packages/action-listener-middleware", "packages/rtk-query-codegen-openapi" ], "publishDirectory": { "@reduxjs/toolkit": "packages/toolkit", "@rtk-query/graphql-request-base-query": "packages/rtk-query-graphql-request-base-query", - "@rtk-incubator/action-listener-middleware": "packages/action-listener-middleware", "@rtk-query/codegen-openapi": "packages/rtk-query-codegen-openapi" } } diff --git a/.github/workflows/listenerTests.yml b/.github/workflows/listenerTests.yml deleted file mode 100644 index d796f27296..0000000000 --- a/.github/workflows/listenerTests.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: CI -on: [push, pull_request] - -jobs: - build: - name: Test Listener Middleware on Node ${{ matrix.node }} - - runs-on: ubuntu-latest - strategy: - matrix: - node: ['14.x'] - - steps: - - name: Checkout repo - uses: actions/checkout@v2 - - - name: Use node ${{ matrix.node }} - uses: actions/setup-node@v2 - with: - node-version: ${{ matrix.node }} - cache: 'yarn' - - - name: Install deps - run: yarn install - - # The middleware apparently needs RTK built first for tests to compile (?!?) - - name: Build RTK - run: cd packages/toolkit && yarn build - - - name: Run action listener tests - run: cd packages/action-listener-middleware && yarn test diff --git a/package.json b/package.json index 02a6bbaa97..3dfa2670b3 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "test": "yarn test:packages", "build:examples": "yarn workspaces foreach --include '@reduxjs/*' --include '@examples-query-react/*' --include '@examples-action-listener/*' -vtp run build", "build:docs": "yarn workspace website run build", - "build:packages": "yarn workspaces foreach --include '@reduxjs/*' --include '@rtk-query/*' --include '@rtk-incubator/*' --topological-dev run build", + "build:packages": "yarn workspaces foreach --include '@reduxjs/*' --include '@rtk-query/*' --topological-dev run build", "test:packages": "yarn workspaces foreach --include '@reduxjs/*' --include '@rtk-query/*' --include '@rtk-incubator/*' run test", "dev:docs": "yarn workspace website run start" } diff --git a/packages/action-listener-middleware/.gitignore b/packages/action-listener-middleware/.gitignore deleted file mode 100644 index f2de354d38..0000000000 --- a/packages/action-listener-middleware/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -dist -node_modules -yarn-error.log -coverage \ No newline at end of file diff --git a/packages/action-listener-middleware/LICENSE b/packages/action-listener-middleware/LICENSE deleted file mode 100644 index 573d9de13b..0000000000 --- a/packages/action-listener-middleware/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021 Lenz Weber - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/action-listener-middleware/README.md b/packages/action-listener-middleware/README.md deleted file mode 100644 index 17ae551670..0000000000 --- a/packages/action-listener-middleware/README.md +++ /dev/null @@ -1,604 +0,0 @@ -# RTK Incubator - Action Listener Middleware - -This package provides a callback-based Redux middleware that we plan to include in Redux Toolkit directly in the next feature release. We're publishing it as a standalone package to allow users to try it out separately and give us feedback on its API design. - -This middleware lets you define "listener" entries containing "effect" callbacks that will run in response to specific actions being dispatched. It's intended to be a lightweight alternative to more widely used Redux async middleware like sagas and observables. While similar to thunks in level of complexity and concept, it can be used to replicate some common saga usage patterns. - -Conceptually, you can think of this as being similar to React's `useEffect` hook, except that it runs logic in response to Redux store updates instead of component props/state updates. - -## Installation - -```bash -npm i @rtk-incubator/action-listener-middleware - -yarn add @rtk-incubator/action-listener-middleware -``` - -### Basic Usage - -```js -import { configureStore } from '@reduxjs/toolkit' -import { createListenerMiddleware } from '@rtk-incubator/action-listener-middleware' - -import todosReducer, { - todoAdded, - todoToggled, - todoDeleted, -} from '../features/todos/todosSlice' - -// Create the middleware instance and methods -const listenerMiddleware = createListenerMiddleware() - -// Add one or more listener entries that look for specific actions. -// They may contain any sync or async logic, similar to thunks. -listenerMiddleware.startListening({ - actionCreator: todoAdded, - effect: async (action, listenerApi) => { - // Run whatever additional side-effect-y logic you want here - console.log('Todo added: ', action.payload.text) - - // Can cancel other running instances - listenerApi.cancelActiveListeners() - - // Run async logic - const data = await fetchData() - - // Pause until action dispatched or state changed - if (await listenerApi.condition(matchSomeAction)) { - // Use the listener API methods to dispatch, get state, - // unsubscribe the listener, or cancel previous - listenerApi.dispatch(todoAdded('Buy pet food')) - listenerApi.unsubscribe() - } - }, -}) - -const store = configureStore({ - reducer: { - todos: todosReducer, - }, - // Add the listener middleware to the store. - // NOTE: Since this can receive actions with functions inside, - // it should go before the serializability check middleware - middleware: (getDefaultMiddleware) => - getDefaultMiddleware().prepend(listenerMiddleware.middleware), -}) -``` - -## Motivation - -The Redux community has settled around three primary side effects libraries over time: - -- Thunks use basic functions passed to `dispatch`. They let users run arbitrary logic, including dispatching actions and getting state. These are mostly used for basic AJAX requests and logic that needs to read from state before dispatching actions -- Sagas use generator functions and a custom set of "effects" APIs, which are then executed by a middleware. Sagas let users write powerful async logic and workflows that can respond to any dispatched action, including "background thread"-type behavior like infinite loops and cancelation. -- Observables use RxJS observable operators. Observables form pipelines that do arbitrary processing similar to sagas, but with a more functional API style. - -All three of those have strengths and weaknesses: - -- Thunks are simple to use, but can only run imperative code and have no way to _respond_ to dispatched actions -- Sagas are extremely powerful, but require learning generator functions and the specifics of `redux-saga`'s effects API, and are overkill for many simpler use cases -- Observables are also powerful, but RxJS is its own complex API to learn and they can be hard to debug - -If you need to run some code in response to a specific action being dispatched, you _could_ write a custom middleware: - -```js -const myMiddleware = (storeAPI) => (next) => (action) => { - if (action.type === 'some/specificAction') { - console.log('Do something useful here') - } - - return next(action) -} -``` - -However, it would be nice to have a more structured API to help abstract this process. - -The `createListenerMiddleware` API provides that structure. - -For more background and debate over the use cases and API design, see the original discussion issue and PR: - -- [RTK issue #237: Add an action listener middleware](https://github.com/reduxjs/redux-toolkit/issues/237) -- [RTK PR #547: yet another attempt at an action listener middleware](https://github.com/reduxjs/redux-toolkit/pull/547) -- [RTK discussion #1648: New experimental "action listener middleware" package available](https://github.com/reduxjs/redux-toolkit/discussions/1648) - -## API Reference - -`createListenerMiddleware` lets you add listeners by providing an "effect callback" containing additional logic, and a way to specify when that callback should run based on dispatched actions or state changes. - -The middleware then gives you access to `dispatch` and `getState` for use in your effect callback's logic, similar to thunks. The listener also receives a set of async workflow functions like `take`, `condition`, `pause`, `fork`, and `unsubscribe`, which allow writing more complex async logic. - -Listeners can be defined statically by calling `listenerMiddleware.startListening()` during setup, or added and removed dynamically at runtime with special `dispatch(addListener())` and `dispatch(removeListener())` actions. - -### `createListenerMiddleware: (options?: CreateMiddlewareOptions) => ListenerMiddlewareInstance` - -Creates an instance of the middleware, which should then be added to the store via `configureStore`'s `middleware` parameter. - -Current options are: - -- `extra`: an optional "extra argument" that will be injected into the `listenerApi` parameter of each listener. Equivalent to [the "extra argument" in the Redux Thunk middleware](https://redux.js.org/usage/writing-logic-thunks#injecting-config-values-into-thunks). - -- `onError`: an optional error handler that gets called with synchronous and async errors raised by `listener` and synchronous errors thrown by `predicate`. - -`createListenerMiddleware` returns an object (similar to how `createSlice` does), with the following fields: - -- `middleware`: the actual listener middleware instance. Add this to `configureStore()` -- `startListening`: adds a single listener entry to this specific middleware instance -- `stopListening`: removes a single listener entry from this specific middleware instance -- `clearListeners`: removes all listener entries from this specific middleware instance - -### `startListening(options: AddListenerOptions) : Unsubscribe` - -Statically adds a new listener entry to the middleware. - -The available options are: - -```ts -type ListenerPredicate = ( - action: Action, - currentState?: State, - originalState?: State -) => boolean - -interface AddListenerOptions { - // Four options for deciding when the listener will run: - - // 1) Exact action type string match - type?: string - - // 2) Exact action type match based on the RTK action creator - actionCreator?: ActionCreator - - // 3) Match one of many actions using an RTK matcher - matcher?: Matcher - - // 4) Return true based on a combination of action + state - predicate?: ListenerPredicate - - // The actual callback to run when the action is matched - effect: (action: Action, listenerApi: ListenerApi) => void | Promise -} -``` - -You must provide exactly _one_ of the four options for deciding when the listener will run: `type`, `actionCreator`, `matcher`, or `predicate`. Every time an action is dispatched, each listener will be checked to see if it should run based on the current action vs the comparison option provided. - -These are all acceptable: - -```ts -// 1) Action type string -startListening({ type: 'todos/todoAdded', listener }) -// 2) RTK action creator -startListening({ actionCreator: todoAdded, listener }) -// 3) RTK matcher function -startListening({ matcher: isAnyOf(todoAdded, todoToggled), listener }) -// 4) Listener predicate -startListening({ - predicate: (action, currentState, previousState) => { - // return true when the listener should run - }, - listener, -}) -``` - -Note that the `predicate` option actually allows matching solely against state-related checks, such as "did `state.x` change" or "the current value of `state.x` matches some criteria", regardless of the actual action. - -The ["matcher" utility functions included in RTK](https://redux-toolkit.js.org/api/matching-utilities) are acceptable as predicates. - -The return value is a standard `unsubscribe()` callback that will remove this listener. If you try to add a listener entry but another entry with this exact function reference already exists, no new entry will be added, and the existing `unsubscribe` method will be returned. - -The `effect` callback will receive the current action as its first argument, as well as a "listener API" object similar to the "thunk API" object in `createAsyncThunk`. - -All listener predicates and callbacks are checked _after_ the root reducer has already processed the action and updated the state. The `listenerApi.getOriginalState()` method can be used to get the state value that existed before the action that triggered this listener was processed. - -### `stopListening(options: AddListenerOptions): boolean` - -Removes a given listener. It accepts the same arguments as `startListening()`. It checks for an existing listener entry by comparing the function references of `listener` and the provided `actionCreator/matcher/predicate` function or `type` string. - -Returns `true` if the `options.effect` listener has been removed, or `false` if no subscription matching the input provided has been found. - -```ts -// 1) Action type string -stopListening({ type: 'todos/todoAdded', listener }) -// 2) RTK action creator -stopListening({ actionCreator: todoAdded, listener }) -// 3) RTK matcher function -stopListening({ matcher, listener }) -// 4) Listener predicate -stopListening({ predicate, listener }) -``` - -### `clearListeners(): void` - -Removes all current listener entries. This is most likely useful for test scenarios where a single middleware or store instance might be used in multiple tests, as well as some app cleanup situations. - -### `addListener` - -A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically add a new listener at runtime. It accepts exactly the same options as `startListening()` - -Dispatching this action returns an `unsubscribe()` callback from `dispatch`. - -```js -// Per above, provide `predicate` or any of the other comparison options -const unsubscribe = store.dispatch(addListener({ predicate, listener })) -``` - -### `removeListener` - -A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically remove a listener at runtime. Accepts the same arguments as `stopListening()`. - -Returns `true` if the `options.listener` listener has been removed, `false` if no subscription matching the input provided has been found. - -```js -store.dispatch(removeListener({ predicate, listener })) -``` - -### `removeAllListeners` - -A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically remove all listeners at runtime. - -```js -store.dispatch(removeAllListeners()) -``` - -### `listenerApi` - -The `listenerApi` object is the second argument to each listener callback. It contains several utility functions that may be called anywhere inside the listener's logic. These can be divided into several categories: - -#### Store Interaction Methods - -- `dispatch: Dispatch`: the standard `store.dispatch` method -- `getState: () => State`: the standard `store.getState` method -- `getOriginalState: () => State`: returns the store state as it existed when the action was originally dispatched, _before_ the reducers ran. (**Note**: this method can only be called synchronously, during the initial dispatch call stack, to avoid memory leaks. Calling it asynchronously will throw an error.) - -`dispatch` and `getState` are exactly the same as in a thunk. `getOriginalState` can be used to compare the original state before the listener was started. - -#### Middleware Options - -- `extra: unknown`: the "extra argument" that was provided as part of the middleware setup, if any - -`extra` can be used to inject a value such as an API service layer into the middleware at creation time, and is accessible here. - -#### Listener Subscription Management - -- `unsubscribe: () => void`: will remove the listener from the middleware -- `subscribe: () => void`: will re-subscribe the listener if it was previously removed, or no-op if currently subscribed -- `cancelActiveListeners: () => void`: cancels all other running instances of this same listener _except_ for the one that made this call. (The cancelation will only have a meaningful effect if the other instances are paused using one of the cancelation-aware APIs like `take/cancel/pause/delay` - see "Cancelation and Task Management" in the "Usage" section for more details) -- `signal: AbortSignal`: An [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) whose `aborted` property will be set to `true` if the listener execution is aborted or completed. - -Dynamically unsubscribing and re-subscribing this listener allows for more complex async workflows, such as avoiding duplicate running instances by calling `listenerApi.unsubscribe()` at the start of a listener, or calling `listenerApi.cancelActiveListeners()` to ensure that only the most recent instance is allowed to complete. - -#### Conditional Workflow Execution - -- `take: (predicate: ListenerPredicate, timeout?: number) => Promise<[Action, State, State] | null>`: returns a promise that will resolve when the `predicate` returns `true`. The return value is the `[action, currentState, previousState]` combination that the predicate saw as arguments. If a `timeout` is provided and expires first, the promise resolves to `null`. -- `condition: (predicate: ListenerPredicate, timeout?: number) => Promise`: Similar to `take`, but resolves to `true` if the predicate succeeds, and `false` if a `timeout` is provided and expires first. This allows async logic to pause and wait for some condition to occur before continuing. See "Writing Async Workflows" below for details on usage. -- `delay: (timeoutMs: number) => Promise`: returns a cancelation-aware promise that resolves after the timeout, or rejects if canceled before the expiration -- `pause: (promise: Promise) => Promise`: accepts any promise, and returns a cancelation-aware promise that either resolves with the argument promise or rejects if canceled before the resolution - -These methods provide the ability to write conditional logic based on future dispatched actions and state changes. Both also accept an optional `timeout` in milliseconds. - -`take` resolves to a `[action, currentState, previousState]` tuple or `null` if it timed out, whereas `condition` resolves to `true` if it succeeded or `false` if timed out. - -`take` is meant for "wait for an action and get its contents", while `condition` is meant for checks like `if (await condition(predicate))`. - -Both these methods are cancelation-aware, and will throw a `TaskAbortError` if the listener instance is canceled while paused. - -#### Child Tasks - -- `fork: (executor: (forkApi: ForkApi) => T | Promise) => ForkedTask`: Launches a "child task" that may be used to accomplish additional work. Accepts any sync or async function as its argument, and returns a `{result, cancel}` object that can be used to check the final status and return value of the child task, or cancel it while in-progress. - -Child tasks can be launched, and waited on to collect their return values. The provided `executor` function will be called asynchronously with a `forkApi` object containing `{pause, delay, signal}`, allowing it to pause or check cancelation status. It can also make use of the `listenerApi` from the listener's scope. - -An example of this might be a listener that forks a child task containing an infinite loop that listens for events from a server. The parent then uses `listenerApi.condition()` to wait for a "stop" action, and cancels the child task. - -The task and result types are: - -```ts -export interface ForkedTaskAPI { - pause(waitFor: Promise): Promise - delay(timeoutMs: number): Promise - signal: AbortSignal -} - -export type TaskResolved = { - readonly status: 'ok' - readonly value: T -} - -export type TaskRejected = { - readonly status: 'rejected' - readonly error: unknown -} - -export type TaskCancelled = { - readonly status: 'cancelled' - readonly error: TaskAbortError -} - -export type TaskResult = - | TaskResolved - | TaskRejected - | TaskCancelled - -export interface ForkedTask { - result: Promise> - cancel(): void -} -``` - -## Usage Guide - -### Overall Purpose - -This middleware lets you run additional logic when some action is dispatched, as a lighter-weight alternative to middleware like sagas and observables that have both a heavy runtime bundle cost and a large conceptual overhead. - -This middleware is not intended to handle all possible use cases. Like thunks, it provides you with a basic set of primitives (including access to `dispatch` and `getState`), and gives you freedom to write any sync or async logic you want. This is both a strength (you can do anything!) and a weakness (you can do anything, with no guard rails!). - -As of v0.5.0, the middleware does include several async workflow primitives that are sufficient to write equivalents to many Redux-Saga effects operators like `takeLatest`, `takeLeading`, and `debounce`. - -### Standard Usage Patterns - -The most common expected usage is "run some logic after a given action was dispatched". For example, you could set up a simple analytics tracker by looking for certain actions and sending extracted data to the server, including pulling user details from the store: - -```js -listenerMiddleware.startListening({ - matcher: isAnyOf(action1, action2, action3), - effect: (action, listenerApi) => { - const user = selectUserDetails(listenerApi.getState()) - - const { specialData } = action.meta - - analyticsApi.trackUsage(action.type, user, specialData) - }, -}) -``` - -However, the `predicate` option also allows triggering logic when some state value has changed, or when the state matches a particular condition: - -```js -listenerMiddleware.startListening({ - predicate: (action, currentState, previousState) => { - // Trigger logic whenever this field changes - return currentState.counter.value !== previousState.counter.value - }, - effect, -}) - -listenerMiddleware.startListening({ - predicate: (action, currentState, previousState) => { - // Trigger logic after every action if this condition is true - return currentState.counter.value > 3 - }, - effect, -}) -``` - -You could also implement a generic API fetching capability, where the UI dispatches a plain action describing the type of resource to be requested, and the middleware automatically fetches it and dispatches a result action: - -```js -listenerMiddleware.startListening({ - actionCreator: resourceRequested, - effect: async (action, listenerApi) => { - const { name, args } = action.payload - listenerApi.dispatch(resourceLoading()) - - const res = await serverApi.fetch(`/api/${name}`, ...args) - listenerApi.dispatch(resourceLoaded(res.data)) - }, -}) -``` - -The `listenerApi.unsubscribe` method may be used at any time, and will remove the listener from handling any future actions. As an example, you could create a one-shot listener by unconditionally calling `unsubscribe()` in the body - it would run the first time the relevant action is seen, and then immediately stop and not handle any future actions. (The middleware actually uses this technique internally for the `take/condition` methods) - -### Writing Async Workflows with Conditions - -One of the great strengths of both sagas and observables is their support for complex async workflows, including stopping and starting behavior based on specific dispatched actions. However, the weakness is that both require mastering a complex API with many unique operators (effects methods like `call()` and `fork()` for sagas, RxJS operators for observables), and both add a significant amount to application bundle size. - -While the listener middleware is _not_ meant to fully replace sagas or observables, it does provide a carefully chosen set of APIs to implement long-running async workflows as well. - -Listeners can use the `condition` and `take` methods in `listenerApi` to wait until some action is dispatched or state check is met. The `condition` method is directly inspired by [the `condition` function in Temporal.io's workflow API](https://docs.temporal.io/docs/typescript/workflows/#condition) (credit to [@swyx](https://twitter.com/swyx) for the suggestion!), and `take` is inspired by [the `take` effect from Redux-Saga](https://redux-saga.js.org/docs/api#takepattern). - -The signatures are: - -```ts -type ConditionFunction = ( - predicate: ListenerPredicate | (() => boolean), - timeout?: number -) => Promise - -type TakeFunction = ( - predicate: ListenerPredicate | (() => boolean), - timeout?: number -) => Promise<[Action, State, State] | null> -``` - -You can use `await condition(somePredicate)` as a way to pause execution of your listener callback until some criteria is met. - -The `predicate` will be called before and after every action is processed, and should return `true` when the condition should resolve. (It is effectively a one-shot listener itself.) If a `timeout` number (in ms) is provided, the promise will resolve `true` if the `predicate` returns first, or `false` if the timeout expires. This allows you to write comparisons like `if (await condition(predicate))`. - -This should enable writing longer-running workflows with more complex async logic, such as [the "cancellable counter" example from Redux-Saga](https://github.com/redux-saga/redux-saga/blob/1ecb1bed867eeafc69757df8acf1024b438a79e0/examples/cancellable-counter/src/sagas/index.js). - -An example of usage, from the test suite: - -```ts -test('condition method resolves promise when there is a timeout', async () => { - let finalCount = 0 - let listenerStarted = false - - listenerMiddleware.startListening({ - predicate: (action, currentState: CounterState) => { - return increment.match(action) && currentState.value === 0 - }, - effect: async (action, listenerApi) => { - listenerStarted = true - // Wait for either the counter to hit 3, or 50ms to elapse - const result = await listenerApi.condition( - (action, currentState: CounterState) => { - return currentState.value === 3 - }, - 50 - ) - - // In this test, we expect the timeout to happen first - expect(result).toBe(false) - // Save the state for comparison outside the listener - const latestState = listenerApi.getState() - finalCount = latestState.value - }, - }) - - store.dispatch(increment()) - // The listener should have started right away - expect(listenerStarted).toBe(true) - - store.dispatch(increment()) - - // If we wait 150ms, the condition timeout will expire first - await delay(150) - // Update the state one more time to confirm the listener isn't checking it - store.dispatch(increment()) - - // Handled the state update before the delay, but not after - expect(finalCount).toBe(2) -}) -``` - -### Cancelation and Task Management - -As of 0.5.0, the middleware now supports cancelation of running listener instances, `take/condition/pause/delay` functions, and "child tasks", with an implementation based on [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). - -The `listenerApi.pause/delay()` functions provide a cancelation-aware way to have the current listener sleep. `pause()` accepts a promise, while `delay` accepts a timeout value. If the listener is canceled while waiting, a `TaskAbortError` will be thrown. In addition, both `take` and `condition` support cancelation interruption as well. - -`listenerApi.fork()` can used to launch "child tasks" that can do additional work. These can be waited on to collect their results. An example of this might look like: - -```ts -listenerMiddleware.startListening({ - actionCreator: increment, - effect: async (action, listenerApi) => { - // Spawn a child task and start it immediately - const task = listenerApi.fork(async (forkApi) => { - // Artificially wait a bit inside the child - await forkApi.delay(5) - // Complete the child by returning an Ovalue - return 42 - }) - - const result = await task.result - // Unwrap the child result in the listener - if (result.status === 'ok') { - console.log('Child succeeded: ', result.value) - } - }, -}) -``` - -### Complex Async Workflows - -The provided async workflow primitives (`cancelActiveListeners`, `unsuscribe`, `subscribe`, `take`, `condition`, `pause`, `delay`) can be used to implement many of the more complex async workflow capabilities found in the Redux-Saga library. This includes effects such as `throttle`, `debounce`, `takeLatest`, `takeLeading`, and `fork/join`. Some examples: - -```js -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 - - listenerMiddleware.startListening({ - actionCreator: increment, - effect: async (action, listenerApi) => { - // Cancel any in-progress instances of this listener - listenerApi.cancelActiveListeners() - - // Delay before starting actual work - await listenerApi.delay(15) - - // do work here - }, - }) -} - -test('takeLeading', async () => { - // Starts listener on first action, ignores others until task completes - // Ref: https://redux-saga.js.org/docs/api#takeleadingpattern-saga-args - - listenerMiddleware.startListening({ - actionCreator: increment, - effect: async (action, listenerApi) => { - listenerCalls++ - - // Stop listening for this action - listenerApi.unsubscribe() - - // Pretend we're doing expensive work - - // Re-enable the listener - listenerApi.subscribe() - }, - }) -}) - -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 - - // Example of canceling prior instances conditionally and checking cancelation - listenerMiddleware.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() - } - }, - }) -}) -``` - -### TypeScript Usage - -The code is fully typed. However, the `startListening` and `addListener` functions do not know what the store's `RootState` type looks like by default, so `getState()` will return `unknown`. - -To fix this, the middleware provides types for defining "pre-typed" versions of those methods, similar to the pattern used for defing pre-typed React-Redux hooks: - -```ts -// listenerMiddleware.ts -import { - createListenerMiddleware, - addListener, -} from '@rtk-incubator/action-listener-middleware' -import type { - TypedStartListening, - TypedAddListener, -} from '@rtk-incubator/action-listener-middleware' - -import type { RootState } from './store' - -export const listenerMiddleware = createListenerMiddleware() - -export const startAppListening = - listenerMiddleware.startListening as TypedStartListening -export const addAppListener = addListener as TypedAddListenern -``` - -Then import and use those pre-typed versions in your components. - -## Feedback - -Please provide feedback in [RTK discussion #1648: "New experimental "action listener middleware" package"](https://github.com/reduxjs/redux-toolkit/discussions/1648). diff --git a/packages/action-listener-middleware/cjs.json b/packages/action-listener-middleware/cjs.json deleted file mode 100644 index a3c15a7a63..0000000000 --- a/packages/action-listener-middleware/cjs.json +++ /dev/null @@ -1 +0,0 @@ -{ "type": "commonjs" } diff --git a/packages/action-listener-middleware/jest.config.js b/packages/action-listener-middleware/jest.config.js deleted file mode 100644 index 024aeff39f..0000000000 --- a/packages/action-listener-middleware/jest.config.js +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = { - testMatch: ['/src/**/*.(spec|test).[jt]s?(x)'], - moduleNameMapper: { - '^@reduxjs/toolkit$': '/../toolkit/src/index.ts', // @remap-prod-remove-line - }, - preset: 'ts-jest', - globals: { - 'ts-jest': { - tsconfig: 'tsconfig.test.json', - diagnostics: { - ignoreCodes: [6133], - }, - }, - }, -} diff --git a/packages/action-listener-middleware/package.json b/packages/action-listener-middleware/package.json deleted file mode 100644 index a17900d43e..0000000000 --- a/packages/action-listener-middleware/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@rtk-incubator/action-listener-middleware", - "version": "0.8.0", - "author": { - "name": "Lenz Weber", - "email": "mail@phryneas.de", - "url": "https://phryneas.de/" - }, - "license": "MIT", - "source": "src/index.ts", - "module": "./dist/module/index.js", - "main": "./dist/cjs/index.js", - "types": "./dist/index.d.ts", - "scripts": { - "build": "rimraf dist && yarn build:esm && yarn build:module && yarn build:cjs", - "build:esm": "microbundle -o ./dist/esm/index.js -f modern", - "build:module": "microbundle -o ./dist/module/index.js -f esm --generate-types false", - "build:cjs": "microbundle -o ./dist/cjs/index.js -f cjs --generate-types false && cp ./cjs.json ./dist/cjs/package.json", - "dev": "microbundle watch", - "prepublishOnly": "yarn build", - "test": "jest --runInBand" - }, - "peerDependencies": { - "@reduxjs/toolkit": "^1.6.0" - }, - "devDependencies": { - "@reduxjs/toolkit": "^1.6.0", - "@types/jest": "^24.0.11", - "@types/node": "^10.14.4", - "jest": "^26.6.3", - "microbundle": "^0.13.3", - "rimraf": "^3.0.2", - "ts-jest": "^26.5.5", - "typescript": "^4.3.4" - }, - "publishConfig": { - "access": "public" - }, - "files": [ - "src", - "dist" - ], - "sideEffects": false -} diff --git a/packages/action-listener-middleware/tsconfig.base.json b/packages/action-listener-middleware/tsconfig.base.json deleted file mode 100644 index 81fd69009d..0000000000 --- a/packages/action-listener-middleware/tsconfig.base.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "compilerOptions": { - "target": "ESnext", - "module": "esnext", - "lib": ["dom", "esnext"], - "importHelpers": true, - // output .d.ts declaration files for consumers - "declaration": true, - // match output dir to input dir. e.g. dist/index instead of dist/src/index - "rootDir": "./src", - // stricter type-checking for stronger correctness. Recommended by TS - "strict": true, - // linter checks for common issues - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative - "noUnusedLocals": false, - "noUnusedParameters": false, - // use Node's module resolution algorithm, instead of the legacy TS one - "moduleResolution": "node", - // transpile JSX to React.createElement - "jsx": "react", - // interop between ESM and CJS modules. Recommended by TS - "esModuleInterop": true, - // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS - "skipLibCheck": true, - // error out if import and file system have a casing mismatch. Recommended by TS - "forceConsistentCasingInFileNames": true, - "downlevelIteration": false, - "allowSyntheticDefaultImports": true, - "emitDeclarationOnly": true, - "baseUrl": ".", - "paths": {} - } -} diff --git a/packages/action-listener-middleware/tsconfig.json b/packages/action-listener-middleware/tsconfig.json deleted file mode 100644 index a01c9d4430..0000000000 --- a/packages/action-listener-middleware/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "outDir": "dist" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts*", "src/**/tests/*"] -} diff --git a/packages/action-listener-middleware/tsconfig.test.json b/packages/action-listener-middleware/tsconfig.test.json deleted file mode 100644 index 62e6ee81c1..0000000000 --- a/packages/action-listener-middleware/tsconfig.test.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "node", - "emitDeclarationOnly": false, - "strict": true, - "noEmit": true, - "target": "es2018", - "jsx": "react", - "baseUrl": ".", - "skipLibCheck": true, - "noImplicitReturns": false - } -} diff --git a/yarn.lock b/yarn.lock index 563ce0f517..2338f5283a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5513,22 +5513,14 @@ __metadata: languageName: node linkType: hard -"@rtk-incubator/action-listener-middleware@^0.8.0, @rtk-incubator/action-listener-middleware@workspace:packages/action-listener-middleware": - version: 0.0.0-use.local - resolution: "@rtk-incubator/action-listener-middleware@workspace:packages/action-listener-middleware" - dependencies: - "@reduxjs/toolkit": ^1.6.0 - "@types/jest": ^24.0.11 - "@types/node": ^10.14.4 - jest: ^26.6.3 - microbundle: ^0.13.3 - rimraf: ^3.0.2 - ts-jest: ^26.5.5 - typescript: ^4.3.4 +"@rtk-incubator/action-listener-middleware@npm:^0.8.0": + version: 0.8.0 + resolution: "@rtk-incubator/action-listener-middleware@npm:0.8.0" peerDependencies: "@reduxjs/toolkit": ^1.6.0 - languageName: unknown - linkType: soft + checksum: 2e9c0a235758bf2e7915c708ad641a7d3c25b0f7b0da787dca0fbdf73b0f530f3e9923f251232d1650c847e3447e96377ac592d5b167b7b63f216dccbb7c4d61 + languageName: node + linkType: hard "@rtk-query/codegen-openapi@workspace:packages/rtk-query-codegen-openapi": version: 0.0.0-use.local From 19b89044b0cc9f3478a0e6c4eb67ce3c8b22e60c Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Sat, 26 Feb 2022 23:39:20 +0100 Subject: [PATCH 21/27] fix(alm): cancel forkApi.delay and forkApi.pause if listener is cancelled or completed -- Test log $ yarn workspace @reduxjs/toolkit run test PASS src/query/tests/buildHooks.test.tsx (21.737 s) PASS src/listenerMiddleware/tests/listenerMiddleware.test.ts PASS src/tests/createAsyncThunk.test.ts PASS src/query/tests/createApi.test.ts PASS src/entities/tests/sorted_state_adapter.test.ts PASS src/query/tests/fetchBaseQuery.test.tsx PASS src/tests/createReducer.test.ts PASS src/entities/tests/unsorted_state_adapter.test.ts PASS src/query/tests/cacheLifecycle.test.ts PASS src/query/tests/errorHandling.test.tsx PASS src/query/tests/queryLifecycle.test.tsx PASS src/tests/serializableStateInvariantMiddleware.test.ts PASS src/tests/matchers.test.ts PASS src/listenerMiddleware/tests/fork.test.ts PASS src/query/tests/devWarnings.test.tsx PASS src/query/tests/queryFn.test.tsx PASS src/query/tests/refetchingBehaviors.test.tsx PASS src/tests/immutableStateInvariantMiddleware.test.ts PASS src/query/tests/useMutation-fixedCacheKey.test.tsx PASS src/listenerMiddleware/tests/effectScenarios.test.ts PASS src/query/tests/optimisticUpdates.test.tsx PASS src/tests/createSlice.test.ts PASS src/query/tests/retry.test.ts PASS src/tests/getDefaultMiddleware.test.ts PASS src/tests/configureStore.test.ts PASS src/query/tests/matchers.test.tsx PASS src/listenerMiddleware/tests/useCases.test.ts PASS src/query/tests/buildThunks.test.tsx PASS src/tests/createAction.test.ts PASS src/tests/combinedTest.test.ts PASS src/query/tests/fakeBaseQuery.test.tsx PASS src/query/tests/buildMiddleware.test.tsx PASS src/entities/tests/state_selectors.test.ts PASS src/query/tests/invalidation.test.tsx PASS src/query/tests/cacheCollection.test.ts PASS src/query/tests/utils.test.ts PASS src/query/tests/cleanup.test.tsx PASS src/query/tests/polling.test.tsx PASS src/entities/tests/entity_state.test.ts PASS src/query/tests/copyWithStructuralSharing.test.ts PASS src/entities/tests/state_adapter.test.ts PASS src/query/tests/buildSlice.test.ts PASS src/entities/tests/utils.spec.ts PASS src/query/tests/apiProvider.test.tsx PASS src/query/tests/defaultSerializeQueryArgs.test.ts PASS src/tests/createDraftSafeSelector.test.ts PASS src/tests/isPlainObject.test.ts Test Suites: 2 skipped, 47 passed, 47 of 49 total Tests: 23 skipped, 685 passed, 708 total Snapshots: 51 passed, 51 total Time: 65.182 s Ran all test suites. --- .../src/listenerMiddleware/exceptions.ts | 10 +- .../toolkit/src/listenerMiddleware/index.ts | 11 ++- .../toolkit/src/listenerMiddleware/task.ts | 4 +- .../src/listenerMiddleware/tests/fork.test.ts | 92 ++++++++++++++++++- .../toolkit/src/listenerMiddleware/types.ts | 4 +- .../toolkit/src/listenerMiddleware/utils.ts | 7 ++ 6 files changed, 116 insertions(+), 12 deletions(-) diff --git a/packages/toolkit/src/listenerMiddleware/exceptions.ts b/packages/toolkit/src/listenerMiddleware/exceptions.ts index 8374085619..ab93a787b2 100644 --- a/packages/toolkit/src/listenerMiddleware/exceptions.ts +++ b/packages/toolkit/src/listenerMiddleware/exceptions.ts @@ -6,15 +6,15 @@ const completed = 'completed' const cancelled = 'cancelled' /* TaskAbortError error codes */ -export const taskCancelled = `${task}-${cancelled}` as const -export const taskCompleted = `${task}-${completed}` as const +export const taskCancelled = `task-${cancelled}` as const +export const taskCompleted = `task-${completed}` as const export const listenerCancelled = `${listener}-${cancelled}` as const export const listenerCompleted = `${listener}-${completed}` as const export class TaskAbortError implements SerializedError { name = 'TaskAbortError' - message = '' - constructor(public code = 'unknown') { - this.message = `task cancelled (reason: ${code})` + message: string + constructor(public code: string | undefined) { + this.message = `${task} ${cancelled} (reason: ${code})` } } diff --git a/packages/toolkit/src/listenerMiddleware/index.ts b/packages/toolkit/src/listenerMiddleware/index.ts index 47c7024f79..3ea82c08df 100644 --- a/packages/toolkit/src/listenerMiddleware/index.ts +++ b/packages/toolkit/src/listenerMiddleware/index.ts @@ -21,9 +21,11 @@ import type { ForkedTask, TypedRemoveListener, TaskResult, + AbortSignalWithReason, } from './types' import { abortControllerWithReason, + addAbortSignalListener, assertFunction, catchRejection, } from './utils' @@ -74,11 +76,18 @@ const INTERNAL_NIL_TOKEN = {} as const const alm = 'listenerMiddleware' as const -const createFork = (parentAbortSignal: AbortSignal) => { +const createFork = (parentAbortSignal: AbortSignalWithReason) => { + const linkControllers = (controller: AbortController) => + addAbortSignalListener(parentAbortSignal, () => + abortControllerWithReason(controller, parentAbortSignal.reason) + ) + return (taskExecutor: ForkedTaskExecutor): ForkedTask => { assertFunction(taskExecutor, 'taskExecutor') const childAbortController = new AbortController() + linkControllers(childAbortController) + const result = runTask( async (): Promise => { validateActive(parentAbortSignal) diff --git a/packages/toolkit/src/listenerMiddleware/task.ts b/packages/toolkit/src/listenerMiddleware/task.ts index 21839abad8..ca57aad422 100644 --- a/packages/toolkit/src/listenerMiddleware/task.ts +++ b/packages/toolkit/src/listenerMiddleware/task.ts @@ -1,6 +1,6 @@ import { TaskAbortError } from './exceptions' import type { AbortSignalWithReason, TaskResult } from './types' -import { catchRejection } from './utils' +import { addAbortSignalListener, catchRejection } from './utils' /** * Synchronously raises {@link TaskAbortError} if the task tied to the input `signal` has been cancelled. @@ -29,7 +29,7 @@ export const promisifyAbortSignal = ( if (signal.aborted) { notifyRejection() } else { - signal.addEventListener('abort', notifyRejection, { once: true }) + addAbortSignalListener(signal, notifyRejection) } }) ) diff --git a/packages/toolkit/src/listenerMiddleware/tests/fork.test.ts b/packages/toolkit/src/listenerMiddleware/tests/fork.test.ts index c54285d12c..2756e09f15 100644 --- a/packages/toolkit/src/listenerMiddleware/tests/fork.test.ts +++ b/packages/toolkit/src/listenerMiddleware/tests/fork.test.ts @@ -2,9 +2,17 @@ import type { EnhancedStore } from '@reduxjs/toolkit' import { configureStore, createSlice, createAction } from '@reduxjs/toolkit' import type { PayloadAction } from '@reduxjs/toolkit' -import type { ForkedTaskExecutor, TaskResult } from '../types' +import type { + AbortSignalWithReason, + ForkedTaskExecutor, + TaskResult, +} from '../types' import { createListenerMiddleware, TaskAbortError } from '../index' -import { listenerCancelled, taskCancelled } from '../exceptions' +import { + listenerCancelled, + listenerCompleted, + taskCancelled, +} from '../exceptions' function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -312,6 +320,58 @@ describe('fork', () => { }) }) + 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) + ) + }) + + test('forkApi.signal listener is invoked as soon as the parent listener is cancelled or completed', async () => { + let deferredResult = deferred() + + startListening({ + actionCreator: increment, + async effect(_, listenerApi) { + const wronglyDoNotAwaitResultOfTask = listenerApi.fork( + async (forkApi) => { + forkApi.signal.addEventListener('abort', () => { + deferredResult.resolve( + (forkApi.signal as AbortSignalWithReason).reason + ) + }) + } + ) + }, + }) + + store.dispatch(increment) + + expect(await deferredResult).toBe(listenerCompleted) + }) + test('fork.delay does not trigger unhandledRejections for completed or cancelled tasks', async () => { let deferredCompletedEvt = deferred() let deferredCancelledEvt = deferred() @@ -384,6 +444,34 @@ describe('fork', () => { }) }) + 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('incrementByInListener') diff --git a/packages/toolkit/src/listenerMiddleware/types.ts b/packages/toolkit/src/listenerMiddleware/types.ts index 25fca90270..e441828345 100644 --- a/packages/toolkit/src/listenerMiddleware/types.ts +++ b/packages/toolkit/src/listenerMiddleware/types.ts @@ -54,12 +54,12 @@ export type MatchFunction = (v: any) => v is T export interface ForkedTaskAPI { /** * Returns a promise that resolves when `waitFor` resolves or - * rejects if the task has been cancelled or completed. + * rejects if the task or the parent listener has been cancelled or is completed. */ pause(waitFor: Promise): Promise /** * Returns a promise resolves after `timeoutMs` or - * rejects if the task has been cancelled or is completed. + * rejects if the task or the parent listener has been cancelled or is completed. * @param timeoutMs */ delay(timeoutMs: number): Promise diff --git a/packages/toolkit/src/listenerMiddleware/utils.ts b/packages/toolkit/src/listenerMiddleware/utils.ts index 0d12eb8e90..3f86348776 100644 --- a/packages/toolkit/src/listenerMiddleware/utils.ts +++ b/packages/toolkit/src/listenerMiddleware/utils.ts @@ -23,6 +23,13 @@ export const catchRejection = ( return promise } +export const addAbortSignalListener = ( + abortSignal: AbortSignal, + callback: (evt: Event) => void +) => { + abortSignal.addEventListener('abort', callback, { once: true }) +} + /** * Calls `abortController.abort(reason)` and patches `signal.reason`. * if it is not supported. From 1ddda6c11303c838d5abbf8b7a40f4ee0f37c95c Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 26 Feb 2022 21:03:30 -0500 Subject: [PATCH 22/27] Fix broken transpilation of map iteration in listener middleware Same bug as https://github.com/reduxjs/redux-toolkit/commit/be6e198fecbc22c95b030e6796a947ff78c0888e This appears to be some kind of issue with either ESBuild, TS, or both of them being used together. Replay of the bug: https://app.replay.io/recording/rtk-listener-middleware-not-updating--1bb6f325-3e75-4958-83b5-1d87719d4b6c --- packages/toolkit/src/listenerMiddleware/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/toolkit/src/listenerMiddleware/index.ts b/packages/toolkit/src/listenerMiddleware/index.ts index 3ea82c08df..0caccd0217 100644 --- a/packages/toolkit/src/listenerMiddleware/index.ts +++ b/packages/toolkit/src/listenerMiddleware/index.ts @@ -437,7 +437,9 @@ export function createListenerMiddleware< if (listenerMap.size > 0) { let currentState = api.getState() - for (let entry of listenerMap.values()) { + // Work around ESBuild+TS transpilation issue + const listenerEntries = Array.from(listenerMap.values()) + for (let entry of listenerEntries) { let runListener = false try { From 202a88aeda11c6c7756626800faf9a0cd3104cab Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 26 Feb 2022 21:39:50 -0500 Subject: [PATCH 23/27] Release 1.8.0-rc.0 --- packages/toolkit/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index c13674ee56..e7eebe9800 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@reduxjs/toolkit", - "version": "1.7.2", + "version": "1.8.0-rc.0", "description": "The official, opinionated, batteries-included toolset for efficient Redux development", "author": "Mark Erikson ", "license": "MIT", From 22cbc98abedacde7260ee44dda21da48be3f922e Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 26 Feb 2022 21:56:44 -0500 Subject: [PATCH 24/27] Update lockfile from release --- yarn.lock | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 2338f5283a..42ba6e9427 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5332,7 +5332,27 @@ __metadata: languageName: node linkType: hard -"@reduxjs/toolkit@^1.6.0, @reduxjs/toolkit@^1.6.0-rc.1, @reduxjs/toolkit@workspace:packages/toolkit": +"@reduxjs/toolkit@npm:^1.6.0, @reduxjs/toolkit@npm:^1.6.0-rc.1": + version: 1.7.2 + resolution: "@reduxjs/toolkit@npm:1.7.2" + dependencies: + immer: ^9.0.7 + redux: ^4.1.2 + redux-thunk: ^2.4.1 + reselect: ^4.1.5 + peerDependencies: + react: ^16.9.0 || ^17.0.0 || 18.0.0-beta + react-redux: ^7.2.1 || ^8.0.0-beta + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + checksum: 41c17c660f7c95d776658b9a101f9803c37589b2c08861e90283abe5fab70f99c37af064fae0c5b7501f67308946fa072504f6a801e272f157b9f7427a5e571e + languageName: node + linkType: hard + +"@reduxjs/toolkit@workspace:packages/toolkit": version: 0.0.0-use.local resolution: "@reduxjs/toolkit@workspace:packages/toolkit" dependencies: From 7cedc2dbc6c195b6cbed02ba68e30dd910c57e0a Mon Sep 17 00:00:00 2001 From: FaberVitale Date: Sun, 27 Feb 2022 10:28:10 +0100 Subject: [PATCH 25/27] chore(alm): update counter example to 1.8.0 --- examples/action-listener/counter/package.json | 3 +- .../counter/src/components/App/App.tsx | 3 +- .../counter/src/services/counter/listeners.ts | 12 ++++- examples/action-listener/counter/src/store.ts | 6 +-- yarn.lock | 54 ++++++++----------- 5 files changed, 37 insertions(+), 41 deletions(-) diff --git a/examples/action-listener/counter/package.json b/examples/action-listener/counter/package.json index f5e5e5b068..39c47b062c 100644 --- a/examples/action-listener/counter/package.json +++ b/examples/action-listener/counter/package.json @@ -3,8 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { - "@reduxjs/toolkit": "^1.6.0-rc.1", - "@rtk-incubator/action-listener-middleware": "^0.8.0", + "@reduxjs/toolkit": "^1.8.0-rc.0", "@types/node": "^12.0.0", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", diff --git a/examples/action-listener/counter/src/components/App/App.tsx b/examples/action-listener/counter/src/components/App/App.tsx index 5779032d6d..ca09482b2b 100644 --- a/examples/action-listener/counter/src/components/App/App.tsx +++ b/examples/action-listener/counter/src/components/App/App.tsx @@ -8,7 +8,6 @@ import { CounterList } from '../CounterList/CounterList' import { CreateCounterForm } from '../CreateCounterForm/CreateCounterForm' import { store, startAppListening } from '../../store' - export function App() { useEffect(() => { const subscriptions: Unsubscribe[] = [ @@ -32,5 +31,5 @@ export function App() { - ) + ) } diff --git a/examples/action-listener/counter/src/services/counter/listeners.ts b/examples/action-listener/counter/src/services/counter/listeners.ts index 3aa6113493..3fe74b7bfd 100644 --- a/examples/action-listener/counter/src/services/counter/listeners.ts +++ b/examples/action-listener/counter/src/services/counter/listeners.ts @@ -1,5 +1,11 @@ import { counterActions, counterSelectors } from './slice' -import { AnyAction, isAllOf, isAnyOf, PayloadAction, Unsubscribe } from '@reduxjs/toolkit' +import { + AnyAction, + isAllOf, + isAnyOf, + PayloadAction, + Unsubscribe, +} from '@reduxjs/toolkit' import type { AppListenerEffectAPI, AppStartListening } from '../../store' function shouldStopAsyncTasksOf(id: string) { @@ -70,7 +76,9 @@ async function onUpdateAsync( * }, []); * ``` */ -export function setupCounterListeners(startListening: AppStartListening): Unsubscribe { +export function setupCounterListeners( + startListening: AppStartListening +): Unsubscribe { const subscriptions = [ startListening({ actionCreator: counterActions.updateByPeriodically, diff --git a/examples/action-listener/counter/src/store.ts b/examples/action-listener/counter/src/store.ts index 4c7328299f..801bb28018 100644 --- a/examples/action-listener/counter/src/store.ts +++ b/examples/action-listener/counter/src/store.ts @@ -1,13 +1,13 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' -import { configureStore } from '@reduxjs/toolkit' -import { counterSlice } from './services/counter/slice' import { + configureStore, createListenerMiddleware, TypedStartListening, TypedAddListener, ListenerEffectAPI, addListener, -} from '@rtk-incubator/action-listener-middleware' +} from '@reduxjs/toolkit' +import { counterSlice } from './services/counter/slice' import { themeSlice } from './services/theme/slice' const listenerMiddlewareInstance = createListenerMiddleware({ diff --git a/yarn.lock b/yarn.lock index 42ba6e9427..ea933f3bbe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3672,8 +3672,7 @@ __metadata: version: 0.0.0-use.local resolution: "@examples-action-listener/counter@workspace:examples/action-listener/counter" dependencies: - "@reduxjs/toolkit": ^1.6.0-rc.1 - "@rtk-incubator/action-listener-middleware": ^0.8.0 + "@reduxjs/toolkit": ^1.8.0-rc.0 "@types/node": ^12.0.0 "@types/react": ^17.0.0 "@types/react-dom": ^17.0.0 @@ -5332,27 +5331,7 @@ __metadata: languageName: node linkType: hard -"@reduxjs/toolkit@npm:^1.6.0, @reduxjs/toolkit@npm:^1.6.0-rc.1": - version: 1.7.2 - resolution: "@reduxjs/toolkit@npm:1.7.2" - dependencies: - immer: ^9.0.7 - redux: ^4.1.2 - redux-thunk: ^2.4.1 - reselect: ^4.1.5 - peerDependencies: - react: ^16.9.0 || ^17.0.0 || 18.0.0-beta - react-redux: ^7.2.1 || ^8.0.0-beta - peerDependenciesMeta: - react: - optional: true - react-redux: - optional: true - checksum: 41c17c660f7c95d776658b9a101f9803c37589b2c08861e90283abe5fab70f99c37af064fae0c5b7501f67308946fa072504f6a801e272f157b9f7427a5e571e - languageName: node - linkType: hard - -"@reduxjs/toolkit@workspace:packages/toolkit": +"@reduxjs/toolkit@^1.8.0-rc.0, @reduxjs/toolkit@workspace:packages/toolkit": version: 0.0.0-use.local resolution: "@reduxjs/toolkit@workspace:packages/toolkit" dependencies: @@ -5421,6 +5400,26 @@ __metadata: languageName: unknown linkType: soft +"@reduxjs/toolkit@npm:^1.6.0, @reduxjs/toolkit@npm:^1.6.0-rc.1": + version: 1.7.2 + resolution: "@reduxjs/toolkit@npm:1.7.2" + dependencies: + immer: ^9.0.7 + redux: ^4.1.2 + redux-thunk: ^2.4.1 + reselect: ^4.1.5 + peerDependencies: + react: ^16.9.0 || ^17.0.0 || 18.0.0-beta + react-redux: ^7.2.1 || ^8.0.0-beta + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + checksum: 41c17c660f7c95d776658b9a101f9803c37589b2c08861e90283abe5fab70f99c37af064fae0c5b7501f67308946fa072504f6a801e272f157b9f7427a5e571e + languageName: node + linkType: hard + "@rollup/plugin-alias@npm:^3.1.1": version: 3.1.2 resolution: "@rollup/plugin-alias@npm:3.1.2" @@ -5533,15 +5532,6 @@ __metadata: languageName: node linkType: hard -"@rtk-incubator/action-listener-middleware@npm:^0.8.0": - version: 0.8.0 - resolution: "@rtk-incubator/action-listener-middleware@npm:0.8.0" - peerDependencies: - "@reduxjs/toolkit": ^1.6.0 - checksum: 2e9c0a235758bf2e7915c708ad641a7d3c25b0f7b0da787dca0fbdf73b0f530f3e9923f251232d1650c847e3447e96377ac592d5b167b7b63f216dccbb7c4d61 - languageName: node - linkType: hard - "@rtk-query/codegen-openapi@workspace:packages/rtk-query-codegen-openapi": version: 0.0.0-use.local resolution: "@rtk-query/codegen-openapi@workspace:packages/rtk-query-codegen-openapi" From 5d39ecf2d1f0e09873513e317d796a4c6a77d12f Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 27 Feb 2022 13:59:58 -0500 Subject: [PATCH 26/27] Rename `removeAllListeners` to `clearAllListeners` --- docs/api/createListenerMiddleware.mdx | 4 ++-- packages/toolkit/src/index.ts | 2 +- packages/toolkit/src/listenerMiddleware/index.ts | 4 ++-- .../src/listenerMiddleware/tests/listenerMiddleware.test.ts | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/api/createListenerMiddleware.mdx b/docs/api/createListenerMiddleware.mdx index 879ae01161..eac9110914 100644 --- a/docs/api/createListenerMiddleware.mdx +++ b/docs/api/createListenerMiddleware.mdx @@ -259,12 +259,12 @@ Returns `true` if the `options.listener` listener has been removed, `false` if n store.dispatch(removeListener({ predicate, listener })) ``` -### `removeAllListeners` +### `clearAllListeners` A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically remove all listeners at runtime. ```js -store.dispatch(removeAllListeners()) +store.dispatch(clearAllListeners()) ``` ## Listener API diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index e4d6f5745c..723047924f 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -178,6 +178,6 @@ export { createListenerMiddleware, addListener, removeListener, - removeAllListeners, + clearAllListeners, TaskAbortError, } from './listenerMiddleware/index' diff --git a/packages/toolkit/src/listenerMiddleware/index.ts b/packages/toolkit/src/listenerMiddleware/index.ts index 0caccd0217..9f54bbc23c 100644 --- a/packages/toolkit/src/listenerMiddleware/index.ts +++ b/packages/toolkit/src/listenerMiddleware/index.ts @@ -266,7 +266,7 @@ export const addListener = createAction( /** * @alpha */ -export const removeAllListeners = createAction(`${alm}/removeAll`) +export const clearAllListeners = createAction(`${alm}/removeAll`) /** * @alpha @@ -405,7 +405,7 @@ export function createListenerMiddleware< return startListening(action.payload) } - if (removeAllListeners.match(action)) { + if (clearAllListeners.match(action)) { clearListenerMiddleware() return } diff --git a/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts index 5b600f5e60..0eb8b3e8ef 100644 --- a/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts +++ b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts @@ -14,7 +14,7 @@ import { addListener, removeListener, TaskAbortError, - removeAllListeners, + clearAllListeners, } from '../index' import type { @@ -649,7 +649,7 @@ describe('createListenerMiddleware', () => { }) startListening({ - actionCreator: removeAllListeners, + actionCreator: clearAllListeners, effect() { listener2Calls++ }, @@ -663,7 +663,7 @@ describe('createListenerMiddleware', () => { }) store.dispatch(testAction1('a')) - store.dispatch(removeAllListeners()) + store.dispatch(clearAllListeners()) store.dispatch(testAction1('b')) expect(await listener1Test).toBe(1) expect(listener1Calls).toBe(1) From 5acc6ef1385c6ea42205aca57d0f1613f28e69a3 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 27 Feb 2022 14:02:39 -0500 Subject: [PATCH 27/27] Enable cancelling listeners when unsubscribing --- docs/api/createListenerMiddleware.mdx | 51 +++++-- packages/toolkit/src/index.ts | 3 +- .../toolkit/src/listenerMiddleware/index.ts | 54 +++++--- .../tests/listenerMiddleware.test.ts | 78 ++++++++++- .../toolkit/src/listenerMiddleware/types.ts | 128 +++++++++++------- 5 files changed, 236 insertions(+), 78 deletions(-) diff --git a/docs/api/createListenerMiddleware.mdx b/docs/api/createListenerMiddleware.mdx index eac9110914..446e3fba1f 100644 --- a/docs/api/createListenerMiddleware.mdx +++ b/docs/api/createListenerMiddleware.mdx @@ -144,7 +144,7 @@ const store = configureStore({ Adds a new listener entry to the middleware. Typically used to "statically" add new listeners during application setup. ```ts no-transpile -const startListening = (options: AddListenerOptions) => Unsubscribe +const startListening = (options: AddListenerOptions) => UnsubscribeListener interface AddListenerOptions { // Four options for deciding when the listener will run: @@ -170,6 +170,14 @@ type ListenerPredicate = ( currentState?: State, originalState?: State ) => boolean + +type UnsubscribeListener = ( + unsuscribeOptions?: UnsubscribeListenerOptions +) => void + +interface UnsubscribeListenerOptions { + cancelActive?: true +} ``` **You must provide exactly _one_ of the four options for deciding when the listener will run: `type`, `actionCreator`, `matcher`, or `predicate`**. Every time an action is dispatched, each listener will be checked to see if it should run based on the current action vs the comparison option provided. @@ -199,7 +207,9 @@ Note that the `predicate` option actually allows matching solely against state-r The ["matcher" utility functions included in RTK](./matching-utilities.mdx) are acceptable as either the `matcher` or `predicate` option. -The return value is a standard `unsubscribe()` callback that will remove this listener. If you try to add a listener entry but another entry with this exact function reference already exists, no new entry will be added, and the existing `unsubscribe` method will be returned. +The return value is an `unsubscribe()` callback that will remove this listener. By default, unsubscribing will _not_ cancel any active instances of the listener. However, you may also pass in `{cancelActive: true}` to cancel running instances. + +If you try to add a listener entry but another entry with this exact function reference already exists, no new entry will be added, and the existing `unsubscribe` method will be returned. The `effect` callback will receive the current action as its first argument, as well as a "listener API" object similar to the "thunk API" object in `createAsyncThunk`. @@ -207,28 +217,45 @@ All listener predicates and callbacks are checked _after_ the root reducer has a ### `stopListening` -Removes a given listener. It accepts the same arguments as `startListening()`. It checks for an existing listener entry by comparing the function references of `listener` and the provided `actionCreator/matcher/predicate` function or `type` string. +Removes a given listener entry. + +It accepts the same arguments as `startListening()`. It checks for an existing listener entry by comparing the function references of `listener` and the provided `actionCreator/matcher/predicate` function or `type` string. + +By default, this does _not_ cancel any active running instances. However, you may also pass in `{cancelActive: true}` to cancel running instances. ```ts no-transpile -const stopListening = (options: AddListenerOptions) => boolean +const stopListening = ( + options: AddListenerOptions & UnsubscribeListenerOptions +) => boolean + +interface UnsubscribeListenerOptions { + cancelActive?: true +} ``` Returns `true` if the `options.effect` listener has been removed, or `false` if no subscription matching the input provided has been found. ```js +// Examples: // 1) Action type string -listenerMiddleware.stopListening({ type: 'todos/todoAdded', listener }) +listenerMiddleware.stopListening({ + type: 'todos/todoAdded', + listener, + cancelActive: true, +}) // 2) RTK action creator listenerMiddleware.stopListening({ actionCreator: todoAdded, listener }) // 3) RTK matcher function -listenerMiddleware.stopListening({ matcher, listener }) +listenerMiddleware.stopListening({ matcher, listener, cancelActive: true }) // 4) Listener predicate listenerMiddleware.stopListening({ predicate, listener }) ``` ### `clearListeners` -Removes all current listener entries. This is most likely useful for test scenarios where a single middleware or store instance might be used in multiple tests, as well as some app cleanup situations. +Removes all current listener entries. It also cancels all active running instances of those listeners as well. + +This is most likely useful for test scenarios where a single middleware or store instance might be used in multiple tests, as well as some app cleanup situations. ```ts no-transpile const clearListeners = () => void; @@ -253,15 +280,19 @@ const unsubscribe = store.dispatch(addListener({ predicate, listener })) A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically remove a listener at runtime. Accepts the same arguments as `stopListening()`. +By default, this does _not_ cancel any active running instances. However, you may also pass in `{cancelActive: true}` to cancel running instances. + Returns `true` if the `options.listener` listener has been removed, `false` if no subscription matching the input provided has been found. ```js -store.dispatch(removeListener({ predicate, listener })) +const wasRemoved = store.dispatch( + removeListener({ predicate, listener, cancelActive: true }) +) ``` ### `clearAllListeners` -A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically remove all listeners at runtime. +A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to remove all current listener entries. It also cancels all active running instances of those listeners as well. ```js store.dispatch(clearAllListeners()) @@ -284,7 +315,7 @@ The `listenerApi` object is the second argument to each listener callback. It co ### Listener Subscription Management -- `unsubscribe: () => void`: removes the listener entry from the middleware, and prevent future instances of the listener from running. +- `unsubscribe: () => void`: removes the listener entry from the middleware, and prevent future instances of the listener from running. (This does _not_ cancel any active instances.) - `subscribe: () => void`: will re-subscribe the listener entry if it was previously removed, or no-op if currently subscribed - `cancelActiveListeners: () => void`: cancels all other running instances of this same listener _except_ for the one that made this call. (The cancellation will only have a meaningful effect if the other instances are paused using one of the cancellation-aware APIs like `take/cancel/pause/delay` - see "Cancelation and Task Management" in the "Usage" section for more details) - `signal: AbortSignal`: An [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) whose `aborted` property will be set to `true` if the listener execution is aborted or completed. diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index 723047924f..17b53d00b0 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -162,7 +162,8 @@ export type { TypedAddListener, TypedStopListening, TypedRemoveListener, - Unsubscribe, + UnsubscribeListener, + UnsubscribeListenerOptions, ForkedTaskExecutor, ForkedTask, ForkedTaskAPI, diff --git a/packages/toolkit/src/listenerMiddleware/index.ts b/packages/toolkit/src/listenerMiddleware/index.ts index 9f54bbc23c..baa0864e16 100644 --- a/packages/toolkit/src/listenerMiddleware/index.ts +++ b/packages/toolkit/src/listenerMiddleware/index.ts @@ -14,7 +14,7 @@ import type { FallbackAddListenerOptions, ListenerEntry, ListenerErrorHandler, - Unsubscribe, + UnsubscribeListener, TakePattern, ListenerErrorInfo, ForkedTaskExecutor, @@ -22,6 +22,7 @@ import type { TypedRemoveListener, TaskResult, AbortSignalWithReason, + UnsubscribeListenerOptions, } from './types' import { abortControllerWithReason, @@ -55,7 +56,8 @@ export type { TypedAddListener, TypedStopListening, TypedRemoveListener, - Unsubscribe, + UnsubscribeListener, + UnsubscribeListenerOptions, ForkedTaskExecutor, ForkedTask, ForkedTaskAPI, @@ -113,7 +115,11 @@ const createFork = (parentAbortSignal: AbortSignalWithReason) => { } const createTakePattern = ( - startListening: AddListenerOverloads>, + startListening: AddListenerOverloads< + UnsubscribeListener, + S, + Dispatch + >, signal: AbortSignal ): TakePattern => { /** @@ -130,7 +136,7 @@ const createTakePattern = ( validateActive(signal) // Placeholder unsubscribe function until the listener is added - let unsubscribe: Unsubscribe = () => {} + let unsubscribe: UnsubscribeListener = () => {} const tuplePromise = new Promise<[AnyAction, S, S]>((resolve) => { // Inside the Promise, we synchronously add the listener. @@ -223,11 +229,7 @@ const createClearListenerMiddleware = ( listenerMap: Map ) => { return () => { - listenerMap.forEach((entry) => { - entry.pending.forEach((controller) => { - abortControllerWithReason(controller, listenerCancelled) - }) - }) + listenerMap.forEach(cancelActiveListeners) listenerMap.clear() } @@ -257,19 +259,19 @@ const safelyNotifyError = ( } /** - * @alpha + * @public */ export const addListener = createAction( `${alm}/add` ) as TypedAddListener /** - * @alpha + * @public */ export const clearAllListeners = createAction(`${alm}/removeAll`) /** - * @alpha + * @public */ export const removeListener = createAction( `${alm}/remove` @@ -279,8 +281,16 @@ const defaultErrorHandler: ListenerErrorHandler = (...args: unknown[]) => { console.error(`${alm}/error`, ...args) } +const cancelActiveListeners = ( + entry: ListenerEntry> +) => { + entry.pending.forEach((controller) => { + abortControllerWithReason(controller, listenerCancelled) + }) +} + /** - * @alpha + * @public */ export function createListenerMiddleware< S = unknown, @@ -296,7 +306,12 @@ export function createListenerMiddleware< entry.unsubscribe = () => listenerMap.delete(entry!.id) listenerMap.set(entry.id, entry) - return entry.unsubscribe + return (cancelOptions?: UnsubscribeListenerOptions) => { + entry.unsubscribe() + if (cancelOptions?.cancelActive) { + cancelActiveListeners(entry) + } + } } const findListenerEntry = ( @@ -323,7 +338,9 @@ export function createListenerMiddleware< return insertEntry(entry) } - const stopListening = (options: FallbackAddListenerOptions): boolean => { + const stopListening = ( + options: FallbackAddListenerOptions & UnsubscribeListenerOptions + ): boolean => { const { type, effect, predicate } = getListenerEntryPropsFrom(options) const entry = findListenerEntry((entry) => { @@ -335,7 +352,12 @@ export function createListenerMiddleware< return matchPredicateOrType && entry.effect === effect }) - entry?.unsubscribe() + if (entry) { + entry.unsubscribe() + if (options.cancelActive) { + cancelActiveListeners(entry) + } + } return !!entry } diff --git a/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts index 0eb8b3e8ef..f6a331276d 100644 --- a/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts +++ b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts @@ -22,7 +22,7 @@ import type { ListenerEffectAPI, TypedAddListener, TypedStartListening, - Unsubscribe, + UnsubscribeListener, ListenerMiddleware, } from '../index' import type { @@ -445,7 +445,7 @@ describe('createListenerMiddleware', () => { }) ) - expectType(unsubscribe) + expectType(unsubscribe) store.dispatch(testAction1('a')) @@ -478,6 +478,80 @@ describe('createListenerMiddleware', () => { expect(effect.mock.calls).toEqual([[testAction1('a'), middlewareApi]]) }) + test('can cancel an active listener when unsubscribing directly', async () => { + let wasCancelled = false + const unsubscribe = startListening({ + actionCreator: testAction1, + effect: async (action, listenerApi) => { + try { + await listenerApi.condition(testAction2.match) + } catch (err) { + if (err instanceof TaskAbortError) { + wasCancelled = true + } + } + }, + }) + + store.dispatch(testAction1('a')) + unsubscribe({ cancelActive: true }) + expect(wasCancelled).toBe(false) + await delay(10) + expect(wasCancelled).toBe(true) + }) + + test('can cancel an active listener when unsubscribing via stopListening', async () => { + let wasCancelled = false + const effect = async (action: any, listenerApi: any) => { + try { + await listenerApi.condition(testAction2.match) + } catch (err) { + if (err instanceof TaskAbortError) { + wasCancelled = true + } + } + } + startListening({ + actionCreator: testAction1, + effect, + }) + + store.dispatch(testAction1('a')) + stopListening({ actionCreator: testAction1, effect, cancelActive: true }) + expect(wasCancelled).toBe(false) + await delay(10) + expect(wasCancelled).toBe(true) + }) + + test('can cancel an active listener when unsubscribing via removeListener', async () => { + let wasCancelled = false + const effect = async (action: any, listenerApi: any) => { + try { + await listenerApi.condition(testAction2.match) + } catch (err) { + if (err instanceof TaskAbortError) { + wasCancelled = true + } + } + } + startListening({ + actionCreator: testAction1, + effect, + }) + + store.dispatch(testAction1('a')) + store.dispatch( + removeListener({ + actionCreator: testAction1, + effect, + cancelActive: true, + }) + ) + expect(wasCancelled).toBe(false) + await delay(10) + expect(wasCancelled).toBe(true) + }) + const addListenerOptions: [ string, Omit< diff --git a/packages/toolkit/src/listenerMiddleware/types.ts b/packages/toolkit/src/listenerMiddleware/types.ts index e441828345..7223634e92 100644 --- a/packages/toolkit/src/listenerMiddleware/types.ts +++ b/packages/toolkit/src/listenerMiddleware/types.ts @@ -245,7 +245,7 @@ export type ListenerMiddleware< ExtraArgument = unknown > = Middleware< { - (action: ReduxAction<'listenerMiddleware/add'>): Unsubscribe + (action: ReduxAction<'listenerMiddleware/add'>): UnsubscribeListener }, State, Dispatch @@ -263,7 +263,7 @@ export interface ListenerMiddlewareInstance< > { middleware: ListenerMiddleware startListening: AddListenerOverloads< - Unsubscribe, + UnsubscribeListener, State, Dispatch, ExtraArgument @@ -310,6 +310,16 @@ export interface TakePattern { ): Promise<[AnyAction, State, State] | null> } +/** @public */ +export interface UnsubscribeListenerOptions { + cancelActive?: true +} + +/** @public */ +export type UnsubscribeListener = ( + unsuscribeOptions?: UnsubscribeListenerOptions +) => void + /** * @public * The possible overloads and options for defining a listener. The return type of each function is specified as a generic arg, so the overloads can be reused for multiple different functions @@ -318,64 +328,81 @@ export interface AddListenerOverloads< Return, State = unknown, Dispatch extends ReduxDispatch = ThunkDispatch, - ExtraArgument = unknown + ExtraArgument = unknown, + AdditionalOptions = unknown > { /** Accepts a "listener predicate" that is also a TS type predicate for the action*/ - >(options: { - actionCreator?: never - type?: never - matcher?: never - predicate: LP - effect: ListenerEffect< - ListenerPredicateGuardedActionType, - State, - Dispatch, - ExtraArgument - > - }): Return + >( + options: { + actionCreator?: never + type?: never + matcher?: never + predicate: LP + effect: ListenerEffect< + ListenerPredicateGuardedActionType, + State, + Dispatch, + ExtraArgument + > + } & AdditionalOptions + ): Return /** Accepts an RTK action creator, like `incrementByAmount` */ - >(options: { - actionCreator: C - type?: never - matcher?: never - predicate?: never - effect: ListenerEffect, State, Dispatch, ExtraArgument> - }): Return + >( + options: { + actionCreator: C + type?: never + matcher?: never + predicate?: never + effect: ListenerEffect, State, Dispatch, ExtraArgument> + } & AdditionalOptions + ): Return /** Accepts a specific action type string */ - (options: { - actionCreator?: never - type: T - matcher?: never - predicate?: never - effect: ListenerEffect, State, Dispatch, ExtraArgument> - }): Return + ( + options: { + actionCreator?: never + type: T + matcher?: never + predicate?: never + effect: ListenerEffect, State, Dispatch, ExtraArgument> + } & AdditionalOptions + ): Return /** Accepts an RTK matcher function, such as `incrementByAmount.match` */ - >(options: { - actionCreator?: never - type?: never - matcher: M - predicate?: never - effect: ListenerEffect, State, Dispatch, ExtraArgument> - }): Return + >( + options: { + actionCreator?: never + type?: never + matcher: M + predicate?: never + effect: ListenerEffect, State, Dispatch, ExtraArgument> + } & AdditionalOptions + ): Return /** Accepts a "listener predicate" that just returns a boolean, no type assertion */ - >(options: { - actionCreator?: never - type?: never - matcher?: never - predicate: LP - effect: ListenerEffect - }): Return + >( + options: { + actionCreator?: never + type?: never + matcher?: never + predicate: LP + effect: ListenerEffect + } & AdditionalOptions + ): Return } /** @public */ export type RemoveListenerOverloads< State = unknown, Dispatch extends ReduxDispatch = ThunkDispatch -> = AddListenerOverloads +> = AddListenerOverloads< + boolean, + State, + Dispatch, + any, + UnsubscribeListenerOptions +> /** @public */ export interface RemoveListenerAction< @@ -424,7 +451,13 @@ export type TypedRemoveListener< Payload = ListenerEntry, T extends string = 'listenerMiddleware/remove' > = BaseActionCreator & - AddListenerOverloads, State, Dispatch> + AddListenerOverloads< + PayloadAction, + State, + Dispatch, + any, + UnsubscribeListenerOptions + > /** * @public @@ -437,7 +470,7 @@ export type TypedStartListening< AnyAction >, ExtraArgument = unknown -> = AddListenerOverloads +> = AddListenerOverloads /** @public * A "pre-typed" version of `middleware.stopListening`, so the listener args are well-typed */ @@ -493,9 +526,6 @@ export type FallbackAddListenerOptions = { * Utility Types */ -/** @public */ -export type Unsubscribe = () => void - /** @public */ export type GuardedType = T extends ( x: any,