Skip to content

Commit 1ab9c2b

Browse files
authored
Merge pull request #1785 from FaberVitale/feat/add-take-to-action-listener-middleware
2 parents cf3714f + d487b9c commit 1ab9c2b

File tree

2 files changed

+194
-89
lines changed

2 files changed

+194
-89
lines changed

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

Lines changed: 131 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,34 @@ interface ConditionFunction<State> {
4646

4747
type MatchFunction<T> = (v: any) => v is T
4848

49+
type TakePatternOutputWithoutTimeout<
50+
State,
51+
Predicate extends AnyActionListenerPredicate<State>
52+
> = Predicate extends MatchFunction<infer Action>
53+
? Promise<[Action, State, State]>
54+
: Promise<[AnyAction, State, State]>
55+
56+
type TakePatternOutputWithTimeout<
57+
State,
58+
Predicate extends AnyActionListenerPredicate<State>
59+
> = Predicate extends MatchFunction<infer Action>
60+
? Promise<[Action, State, State] | null>
61+
: Promise<[AnyAction, State, State] | null>
62+
63+
interface TakePattern<State> {
64+
<Predicate extends AnyActionListenerPredicate<State>>(
65+
predicate: Predicate
66+
): TakePatternOutputWithoutTimeout<State, Predicate>
67+
<Predicate extends AnyActionListenerPredicate<State>>(
68+
predicate: Predicate,
69+
timeout: number
70+
): TakePatternOutputWithTimeout<State, Predicate>;
71+
<Predicate extends AnyActionListenerPredicate<State>>(
72+
predicate: Predicate,
73+
timeout?: number | undefined
74+
): Promise<[AnyAction, State, State] | null>;
75+
}
76+
4977
export interface HasMatchFunction<T> {
5078
match: MatchFunction<T>
5179
}
@@ -93,6 +121,7 @@ export interface ActionListenerMiddlewareAPI<S, D extends Dispatch<AnyAction>>
93121
unsubscribe(): void
94122
subscribe(): void
95123
condition: ConditionFunction<S>
124+
take: TakePattern<S>
96125
currentPhase: MiddlewarePhase
97126
// TODO Figure out how to pass this through the other types correctly
98127
extra: unknown
@@ -246,6 +275,49 @@ type FallbackAddListenerOptions = (
246275
) &
247276
ActionListenerOptions & { listener: ActionListener<any, any, any> }
248277

278+
function createTakePattern<S>(
279+
addListener: AddListenerOverloads<Unsubscribe, S,Dispatch<AnyAction>>
280+
): TakePattern<S> {
281+
async function take<P extends AnyActionListenerPredicate<S>>(
282+
predicate: P,
283+
timeout: number | undefined
284+
) {
285+
let unsubscribe: Unsubscribe = () => {}
286+
287+
const tuplePromise = new Promise<[AnyAction, S, S]>((resolve) => {
288+
unsubscribe = addListener({
289+
predicate: predicate as any,
290+
listener: (action, listenerApi): void => {
291+
// One-shot listener that cleans up as soon as the predicate resolves
292+
listenerApi.unsubscribe()
293+
resolve([
294+
action,
295+
listenerApi.getState(),
296+
listenerApi.getOriginalState(),
297+
])
298+
},
299+
})
300+
})
301+
302+
if (timeout === undefined) {
303+
return tuplePromise
304+
}
305+
306+
const timedOutPromise = new Promise<null>((resolve, reject) => {
307+
setTimeout(() => {
308+
resolve(null)
309+
}, timeout)
310+
})
311+
312+
const result = await Promise.race([tuplePromise, timedOutPromise])
313+
314+
unsubscribe()
315+
return result
316+
}
317+
318+
return take as TakePattern<S>
319+
}
320+
249321
/** Accepts the possible options for creating a listener, and returns a formatted listener entry */
250322
export const createListenerEntry: TypedCreateListenerEntry<unknown> = (
251323
options: FallbackAddListenerOptions
@@ -413,6 +485,64 @@ export function createActionListenerMiddleware<
413485
return entry.unsubscribe
414486
}
415487

488+
function findListenerEntry(
489+
comparator: (entry: ListenerEntry) => boolean
490+
): ListenerEntry | undefined {
491+
for (const entry of listenerMap.values()) {
492+
if (comparator(entry)) {
493+
return entry
494+
}
495+
}
496+
497+
return undefined
498+
}
499+
500+
const addListener = ((options: FallbackAddListenerOptions) => {
501+
let entry = findListenerEntry(
502+
(existingEntry) => existingEntry.listener === options.listener
503+
)
504+
505+
if (!entry) {
506+
entry = createListenerEntry(options as any)
507+
}
508+
509+
return insertEntry(entry)
510+
}) as TypedAddListener<S, D>
511+
512+
function removeListener<C extends TypedActionCreator<any>>(
513+
actionCreator: C,
514+
listener: ActionListener<ReturnType<C>, S, D>
515+
): boolean
516+
function removeListener(
517+
type: string,
518+
listener: ActionListener<AnyAction, S, D>
519+
): boolean
520+
function removeListener(
521+
typeOrActionCreator: string | TypedActionCreator<any>,
522+
listener: ActionListener<AnyAction, S, D>
523+
): boolean {
524+
const type =
525+
typeof typeOrActionCreator === 'string'
526+
? typeOrActionCreator
527+
: typeOrActionCreator.type
528+
529+
let entry = findListenerEntry(
530+
(entry) => entry.type === type && entry.listener === listener
531+
)
532+
533+
if (!entry) {
534+
return false
535+
}
536+
537+
listenerMap.delete(entry.id)
538+
return true
539+
}
540+
541+
const take = createTakePattern(addListener)
542+
const condition: ConditionFunction<S> = (predicate, timeout) => {
543+
return take(predicate, timeout).then(Boolean)
544+
}
545+
416546
const middleware: Middleware<
417547
{
418548
(action: Action<'actionListenerMiddleware/add'>): Unsubscribe
@@ -469,8 +599,8 @@ export function createActionListenerMiddleware<
469599
entry.listener(action, {
470600
...api,
471601
getOriginalState,
472-
// eslint-disable-next-line @typescript-eslint/no-use-before-define
473602
condition,
603+
take,
474604
currentPhase,
475605
extra,
476606
unsubscribe: entry.unsubscribe,
@@ -490,94 +620,6 @@ export function createActionListenerMiddleware<
490620
}
491621
}
492622

493-
const addListener = ((options: FallbackAddListenerOptions) => {
494-
let entry = findListenerEntry(
495-
(existingEntry) => existingEntry.listener === options.listener
496-
)
497-
498-
if (!entry) {
499-
entry = createListenerEntry(options as any)
500-
}
501-
502-
return insertEntry(entry)
503-
}) as TypedAddListener<S, D>
504-
505-
function removeListener<C extends TypedActionCreator<any>>(
506-
actionCreator: C,
507-
listener: ActionListener<ReturnType<C>, S, D>
508-
): boolean
509-
function removeListener(
510-
type: string,
511-
listener: ActionListener<AnyAction, S, D>
512-
): boolean
513-
function removeListener(
514-
typeOrActionCreator: string | TypedActionCreator<any>,
515-
listener: ActionListener<AnyAction, S, D>
516-
): boolean {
517-
const type =
518-
typeof typeOrActionCreator === 'string'
519-
? typeOrActionCreator
520-
: typeOrActionCreator.type
521-
522-
let entry = findListenerEntry(
523-
(entry) => entry.type === type && entry.listener === listener
524-
)
525-
526-
if (!entry) {
527-
return false
528-
}
529-
530-
listenerMap.delete(entry.id)
531-
return true
532-
}
533-
534-
function findListenerEntry(
535-
comparator: (entry: ListenerEntry) => boolean
536-
): ListenerEntry | undefined {
537-
for (const entry of listenerMap.values()) {
538-
if (comparator(entry)) {
539-
return entry
540-
}
541-
}
542-
543-
return undefined
544-
}
545-
546-
const condition: ConditionFunction<S> = async (predicate, timeout) => {
547-
let unsubscribe: Unsubscribe = () => {}
548-
549-
const conditionSucceededPromise = new Promise<boolean>(
550-
(resolve, reject) => {
551-
unsubscribe = addListener({
552-
predicate,
553-
listener: (action, listenerApi) => {
554-
// One-shot listener that cleans up as soon as the predicate resolves
555-
listenerApi.unsubscribe()
556-
resolve(true)
557-
},
558-
})
559-
}
560-
)
561-
562-
if (timeout === undefined) {
563-
return conditionSucceededPromise
564-
}
565-
566-
const timedOutPromise = new Promise<boolean>((resolve, reject) => {
567-
setTimeout(() => {
568-
resolve(false)
569-
}, timeout)
570-
})
571-
572-
const result = await Promise.race([
573-
conditionSucceededPromise,
574-
timedOutPromise,
575-
])
576-
577-
unsubscribe()
578-
return result
579-
}
580-
581623
return Object.assign(
582624
middleware,
583625
{

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const middlewareApi = {
2626
getOriginalState: expect.any(Function),
2727
condition: expect.any(Function),
2828
extra: undefined,
29+
take: expect.any(Function),
2930
dispatch: expect.any(Function),
3031
currentPhase: expect.stringMatching(/beforeReducer|afterReducer/),
3132
unsubscribe: expect.any(Function),
@@ -648,6 +649,68 @@ describe('createActionListenerMiddleware', () => {
648649
expect(onError).toBeCalledWith(listenerError)
649650
})
650651

652+
test('take resolves to the tuple [A, CurrentState, PreviousState] when the predicate matches the action', (done) => {
653+
const store = configureStore({
654+
reducer: counterSlice.reducer,
655+
middleware: (gDM) => gDM().prepend(middleware),
656+
})
657+
658+
middleware.addListener({
659+
predicate: incrementByAmount.match,
660+
listener: async (_, listenerApi) => {
661+
const stateBefore = listenerApi.getState()
662+
const result = await listenerApi.take(increment.match)
663+
664+
expect(result).toEqual([
665+
increment(),
666+
listenerApi.getState(),
667+
stateBefore,
668+
])
669+
done()
670+
},
671+
})
672+
store.dispatch(incrementByAmount(1))
673+
store.dispatch(increment())
674+
})
675+
676+
test('take resolves to null if the timeout exipires', async () => {
677+
const store = configureStore({
678+
reducer: counterSlice.reducer,
679+
middleware: (gDM) => gDM().prepend(middleware),
680+
})
681+
682+
middleware.addListener({
683+
predicate: incrementByAmount.match,
684+
listener: async (_, listenerApi) => {
685+
const result = await listenerApi.take(increment.match, 50)
686+
687+
expect(result).toBe(null)
688+
},
689+
})
690+
store.dispatch(incrementByAmount(1))
691+
await delay(200)
692+
})
693+
694+
test("take resolves to [A, CurrentState, PreviousState] if the timeout is provided but doesn't expires", (done) => {
695+
const store = configureStore({
696+
reducer: counterSlice.reducer,
697+
middleware: (gDM) => gDM().prepend(middleware),
698+
})
699+
700+
middleware.addListener({
701+
predicate: incrementByAmount.match,
702+
listener: async (_, listenerApi) => {
703+
const stateBefore = listenerApi.getState()
704+
const result = await listenerApi.take(increment.match, 50)
705+
706+
expect(result).toEqual([increment(), listenerApi.getState(), stateBefore])
707+
done()
708+
},
709+
})
710+
store.dispatch(incrementByAmount(1))
711+
store.dispatch(increment())
712+
})
713+
651714
test('condition method resolves promise when the predicate succeeds', async () => {
652715
const store = configureStore({
653716
reducer: counterSlice.reducer,

0 commit comments

Comments
 (0)