Skip to content

Commit 66389b3

Browse files
committed
Add a condition method that enables async workflows
1 parent 072a430 commit 66389b3

File tree

2 files changed

+131
-0
lines changed

2 files changed

+131
-0
lines changed

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ type ListenerPredicate<Action extends AnyAction, State> = (
2727
originalState?: State
2828
) => boolean
2929

30+
type ConditionFunction<Action extends AnyAction, State> = (
31+
predicate: ListenerPredicate<Action, State> | (() => boolean),
32+
timeout?: number
33+
) => Promise<boolean>
34+
3035
type MatchFunction<T> = (v: any) => v is T
3136

3237
export interface HasMatchFunction<T> {
@@ -73,6 +78,7 @@ export interface ActionListenerMiddlewareAPI<
7378
> extends MiddlewareAPI<D, S> {
7479
getOriginalState: () => S
7580
unsubscribe(): void
81+
condition: ConditionFunction<AnyAction, S>
7682
currentPhase: MiddlewarePhase
7783
// TODO Figure out how to pass this through the other types correctly
7884
extra: unknown
@@ -281,6 +287,8 @@ export function createActionListenerMiddleware<
281287
entry.listener(action, {
282288
...api,
283289
getOriginalState,
290+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
291+
condition,
284292
currentPhase,
285293
extra,
286294
unsubscribe: entry.unsubscribe,
@@ -428,6 +436,41 @@ export function createActionListenerMiddleware<
428436
return undefined
429437
}
430438

439+
const condition: ConditionFunction<AnyAction, S> = async (
440+
predicate,
441+
timeout
442+
) => {
443+
let unsubscribe: Unsubscribe = () => {}
444+
445+
const conditionSucceededPromise = new Promise<boolean>(
446+
(resolve, reject) => {
447+
unsubscribe = addListener(predicate, (action, listenerApi) => {
448+
// One-shot listener that cleans up as soon as the predicate resolves
449+
listenerApi.unsubscribe()
450+
resolve(true)
451+
})
452+
}
453+
)
454+
455+
if (timeout === undefined) {
456+
return conditionSucceededPromise
457+
}
458+
459+
const timedOutPromise = new Promise<boolean>((resolve, reject) => {
460+
setTimeout(() => {
461+
resolve(false)
462+
}, timeout)
463+
})
464+
465+
const result = await Promise.race([
466+
conditionSucceededPromise,
467+
timedOutPromise,
468+
])
469+
470+
unsubscribe()
471+
return result
472+
}
473+
431474
return Object.assign(
432475
middleware,
433476
{ addListener, removeListener },

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
const middlewareApi = {
1717
getState: expect.any(Function),
1818
getOriginalState: expect.any(Function),
19+
condition: expect.any(Function),
1920
extra: undefined,
2021
dispatch: expect.any(Function),
2122
currentPhase: expect.stringMatching(/beforeReducer|afterReducer/),
@@ -45,6 +46,10 @@ describe('createActionListenerMiddleware', () => {
4546
})
4647
const { increment } = counterSlice.actions
4748

49+
function delay(ms: number) {
50+
return new Promise((resolve) => setTimeout(resolve, ms))
51+
}
52+
4853
let reducer: jest.Mock
4954
let middleware: ReturnType<typeof createActionListenerMiddleware>
5055

@@ -469,4 +474,87 @@ describe('createActionListenerMiddleware', () => {
469474
store.dispatch(testAction1('a'))
470475
expect(listener.mock.calls).toEqual([[testAction1('a'), middlewareApi]])
471476
})
477+
478+
test('condition method resolves promise when the predicate succeeds', async () => {
479+
const store = configureStore({
480+
reducer: counterSlice.reducer,
481+
middleware: (gDM) => gDM().prepend(middleware),
482+
})
483+
484+
let finalCount = 0
485+
let listenerStarted = false
486+
487+
middleware.addListener(
488+
// @ts-expect-error
489+
(action, currentState: CounterState) => {
490+
return increment.match(action) && currentState.value === 0
491+
},
492+
async (action, listenerApi) => {
493+
listenerStarted = true
494+
const result = await listenerApi.condition(
495+
// @ts-expect-error
496+
(action, currentState: CounterState) => {
497+
return currentState.value === 3
498+
}
499+
)
500+
501+
expect(result).toBe(true)
502+
const latestState = listenerApi.getState() as CounterState
503+
finalCount = latestState.value
504+
},
505+
{ when: 'beforeReducer' }
506+
)
507+
508+
store.dispatch(increment())
509+
expect(listenerStarted).toBe(true)
510+
await delay(50)
511+
store.dispatch(increment())
512+
store.dispatch(increment())
513+
514+
await delay(50)
515+
516+
expect(finalCount).toBe(3)
517+
})
518+
519+
test('condition method resolves promise when there is a timeout', async () => {
520+
const store = configureStore({
521+
reducer: counterSlice.reducer,
522+
middleware: (gDM) => gDM().prepend(middleware),
523+
})
524+
525+
let finalCount = 0
526+
let listenerStarted = false
527+
528+
middleware.addListener(
529+
// @ts-expect-error
530+
(action, currentState: CounterState) => {
531+
return increment.match(action) && currentState.value === 0
532+
},
533+
async (action, listenerApi) => {
534+
listenerStarted = true
535+
const result = await listenerApi.condition(
536+
// @ts-expect-error
537+
(action, currentState: CounterState) => {
538+
return currentState.value === 3
539+
},
540+
50
541+
)
542+
543+
expect(result).toBe(false)
544+
const latestState = listenerApi.getState() as CounterState
545+
finalCount = latestState.value
546+
},
547+
{ when: 'beforeReducer' }
548+
)
549+
550+
store.dispatch(increment())
551+
expect(listenerStarted).toBe(true)
552+
553+
store.dispatch(increment())
554+
555+
await delay(150)
556+
store.dispatch(increment())
557+
558+
expect(finalCount).toBe(2)
559+
})
472560
})

0 commit comments

Comments
 (0)