Skip to content

Commit 55f0f7a

Browse files
author
David
authored
feat(effects): concatLatestFrom operator (#2760)
1 parent abcc599 commit 55f0f7a

File tree

6 files changed

+203
-12
lines changed

6 files changed

+203
-12
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Observable, of } from 'rxjs';
2+
import { skipWhile } from 'rxjs/operators';
3+
import { hot } from 'jasmine-marbles';
4+
import { concatLatestFrom } from '../src/concat_latest_from';
5+
6+
describe('concatLatestFrom', () => {
7+
describe('no triggering value appears in source', () => {
8+
it('should not evaluate the array', () => {
9+
let evaluated = false;
10+
const toBeLazilyEvaluated = () => {
11+
evaluated = true;
12+
return of(4);
13+
};
14+
const input$: Observable<number> = hot('-a-b-', { a: 1, b: 2 });
15+
const numbers$: Observable<[number, number]> = input$.pipe(
16+
skipWhile((value) => value < 3),
17+
concatLatestFrom(() => [toBeLazilyEvaluated()])
18+
);
19+
expect(numbers$).toBeObservable(hot('----'));
20+
expect(evaluated).toBe(false);
21+
});
22+
it('should not evaluate the observable', () => {
23+
let evaluated = false;
24+
const toBeLazilyEvaluated = () => {
25+
evaluated = true;
26+
return of(4);
27+
};
28+
const input$: Observable<number> = hot('-a-b-', { a: 1, b: 2 });
29+
const numbers$: Observable<[number, number]> = input$.pipe(
30+
skipWhile((value) => value < 3),
31+
concatLatestFrom(() => toBeLazilyEvaluated())
32+
);
33+
expect(numbers$).toBeObservable(hot('----'));
34+
expect(evaluated).toBe(false);
35+
});
36+
});
37+
describe('a triggering value appears in source', () => {
38+
it('should evaluate the array of observables', () => {
39+
let evaluated = false;
40+
const toBeLazilyEvaluated = () => {
41+
evaluated = true;
42+
return of(4);
43+
};
44+
const input$: Observable<number> = hot('-a-b-c-', { a: 1, b: 2, c: 3 });
45+
const numbers$: Observable<[number, number]> = input$.pipe(
46+
skipWhile((value) => value < 3),
47+
concatLatestFrom(() => [toBeLazilyEvaluated()])
48+
);
49+
expect(numbers$).toBeObservable(hot('-----d', { d: [3, 4] }));
50+
expect(evaluated).toBe(true);
51+
});
52+
it('should evaluate the observable', () => {
53+
let evaluated = false;
54+
const toBeLazilyEvaluated = () => {
55+
evaluated = true;
56+
return of(4);
57+
};
58+
const input$: Observable<number> = hot('-a-b-c-', { a: 1, b: 2, c: 3 });
59+
const numbers$: Observable<[number, number]> = input$.pipe(
60+
skipWhile((value) => value < 3),
61+
concatLatestFrom(() => toBeLazilyEvaluated())
62+
);
63+
expect(numbers$).toBeObservable(hot('-----d', { d: [3, 4] }));
64+
expect(evaluated).toBe(true);
65+
});
66+
});
67+
describe('multiple triggering values appear in source', () => {
68+
it('evaluates the array of observables', () => {
69+
const input$: Observable<number> = hot('-a-b-c-', { a: 1, b: 2, c: 3 });
70+
const numbers$: Observable<[number, string]> = input$.pipe(
71+
concatLatestFrom(() => [of('eval')])
72+
);
73+
expect(numbers$).toBeObservable(
74+
hot('-a-b-c', { a: [1, 'eval'], b: [2, 'eval'], c: [3, 'eval'] })
75+
);
76+
});
77+
it('uses incoming value', () => {
78+
const input$: Observable<number> = hot('-a-b-c-', { a: 1, b: 2, c: 3 });
79+
const numbers$: Observable<[number, string]> = input$.pipe(
80+
concatLatestFrom((num) => [of(num + ' eval')])
81+
);
82+
expect(numbers$).toBeObservable(
83+
hot('-a-b-c', { a: [1, '1 eval'], b: [2, '2 eval'], c: [3, '3 eval'] })
84+
);
85+
});
86+
});
87+
describe('evaluates multiple observables', () => {
88+
it('gets values from both observable in specific order', () => {
89+
const input$: Observable<number> = hot('-a-b-c-', { a: 1, b: 2, c: 3 });
90+
const numbers$: Observable<[number, string, string]> = input$.pipe(
91+
skipWhile((value) => value < 3),
92+
concatLatestFrom(() => [of('one'), of('two')])
93+
);
94+
expect(numbers$).toBeObservable(hot('-----d', { d: [3, 'one', 'two'] }));
95+
});
96+
it('can use the value passed through source observable', () => {
97+
const input$: Observable<number> = hot('-a-b-c-d', {
98+
a: 1,
99+
b: 2,
100+
c: 3,
101+
d: 4,
102+
});
103+
const numbers$: Observable<[number, string, string]> = input$.pipe(
104+
skipWhile((value) => value < 3),
105+
concatLatestFrom((num) => [of(num + ' one'), of(num + ' two')])
106+
);
107+
expect(numbers$).toBeObservable(
108+
hot('-----c-d', { c: [3, '3 one', '3 two'], d: [4, '4 one', '4 two'] })
109+
);
110+
});
111+
});
112+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Observable, ObservedValueOf, of, OperatorFunction, pipe } from 'rxjs';
2+
import { concatMap, withLatestFrom } from 'rxjs/operators';
3+
4+
// The array overload is needed first because we want to maintain the proper order in the resulting tuple
5+
export function concatLatestFrom<T extends Observable<unknown>[], V>(
6+
observablesFactory: (value: V) => [...T]
7+
): OperatorFunction<V, [V, ...{ [i in keyof T]: ObservedValueOf<T[i]> }]>;
8+
export function concatLatestFrom<T extends Observable<unknown>, V>(
9+
observableFactory: (value: V) => T
10+
): OperatorFunction<V, [V, ObservedValueOf<T>]>;
11+
/**
12+
* 'concatLatestFrom' combines the source value
13+
* and the last available value from a lazily evaluated Observable
14+
* in a new array
15+
*/
16+
export function concatLatestFrom<
17+
T extends Observable<unknown>[] | Observable<unknown>,
18+
V,
19+
R = [
20+
V,
21+
...(T extends Observable<unknown>[]
22+
? { [i in keyof T]: ObservedValueOf<T[i]> }
23+
: [ObservedValueOf<T>])
24+
]
25+
>(observablesFactory: (value: V) => T): OperatorFunction<V, R> {
26+
return pipe(
27+
concatMap((value) => {
28+
const observables = observablesFactory(value);
29+
const observablesAsArray = Array.isArray(observables)
30+
? observables
31+
: [observables];
32+
return of(value).pipe(
33+
withLatestFrom(...observablesAsArray)
34+
) as Observable<R>;
35+
})
36+
);
37+
}

modules/effects/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ export {
2727
OnInitEffects,
2828
} from './lifecycle_hooks';
2929
export { USER_PROVIDED_EFFECTS } from './tokens';
30+
export { concatLatestFrom } from './concat_latest_from';

projects/example-app/src/app/core/effects/router.effects.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { Injectable } from '@angular/core';
22
import { Title } from '@angular/platform-browser';
33

4-
import { of } from 'rxjs';
5-
import { concatMap, map, tap, withLatestFrom } from 'rxjs/operators';
4+
import { map, tap } from 'rxjs/operators';
65

7-
import { Actions, createEffect, ofType } from '@ngrx/effects';
6+
import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects';
87
import { Store } from '@ngrx/store';
98
import { routerNavigatedAction } from '@ngrx/router-store';
109

@@ -16,11 +15,7 @@ export class RouterEffects {
1615
() =>
1716
this.actions$.pipe(
1817
ofType(routerNavigatedAction),
19-
concatMap((action) =>
20-
of(action).pipe(
21-
withLatestFrom(this.store.select(fromRoot.selectRouteData))
22-
)
23-
),
18+
concatLatestFrom(() => this.store.select(fromRoot.selectRouteData)),
2419
map(([, data]) => `Book Collection - ${data['title']}`),
2520
tap((title) => this.titleService.setTitle(title))
2621
),

projects/ngrx.io/content/guide/effects/index.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,9 +316,7 @@ export class CollectionEffects {
316316
() =>
317317
this.actions$.pipe(
318318
ofType(CollectionApiActions.addBookSuccess),
319-
concatMap(action => of(action).pipe(
320-
withLatestFrom(this.store.select(fromBooks.getCollectionBookIds))
321-
)),
319+
concatLatestFrom(action => this.store.select(fromBooks.getCollectionBookIds)),
322320
tap(([action, bookCollection]) => {
323321
if (bookCollection.length === 1) {
324322
window.alert('Congrats on adding your first book!');
@@ -339,7 +337,7 @@ export class CollectionEffects {
339337

340338
<div class="alert is-important">
341339

342-
**Note:** For performance reasons, use a flattening operator in combination with `withLatestFrom` to prevent the selector from firing until the correct action is dispatched.
340+
**Note:** For performance reasons, use a flattening operator like `concatLatestFrom` to prevent the selector from firing until the correct action is dispatched.
343341

344342
</div>
345343

projects/ngrx.io/content/guide/effects/operators.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,51 @@ export class AuthEffects {
4949
) {}
5050
}
5151
</code-example>
52+
53+
## `concatLatestFrom`
54+
55+
The `concatLatestFrom` operator functions similarly to `withLatestFrom` with one important difference-
56+
it lazily evaluates the provided Observable factory.
57+
58+
This allows you to utilize the source value when selecting additional sources to concat.
59+
60+
Additionally, because the factory is not executed until it is needed, it also mitigates the performance impact of creating some kinds of Observables.
61+
62+
For example, when selecting data from the store with `store.select`, `concatLatestFrom` will prevent the
63+
selector from being evaluated until the source emits a value.
64+
65+
The `concatLatestFrom` operator takes an Observable factory function that returns an array of Observables, or a single Observable.
66+
67+
<code-example header="router.effects.ts">
68+
import { Injectable } from '@angular/core';
69+
import { Title } from '@angular/platform-browser';
70+
71+
import { map, tap } from 'rxjs/operators';
72+
73+
import {Actions, concatLatestFrom, createEffect, ofType} from '@ngrx/effects';
74+
import { Store } from '@ngrx/store';
75+
import { routerNavigatedAction } from '@ngrx/router-store';
76+
77+
import * as fromRoot from '@example-app/reducers';
78+
79+
@Injectable()
80+
export class RouterEffects {
81+
updateTitle$ = createEffect(() =>
82+
this.actions$.pipe(
83+
ofType(routerNavigatedAction),
84+
concatLatestFrom(() => this.store.select(fromRoot.selectRouteData)),
85+
map(([, data]) => `Book Collection - ${data['title']}`),
86+
tap((title) => this.titleService.setTitle(title))
87+
),
88+
{
89+
dispatch: false,
90+
}
91+
);
92+
93+
constructor(
94+
private actions$: Actions,
95+
private store: Store<fromRoot.State>,
96+
private titleService: Title
97+
) {}
98+
}
99+
</code-example>

0 commit comments

Comments
 (0)