Skip to content

Commit d487b9c

Browse files
committed
feat(action-listener-middleware): add take
1 parent 960a7c2 commit d487b9c

File tree

2 files changed

+139
-33
lines changed

2 files changed

+139
-33
lines changed

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

Lines changed: 76 additions & 33 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
@@ -466,39 +538,9 @@ export function createActionListenerMiddleware<
466538
return true
467539
}
468540

469-
const condition: ConditionFunction<S> = async (predicate, timeout) => {
470-
let unsubscribe: Unsubscribe = () => {}
471-
472-
const conditionSucceededPromise = new Promise<boolean>(
473-
(resolve, reject) => {
474-
unsubscribe = addListener({
475-
predicate,
476-
listener: (action, listenerApi) => {
477-
// One-shot listener that cleans up as soon as the predicate resolves
478-
listenerApi.unsubscribe()
479-
resolve(true)
480-
},
481-
})
482-
}
483-
)
484-
485-
if (timeout === undefined) {
486-
return conditionSucceededPromise
487-
}
488-
489-
const timedOutPromise = new Promise<boolean>((resolve, reject) => {
490-
setTimeout(() => {
491-
resolve(false)
492-
}, timeout)
493-
})
494-
495-
const result = await Promise.race([
496-
conditionSucceededPromise,
497-
timedOutPromise,
498-
])
499-
500-
unsubscribe()
501-
return result
541+
const take = createTakePattern(addListener)
542+
const condition: ConditionFunction<S> = (predicate, timeout) => {
543+
return take(predicate, timeout).then(Boolean)
502544
}
503545

504546
const middleware: Middleware<
@@ -558,6 +600,7 @@ export function createActionListenerMiddleware<
558600
...api,
559601
getOriginalState,
560602
condition,
603+
take,
561604
currentPhase,
562605
extra,
563606
unsubscribe: entry.unsubscribe,

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)