Skip to content
Closed
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