From 55f0f7ac3f48e87821d4706bd57542a836f309dc Mon Sep 17 00:00:00 2001 From: David Date: Mon, 1 Feb 2021 21:53:28 -0500 Subject: [PATCH] feat(effects): concatLatestFrom operator (#2760) --- .../effects/spec/concat_latest_from.spec.ts | 112 ++++++++++++++++++ modules/effects/src/concat_latest_from.ts | 37 ++++++ modules/effects/src/index.ts | 1 + .../src/app/core/effects/router.effects.ts | 11 +- .../ngrx.io/content/guide/effects/index.md | 6 +- .../content/guide/effects/operators.md | 48 ++++++++ 6 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 modules/effects/spec/concat_latest_from.spec.ts create mode 100644 modules/effects/src/concat_latest_from.ts diff --git a/modules/effects/spec/concat_latest_from.spec.ts b/modules/effects/spec/concat_latest_from.spec.ts new file mode 100644 index 0000000000..28c251fab7 --- /dev/null +++ b/modules/effects/spec/concat_latest_from.spec.ts @@ -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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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'] }) + ); + }); + }); +}); diff --git a/modules/effects/src/concat_latest_from.ts b/modules/effects/src/concat_latest_from.ts new file mode 100644 index 0000000000..3f9bcb58dc --- /dev/null +++ b/modules/effects/src/concat_latest_from.ts @@ -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[], V>( + observablesFactory: (value: V) => [...T] +): OperatorFunction }]>; +export function concatLatestFrom, V>( + observableFactory: (value: V) => T +): OperatorFunction]>; +/** + * '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[] | Observable, + V, + R = [ + V, + ...(T extends Observable[] + ? { [i in keyof T]: ObservedValueOf } + : [ObservedValueOf]) + ] +>(observablesFactory: (value: V) => T): OperatorFunction { + return pipe( + concatMap((value) => { + const observables = observablesFactory(value); + const observablesAsArray = Array.isArray(observables) + ? observables + : [observables]; + return of(value).pipe( + withLatestFrom(...observablesAsArray) + ) as Observable; + }) + ); +} diff --git a/modules/effects/src/index.ts b/modules/effects/src/index.ts index 00efcb88ab..d57871d89a 100644 --- a/modules/effects/src/index.ts +++ b/modules/effects/src/index.ts @@ -27,3 +27,4 @@ export { OnInitEffects, } from './lifecycle_hooks'; export { USER_PROVIDED_EFFECTS } from './tokens'; +export { concatLatestFrom } from './concat_latest_from'; diff --git a/projects/example-app/src/app/core/effects/router.effects.ts b/projects/example-app/src/app/core/effects/router.effects.ts index f34141261a..4d03d072e0 100644 --- a/projects/example-app/src/app/core/effects/router.effects.ts +++ b/projects/example-app/src/app/core/effects/router.effects.ts @@ -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'; @@ -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)) ), diff --git a/projects/ngrx.io/content/guide/effects/index.md b/projects/ngrx.io/content/guide/effects/index.md index 79ddf6b430..2472d949b3 100644 --- a/projects/ngrx.io/content/guide/effects/index.md +++ b/projects/ngrx.io/content/guide/effects/index.md @@ -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!'); @@ -339,7 +337,7 @@ export class CollectionEffects {
-**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.
diff --git a/projects/ngrx.io/content/guide/effects/operators.md b/projects/ngrx.io/content/guide/effects/operators.md index a7a1dfb6b8..785a629d4e 100644 --- a/projects/ngrx.io/content/guide/effects/operators.md +++ b/projects/ngrx.io/content/guide/effects/operators.md @@ -49,3 +49,51 @@ export class AuthEffects { ) {} } + +## `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. + + +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, + private titleService: Title + ) {} +} +