From 47e7ba3878246dfb183b0f9a1b35075f4af6cd98 Mon Sep 17 00:00:00 2001 From: Alex Okrushko Date: Thu, 28 May 2020 16:42:32 -0400 Subject: [PATCH] feat(component-store): add support for selectors (#2539) --- LICENSE | 4 + .../spec/component-store.spec.ts | 228 +++++++++++++++++- .../component-store/src/component-store.ts | 77 +++++- modules/component-store/src/debounceSync.ts | 61 +++++ 4 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 modules/component-store/src/debounceSync.ts diff --git a/LICENSE b/LICENSE index ac4cfdf68e..4021bf5ca9 100644 --- a/LICENSE +++ b/LICENSE @@ -19,3 +19,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +This repository includes a file "debounceSync.ts" originially copied from +https://github.com/cartant/rxjs-etc by Nicholas Jamieson, MIT licensed. See the +file header for details. diff --git a/modules/component-store/spec/component-store.spec.ts b/modules/component-store/spec/component-store.spec.ts index fad938846d..25dd42d4cf 100644 --- a/modules/component-store/spec/component-store.spec.ts +++ b/modules/component-store/spec/component-store.spec.ts @@ -149,7 +149,7 @@ describe('Component Store', () => { // Trigger initial state. componentStore.setState(INIT_STATE); - expect(results).toEqual([INIT_STATE, UPDATED_STATE, UPDATED_STATE]); + expect(results).toEqual([INIT_STATE, UPDATED_STATE]); } ); }); @@ -401,4 +401,230 @@ describe('Component Store', () => { }) ); }); + + describe('selectors', () => { + interface State { + value: string; + updated?: boolean; + } + + const INIT_STATE: State = { value: 'init' }; + let componentStore: ComponentStore; + + beforeEach(() => { + componentStore = new ComponentStore(INIT_STATE); + }); + + it( + 'uninitialized Component Store does not emit values', + marbles(m => { + const uninitializedComponentStore = new ComponentStore(); + m.expect(uninitializedComponentStore.select(s => s)).toBeObservable( + m.hot('-') + ); + }) + ); + + it( + 'selects component root state', + marbles(m => { + m.expect(componentStore.select(s => s)).toBeObservable( + m.hot('i', { i: INIT_STATE }) + ); + }) + ); + + it( + 'selects component property from the state', + marbles(m => { + m.expect(componentStore.select(s => s.value)).toBeObservable( + m.hot('i', { i: INIT_STATE.value }) + ); + }) + ); + + it( + 'can be combined with other selectors', + marbles(m => { + const selector1 = componentStore.select(s => s.value); + const selector2 = componentStore.select(s => s.updated); + const selector3 = componentStore.select( + selector1, + selector2, + // Returning an object to make sure that distinctUntilChanged does + // not hold it + (s1, s2) => ({ result: s2 ? s1 : 'empty' }) + ); + + const selectorResults: Array<{ result: string }> = []; + selector3.subscribe(s3 => { + selectorResults.push(s3); + }); + + m.flush(); + componentStore.setState(() => ({ value: 'new value', updated: true })); + m.flush(); + + expect(selectorResults).toEqual([ + { result: 'empty' }, + { result: 'new value' }, + ]); + }) + ); + + it( + 'can combine with other Observables', + marbles(m => { + const observableValues = { + '1': 'one', + '2': 'two', + '3': 'three', + }; + + const observable$ = m.hot(' 1-2---3', observableValues); + const updater$ = m.cold(' a--b--c|'); + const expectedSelector$ = m.hot('w-xy--z-', { + w: 'one a', + x: 'two a', + y: 'two b', + z: 'three c', + }); + + const selectorValue$ = componentStore.select(s => s.value); + const selector$ = componentStore.select( + selectorValue$, + observable$, + (s1, o) => o + ' ' + s1 + ); + + componentStore.updater((state, newValue: string) => ({ + value: newValue, + }))(updater$); + + m.expect(selector$).toBeObservable(expectedSelector$); + }) + ); + + it( + 'would emit a single value even when all 4 selectors produce values', + marbles(m => { + const s1$ = componentStore.select(s => `fromS1(${s.value})`); + const s2$ = componentStore.select(s => `fromS2(${s.value})`); + const s3$ = componentStore.select(s => `fromS3(${s.value})`); + const s4$ = componentStore.select(s => `fromS4(${s.value})`); + + const selector$ = componentStore.select( + s1$, + s2$, + s3$, + s4$, + (s1, s2, s3, s4) => `${s1} & ${s2} & ${s3} & ${s4}` + ); + + const updater$ = m.cold(' -----e-|'); + const expectedSelector$ = m.hot('i----c--', { + // initial👆 👆 combined single value + i: 'fromS1(init) & fromS2(init) & fromS3(init) & fromS4(init)', + c: 'fromS1(e) & fromS2(e) & fromS3(e) & fromS4(e)', + }); + + componentStore.updater((_, newValue: string) => ({ + value: newValue, + }))(updater$); + + m.expect(selector$).toBeObservable(expectedSelector$); + }) + ); + + it( + 'can combine with Observables that complete', + marbles(m => { + const observableValues = { + '1': 'one', + '2': 'two', + '3': 'three', + }; + + const observable$ = m.cold(' 1-2---3|', observableValues); + const updater$ = m.cold(' a--b--c|'); + const expectedSelector$ = m.hot('w-xy--z-', { + w: 'one a', + x: 'two a', + y: 'two b', + z: 'three c', + }); + + const selectorValue$ = componentStore.select(s => s.value); + const selector$ = componentStore.select( + selectorValue$, + observable$, + (s1, o) => o + ' ' + s1 + ); + + componentStore.updater((state, newValue: string) => ({ + value: newValue, + }))(updater$); + + m.expect(selector$).toBeObservable(expectedSelector$); + }) + ); + + it( + 'does not emit the same value if it did not change', + marbles(m => { + const selector1 = componentStore.select(s => s.value); + const selector2 = componentStore.select(s => s.updated); + const selector3 = componentStore.select( + selector1, + selector2, + // returning the same value, which should be caught by + // distinctUntilChanged + () => 'selector3 result' + ); + + const selectorResults: string[] = []; + selector3.subscribe(s3 => { + selectorResults.push(s3); + }); + + m.flush(); + componentStore.setState(() => ({ value: 'new value', updated: true })); + + m.flush(); + expect(selectorResults).toEqual(['selector3 result']); + }) + ); + + it( + 'are shared between subscribers', + marbles(m => { + const projectorCallback = jest.fn(s => s.value); + const selector = componentStore.select(projectorCallback); + + const resultsArray: string[] = []; + selector.subscribe(value => resultsArray.push('subscriber1: ' + value)); + selector.subscribe(value => resultsArray.push('subscriber2: ' + value)); + + m.flush(); + componentStore.setState(() => ({ value: 'new value', updated: true })); + m.flush(); + + // Even though we have 2 subscribers for 2 values, the projector + // function is called only twice - once for each new value. + expect(projectorCallback.mock.calls.length).toBe(2); + }) + ); + + it('complete when componentStore is destroyed', (doneFn: jest.DoneCallback) => { + const selector = componentStore.select(() => ({})); + + selector.subscribe({ + complete: () => { + doneFn(); + }, + }); + + componentStore.ngOnDestroy(); + }); + }); }); diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index 059b4d25b2..fc1f475cfa 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -5,19 +5,29 @@ import { ReplaySubject, Subscription, throwError, + combineLatest, } from 'rxjs'; -import { concatMap, takeUntil, withLatestFrom } from 'rxjs/operators'; +import { + concatMap, + takeUntil, + withLatestFrom, + map, + distinctUntilChanged, + shareReplay, +} from 'rxjs/operators'; +import { debounceSync } from './debounceSync'; export class ComponentStore { - private readonly stateSubject$ = new ReplaySubject(1); - private isInitialized = false; - readonly state$: Observable = this.stateSubject$.asObservable(); - // Should be used only in ngOnDestroy. private readonly destroySubject$ = new ReplaySubject(1); // Exposed to any extending Store to be used for the teardowns. readonly destroy$ = this.destroySubject$.asObservable(); + private readonly stateSubject$ = new ReplaySubject(1); + private isInitialized = false; + // Needs to be after destroy$ is declared because it's used in select. + readonly state$: Observable = this.select(s => s); + constructor(defaultState?: T) { // State can be initialized either through constructor, or initState or // setState. @@ -111,4 +121,61 @@ export class ComponentStore { this.updater(stateOrUpdaterFn as (state: T) => T)(); } } + + /** + * Creates a selector. + * + * This supports chaining up to 4 selectors. More could be added as needed. + * + * @param projector A pure projection function that takes the current state and + * returns some new slice/projection of that state. + * @return An observable of the projector results. + */ + select(projector: (s: T) => R): Observable; + select(s1: Observable, projector: (s1: S1) => R): Observable; + select( + s1: Observable, + s2: Observable, + projector: (s1: S1, s2: S2) => R + ): Observable; + select( + s1: Observable, + s2: Observable, + s3: Observable, + projector: (s1: S1, s2: S2, s3: S3) => R + ): Observable; + select( + s1: Observable, + s2: Observable, + s3: Observable, + s4: Observable, + projector: (s1: S1, s2: S2, s3: S3, s4: S4) => R + ): Observable; + select(...args: any[]): Observable { + let observable$: Observable; + // project is always the last argument, so `pop` it from args. + const projector: (...args: any[]) => R = args.pop(); + if (args.length === 0) { + // If projector was the only argument then we'll use map operator. + observable$ = this.stateSubject$.pipe(map(projector)); + } else { + // If there are multiple arguments, we're chaining selectors, so we need + // to take the combineLatest of them before calling the map function. + observable$ = combineLatest(args).pipe( + // The most performant way to combine Observables avoiding unnecessary + // emissions and projector calls. + debounceSync(), + map((args: any[]) => projector(...args)) + ); + } + const distinctSharedObservable$ = observable$.pipe( + distinctUntilChanged(), + shareReplay({ + refCount: true, + bufferSize: 1, + }), + takeUntil(this.destroy$) + ); + return distinctSharedObservable$; + } } diff --git a/modules/component-store/src/debounceSync.ts b/modules/component-store/src/debounceSync.ts new file mode 100644 index 0000000000..bdcb8bafbc --- /dev/null +++ b/modules/component-store/src/debounceSync.ts @@ -0,0 +1,61 @@ +/** + * @license MIT License + * + * Copyright (c) 2017-2020 Nicholas Jamieson and contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + asapScheduler, + MonoTypeOperatorFunction, + Observable, + Subscription, +} from 'rxjs'; + +export function debounceSync(): MonoTypeOperatorFunction { + return source => + new Observable(observer => { + let actionSubscription: Subscription | undefined; + let actionValue: T | undefined; + const rootSubscription = new Subscription(); + rootSubscription.add( + source.subscribe({ + complete: () => { + if (actionSubscription) { + observer.next(actionValue); + } + observer.complete(); + }, + error: error => observer.error(error), + next: value => { + actionValue = value; + if (!actionSubscription) { + actionSubscription = asapScheduler.schedule(() => { + observer.next(actionValue); + actionSubscription = undefined; + }); + rootSubscription.add(actionSubscription); + } + }, + }) + ); + return rootSubscription; + }); +}