Skip to content

Commit

Permalink
feat(effects): concatLatestFrom operator (#2760)
Browse files Browse the repository at this point in the history
  • Loading branch information
David authored Feb 2, 2021
1 parent abcc599 commit 55f0f7a
Show file tree
Hide file tree
Showing 6 changed files with 203 additions and 12 deletions.
112 changes: 112 additions & 0 deletions modules/effects/spec/concat_latest_from.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Observable, of } from 'rxjs';
import { skipWhile } from 'rxjs/operators';
import { hot } from 'jasmine-marbles';
import { concatLatestFrom } from '../src/concat_latest_from';

describe('concatLatestFrom', () => {
describe('no triggering value appears in source', () => {
it('should not evaluate the array', () => {
let evaluated = false;
const toBeLazilyEvaluated = () => {
evaluated = true;
return of(4);
};
const input$: Observable<number> = hot('-a-b-', { a: 1, b: 2 });
const numbers$: Observable<[number, number]> = input$.pipe(
skipWhile((value) => value < 3),
concatLatestFrom(() => [toBeLazilyEvaluated()])
);
expect(numbers$).toBeObservable(hot('----'));
expect(evaluated).toBe(false);
});
it('should not evaluate the observable', () => {
let evaluated = false;
const toBeLazilyEvaluated = () => {
evaluated = true;
return of(4);
};
const input$: Observable<number> = hot('-a-b-', { a: 1, b: 2 });
const numbers$: Observable<[number, number]> = input$.pipe(
skipWhile((value) => value < 3),
concatLatestFrom(() => toBeLazilyEvaluated())
);
expect(numbers$).toBeObservable(hot('----'));
expect(evaluated).toBe(false);
});
});
describe('a triggering value appears in source', () => {
it('should evaluate the array of observables', () => {
let evaluated = false;
const toBeLazilyEvaluated = () => {
evaluated = true;
return of(4);
};
const input$: Observable<number> = hot('-a-b-c-', { a: 1, b: 2, c: 3 });
const numbers$: Observable<[number, number]> = input$.pipe(
skipWhile((value) => value < 3),
concatLatestFrom(() => [toBeLazilyEvaluated()])
);
expect(numbers$).toBeObservable(hot('-----d', { d: [3, 4] }));
expect(evaluated).toBe(true);
});
it('should evaluate the observable', () => {
let evaluated = false;
const toBeLazilyEvaluated = () => {
evaluated = true;
return of(4);
};
const input$: Observable<number> = hot('-a-b-c-', { a: 1, b: 2, c: 3 });
const numbers$: Observable<[number, number]> = input$.pipe(
skipWhile((value) => value < 3),
concatLatestFrom(() => toBeLazilyEvaluated())
);
expect(numbers$).toBeObservable(hot('-----d', { d: [3, 4] }));
expect(evaluated).toBe(true);
});
});
describe('multiple triggering values appear in source', () => {
it('evaluates the array of observables', () => {
const input$: Observable<number> = hot('-a-b-c-', { a: 1, b: 2, c: 3 });
const numbers$: Observable<[number, string]> = input$.pipe(
concatLatestFrom(() => [of('eval')])
);
expect(numbers$).toBeObservable(
hot('-a-b-c', { a: [1, 'eval'], b: [2, 'eval'], c: [3, 'eval'] })
);
});
it('uses incoming value', () => {
const input$: Observable<number> = hot('-a-b-c-', { a: 1, b: 2, c: 3 });
const numbers$: Observable<[number, string]> = input$.pipe(
concatLatestFrom((num) => [of(num + ' eval')])
);
expect(numbers$).toBeObservable(
hot('-a-b-c', { a: [1, '1 eval'], b: [2, '2 eval'], c: [3, '3 eval'] })
);
});
});
describe('evaluates multiple observables', () => {
it('gets values from both observable in specific order', () => {
const input$: Observable<number> = hot('-a-b-c-', { a: 1, b: 2, c: 3 });
const numbers$: Observable<[number, string, string]> = input$.pipe(
skipWhile((value) => value < 3),
concatLatestFrom(() => [of('one'), of('two')])
);
expect(numbers$).toBeObservable(hot('-----d', { d: [3, 'one', 'two'] }));
});
it('can use the value passed through source observable', () => {
const input$: Observable<number> = hot('-a-b-c-d', {
a: 1,
b: 2,
c: 3,
d: 4,
});
const numbers$: Observable<[number, string, string]> = input$.pipe(
skipWhile((value) => value < 3),
concatLatestFrom((num) => [of(num + ' one'), of(num + ' two')])
);
expect(numbers$).toBeObservable(
hot('-----c-d', { c: [3, '3 one', '3 two'], d: [4, '4 one', '4 two'] })
);
});
});
});
37 changes: 37 additions & 0 deletions modules/effects/src/concat_latest_from.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Observable, ObservedValueOf, of, OperatorFunction, pipe } from 'rxjs';
import { concatMap, withLatestFrom } from 'rxjs/operators';

// The array overload is needed first because we want to maintain the proper order in the resulting tuple
export function concatLatestFrom<T extends Observable<unknown>[], V>(
observablesFactory: (value: V) => [...T]
): OperatorFunction<V, [V, ...{ [i in keyof T]: ObservedValueOf<T[i]> }]>;
export function concatLatestFrom<T extends Observable<unknown>, V>(
observableFactory: (value: V) => T
): OperatorFunction<V, [V, ObservedValueOf<T>]>;
/**
* 'concatLatestFrom' combines the source value
* and the last available value from a lazily evaluated Observable
* in a new array
*/
export function concatLatestFrom<
T extends Observable<unknown>[] | Observable<unknown>,
V,
R = [
V,
...(T extends Observable<unknown>[]
? { [i in keyof T]: ObservedValueOf<T[i]> }
: [ObservedValueOf<T>])
]
>(observablesFactory: (value: V) => T): OperatorFunction<V, R> {
return pipe(
concatMap((value) => {
const observables = observablesFactory(value);
const observablesAsArray = Array.isArray(observables)
? observables
: [observables];
return of(value).pipe(
withLatestFrom(...observablesAsArray)
) as Observable<R>;
})
);
}
1 change: 1 addition & 0 deletions modules/effects/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ export {
OnInitEffects,
} from './lifecycle_hooks';
export { USER_PROVIDED_EFFECTS } from './tokens';
export { concatLatestFrom } from './concat_latest_from';
11 changes: 3 additions & 8 deletions projects/example-app/src/app/core/effects/router.effects.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';

import { of } from 'rxjs';
import { concatMap, map, tap, withLatestFrom } from 'rxjs/operators';
import { map, tap } from 'rxjs/operators';

import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { routerNavigatedAction } from '@ngrx/router-store';

Expand All @@ -16,11 +15,7 @@ export class RouterEffects {
() =>
this.actions$.pipe(
ofType(routerNavigatedAction),
concatMap((action) =>
of(action).pipe(
withLatestFrom(this.store.select(fromRoot.selectRouteData))
)
),
concatLatestFrom(() => this.store.select(fromRoot.selectRouteData)),
map(([, data]) => `Book Collection - ${data['title']}`),
tap((title) => this.titleService.setTitle(title))
),
Expand Down
6 changes: 2 additions & 4 deletions projects/ngrx.io/content/guide/effects/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,7 @@ export class CollectionEffects {
() =>
this.actions$.pipe(
ofType(CollectionApiActions.addBookSuccess),
concatMap(action => of(action).pipe(
withLatestFrom(this.store.select(fromBooks.getCollectionBookIds))
)),
concatLatestFrom(action => this.store.select(fromBooks.getCollectionBookIds)),
tap(([action, bookCollection]) => {
if (bookCollection.length === 1) {
window.alert('Congrats on adding your first book!');
Expand All @@ -339,7 +337,7 @@ export class CollectionEffects {

<div class="alert is-important">

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

</div>

Expand Down
48 changes: 48 additions & 0 deletions projects/ngrx.io/content/guide/effects/operators.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,51 @@ export class AuthEffects {
) {}
}
</code-example>

## `concatLatestFrom`

The `concatLatestFrom` operator functions similarly to `withLatestFrom` with one important difference-
it lazily evaluates the provided Observable factory.

This allows you to utilize the source value when selecting additional sources to concat.

Additionally, because the factory is not executed until it is needed, it also mitigates the performance impact of creating some kinds of Observables.

For example, when selecting data from the store with `store.select`, `concatLatestFrom` will prevent the
selector from being evaluated until the source emits a value.

The `concatLatestFrom` operator takes an Observable factory function that returns an array of Observables, or a single Observable.

<code-example header="router.effects.ts">
import { Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';

import { map, tap } from 'rxjs/operators';

import {Actions, concatLatestFrom, createEffect, ofType} from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { routerNavigatedAction } from '@ngrx/router-store';

import * as fromRoot from '@example-app/reducers';

@Injectable()
export class RouterEffects {
updateTitle$ = createEffect(() =>
this.actions$.pipe(
ofType(routerNavigatedAction),
concatLatestFrom(() => this.store.select(fromRoot.selectRouteData)),
map(([, data]) => `Book Collection - ${data['title']}`),
tap((title) => this.titleService.setTitle(title))
),
{
dispatch: false,
}
);

constructor(
private actions$: Actions,
private store: Store<fromRoot.State>,
private titleService: Title
) {}
}
</code-example>

0 comments on commit 55f0f7a

Please sign in to comment.