Skip to content

Commit 24bbf9f

Browse files
authored
Merge pull request #1731 from reduxjs/feature/listener-resubscribe
2 parents d0cc194 + 37c51e7 commit 24bbf9f

File tree

2 files changed

+70
-38
lines changed

2 files changed

+70
-38
lines changed

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

Lines changed: 32 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -59,25 +59,6 @@ function assertFunction(
5959
}
6060
}
6161

62-
export const hasMatchFunction = <T>(
63-
v: Matcher<T>
64-
): v is HasMatchFunction<T> => {
65-
return v && typeof (v as HasMatchFunction<T>).match === 'function'
66-
}
67-
68-
export const isActionCreator = (
69-
item: Function
70-
): item is TypedActionCreator<any> => {
71-
return (
72-
typeof item === 'function' &&
73-
typeof (item as any).type === 'string' &&
74-
hasMatchFunction(item as any)
75-
)
76-
}
77-
78-
/** @public */
79-
export type Matcher<T> = HasMatchFunction<T> | MatchFunction<T>
80-
8162
type Unsubscribe = () => void
8263

8364
type GuardedType<T> = T extends (x: any, ...args: unknown[]) => x is infer T
@@ -110,6 +91,7 @@ export interface ActionListenerMiddlewareAPI<S, D extends Dispatch<AnyAction>>
11091
extends MiddlewareAPI<D, S> {
11192
getOriginalState: () => S
11293
unsubscribe(): void
94+
subscribe(): void
11395
condition: ConditionFunction<S>
11496
currentPhase: MiddlewarePhase
11597
// TODO Figure out how to pass this through the other types correctly
@@ -146,11 +128,15 @@ export interface CreateListenerMiddlewareOptions<ExtraArgument = unknown> {
146128
onError?: ListenerErrorHandler
147129
}
148130

131+
/**
132+
* 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
133+
*/
149134
interface AddListenerOverloads<
150135
Return,
151136
S = unknown,
152137
D extends Dispatch = ThunkDispatch<S, unknown, AnyAction>
153138
> {
139+
/** Accepts a "listener predicate" that is also a TS type predicate for the action*/
154140
<MA extends AnyAction, LP extends ListenerPredicate<MA, S>>(
155141
options: {
156142
actionCreator?: never
@@ -160,6 +146,8 @@ interface AddListenerOverloads<
160146
listener: ActionListener<ListenerPredicateGuardedActionType<LP>, S, D>
161147
} & ActionListenerOptions
162148
): Return
149+
150+
/** Accepts an RTK action creator, like `incrementByAmount` */
163151
<C extends TypedActionCreator<any>>(
164152
options: {
165153
actionCreator: C
@@ -169,6 +157,8 @@ interface AddListenerOverloads<
169157
listener: ActionListener<ReturnType<C>, S, D>
170158
} & ActionListenerOptions
171159
): Return
160+
161+
/** Accepts a specific action type string */
172162
<T extends string>(
173163
options: {
174164
actionCreator?: never
@@ -178,6 +168,8 @@ interface AddListenerOverloads<
178168
listener: ActionListener<Action<T>, S, D>
179169
} & ActionListenerOptions
180170
): Return
171+
172+
/** Accepts an RTK matcher function, such as `incrementByAmount.match` */
181173
<MA extends AnyAction, M extends MatchFunction<MA>>(
182174
options: {
183175
actionCreator?: never
@@ -188,6 +180,7 @@ interface AddListenerOverloads<
188180
} & ActionListenerOptions
189181
): Return
190182

183+
/** Accepts a "listener predicate" that just returns a boolean, no type assertion */
191184
<LP extends AnyActionListenerPredicate<S>>(
192185
options: {
193186
actionCreator?: never
@@ -210,19 +203,22 @@ interface RemoveListenerOverloads<
210203
(type: string, listener: ActionListener<AnyAction, S, D>): boolean
211204
}
212205

206+
/** A "pre-typed" version of `addListenerAction`, so the listener args are well-typed */
213207
export type TypedAddListenerAction<
214208
S,
215209
D extends Dispatch<AnyAction> = ThunkDispatch<S, unknown, AnyAction>,
216210
Payload = ListenerEntry<S, D>,
217211
T extends string = 'actionListenerMiddleware/add'
218212
> = BaseActionCreator<Payload, T> &
219-
AddListenerOverloads<PayloadAction<Payload>, S, D>
213+
AddListenerOverloads<PayloadAction<Payload, T>, S, D>
220214

215+
/** A "pre-typed" version of `middleware.addListener`, so the listener args are well-typed */
221216
export type TypedAddListener<
222217
S,
223218
D extends Dispatch<AnyAction> = ThunkDispatch<S, unknown, AnyAction>
224219
> = AddListenerOverloads<Unsubscribe, S, D>
225220

221+
/** @internal An single listener entry */
226222
type ListenerEntry<
227223
S = unknown,
228224
D extends Dispatch<AnyAction> = Dispatch<AnyAction>
@@ -235,16 +231,13 @@ type ListenerEntry<
235231
predicate: ListenerPredicate<AnyAction, S>
236232
}
237233

234+
/** A "pre-typed" version of `createListenerEntry`, so the listener args are well-typed */
238235
export type TypedCreateListenerEntry<
239236
S,
240237
D extends Dispatch<AnyAction> = ThunkDispatch<S, unknown, AnyAction>
241238
> = AddListenerOverloads<ListenerEntry<S, D>, S, D>
242239

243-
export type TypedAddListenerPrepareFunction<
244-
S,
245-
D extends Dispatch<AnyAction> = ThunkDispatch<S, unknown, AnyAction>
246-
> = AddListenerOverloads<{ payload: ListenerEntry<S, D> }, S, D>
247-
240+
// A shorthand form of the accepted args, solely so that `createListenerEntry` has validly-typed conditional logic when checking the options contents
248241
type FallbackAddListenerOptions = (
249242
| { actionCreator: TypedActionCreator<string> }
250243
| { type: string }
@@ -253,6 +246,7 @@ type FallbackAddListenerOptions = (
253246
) &
254247
ActionListenerOptions & { listener: ActionListener<any, any, any> }
255248

249+
/** Accepts the possible options for creating a listener, and returns a formatted listener entry */
256250
export const createListenerEntry: TypedCreateListenerEntry<unknown> = (
257251
options: FallbackAddListenerOptions
258252
) => {
@@ -336,6 +330,7 @@ export const addListenerAction = createAction(
336330
'actionListenerMiddleware/add',
337331
function prepare(options: unknown) {
338332
const entry = createListenerEntry(
333+
// Fake out TS here
339334
options as Parameters<AddListenerOverloads<unknown>>[0]
340335
)
341336

@@ -406,14 +401,6 @@ export function createActionListenerMiddleware<
406401
D extends Dispatch<AnyAction> = ThunkDispatch<S, unknown, AnyAction>,
407402
ExtraArgument = unknown
408403
>(middlewareOptions: CreateListenerMiddlewareOptions<ExtraArgument> = {}) {
409-
type ListenerEntry = ActionListenerOptions & {
410-
id: string
411-
listener: ActionListener<any, S, D>
412-
unsubscribe: () => void
413-
type?: string
414-
predicate: ListenerPredicate<any, any>
415-
}
416-
417404
const listenerMap = new Map<string, ListenerEntry>()
418405
const { extra, onError = defaultErrorHandler } = middlewareOptions
419406

@@ -434,7 +421,15 @@ export function createActionListenerMiddleware<
434421
D
435422
> = (api) => (next) => (action) => {
436423
if (addListenerAction.match(action)) {
437-
return insertEntry(action.payload)
424+
let entry = findListenerEntry(
425+
(existingEntry) => existingEntry.listener === action.payload.listener
426+
)
427+
428+
if (!entry) {
429+
entry = action.payload
430+
}
431+
432+
return insertEntry(entry)
438433
}
439434
if (removeListenerAction.match(action)) {
440435
removeListener(action.payload.type, action.payload.listener)
@@ -479,6 +474,9 @@ export function createActionListenerMiddleware<
479474
currentPhase,
480475
extra,
481476
unsubscribe: entry.unsubscribe,
477+
subscribe: () => {
478+
listenerMap.set(entry.id, entry)
479+
},
482480
})
483481
} catch (listenerError) {
484482
safelyNotifyError(onError, listenerError)

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

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@ import {
22
configureStore,
33
createAction,
44
createSlice,
5-
AnyAction,
65
isAnyOf,
7-
PayloadAction,
86
} from '@reduxjs/toolkit'
7+
8+
import type { AnyAction, PayloadAction, Action } from '@reduxjs/toolkit'
9+
910
import {
1011
createActionListenerMiddleware,
1112
createListenerEntry,
1213
addListenerAction,
1314
removeListenerAction,
15+
} from '../index'
16+
17+
import type {
1418
When,
1519
ActionListenerMiddlewareAPI,
16-
ActionListenerMiddleware,
17-
TypedCreateListenerEntry,
1820
TypedAddListenerAction,
1921
TypedAddListener,
2022
} from '../index'
@@ -27,6 +29,7 @@ const middlewareApi = {
2729
dispatch: expect.any(Function),
2830
currentPhase: expect.stringMatching(/beforeReducer|afterReducer/),
2931
unsubscribe: expect.any(Function),
32+
subscribe: expect.any(Function),
3033
}
3134

3235
const noop = () => {}
@@ -344,6 +347,7 @@ describe('createActionListenerMiddleware', () => {
344347
listener,
345348
})
346349
)
350+
expectType<Action<'actionListenerMiddleware/add'>>(unsubscribe)
347351

348352
store.dispatch(testAction1('a'))
349353
// TODO This return type isn't correct
@@ -419,6 +423,36 @@ describe('createActionListenerMiddleware', () => {
419423
])
420424
})
421425

426+
test('Can re-subscribe via middleware api', async () => {
427+
let numListenerRuns = 0
428+
middleware.addListener({
429+
actionCreator: testAction1,
430+
listener: async (action, listenerApi) => {
431+
numListenerRuns++
432+
433+
listenerApi.unsubscribe()
434+
435+
await listenerApi.condition(testAction2.match)
436+
437+
listenerApi.subscribe()
438+
},
439+
})
440+
441+
store.dispatch(testAction1('a'))
442+
expect(numListenerRuns).toBe(1)
443+
444+
store.dispatch(testAction1('a'))
445+
expect(numListenerRuns).toBe(1)
446+
447+
store.dispatch(testAction2('b'))
448+
expect(numListenerRuns).toBe(1)
449+
450+
await delay(5)
451+
452+
store.dispatch(testAction1('b'))
453+
expect(numListenerRuns).toBe(2)
454+
})
455+
422456
const whenMap: [When, string, string, number][] = [
423457
[undefined, 'reducer', 'listener', 1],
424458
['beforeReducer', 'listener', 'reducer', 1],

0 commit comments

Comments
 (0)