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(component-store): selectors #2539

Merged
merged 3 commits into from
May 28, 2020
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -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.
197 changes: 196 additions & 1 deletion modules/component-store/spec/component-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
);
});
Expand Down Expand Up @@ -401,4 +401,199 @@ describe('Component Store', () => {
})
);
});

describe('selectors', () => {
interface State {
value: string;
updated?: boolean;
}

const INIT_STATE: State = { value: 'init' };
let componentStore: ComponentStore<State>;

beforeEach(() => {
componentStore = new ComponentStore<State>(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(
alex-okrushko marked this conversation as resolved.
Show resolved Hide resolved
'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(
'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();
});
});
});
77 changes: 72 additions & 5 deletions modules/component-store/src/component-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends object> {
private readonly stateSubject$ = new ReplaySubject<T>(1);
private isInitialized = false;
readonly state$: Observable<T> = this.stateSubject$.asObservable();

// Should be used only in ngOnDestroy.
private readonly destroySubject$ = new ReplaySubject<void>(1);
// Exposed to any extending Store to be used for the teardowns.
readonly destroy$ = this.destroySubject$.asObservable();

private readonly stateSubject$ = new ReplaySubject<T>(1);
private isInitialized = false;
// Needs to be after destroy$ is declared because it's used in select.
readonly state$: Observable<T> = this.select(s => s);

constructor(defaultState?: T) {
// State can be initialized either through constructor, or initState or
// setState.
Expand Down Expand Up @@ -111,4 +121,61 @@ export class ComponentStore<T extends object> {
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<R>(projector: (s: T) => R): Observable<R>;
select<R, S1>(s1: Observable<S1>, projector: (s1: S1) => R): Observable<R>;
select<R, S1, S2>(
s1: Observable<S1>,
s2: Observable<S2>,
projector: (s1: S1, s2: S2) => R
): Observable<R>;
select<R, S1, S2, S3>(
s1: Observable<S1>,
s2: Observable<S2>,
s3: Observable<S3>,
projector: (s1: S1, s2: S2, s3: S3) => R
): Observable<R>;
select<R, S1, S2, S3, S4>(
s1: Observable<S1>,
s2: Observable<S2>,
s3: Observable<S3>,
s4: Observable<S4>,
projector: (s1: S1, s2: S2, s3: S3, s4: S4) => R
): Observable<R>;
select<R>(...args: any[]): Observable<R> {
let observable$: Observable<R>;
// 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$;
}
}
61 changes: 61 additions & 0 deletions modules/component-store/src/debounceSync.ts
Original file line number Diff line number Diff line change
@@ -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<T>(): MonoTypeOperatorFunction<T> {
return source =>
new Observable<T>(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;
});
}