Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

yet another attempt at actionListenerMiddleware #547

Closed
wants to merge 11 commits into from
32 changes: 32 additions & 0 deletions etc/redux-toolkit.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { DeepPartial } from 'redux';
import { Dispatch } from 'redux';
import { Draft } from 'immer';
import { Middleware } from 'redux';
import { MiddlewareAPI } from 'redux';
import { OutputParametricSelector } from 'reselect';
import { OutputSelector } from 'reselect';
import { ParametricSelector } from 'reselect';
Expand Down Expand Up @@ -59,6 +60,16 @@ export interface ActionReducerMapBuilder<State> {
// @public @deprecated
export type Actions<T extends keyof any = string> = Record<T, Action>;

// @alpha (undocumented)
export const addListenerAction: BaseActionCreator<{
type: string;
listener: ActionListener<any, any, any>;
options: ActionListenerOptions<any, any, any>;
}, "actionListenerMiddleware/add", never, never> & {
<C extends TypedActionCreator<any>, S, D extends Dispatch<AnyAction>>(actionCreator: C, listener: ActionListener<ReturnType<C>, S, D>, options?: ActionListenerOptions<ReturnType<C>, S, D> | undefined): AddListenerAction<ReturnType<C>, S, D>;
<S_1, D_1 extends Dispatch<AnyAction>>(type: string, listener: ActionListener<AnyAction, S_1, D_1>, options?: ActionListenerOptions<AnyAction, S_1, D_1> | undefined): AddListenerAction<AnyAction, S_1, D_1>;
};

// @public
export type AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig extends AsyncThunkConfig> = (dispatch: GetDispatch<ThunkApiConfig>, getState: () => GetState<ThunkApiConfig>, extra: GetExtra<ThunkApiConfig>) => Promise<PayloadAction<Returned, string, {
arg: ThunkArg;
Expand Down Expand Up @@ -123,6 +134,18 @@ export function createAction<P = void, T extends string = string>(type: T): Payl
// @public
export function createAction<PA extends PrepareAction<any>, T extends string = string>(type: T, prepareAction: PA): PayloadActionCreator<ReturnType<PA>['payload'], T, PA>;

// @alpha (undocumented)
export function createActionListenerMiddleware<S, D extends Dispatch<AnyAction> = Dispatch>(): Middleware<(action: Action<"actionListenerMiddleware/add">) => () => void, S, D> & {
addListener: {
<C extends TypedActionCreator<any>>(actionCreator: C, listener: ActionListener<ReturnType<C>, S, D>, options?: ActionListenerOptions<ReturnType<C>, S, D> | undefined): () => void;
(type: string, listener: ActionListener<AnyAction, S, D>, options?: ActionListenerOptions<AnyAction, S, D> | undefined): () => void;
};
removeListener: {
<C_1 extends TypedActionCreator<any>>(actionCreator: C_1, listener: ActionListener<ReturnType<C_1>, S, D>): boolean;
(type: string, listener: ActionListener<AnyAction, S, D>): boolean;
};
};

// @public (undocumented)
export function createAsyncThunk<Returned, ThunkArg = void, ThunkApiConfig extends AsyncThunkConfig = {}>(typePrefix: string, payloadCreator: (arg: ThunkArg, thunkAPI: GetThunkAPI<ThunkApiConfig>) => Promise<Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>> | Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>, options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>): IsAny<ThunkArg, (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>, unknown extends ThunkArg ? (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> : [ThunkArg] extends [void] | [undefined] ? () => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> : [void] extends [ThunkArg] ? (arg?: ThunkArg | undefined) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> : [undefined] extends [ThunkArg] ? (arg?: ThunkArg | undefined) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> : (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>> & {
pending: ActionCreatorWithPreparedPayload<[string, ThunkArg], undefined, string, never, {
Expand Down Expand Up @@ -342,6 +365,15 @@ export type PrepareAction<P> = ((...args: any[]) => {
error: any;
});

// @alpha (undocumented)
export const removeListenerAction: BaseActionCreator<{
type: string;
listener: ActionListener<any, any, any>;
}, "actionListenerMiddleware/remove", never, never> & {
<C extends TypedActionCreator<any>, S, D extends Dispatch<AnyAction>>(actionCreator: C, listener: ActionListener<ReturnType<C>, S, D>): RemoveListenerAction<ReturnType<C>, S, D>;
<S_1, D_1 extends Dispatch<AnyAction>>(type: string, listener: ActionListener<AnyAction, S_1, D_1>): RemoveListenerAction<AnyAction, S_1, D_1>;
};

export { Selector }

// @public
Expand Down
2 changes: 1 addition & 1 deletion src/createAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export type _ActionCreatorWithPreparedPayload<
*
* @inheritdoc {redux#ActionCreator}
*/
interface BaseActionCreator<P, T extends string, M = never, E = never> {
export interface BaseActionCreator<P, T extends string, M = never, E = never> {
type: T
match(action: Action<unknown>): action is PayloadAction<P, T, M, E>
}
Expand Down
258 changes: 258 additions & 0 deletions src/createActionListenerMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { configureStore } from './configureStore'
import {
createActionListenerMiddleware,
addListenerAction,
removeListenerAction
} from './createActionListenerMiddleware'
import { createAction } from './createAction'
import { AnyAction } from 'redux'

const middlewareApi = {
getState: expect.any(Function),
dispatch: expect.any(Function)
}

const noop = () => {}

describe('createActionListenerMiddleware', () => {
let store = configureStore({
reducer: () => ({}),
middleware: [createActionListenerMiddleware()] as const
})
let reducer: jest.Mock
let middleware: ReturnType<typeof createActionListenerMiddleware>

const testAction1 = createAction<string>('testAction1')
type TestAction1 = ReturnType<typeof testAction1>
const testAction2 = createAction<string>('testAction2')

beforeEach(() => {
middleware = createActionListenerMiddleware()
reducer = jest.fn(() => ({}))
store = configureStore({
reducer,
middleware: [middleware] as const
})
})

test('directly subscribing', () => {
const listener = jest.fn((_: TestAction1) => {})

middleware.addListener(testAction1, listener)

store.dispatch(testAction1('a'))
store.dispatch(testAction2('b'))
store.dispatch(testAction1('c'))

expect(listener.mock.calls).toEqual([
[testAction1('a'), middlewareApi],
[testAction1('c'), middlewareApi]
])
})

test('subscribing with the same listener will not make it trigger twice (like EventTarget.addEventListener())', () => {
const listener = jest.fn((_: TestAction1) => {})

middleware.addListener(testAction1, listener)
middleware.addListener(testAction1, listener)

store.dispatch(testAction1('a'))
store.dispatch(testAction2('b'))
store.dispatch(testAction1('c'))

expect(listener.mock.calls).toEqual([
[testAction1('a'), middlewareApi],
[testAction1('c'), middlewareApi]
])
})

test('unsubscribing via callback', () => {
const listener = jest.fn((_: TestAction1) => {})

const unsubscribe = middleware.addListener(testAction1, listener)

store.dispatch(testAction1('a'))
unsubscribe()
store.dispatch(testAction2('b'))
store.dispatch(testAction1('c'))

expect(listener.mock.calls).toEqual([[testAction1('a'), middlewareApi]])
})

test('directly unsubscribing', () => {
const listener = jest.fn((_: TestAction1) => {})

middleware.addListener(testAction1, listener)

store.dispatch(testAction1('a'))

middleware.removeListener(testAction1, listener)
store.dispatch(testAction2('b'))
store.dispatch(testAction1('c'))

expect(listener.mock.calls).toEqual([[testAction1('a'), middlewareApi]])
})

test('unsubscribing without any subscriptions does not trigger an error', () => {
middleware.removeListener(testAction1, noop)
})

test('subscribing via action', () => {
const listener = jest.fn((_: TestAction1) => {})

store.dispatch(addListenerAction(testAction1, listener))

store.dispatch(testAction1('a'))
store.dispatch(testAction2('b'))
store.dispatch(testAction1('c'))

expect(listener.mock.calls).toEqual([
[testAction1('a'), middlewareApi],
[testAction1('c'), middlewareApi]
])
})

test('unsubscribing via callback from dispatch', () => {
const listener = jest.fn((_: TestAction1) => {})

const unsubscribe = store.dispatch(addListenerAction(testAction1, listener))

store.dispatch(testAction1('a'))
// @ts-ignore TODO types
unsubscribe()
store.dispatch(testAction2('b'))
store.dispatch(testAction1('c'))

expect(listener.mock.calls).toEqual([[testAction1('a'), middlewareApi]])
})

test('unsubscribing via action', () => {
const listener = jest.fn((_: TestAction1) => {})

middleware.addListener(testAction1, listener)

store.dispatch(testAction1('a'))

store.dispatch(removeListenerAction(testAction1, listener))
store.dispatch(testAction2('b'))
store.dispatch(testAction1('c'))

expect(listener.mock.calls).toEqual([[testAction1('a'), middlewareApi]])
})

const unforwaredActions: [string, AnyAction][] = [
['addListenerAction', addListenerAction(testAction1, noop)],
['removeListenerAction', removeListenerAction(testAction1, noop)]
]
test.each(unforwaredActions)(
'"%s" is not forwarded to the reducer',
(_, action) => {
reducer.mockClear()

store.dispatch(testAction1('a'))
store.dispatch(action)
store.dispatch(testAction2('b'))

expect(reducer.mock.calls).toEqual([
[{}, testAction1('a')],
[{}, testAction2('b')]
])
}
)

test('"condition" allows to skip the listener', () => {
const listener = jest.fn((_: TestAction1) => {})

middleware.addListener(testAction1, listener, {
condition(action) {
return action.payload !== 'b'
}
})

store.dispatch(testAction1('a'))
store.dispatch(testAction1('b'))
store.dispatch(testAction1('c'))

expect(listener.mock.calls).toEqual([
[testAction1('a'), middlewareApi],
[testAction1('c'), middlewareApi]
])
})

test('"once" unsubscribes the listener automatically after one use', () => {
const listener = jest.fn((_: TestAction1) => {})

middleware.addListener(testAction1, listener, {
once: true
})

store.dispatch(testAction1('a'))
store.dispatch(testAction1('b'))
store.dispatch(testAction1('c'))

expect(listener.mock.calls).toEqual([[testAction1('a'), middlewareApi]])
})

test('combining "once" with "condition', () => {
const listener = jest.fn((_: TestAction1) => {})

middleware.addListener(testAction1, listener, {
once: true,
condition(action) {
return action.payload === 'b'
}
})

store.dispatch(testAction1('a'))
store.dispatch(testAction1('b'))
store.dispatch(testAction1('c'))

expect(listener.mock.calls).toEqual([[testAction1('b'), middlewareApi]])
})

test('by default, actions are forwarded to the store', () => {
reducer.mockClear()

const listener = jest.fn((_: TestAction1) => {})

middleware.addListener(testAction1, listener)

store.dispatch(testAction1('a'))

expect(reducer.mock.calls).toEqual([[{}, testAction1('a')]])
})

test('"preventPropagation" prevents actions from being forwarded to the store', () => {
reducer.mockClear()

const listener = jest.fn((_: TestAction1) => {})

middleware.addListener(testAction1, listener, { preventPropagation: true })

store.dispatch(testAction1('a'))

expect(reducer.mock.calls).toEqual([])
})

test('combining "preventPropagation" and "condition', () => {
reducer.mockClear()

const listener = jest.fn((_: TestAction1) => {})

middleware.addListener(testAction1, listener, {
preventPropagation: true,
condition(action) {
return action.payload === 'b'
}
})

store.dispatch(testAction1('a'))
store.dispatch(testAction1('b'))
store.dispatch(testAction1('c'))

expect(reducer.mock.calls).toEqual([
[{}, testAction1('a')],
[{}, testAction1('c')]
])
})
})
Loading