Skip to content

Commit ce493e3

Browse files
committed
feat(alm): conform removeListener & removeListenerAction overloads to addListener
Why It is now possible to remove entries created with addListener({ predicate, listener })` or `addListener({ matcher, listener })`. Discussion: #1648 (reply in thread) Breaking changes BREAKING CHANGE: Synchronously throw error, if listener is not a function instead of throwing when the predicate matches the action disptched. BREAKING CHANGE: removeListener(type, listener) is not longer a valid function signature Test log ```bash $ yarn workspace @rtk-incubator/action-listener-middleware run test --coverage PASS src/tests/listenerMiddleware.test.ts 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.37 | 94.23 | 94 | 97.16 | exceptions.ts | 100 | 100 | 100 | 100 | index.ts | 97.16 | 97.37 | 91.43 | 97.06 | 178,202,226-227 task.ts | 97.06 | 80 | 100 | 96.3 | 31 utils.ts | 100 | 100 | 100 | 100 | ---------------|---------|----------|---------|---------|------------------- Test Suites: 4 passed, 4 total Tests: 66 passed, 66 total Snapshots: 0 total Time: 5.928 s Ran all test suites. ``` Build log ```bash $ yarn workspace @rtk-incubator/action-listener-middleware run build Build "actionListenerMiddleware" to dist/esm: 1596 B: index.modern.js.gz 1439 B: index.modern.js.br Build "actionListenerMiddleware" to dist/module: 2.27 kB: index.js.gz 2.02 kB: index.js.br Build "actionListenerMiddleware" to dist/cjs: 2.27 kB: index.js.gz 2.02 kB: index.js.br ```
1 parent fc5cf08 commit ce493e3

File tree

4 files changed

+131
-116
lines changed

4 files changed

+131
-116
lines changed

packages/action-listener-middleware/README.md

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ Current options are:
114114

115115
- `onError`: an optional error handler that gets called with synchronous and async errors raised by `listener` and synchronous errors thrown by `predicate`.
116116

117-
### `listenerMiddleware.addListener(options?: AddListenerOptions) : Unsubscribe`
117+
### `listenerMiddleware.addListener(options: AddListenerOptions) : Unsubscribe`
118118

119119
Statically adds a new listener callback to the middleware.
120120

@@ -166,6 +166,7 @@ middleware.addListener({
166166
})
167167
```
168168

169+
It throws error if listener is not a function.
169170
The ["matcher" utility functions included in RTK](https://redux-toolkit.js.org/api/matching-utilities) are acceptable as predicates.
170171

171172
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.
@@ -174,20 +175,33 @@ The `listener` callback will receive the current action as its first argument, a
174175

175176
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.
176177

177-
### `listenerMiddleware.removeListener(typeOrActionCreator, listener)`
178+
### `listenerMiddleware.removeListener(options: AddListenerOptions): boolean`
178179

179-
Removes a given listener. Accepts two arguments:
180+
Removes a given listener. Accepts the same arguments as `middleware.addListener()` and throws error if listener is not a function.
181+
Returns `true` if the `options.listener` listener has been removed, `false` if no subscription matching the input provided has been found.
180182

181-
- `typeOrActionCreator: string | ActionCreator`: the same action type / action creator that was used to add the listener
182-
- `listener: ListenerCallback`: the same listener callback reference that was added originally
183-
184-
Note that matcher-based listeners currently cannot be removed with this approach - you must use the `unsubscribe()` callback that was returned when adding the listener.
183+
```ts
184+
// 1) Action type string
185+
middleware.removeListener({ type: 'todos/todoAdded', listener })
186+
// 2) RTK action creator
187+
middleware.removeListener({ actionCreator: todoAdded, listener })
188+
// 3) RTK matcher function
189+
middleware.removeListener({ matcher: isAnyOf(todoAdded, todoToggled), listener })
190+
// 4) Listener predicate
191+
middleware.removeListener({
192+
predicate: (action, currentState, previousState) => {
193+
// return true when the listener should run
194+
},
195+
listener,
196+
})
197+
```
185198

186199
### `addListenerAction`
187200

188201
A standard RTK action creator that tells the middleware to dynamically add a new listener at runtime. It accepts exactly the same options as `middleware.addListener()`
189202

190203
Dispatching this action returns an `unsubscribe()` callback from `dispatch`.
204+
It throws error if listener is not a function.
191205

192206
```js
193207
// Per above, provide `predicate` or any of the other comparison options
@@ -197,9 +211,11 @@ const unsubscribe = store.dispatch(addListenerAction({ predicate, listener }))
197211
### `removeListenerAction`
198212

199213
A standard RTK action creator that tells the middleware to remove a listener at runtime. Accepts the same arguments as `middleware.removeListener()`:
214+
Returns `true` if the `options.listener` listener has been removed, `false` if no subscription matching the input provided has been found.
215+
It throws error if listener is not a function.
200216

201217
```js
202-
store.dispatch(removeListenerAction('todos/todoAdded', listener))
218+
store.dispatch(removeListenerAction({ predicate, listener }))
203219
```
204220

205221
### `listenerApi`

packages/action-listener-middleware/src/index.ts

Lines changed: 36 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,12 @@ import type {
99
import { createAction, nanoid } from '@reduxjs/toolkit'
1010

1111
import type {
12-
ActionListener,
1312
AddListenerOverloads,
14-
BaseActionCreator,
1513
AnyActionListenerPredicate,
1614
CreateListenerMiddlewareOptions,
17-
ConditionFunction,
18-
ListenerPredicate,
19-
TypedActionCreator,
2015
TypedAddListener,
2116
TypedAddListenerAction,
2217
TypedCreateListenerEntry,
23-
RemoveListenerAction,
2418
FallbackAddListenerOptions,
2519
ListenerEntry,
2620
ListenerErrorHandler,
@@ -30,6 +24,8 @@ import type {
3024
ListenerErrorInfo,
3125
ForkedTaskExecutor,
3226
ForkedTask,
27+
TypedRemoveListenerAction,
28+
TypedRemoveListener,
3329
} from './types'
3430
import { assertFunction, catchRejection } from './utils'
3531
import { TaskAbortError } from './exceptions'
@@ -50,6 +46,8 @@ export type {
5046
ListenerErrorHandler,
5147
TypedAddListener,
5248
TypedAddListenerAction,
49+
TypedRemoveListener,
50+
TypedRemoveListenerAction,
5351
Unsubscribe,
5452
ForkedTaskExecutor,
5553
ForkedTask,
@@ -164,10 +162,7 @@ const createTakePattern = <S>(
164162
) => catchRejection(take(predicate, timeout))) as TakePattern<S>
165163
}
166164

167-
/** Accepts the possible options for creating a listener, and returns a formatted listener entry */
168-
export const createListenerEntry: TypedCreateListenerEntry<unknown> = (
169-
options: FallbackAddListenerOptions
170-
) => {
165+
const getListenerEntryPropsFrom = (options: FallbackAddListenerOptions) => {
171166
let { type, actionCreator, matcher, predicate, listener } = options
172167

173168
if (type) {
@@ -181,10 +176,21 @@ export const createListenerEntry: TypedCreateListenerEntry<unknown> = (
181176
// pass
182177
} else {
183178
throw new Error(
184-
'Creating a listener requires one of the known fields for matching an action'
179+
'Creating or removing a listener requires one of the known fields for matching an action'
185180
)
186181
}
187182

183+
assertFunction(listener, 'options.listener');
184+
185+
return { predicate, type, listener }
186+
}
187+
188+
/** Accepts the possible options for creating a listener, and returns a formatted listener entry */
189+
export const createListenerEntry: TypedCreateListenerEntry<unknown> = (
190+
options: FallbackAddListenerOptions
191+
) => {
192+
const { type, predicate, listener } = getListenerEntryPropsFrom(options)
193+
188194
const id = nanoid()
189195
const entry: ListenerEntry<unknown> = {
190196
id,
@@ -227,54 +233,15 @@ const safelyNotifyError = (
227233
* @alpha
228234
*/
229235
export const addListenerAction = createAction(
230-
`${alm}/add`,
231-
(options: unknown) => {
232-
const entry = createListenerEntry(
233-
// Fake out TS here
234-
options as Parameters<AddListenerOverloads<unknown>>[0]
235-
)
236-
237-
return {
238-
payload: entry,
239-
}
240-
}
236+
`${alm}/add`
241237
) as TypedAddListenerAction<unknown>
242238

243239
/**
244240
* @alpha
245241
*/
246242
export const removeListenerAction = createAction(
247-
`${alm}/remove`,
248-
(
249-
typeOrActionCreator: string | TypedActionCreator<string>,
250-
listener: ActionListener<any, any, any>
251-
) => {
252-
const type =
253-
typeof typeOrActionCreator === 'string'
254-
? typeOrActionCreator
255-
: (typeOrActionCreator as TypedActionCreator<string>).type
256-
257-
return {
258-
payload: {
259-
type,
260-
listener,
261-
},
262-
}
263-
}
264-
) as BaseActionCreator<
265-
{ type: string; listener: ActionListener<any, any, any> },
266-
'actionListenerMiddleware/remove'
267-
> & {
268-
<C extends TypedActionCreator<any>, S, D extends Dispatch>(
269-
actionCreator: C,
270-
listener: ActionListener<ReturnType<C>, S, D>
271-
): RemoveListenerAction<ReturnType<C>, S, D>
272-
273-
<S, D extends Dispatch>(
274-
type: string,
275-
listener: ActionListener<AnyAction, S, D>
276-
): RemoveListenerAction<AnyAction, S, D>
277-
}
243+
`${alm}/remove`
244+
) as TypedRemoveListenerAction<unknown>
278245

279246
const defaultErrorHandler: ListenerErrorHandler = (...args: unknown[]) => {
280247
console.error(`${alm}/error`, ...args)
@@ -313,7 +280,7 @@ export function createActionListenerMiddleware<
313280
return undefined
314281
}
315282

316-
const addListener = ((options: FallbackAddListenerOptions) => {
283+
const addListener = (options: FallbackAddListenerOptions) => {
317284
let entry = findListenerEntry(
318285
(existingEntry) => existingEntry.listener === options.listener
319286
)
@@ -323,39 +290,21 @@ export function createActionListenerMiddleware<
323290
}
324291

325292
return insertEntry(entry)
326-
}) as TypedAddListener<S, D>
327-
328-
type RemoveListener = {
329-
<C extends TypedActionCreator<any>>(
330-
actionCreator: C,
331-
listener: ActionListener<ReturnType<C>, S, D>
332-
): boolean
333-
(type: string, listener: ActionListener<AnyAction, S, D>): boolean
334-
(
335-
typeOrActionCreator: string | TypedActionCreator<any>,
336-
listener: ActionListener<AnyAction, S, D>
337-
): boolean
338293
}
339294

340-
const removeListener: RemoveListener = (
341-
typeOrActionCreator: string | TypedActionCreator<any>,
342-
listener: ActionListener<AnyAction, S, D>
343-
): boolean => {
344-
const type =
345-
typeof typeOrActionCreator === 'string'
346-
? typeOrActionCreator
347-
: typeOrActionCreator.type
295+
const removeListener = (options: FallbackAddListenerOptions): boolean => {
296+
const { type, listener, predicate } = getListenerEntryPropsFrom(options)
348297

349-
let entry = findListenerEntry(
350-
(entry) => entry.type === type && entry.listener === listener
351-
)
298+
const entry = findListenerEntry((entry) => {
299+
const matchPredicateOrType =
300+
typeof type === 'string'
301+
? entry.type === type
302+
: entry.predicate === predicate
352303

353-
if (!entry) {
354-
return false
355-
}
304+
return matchPredicateOrType && entry.listener === listener
305+
})?.unsubscribe()
356306

357-
listenerMap.delete(entry.id)
358-
return true
307+
return !!entry
359308
}
360309

361310
const notifyListener = async (
@@ -420,19 +369,10 @@ export function createActionListenerMiddleware<
420369
D
421370
> = (api) => (next) => (action) => {
422371
if (addListenerAction.match(action)) {
423-
let entry = findListenerEntry(
424-
(existingEntry) => existingEntry.listener === action.payload.listener
425-
)
426-
427-
if (!entry) {
428-
entry = action.payload
429-
}
430-
431-
return insertEntry(entry)
372+
return addListener(action.payload)
432373
}
433374
if (removeListenerAction.match(action)) {
434-
removeListener(action.payload.type, action.payload.listener)
435-
return
375+
return removeListener(action.payload)
436376
}
437377

438378
// Need to get this state _before_ the reducer processes the action
@@ -489,9 +429,10 @@ export function createActionListenerMiddleware<
489429
return assign(
490430
middleware,
491431
{
492-
addListener,
493-
removeListener,
432+
addListener: addListener as TypedAddListener<S, D>,
433+
removeListener: removeListener as TypedRemoveListener<S, D>,
494434
addListenerAction: addListenerAction as TypedAddListenerAction<S>,
435+
removeListenerAction: removeListenerAction as TypedRemoveListenerAction<S>,
495436
},
496437
{} as WithMiddlewareType<typeof middleware>
497438
)

packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import type {
2323
Unsubscribe,
2424
ActionListenerMiddleware,
2525
} from '../index'
26+
import type {
27+
ActionListener,
28+
AddListenerOverloads,
29+
} from '../types'
2630

2731
const middlewareApi = {
2832
getState: expect.any(Function),
@@ -222,7 +226,7 @@ describe('createActionListenerMiddleware', () => {
222226
store.dispatch(testAction2('b'))
223227
expect(listener.mock.calls).toEqual([[testAction2('b'), middlewareApi]])
224228

225-
store.dispatch(removeListenerAction(testAction2.type, listener))
229+
store.dispatch(removeListenerAction({ type: testAction2.type, listener }))
226230

227231
store.dispatch(testAction2('b'))
228232
expect(listener.mock.calls).toEqual([[testAction2('b'), middlewareApi]])
@@ -343,15 +347,15 @@ describe('createActionListenerMiddleware', () => {
343347

344348
store.dispatch(testAction1('a'))
345349

346-
middleware.removeListener(testAction1, listener)
350+
middleware.removeListener({ actionCreator: testAction1, listener })
347351
store.dispatch(testAction2('b'))
348352
store.dispatch(testAction1('c'))
349353

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

353357
test('unsubscribing without any subscriptions does not trigger an error', () => {
354-
middleware.removeListener(testAction1, noop)
358+
middleware.removeListener({ matcher: testAction1.match, listener: noop })
355359
})
356360

357361
test('subscribing via action', () => {
@@ -405,21 +409,65 @@ describe('createActionListenerMiddleware', () => {
405409
listener,
406410
})
407411

412+
middleware.addListener({
413+
actionCreator: testAction1,
414+
listener,
415+
})
416+
408417
store.dispatch(testAction1('a'))
409418

410-
store.dispatch(removeListenerAction(testAction1, listener))
419+
store.dispatch(
420+
removeListenerAction({ actionCreator: testAction1, listener })
421+
)
411422
store.dispatch(testAction2('b'))
412423
store.dispatch(testAction1('c'))
413424

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

428+
const addListenerOptions: [
429+
string,
430+
Omit<
431+
AddListenerOverloads<() => void, typeof store.getState, typeof store.dispatch>,
432+
'listener'
433+
>
434+
][] = [
435+
['predicate', { predicate: () => true }],
436+
['actionCreator', { actionCreator: testAction1 }],
437+
['matcher', { matcher: isAnyOf(testAction1, testAction2) }],
438+
['type', { type: testAction1.type }],
439+
]
440+
441+
test.each(addListenerOptions)(
442+
'add and remove listener with "%s" param correctly',
443+
(_, params) => {
444+
const listener: ActionListener<
445+
AnyAction,
446+
typeof store.getState,
447+
typeof store.dispatch
448+
> = jest.fn()
449+
450+
middleware.addListener({ ...params, listener } as any)
451+
452+
store.dispatch(testAction1('a'))
453+
expect(listener).toBeCalledTimes(1)
454+
455+
middleware.removeListener({ ...params, listener } as any)
456+
457+
store.dispatch(testAction1('b'))
458+
expect(listener).toBeCalledTimes(1)
459+
}
460+
)
461+
417462
const unforwardedActions: [string, AnyAction][] = [
418463
[
419464
'addListenerAction',
420465
addListenerAction({ actionCreator: testAction1, listener: noop }),
421466
],
422-
['removeListenerAction', removeListenerAction(testAction1, noop)],
467+
[
468+
'removeListenerAction',
469+
removeListenerAction({ actionCreator: testAction1, listener: noop }),
470+
],
423471
]
424472
test.each(unforwardedActions)(
425473
'"%s" is not forwarded to the reducer',

0 commit comments

Comments
 (0)