Skip to content

Commit

Permalink
feat(component-store): add support for selectors (#2539)
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-okrushko authored May 28, 2020
1 parent 5269b0d commit 47e7ba3
Show file tree
Hide file tree
Showing 4 changed files with 364 additions and 6 deletions.
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:/cartant/rxjs-etc by Nicholas Jamieson, MIT licensed. See the
file header for details.
228 changes: 227 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,230 @@ 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(
'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();
});
});
});
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$;
}
}
Loading

0 comments on commit 47e7ba3

Please sign in to comment.