Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(effects): concatLatestFrom operator #2760

Merged
merged 1 commit into from
Feb 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>