From 3545df21429676592eacdda6860b20b67cdcf16d Mon Sep 17 00:00:00 2001 From: Alex Okrushko Date: Thu, 21 May 2020 16:24:45 -0400 Subject: [PATCH] feat(component-store): initialization + updater/setState (#2528) --- modules/component-store/spec/BUILD | 1 + .../spec/component-store.spec.ts | 404 ++++++++++++++++++ .../component-store/spec/placeholder.spec.ts | 7 - .../component-store/src/component-store.ts | 114 +++++ modules/component-store/src/index.ts | 2 +- modules/component-store/src/placeholder.ts | 3 - package.json | 1 + .../content/guide/component-store/index.md | 49 ++- yarn.lock | 12 + 9 files changed, 581 insertions(+), 12 deletions(-) create mode 100644 modules/component-store/spec/component-store.spec.ts delete mode 100644 modules/component-store/spec/placeholder.spec.ts create mode 100644 modules/component-store/src/component-store.ts delete mode 100644 modules/component-store/src/placeholder.ts diff --git a/modules/component-store/spec/BUILD b/modules/component-store/spec/BUILD index 82f51cbdf3..9553778c8c 100644 --- a/modules/component-store/spec/BUILD +++ b/modules/component-store/spec/BUILD @@ -10,5 +10,6 @@ ts_jest_test( deps = [ "//modules/component-store", "@npm//rxjs", + "@npm//rxjs-marbles", ], ) diff --git a/modules/component-store/spec/component-store.spec.ts b/modules/component-store/spec/component-store.spec.ts new file mode 100644 index 0000000000..fad938846d --- /dev/null +++ b/modules/component-store/spec/component-store.spec.ts @@ -0,0 +1,404 @@ +import { ComponentStore } from '@ngrx/component-store'; +import { fakeSchedulers, marbles } from 'rxjs-marbles/jest'; +import { of, Subscription, ConnectableObservable, interval, timer } from 'rxjs'; +import { delayWhen, publishReplay, take, map } from 'rxjs/operators'; + +describe('Component Store', () => { + describe('initialization', () => { + it( + 'through constructor', + marbles(m => { + const INIT_STATE = { init: 'state' }; + const componentStore = new ComponentStore(INIT_STATE); + + m.expect(componentStore.state$).toBeObservable( + m.hot('i', { i: INIT_STATE }) + ); + }) + ); + + it( + 'stays uninitialized if initial state is not provided', + marbles(m => { + const componentStore = new ComponentStore(); + + // No values emitted. + m.expect(componentStore.state$).toBeObservable(m.hot('-')); + }) + ); + + it( + 'through setState method', + marbles(m => { + const componentStore = new ComponentStore(); + const INIT_STATE = { setState: 'passed' }; + + componentStore.setState(INIT_STATE); + + m.expect(componentStore.state$).toBeObservable( + m.hot('i', { i: INIT_STATE }) + ); + }) + ); + + it( + 'throws an Error when setState with a function/callback is called' + + ' before initialization', + marbles(m => { + const componentStore = new ComponentStore(); + + m.expect(componentStore.state$).toBeObservable( + m.hot('#', {}, new Error('ComponentStore has not been initialized')) + ); + + expect(() => { + componentStore.setState(() => ({ setState: 'new state' })); + }).toThrow(new Error('ComponentStore has not been initialized')); + }) + ); + + it( + 'throws an Error when updater is called before initialization', + marbles(m => { + const componentStore = new ComponentStore(); + + m.expect(componentStore.state$).toBeObservable( + m.hot('#', {}, new Error('ComponentStore has not been initialized')) + ); + + expect(() => { + componentStore.updater((state, value: object) => value)({ + updater: 'new state', + }); + }).toThrow(new Error('ComponentStore has not been initialized')); + }) + ); + + it( + 'throws an Error when updater is called with sync Observable' + + ' before initialization', + marbles(m => { + const componentStore = new ComponentStore(); + const syncronousObservable$ = of({ + updater: 'new state', + }); + + m.expect(componentStore.state$).toBeObservable( + m.hot('#', {}, new Error('ComponentStore has not been initialized')) + ); + + expect(() => { + componentStore.updater((state, value) => value)( + syncronousObservable$ + ); + }).toThrow(new Error('ComponentStore has not been initialized')); + }) + ); + + it( + 'does not throw an Error when updater is called with async Observable' + + ' before initialization, however closes the subscription and does not' + + ' update the state and sends error in state$', + marbles(m => { + const componentStore = new ComponentStore(); + const asyncronousObservable$ = m.cold('-u', { + u: { updater: 'new state' }, + }); + + let subscription: Subscription | undefined; + + m.expect(componentStore.state$).toBeObservable( + m.hot('-#', {}, new Error('ComponentStore has not been initialized')) + ); + + expect(() => { + subscription = componentStore.updater( + (state, value: object) => value + )(asyncronousObservable$); + }).not.toThrow(); + + m.flush(); + + expect(subscription!.closed).toBe(true); + }) + ); + + it( + 'does not throws an Error when updater is called with async Observable' + + ' before initialization, that emits the value after initialization', + () => { + const componentStore = new ComponentStore(); + const INIT_STATE = { initState: 'passed' }; + const UPDATED_STATE = { updatedState: 'proccessed' }; + + // Record all the values that go through state$ into an array + const results: object[] = []; + componentStore.state$.subscribe(state => results.push(state)); + + const asyncronousObservable$ = of(UPDATED_STATE).pipe( + // Delays until the state gets the init value. + delayWhen(() => componentStore.state$) + ); + + expect(() => { + componentStore.updater((state, value) => value)( + asyncronousObservable$ + ); + }).not.toThrow(); + + // Trigger initial state. + componentStore.setState(INIT_STATE); + + expect(results).toEqual([INIT_STATE, UPDATED_STATE, UPDATED_STATE]); + } + ); + }); + + describe('updates the state', () => { + interface State { + value: string; + updated?: boolean; + } + const INIT_STATE: State = { value: 'init' }; + let componentStore: ComponentStore; + + beforeEach(() => { + componentStore = new ComponentStore(INIT_STATE); + }); + + it( + 'with setState to a specific value', + marbles(m => { + const SET_STATE: State = { value: 'new state' }; + componentStore.setState(SET_STATE); + m.expect(componentStore.state$).toBeObservable( + m.hot('s', { s: SET_STATE }) + ); + }) + ); + + it( + 'with setState to a value based on the previous state', + marbles(m => { + const UPDATE_STATE: Partial = { updated: true }; + componentStore.setState(state => ({ + ...state, + ...UPDATE_STATE, + })); + m.expect(componentStore.state$).toBeObservable( + m.hot('u', { + u: { + value: 'init', + updated: true, + }, + }) + ); + }) + ); + + it( + 'with updater to a value based on the previous state and passed values', + marbles(m => { + const UPDATED: Partial = { updated: true }; + const UPDATE_VALUE: Partial = { value: 'updated' }; + const updater = componentStore.updater( + (state, value: Partial) => ({ + ...state, + ...value, + }) + ); + + // Record all the values that go through state$ into an array + const results: object[] = []; + componentStore.state$.subscribe(state => results.push(state)); + + // Update twice with different values + updater(UPDATED); + updater(UPDATE_VALUE); + + expect(results).toEqual([ + { value: 'init' }, + { + value: 'init', + updated: true, + }, + { + value: 'updated', + updated: true, + }, + ]); + + // New subsriber gets the latest value only. + m.expect(componentStore.state$).toBeObservable( + m.hot('s', { + s: { + value: 'updated', + updated: true, + }, + }) + ); + }) + ); + + it( + 'with updater to a value based on the previous state and passed' + + ' Observable', + marbles(m => { + const updater = componentStore.updater( + (state, value: Partial) => ({ + ...state, + ...value, + }) + ); + + // Record all the values that go through state$. + const recordedStateValues$ = componentStore.state$.pipe( + publishReplay() + ); + // Need to "connect" to start getting notifications. + (recordedStateValues$ as ConnectableObservable).connect(); + + // Update with Observable. + updater( + m.cold('--u--s|', { + u: { updated: true }, + s: { value: 'updated' }, + }) + ); + + m.expect(recordedStateValues$).toBeObservable( + m.hot('i-u--s', { + // First value is here due to ReplaySubject being at the heart of + // ComponentStore. + i: { + value: 'init', + }, + u: { + value: 'init', + updated: true, + }, + s: { + value: 'updated', + updated: true, + }, + }) + ); + }) + ); + }); + + describe('cancels updater Observable', () => { + beforeEach(() => jest.useFakeTimers()); + + interface State { + value: string; + updated?: boolean; + } + const INIT_STATE: State = { value: 'init' }; + let componentStore: ComponentStore; + + beforeEach(() => { + componentStore = new ComponentStore(INIT_STATE); + }); + + it( + 'by unsubscribing with returned Subscriber', + fakeSchedulers(advance => { + const updater = componentStore.updater( + (state, value: Partial) => ({ + ...state, + ...value, + }) + ); + + // Record all the values that go through state$ into an array + const results: State[] = []; + componentStore.state$.subscribe(state => results.push(state)); + + // Update with Observable. + const subsription = updater( + interval(10).pipe( + map(v => ({ value: String(v) })), + take(10) // just in case + ) + ); + + // Advance for 40 fake milliseconds and unsubscribe - should capture + // from '0' to '3' + advance(40); + subsription.unsubscribe(); + + // Advance for 20 more fake milliseconds, to check if anything else + // is captured + advance(20); + + expect(results).toEqual([ + // First value is here due to ReplaySubject being at the heart of + // ComponentStore. + { value: 'init' }, + { value: '0' }, + { value: '1' }, + { value: '2' }, + { value: '3' }, + ]); + }) + ); + + it( + 'and cancels the correct one', + fakeSchedulers(advance => { + const updater = componentStore.updater( + (state, value: Partial) => ({ + ...state, + ...value, + }) + ); + + // Record all the values that go through state$ into an array + const results: State[] = []; + componentStore.state$.subscribe(state => results.push(state)); + + // Update with Observable. + const subsription = updater( + interval(10).pipe( + map(v => ({ value: 'a' + v })), + take(10) // just in case + ) + ); + + // Create the second Observable that updates the state + updater( + timer(15, 10).pipe( + map(v => ({ value: 'b' + v })), + take(10) + ) + ); + + // Advance for 40 fake milliseconds and unsubscribe - should capture + // from '0' to '3' + advance(40); + subsription.unsubscribe(); + + // Advance for 30 more fake milliseconds, to make sure that second + // Observable still emits + advance(30); + + expect(results).toEqual([ + // First value is here due to ReplaySubject being at the heart of + // ComponentStore. + { value: 'init' }, + { value: 'a0' }, + { value: 'b0' }, + { value: 'a1' }, + { value: 'b1' }, + { value: 'a2' }, + { value: 'b2' }, + { value: 'a3' }, + { value: 'b3' }, + { value: 'b4' }, + { value: 'b5' }, // second Observable continues to emit values + ]); + }) + ); + }); +}); diff --git a/modules/component-store/spec/placeholder.spec.ts b/modules/component-store/spec/placeholder.spec.ts deleted file mode 100644 index bd4a1425bb..0000000000 --- a/modules/component-store/spec/placeholder.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { sum } from '@ngrx/component-store'; - -describe('placeholder', () => { - it('should run specs', () => { - expect(sum(2, 5)).toBe(7); - }); -}); diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts new file mode 100644 index 0000000000..059b4d25b2 --- /dev/null +++ b/modules/component-store/src/component-store.ts @@ -0,0 +1,114 @@ +import { + isObservable, + Observable, + of, + ReplaySubject, + Subscription, + throwError, +} from 'rxjs'; +import { concatMap, takeUntil, withLatestFrom } from 'rxjs/operators'; + +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(); + + constructor(defaultState?: T) { + // State can be initialized either through constructor, or initState or + // setState. + if (defaultState) { + this.initState(defaultState); + } + } + + /** Completes all relevant Observable streams. */ + ngOnDestroy() { + this.stateSubject$.complete(); + this.destroySubject$.next(); + } + + /** + * Creates an updater. + * + * Throws an error if updater is called with synchronous values (either + * imperative value or Observable that is synchronous) before ComponentStore + * is initialized. If called with async Observable before initialization then + * state will not be updated and subscription would be closed. + * + * @param updaterFn A static updater function that takes 2 parameters (the + * current state and an argument object) and returns a new instance of the + * state. + * @return A function that accepts one argument which is forwarded as the + * second argument to `updaterFn`. Everytime this function is called + * subscribers will be notified of the state change. + */ + updater( + updaterFn: (state: T, value: V) => T + ): unknown extends V ? () => void : (t: V | Observable) => Subscription { + return ((observableOrValue?: V | Observable): Subscription => { + let initializationError: Error | undefined; + // We can receive either the value or an observable. In case it's a + // simple value, we'll wrap it with `of` operator to turn it into + // Observable. + const observable$ = isObservable(observableOrValue) + ? observableOrValue + : of(observableOrValue); + const subscription = observable$ + .pipe( + concatMap( + value => + this.isInitialized + ? of(value).pipe(withLatestFrom(this.stateSubject$)) + : // If state was not initialized, we'll throw an error. + throwError( + Error(`${this.constructor.name} has not been initialized`) + ) + ), + takeUntil(this.destroy$) + ) + .subscribe({ + next: ([value, currentState]) => { + this.stateSubject$.next(updaterFn(currentState, value!)); + }, + error: error => { + initializationError = error; + this.stateSubject$.error(error); + }, + }); + + if (initializationError) { + throw initializationError; + } + return subscription; + }) as unknown extends V + ? () => void + : (t: V | Observable) => Subscription; + } + + /** + * Initializes state. If it was already initialized then it resets the + * state. + */ + private initState(state: T): void { + this.isInitialized = true; + this.stateSubject$.next(state); + } + + /** + * Sets the state specific value. + * @param stateOrUpdaterFn object of the same type as the state or an + * updaterFn, returning such object. + */ + setState(stateOrUpdaterFn: T | ((state: T) => T)): void { + if (typeof stateOrUpdaterFn !== 'function') { + this.initState(stateOrUpdaterFn); + } else { + this.updater(stateOrUpdaterFn as (state: T) => T)(); + } + } +} diff --git a/modules/component-store/src/index.ts b/modules/component-store/src/index.ts index 44ebde2c43..922983bc91 100644 --- a/modules/component-store/src/index.ts +++ b/modules/component-store/src/index.ts @@ -1 +1 @@ -export * from './placeholder'; +export * from './component-store'; diff --git a/modules/component-store/src/placeholder.ts b/modules/component-store/src/placeholder.ts deleted file mode 100644 index 508f35f62e..0000000000 --- a/modules/component-store/src/placeholder.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function sum(a: number, b: number) { - return a + b; -} diff --git a/package.json b/package.json index 5aa1b35f09..5240772135 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,7 @@ "rollup-plugin-commonjs": "10.1.0", "rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-sourcemaps": "^0.4.2", + "rxjs-marbles": "^6.0.0", "shelljs": "^0.8.3", "sorcery": "^0.10.0", "ts-loader": "^5.3.3", diff --git a/projects/ngrx.io/content/guide/component-store/index.md b/projects/ngrx.io/content/guide/component-store/index.md index 1bce4be83d..ed19827a2b 100644 --- a/projects/ngrx.io/content/guide/component-store/index.md +++ b/projects/ngrx.io/content/guide/component-store/index.md @@ -1,3 +1,50 @@ # @ngrx/component-store -Placeholder +ComponentStore is a standalone library that helps to manage local/component state. It's an alternative to push-based "Service with a Subject". + +## Introduction + +// TODO(alex-okrushko): fill-up the intro + +## Key Concepts + +- Local state can be initialized lazily +- Local state is typically tied to the life-cycle of a particular component and is cleaned up when that component is destroyed. +- Users of ComponentStore can update the state through `setState` or `updater`, either imperatively or by providing an Observable. +- Users of ComponentStore can read the state through `select` or a top-level `state$`. Selectors are extremely performant. +- Users of ComponentStore may start side-effects with `effect`, both sync and async, and feed the data both imperatively or reactively. + +## Installation + +// TODO(alex-okrushko): fill-up the installation, including pros/cons of extending the service vs Component-scoped providers + +## Initialization + +ComponentStore can be initialized in 2 ways: +- through constructor - it would have the initial state +- by calling `setState` and passing an object that matches the state interface. + + +@Component({ + template: ` + <li *ngFor="let movie of (movies$ | async)"> + {{ movie.name }} + </li> + `, + providers: [ComponentStore], +}) +export class MoviesPageComponent { + movies$: this.componentStore.state$.pipe( + map(state => state.movies), + ); + + constructor( + private readonly componentStore: ComponentStore<{movies: Movie[]}> + ) {} + + ngOnInit() { + this.componentStore.setState({movies: []}); + } +} + + diff --git a/yarn.lock b/yarn.lock index 7b46a169ab..174e6276ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6079,6 +6079,11 @@ fast-deep-equal@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" +fast-equals@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-2.0.0.tgz#bef2c423af3939f2c54310df54c57e64cd2adefc" + integrity sha512-u6RBd8cSiLLxAiC04wVsLV6GBFDOXcTCgWkd3wEoFXgidPSoAJENqC9m7Jb2vewSvjBIfXV6icKeh3GTKfIaXA== + fast-glob@^2.0.2: version "2.2.2" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.2.tgz#71723338ac9b4e0e2fff1d6748a2a13d5ed352bf" @@ -12183,6 +12188,13 @@ rx@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782" +rxjs-marbles@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/rxjs-marbles/-/rxjs-marbles-6.0.0.tgz#b8862b20e94cf0e685222dddc248eb8bceaf4f31" + integrity sha512-d9EzhUc0VWeY8OaF/qpZNmunelzE6tml+dOPlyJ7bUWFnmUrQuHAvIOP9C5eJil36dkANNqna1Ev/aPtAd5joA== + dependencies: + fast-equals "^2.0.0" + rxjs@6.5.3, rxjs@^6.5.3: version "6.5.3" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.3.tgz#510e26317f4db91a7eb1de77d9dd9ba0a4899a3a"