From ecc22841e05b36826ab43b40074193a72146fb2a Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Mon, 25 Apr 2022 12:31:55 -0500 Subject: [PATCH 01/10] feat(component-store): add OnStoreInit and OnStateInit lifecycle hooks --- .../component-store/src/component-store.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index a11bb689a6..53b7ecaac8 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -32,6 +32,14 @@ export interface SelectConfig { debounce?: boolean; } +export interface OnStoreInit { + readonly ngrxOnStoreInit: () => void; +} + +export interface OnStateInit { + readonly ngrxOnStateInit: () => void; +} + export const INITIAL_STATE_TOKEN = new InjectionToken( '@ngrx/component-store Initial State' ); @@ -62,12 +70,24 @@ export class ComponentStore implements OnDestroy { readonly state$: Observable = this.select((s) => s); constructor(@Optional() @Inject(INITIAL_STATE_TOKEN) defaultState?: T) { + // check/call store init hook + this.callInitStoreHook(); + // State can be initialized either through constructor or setState. if (defaultState) { this.initState(defaultState); } } + private callInitStoreHook() { + const onStoreInit: Function | undefined = ( + this as unknown as ComponentStore & OnStoreInit + )['ngrxOnStoreInit']; + if (typeof onStoreInit === 'function') { + onStoreInit.call(this); + } + } + /** Completes all relevant Observable streams. */ ngOnDestroy() { this.stateSubject$.complete(); @@ -149,8 +169,18 @@ export class ComponentStore implements OnDestroy { */ private initState(state: T): void { scheduled([state], queueScheduler).subscribe((s) => { + const isInitialized = this.isInitialized; this.isInitialized = true; this.stateSubject$.next(s); + + if (!isInitialized) { + const onStateInit: Function | undefined = ( + this as unknown as ComponentStore & OnStateInit + )['ngrxOnStateInit']; + if (typeof onStateInit === 'function') { + onStateInit.call(this, s); + } + } }); } From ff49897ceae68b2f5f6658860ba2a4996dad07ac Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Tue, 26 Apr 2022 21:23:26 -0500 Subject: [PATCH 02/10] chore: add tests --- .../spec/component-store.spec.ts | 86 ++++++++++++++++++- .../component-store/src/component-store.ts | 4 +- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/modules/component-store/spec/component-store.spec.ts b/modules/component-store/spec/component-store.spec.ts index 2aaa3f54e9..34a00bb8c5 100644 --- a/modules/component-store/spec/component-store.spec.ts +++ b/modules/component-store/spec/component-store.spec.ts @@ -1,5 +1,9 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { ComponentStore } from '@ngrx/component-store'; +import { + ComponentStore, + OnStateInit, + OnStoreInit, +} from '@ngrx/component-store'; import { fakeSchedulers, marbles } from 'rxjs-marbles/jest'; import { of, @@ -1447,4 +1451,84 @@ describe('Component Store', () => { expect(componentStore.get()).toEqual({ value: 'updated' }); }); }); + + describe('lifecycle hooks', () => { + interface LifeCycle { + init: boolean; + } + + const onStoreInitMessage = 'on store init called'; + const onStateInitMessage = 'on state init called'; + let logs: string[] = []; + class LifecycleStore + extends ComponentStore + implements OnStoreInit, OnStateInit + { + constructor(state?: LifeCycle) { + super(state); + } + + logEffect = this.effect( + tap(() => { + logs.push('effect'); + }) + ); + + ngrxOnStoreInit() { + logs.push(onStoreInitMessage); + } + + ngrxOnStateInit() { + logs.push(onStateInitMessage); + } + } + + let componentStore: LifecycleStore; + + beforeEach(() => { + logs = []; + }); + + it('should call the OnInitStore lifecycle hook if defined', async () => { + componentStore = new LifecycleStore({ init: true }); + + expect(logs[0]).toBe(onStoreInitMessage); + }); + + it('should only call the OnInitStore lifecycle hook once', async () => { + componentStore = new LifecycleStore({ init: true }); + expect(logs[0]).toBe(onStoreInitMessage); + + logs = []; + componentStore.setState({ init: false }); + + expect(logs.length).toBe(0); + }); + + it('should call the OnInitState lifecycle hook if defined and state is set eagerly', async () => { + componentStore = new LifecycleStore({ init: true }); + + expect(logs[1]).toBe(onStateInitMessage); + }); + + it('should call the OnInitState lifecycle hook if defined and after state is set lazily', async () => { + componentStore = new LifecycleStore(); + + expect(logs.length).toBe(1); + + componentStore.setState({ init: true }); + + expect(logs[1]).toBe(onStateInitMessage); + }); + + it('should only call the OnInitStore lifecycle hook once', async () => { + componentStore = new LifecycleStore({ init: true }); + + expect(logs[1]).toBe(onStateInitMessage); + logs = []; + componentStore.setState({ init: false }); + + expect(logs.length).toBe(0); + }); + }); }); diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index 53b7ecaac8..d0141b8be2 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -71,7 +71,7 @@ export class ComponentStore implements OnDestroy { constructor(@Optional() @Inject(INITIAL_STATE_TOKEN) defaultState?: T) { // check/call store init hook - this.callInitStoreHook(); + this.callInitStoreHook(defaultState); // State can be initialized either through constructor or setState. if (defaultState) { @@ -79,7 +79,7 @@ export class ComponentStore implements OnDestroy { } } - private callInitStoreHook() { + private callInitStoreHook(ds?: T) { const onStoreInit: Function | undefined = ( this as unknown as ComponentStore & OnStoreInit )['ngrxOnStoreInit']; From 984e259444e397ad7c11056cfba8e5ebcaeddac7 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Sun, 1 May 2022 14:22:11 -0500 Subject: [PATCH 03/10] refactor: update checks for lifecycle hooks --- .../component-store/src/component-store.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index d0141b8be2..4f1403d572 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -71,7 +71,7 @@ export class ComponentStore implements OnDestroy { constructor(@Optional() @Inject(INITIAL_STATE_TOKEN) defaultState?: T) { // check/call store init hook - this.callInitStoreHook(defaultState); + this.callInitStoreHook(); // State can be initialized either through constructor or setState. if (defaultState) { @@ -79,12 +79,9 @@ export class ComponentStore implements OnDestroy { } } - private callInitStoreHook(ds?: T) { - const onStoreInit: Function | undefined = ( - this as unknown as ComponentStore & OnStoreInit - )['ngrxOnStoreInit']; - if (typeof onStoreInit === 'function') { - onStoreInit.call(this); + private callInitStoreHook() { + if (isOnStoreInitDefined(this)) { + this.ngrxOnStoreInit(); } } @@ -173,13 +170,8 @@ export class ComponentStore implements OnDestroy { this.isInitialized = true; this.stateSubject$.next(s); - if (!isInitialized) { - const onStateInit: Function | undefined = ( - this as unknown as ComponentStore & OnStateInit - )['ngrxOnStateInit']; - if (typeof onStateInit === 'function') { - onStateInit.call(this, s); - } + if (!isInitialized && isOnStateInitDefined(this)) { + this.ngrxOnStateInit(); } }); } @@ -374,3 +366,11 @@ function processSelectorArgs< config, }; } + +function isOnStoreInitDefined(cs: unknown): cs is OnStoreInit { + return typeof (cs as OnStoreInit).ngrxOnStoreInit === 'function'; +} + +function isOnStateInitDefined(cs: unknown): cs is OnStateInit { + return typeof (cs as OnStateInit).ngrxOnStateInit === 'function'; +} From d554c0e44a5bb40ddc76437a9080f51090cfb080 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Tue, 3 May 2022 21:33:49 -0500 Subject: [PATCH 04/10] refactor: call hooks internally using an effect after constructor --- .../spec/component-store.spec.ts | 1 - .../component-store/src/component-store.ts | 44 +++++++++++++------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/modules/component-store/spec/component-store.spec.ts b/modules/component-store/spec/component-store.spec.ts index 34a00bb8c5..4454686c3c 100644 --- a/modules/component-store/spec/component-store.spec.ts +++ b/modules/component-store/spec/component-store.spec.ts @@ -1513,7 +1513,6 @@ describe('Component Store', () => { it('should call the OnInitState lifecycle hook if defined and after state is set lazily', async () => { componentStore = new LifecycleStore(); - expect(logs.length).toBe(1); componentStore.setState({ init: true }); diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index 4f1403d572..8d4795e40c 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -9,6 +9,7 @@ import { Subject, queueScheduler, scheduled, + EMPTY, } from 'rxjs'; import { concatMap, @@ -18,6 +19,7 @@ import { distinctUntilChanged, shareReplay, take, + catchError, } from 'rxjs/operators'; import { debounceSync } from './debounce-sync'; import { @@ -69,22 +71,26 @@ export class ComponentStore implements OnDestroy { // Needs to be after destroy$ is declared because it's used in select. readonly state$: Observable = this.select((s) => s); - constructor(@Optional() @Inject(INITIAL_STATE_TOKEN) defaultState?: T) { - // check/call store init hook - this.callInitStoreHook(); + // check/call store init hook + private readonly initStoreHook = this.effect(() => + of(null).pipe(($) => { + if (isOnStoreInitDefined(this)) { + this.ngrxOnStoreInit(); + } + return $; + }) + )(); + + // check/call state init hook on first emission of value + private readonly initStateHook = this.callInitStateHook(); + constructor(@Optional() @Inject(INITIAL_STATE_TOKEN) defaultState?: T) { // State can be initialized either through constructor or setState. if (defaultState) { this.initState(defaultState); } } - private callInitStoreHook() { - if (isOnStoreInitDefined(this)) { - this.ngrxOnStoreInit(); - } - } - /** Completes all relevant Observable streams. */ ngOnDestroy() { this.stateSubject$.complete(); @@ -166,13 +172,8 @@ export class ComponentStore implements OnDestroy { */ private initState(state: T): void { scheduled([state], queueScheduler).subscribe((s) => { - const isInitialized = this.isInitialized; this.isInitialized = true; this.stateSubject$.next(s); - - if (!isInitialized && isOnStateInitDefined(this)) { - this.ngrxOnStateInit(); - } }); } @@ -330,6 +331,21 @@ export class ComponentStore implements OnDestroy { }); }) as unknown as ReturnType; } + + callInitStateHook() { + this.stateSubject$ + .pipe( + take(1), + map((val) => { + if (val && isOnStateInitDefined(this)) { + this.ngrxOnStateInit(); + } + return val; + }), + catchError(() => EMPTY) + ) + .subscribe(); + } } function processSelectorArgs< From 976d88854394567e6ff28e8b0fe01fb5a24d6e76 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Sat, 7 May 2022 15:25:32 -0500 Subject: [PATCH 05/10] chore: added documentation for hooks, review feedback fixes --- .../spec/component-store.spec.ts | 10 ++--- .../component-store/src/component-store.ts | 40 ++++++++++++++----- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/modules/component-store/spec/component-store.spec.ts b/modules/component-store/spec/component-store.spec.ts index 4454686c3c..683fbc187e 100644 --- a/modules/component-store/spec/component-store.spec.ts +++ b/modules/component-store/spec/component-store.spec.ts @@ -1489,13 +1489,13 @@ describe('Component Store', () => { logs = []; }); - it('should call the OnInitStore lifecycle hook if defined', async () => { + it('should call the OnInitStore lifecycle hook if defined', () => { componentStore = new LifecycleStore({ init: true }); expect(logs[0]).toBe(onStoreInitMessage); }); - it('should only call the OnInitStore lifecycle hook once', async () => { + it('should only call the OnInitStore lifecycle hook once', () => { componentStore = new LifecycleStore({ init: true }); expect(logs[0]).toBe(onStoreInitMessage); @@ -1505,13 +1505,13 @@ describe('Component Store', () => { expect(logs.length).toBe(0); }); - it('should call the OnInitState lifecycle hook if defined and state is set eagerly', async () => { + it('should call the OnInitState lifecycle hook if defined and state is set eagerly', () => { componentStore = new LifecycleStore({ init: true }); expect(logs[1]).toBe(onStateInitMessage); }); - it('should call the OnInitState lifecycle hook if defined and after state is set lazily', async () => { + it('should call the OnInitState lifecycle hook if defined and after state is set lazily', () => { componentStore = new LifecycleStore(); expect(logs.length).toBe(1); @@ -1520,7 +1520,7 @@ describe('Component Store', () => { expect(logs[1]).toBe(onStateInitMessage); }); - it('should only call the OnInitStore lifecycle hook once', async () => { + it('should only call the OnInitStore lifecycle hook once', () => { componentStore = new LifecycleStore({ init: true }); expect(logs[1]).toBe(onStateInitMessage); diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index 8d4795e40c..15f417fd7e 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -20,6 +20,7 @@ import { shareReplay, take, catchError, + tap, } from 'rxjs/operators'; import { debounceSync } from './debounce-sync'; import { @@ -34,10 +35,19 @@ export interface SelectConfig { debounce?: boolean; } +/** + * The interface for the lifecycle hook + * called after the ComponentStore is instantiated. + */ export interface OnStoreInit { readonly ngrxOnStoreInit: () => void; } +/** + * The interface for the lifecycle hook + * called only once after the ComponentStore + * state is first initialized. + */ export interface OnStateInit { readonly ngrxOnStateInit: () => void; } @@ -72,14 +82,7 @@ export class ComponentStore implements OnDestroy { readonly state$: Observable = this.select((s) => s); // check/call store init hook - private readonly initStoreHook = this.effect(() => - of(null).pipe(($) => { - if (isOnStoreInitDefined(this)) { - this.ngrxOnStoreInit(); - } - return $; - }) - )(); + private readonly initStoreHook = this.callInitStoreHook(); // check/call state init hook on first emission of value private readonly initStateHook = this.callInitStateHook(); @@ -332,15 +335,30 @@ export class ComponentStore implements OnDestroy { }) as unknown as ReturnType; } - callInitStateHook() { + /** + * Checks to see if the OnInitStore + * hook is defined. If so, it calls + * the method. + */ + private callInitStoreHook() { + if (isOnStoreInitDefined(this)) { + this.ngrxOnStoreInit(); + } + } + + /** + * Checks to see if the OnInitState + * hook is defined. If so, it calls + * the method once after the state is first set. + */ + private callInitStateHook() { this.stateSubject$ .pipe( take(1), - map((val) => { + tap((val) => { if (val && isOnStateInitDefined(this)) { this.ngrxOnStateInit(); } - return val; }), catchError(() => EMPTY) ) From 00b5f70c559b0a6f0f78f05bce02014859c25d0a Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Wed, 11 May 2022 06:29:16 -0500 Subject: [PATCH 06/10] fix: add function/factory to provide component-store with hooks --- .../component-store/src/component-store.ts | 81 +++++++++++-------- .../auth/containers/login-page.component.ts | 9 ++- .../app/auth/containers/login-page.store.ts | 37 +++++++++ 3 files changed, 94 insertions(+), 33 deletions(-) create mode 100644 projects/example-app/src/app/auth/containers/login-page.store.ts diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index 15f417fd7e..0fd386347d 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -20,7 +20,6 @@ import { shareReplay, take, catchError, - tap, } from 'rxjs/operators'; import { debounceSync } from './debounce-sync'; import { @@ -29,25 +28,18 @@ import { Optional, InjectionToken, Inject, + Type, + inject, } from '@angular/core'; export interface SelectConfig { debounce?: boolean; } -/** - * The interface for the lifecycle hook - * called after the ComponentStore is instantiated. - */ export interface OnStoreInit { readonly ngrxOnStoreInit: () => void; } -/** - * The interface for the lifecycle hook - * called only once after the ComponentStore - * state is first initialized. - */ export interface OnStateInit { readonly ngrxOnStateInit: () => void; } @@ -81,11 +73,18 @@ export class ComponentStore implements OnDestroy { // Needs to be after destroy$ is declared because it's used in select. readonly state$: Observable = this.select((s) => s); - // check/call store init hook - private readonly initStoreHook = this.callInitStoreHook(); + // // check/call store init hook + // private readonly initStoreHook = this.effect(() => + // of(null).pipe(($) => { + // if (isOnStoreInitDefined(this)) { + // this.ngrxOnStoreInit(); + // } + // return $; + // }) + // )(); // check/call state init hook on first emission of value - private readonly initStateHook = this.callInitStateHook(); + // private readonly initStateHook = this.callInitStateHook(); constructor(@Optional() @Inject(INITIAL_STATE_TOKEN) defaultState?: T) { // State can be initialized either through constructor or setState. @@ -335,32 +334,20 @@ export class ComponentStore implements OnDestroy { }) as unknown as ReturnType; } - /** - * Checks to see if the OnInitStore - * hook is defined. If so, it calls - * the method. - */ - private callInitStoreHook() { - if (isOnStoreInitDefined(this)) { - this.ngrxOnStoreInit(); - } - } - - /** - * Checks to see if the OnInitState - * hook is defined. If so, it calls - * the method once after the state is first set. - */ - private callInitStateHook() { + callInitStateHook() { this.stateSubject$ .pipe( take(1), - tap((val) => { + map((val) => { if (val && isOnStateInitDefined(this)) { this.ngrxOnStateInit(); } + return val; }), - catchError(() => EMPTY) + catchError((e) => { + console.log(e); + return EMPTY; + }) ) .subscribe(); } @@ -408,3 +395,33 @@ function isOnStoreInitDefined(cs: unknown): cs is OnStoreInit { function isOnStateInitDefined(cs: unknown): cs is OnStateInit { return typeof (cs as OnStateInit).ngrxOnStateInit === 'function'; } + +const WITH_HOOKS = new InjectionToken[]>( + '@ngrx/component-store: ComponentStores with Hooks' +); + +export function provideWithHooks( + componentStoreClass: Type> +) { + return [ + { provide: WITH_HOOKS, multi: true, useClass: componentStoreClass }, + { + provide: componentStoreClass, + useFactory: () => { + const componentStore = inject(WITH_HOOKS).pop(); + + if (isOnStoreInitDefined(componentStore)) { + componentStore.ngrxOnStoreInit(); + } + + if (isOnStateInitDefined(componentStore)) { + componentStore.state$ + .pipe(take(1)) + .subscribe(() => componentStore.ngrxOnStateInit()); + } + + return componentStore; + }, + }, + ]; +} diff --git a/projects/example-app/src/app/auth/containers/login-page.component.ts b/projects/example-app/src/app/auth/containers/login-page.component.ts index 3d76b23df1..a0c6b8f652 100644 --- a/projects/example-app/src/app/auth/containers/login-page.component.ts +++ b/projects/example-app/src/app/auth/containers/login-page.component.ts @@ -3,6 +3,8 @@ import { Store } from '@ngrx/store'; import { Credentials } from '@example-app/auth/models'; import * as fromAuth from '@example-app/auth/reducers'; import { LoginPageActions } from '@example-app/auth/actions'; +import { LoginPageStore } from './login-page.store'; +import { provideWithHooks } from '@ngrx/component-store'; @Component({ selector: 'bc-login-page', @@ -15,12 +17,17 @@ import { LoginPageActions } from '@example-app/auth/actions'; `, styles: [], + providers: [provideWithHooks(LoginPageStore)], }) export class LoginPageComponent { pending$ = this.store.select(fromAuth.selectLoginPagePending); error$ = this.store.select(fromAuth.selectLoginPageError); - constructor(private store: Store) {} + constructor(private store: Store, private lg: LoginPageStore) {} + + ngOnInit() { + this.lg.setState({ init: true }); + } onSubmit(credentials: Credentials) { this.store.dispatch(LoginPageActions.login({ credentials })); diff --git a/projects/example-app/src/app/auth/containers/login-page.store.ts b/projects/example-app/src/app/auth/containers/login-page.store.ts new file mode 100644 index 0000000000..a0b3309d91 --- /dev/null +++ b/projects/example-app/src/app/auth/containers/login-page.store.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { + ComponentStore, + OnStoreInit, + OnStateInit, +} from '@ngrx/component-store'; +import { tap } from 'rxjs'; + +interface LifeCycle { + init: boolean; +} + +@Injectable() +export class LoginPageStore + extends ComponentStore + implements OnStoreInit, OnStateInit +{ + constructor() // private readonly service: Service + { + super({ init: true }); + } + + logEffect = this.effect(tap(console.log)); + + ngrxOnStoreInit() { + console.log('onInitStore'); + // console.log('service', this.service); // undefined + console.log('log effect', this.logEffect('one')); // undefined + // console.log('effect') + } + + ngrxOnStateInit() { + console.log('onInitState'); + // console.log('service', this.service); // undefined + console.log('log effect', this.logEffect('two')); // undefined + } +} From 867d25f1fcf08863dcaefe614e094b174e1fcd79 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Fri, 13 May 2022 22:56:08 -0500 Subject: [PATCH 07/10] fix: add provideComponentStore function for hook logic and update tests --- .../spec/component-store.spec.ts | 68 +++++++++++-------- .../component-store/src/component-store.ts | 65 ++++++------------ .../auth/containers/login-page.component.ts | 9 +-- .../app/auth/containers/login-page.store.ts | 37 ---------- 4 files changed, 61 insertions(+), 118 deletions(-) delete mode 100644 projects/example-app/src/app/auth/containers/login-page.store.ts diff --git a/modules/component-store/spec/component-store.spec.ts b/modules/component-store/spec/component-store.spec.ts index 683fbc187e..c616d5b697 100644 --- a/modules/component-store/spec/component-store.spec.ts +++ b/modules/component-store/spec/component-store.spec.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { ComponentStore, + INITIAL_STATE_TOKEN, OnStateInit, OnStoreInit, + provideComponentStore, } from '@ngrx/component-store'; import { fakeSchedulers, marbles } from 'rxjs-marbles/jest'; import { @@ -28,6 +30,7 @@ import { concatMap, } from 'rxjs/operators'; import { createSelector } from '@ngrx/store'; +import { Inject, Injectable, InjectionToken, Injector } from '@angular/core'; describe('Component Store', () => { describe('initialization', () => { @@ -1459,75 +1462,86 @@ describe('Component Store', () => { const onStoreInitMessage = 'on store init called'; const onStateInitMessage = 'on state init called'; - let logs: string[] = []; + + const INIT_STATE = new InjectionToken('Init State'); + + @Injectable() class LifecycleStore extends ComponentStore implements OnStoreInit, OnStateInit { - constructor(state?: LifeCycle) { + logs: string[] = []; + constructor(@Inject(INIT_STATE) state?: LifeCycle) { super(state); } logEffect = this.effect( tap(() => { - logs.push('effect'); + this.logs.push('effect'); }) ); ngrxOnStoreInit() { - logs.push(onStoreInitMessage); + this.logs.push(onStoreInitMessage); } ngrxOnStateInit() { - logs.push(onStateInitMessage); + this.logs.push(onStateInitMessage); } } - let componentStore: LifecycleStore; + function setup(initialState?: LifeCycle) { + const injector = Injector.create({ + providers: [ + { provide: INIT_STATE, useValue: initialState }, + provideComponentStore(LifecycleStore), + ], + }); - beforeEach(() => { - logs = []; - }); + return { + store: injector.get(LifecycleStore), + }; + } it('should call the OnInitStore lifecycle hook if defined', () => { - componentStore = new LifecycleStore({ init: true }); + const state = setup({ init: true }); - expect(logs[0]).toBe(onStoreInitMessage); + expect(state.store.logs[0]).toBe(onStoreInitMessage); }); it('should only call the OnInitStore lifecycle hook once', () => { - componentStore = new LifecycleStore({ init: true }); - expect(logs[0]).toBe(onStoreInitMessage); + const state = setup({ init: true }); + expect(state.store.logs[0]).toBe(onStoreInitMessage); - logs = []; - componentStore.setState({ init: false }); + state.store.logs = []; + state.store.setState({ init: false }); - expect(logs.length).toBe(0); + expect(state.store.logs.length).toBe(0); }); it('should call the OnInitState lifecycle hook if defined and state is set eagerly', () => { - componentStore = new LifecycleStore({ init: true }); + const state = setup({ init: true }); - expect(logs[1]).toBe(onStateInitMessage); + expect(state.store.logs[1]).toBe(onStateInitMessage); }); it('should call the OnInitState lifecycle hook if defined and after state is set lazily', () => { - componentStore = new LifecycleStore(); - expect(logs.length).toBe(1); + const state = setup(); + expect(state.store.logs.length).toBe(1); - componentStore.setState({ init: true }); + state.store.setState({ init: true }); - expect(logs[1]).toBe(onStateInitMessage); + expect(state.store.logs[1]).toBe(onStateInitMessage); }); it('should only call the OnInitStore lifecycle hook once', () => { - componentStore = new LifecycleStore({ init: true }); + const state = setup({ init: true }); - expect(logs[1]).toBe(onStateInitMessage); - logs = []; - componentStore.setState({ init: false }); + expect(state.store.logs[1]).toBe(onStateInitMessage); + state.store.logs = []; + state.store.setState({ init: false }); - expect(logs.length).toBe(0); + expect(state.store.logs.length).toBe(0); }); }); }); diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index 0fd386347d..079f16653c 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -9,7 +9,6 @@ import { Subject, queueScheduler, scheduled, - EMPTY, } from 'rxjs'; import { concatMap, @@ -19,7 +18,6 @@ import { distinctUntilChanged, shareReplay, take, - catchError, } from 'rxjs/operators'; import { debounceSync } from './debounce-sync'; import { @@ -73,19 +71,6 @@ export class ComponentStore implements OnDestroy { // Needs to be after destroy$ is declared because it's used in select. readonly state$: Observable = this.select((s) => s); - // // check/call store init hook - // private readonly initStoreHook = this.effect(() => - // of(null).pipe(($) => { - // if (isOnStoreInitDefined(this)) { - // this.ngrxOnStoreInit(); - // } - // return $; - // }) - // )(); - - // check/call state init hook on first emission of value - // private readonly initStateHook = this.callInitStateHook(); - constructor(@Optional() @Inject(INITIAL_STATE_TOKEN) defaultState?: T) { // State can be initialized either through constructor or setState. if (defaultState) { @@ -333,24 +318,6 @@ export class ComponentStore implements OnDestroy { }); }) as unknown as ReturnType; } - - callInitStateHook() { - this.stateSubject$ - .pipe( - take(1), - map((val) => { - if (val && isOnStateInitDefined(this)) { - this.ngrxOnStateInit(); - } - return val; - }), - catchError((e) => { - console.log(e); - return EMPTY; - }) - ) - .subscribe(); - } } function processSelectorArgs< @@ -400,7 +367,7 @@ const WITH_HOOKS = new InjectionToken[]>( '@ngrx/component-store: ComponentStores with Hooks' ); -export function provideWithHooks( +export function provideComponentStore( componentStoreClass: Type> ) { return [ @@ -408,19 +375,25 @@ export function provideWithHooks( { provide: componentStoreClass, useFactory: () => { - const componentStore = inject(WITH_HOOKS).pop(); - - if (isOnStoreInitDefined(componentStore)) { - componentStore.ngrxOnStoreInit(); - } - - if (isOnStateInitDefined(componentStore)) { - componentStore.state$ - .pipe(take(1)) - .subscribe(() => componentStore.ngrxOnStateInit()); - } + const componentStores = inject(WITH_HOOKS); + let instance; + componentStores.forEach((componentStore) => { + if (componentStore instanceof componentStoreClass) { + instance = componentStore; + + if (isOnStoreInitDefined(componentStore)) { + componentStore.ngrxOnStoreInit(); + } + + if (isOnStateInitDefined(componentStore)) { + componentStore.state$ + .pipe(take(1)) + .subscribe(() => componentStore.ngrxOnStateInit()); + } + } + }); - return componentStore; + return instance; }, }, ]; diff --git a/projects/example-app/src/app/auth/containers/login-page.component.ts b/projects/example-app/src/app/auth/containers/login-page.component.ts index a0c6b8f652..3d76b23df1 100644 --- a/projects/example-app/src/app/auth/containers/login-page.component.ts +++ b/projects/example-app/src/app/auth/containers/login-page.component.ts @@ -3,8 +3,6 @@ import { Store } from '@ngrx/store'; import { Credentials } from '@example-app/auth/models'; import * as fromAuth from '@example-app/auth/reducers'; import { LoginPageActions } from '@example-app/auth/actions'; -import { LoginPageStore } from './login-page.store'; -import { provideWithHooks } from '@ngrx/component-store'; @Component({ selector: 'bc-login-page', @@ -17,17 +15,12 @@ import { provideWithHooks } from '@ngrx/component-store'; `, styles: [], - providers: [provideWithHooks(LoginPageStore)], }) export class LoginPageComponent { pending$ = this.store.select(fromAuth.selectLoginPagePending); error$ = this.store.select(fromAuth.selectLoginPageError); - constructor(private store: Store, private lg: LoginPageStore) {} - - ngOnInit() { - this.lg.setState({ init: true }); - } + constructor(private store: Store) {} onSubmit(credentials: Credentials) { this.store.dispatch(LoginPageActions.login({ credentials })); diff --git a/projects/example-app/src/app/auth/containers/login-page.store.ts b/projects/example-app/src/app/auth/containers/login-page.store.ts deleted file mode 100644 index a0b3309d91..0000000000 --- a/projects/example-app/src/app/auth/containers/login-page.store.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Injectable } from '@angular/core'; -import { - ComponentStore, - OnStoreInit, - OnStateInit, -} from '@ngrx/component-store'; -import { tap } from 'rxjs'; - -interface LifeCycle { - init: boolean; -} - -@Injectable() -export class LoginPageStore - extends ComponentStore - implements OnStoreInit, OnStateInit -{ - constructor() // private readonly service: Service - { - super({ init: true }); - } - - logEffect = this.effect(tap(console.log)); - - ngrxOnStoreInit() { - console.log('onInitStore'); - // console.log('service', this.service); // undefined - console.log('log effect', this.logEffect('one')); // undefined - // console.log('effect') - } - - ngrxOnStateInit() { - console.log('onInitState'); - // console.log('service', this.service); // undefined - console.log('log effect', this.logEffect('two')); // undefined - } -} From 86d19dd57aef0e786abaa3b7c410e388997d028b Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Tue, 17 May 2022 20:29:35 -0500 Subject: [PATCH 08/10] fix: create injection token inside provideComponentStore --- .../spec/component-store.spec.ts | 43 ++++++++++++--- .../component-store/src/component-store.ts | 54 ------------------- modules/component-store/src/index.ts | 1 + .../component-store/src/lifecycle_hooks.ts | 49 +++++++++++++++++ 4 files changed, 86 insertions(+), 61 deletions(-) create mode 100644 modules/component-store/src/lifecycle_hooks.ts diff --git a/modules/component-store/spec/component-store.spec.ts b/modules/component-store/spec/component-store.spec.ts index c616d5b697..677b1dd0b1 100644 --- a/modules/component-store/spec/component-store.spec.ts +++ b/modules/component-store/spec/component-store.spec.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { ComponentStore, - INITIAL_STATE_TOKEN, OnStateInit, OnStoreInit, provideComponentStore, @@ -30,7 +29,13 @@ import { concatMap, } from 'rxjs/operators'; import { createSelector } from '@ngrx/store'; -import { Inject, Injectable, InjectionToken, Injector } from '@angular/core'; +import { + Inject, + Injectable, + InjectionToken, + Injector, + Provider, +} from '@angular/core'; describe('Component Store', () => { describe('initialization', () => { @@ -1490,27 +1495,39 @@ describe('Component Store', () => { } } - function setup(initialState?: LifeCycle) { + @Injectable() + class ExtraStore extends LifecycleStore { + constructor() { + super(); + } + } + + function setup({ + initialState, + providers = [], + }: { initialState?: LifeCycle; providers?: Provider[] } = {}) { const injector = Injector.create({ providers: [ { provide: INIT_STATE, useValue: initialState }, provideComponentStore(LifecycleStore), + providers, ], }); return { store: injector.get(LifecycleStore), + injector, }; } it('should call the OnInitStore lifecycle hook if defined', () => { - const state = setup({ init: true }); + const state = setup({ initialState: { init: true } }); expect(state.store.logs[0]).toBe(onStoreInitMessage); }); it('should only call the OnInitStore lifecycle hook once', () => { - const state = setup({ init: true }); + const state = setup({ initialState: { init: true } }); expect(state.store.logs[0]).toBe(onStoreInitMessage); state.store.logs = []; @@ -1520,7 +1537,7 @@ describe('Component Store', () => { }); it('should call the OnInitState lifecycle hook if defined and state is set eagerly', () => { - const state = setup({ init: true }); + const state = setup({ initialState: { init: true } }); expect(state.store.logs[1]).toBe(onStateInitMessage); }); @@ -1535,7 +1552,7 @@ describe('Component Store', () => { }); it('should only call the OnInitStore lifecycle hook once', () => { - const state = setup({ init: true }); + const state = setup({ initialState: { init: true } }); expect(state.store.logs[1]).toBe(onStateInitMessage); state.store.logs = []; @@ -1543,5 +1560,17 @@ describe('Component Store', () => { expect(state.store.logs.length).toBe(0); }); + + it('works with multiple stores where one extends the other', () => { + const state = setup({ + providers: [provideComponentStore(ExtraStore)], + }); + + const lifecycleStore = state.store; + const extraStore = state.injector.get(ExtraStore); + + expect(lifecycleStore).toBeDefined(); + expect(extraStore).toBeDefined(); + }); }); }); diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index 079f16653c..a11bb689a6 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -26,22 +26,12 @@ import { Optional, InjectionToken, Inject, - Type, - inject, } from '@angular/core'; export interface SelectConfig { debounce?: boolean; } -export interface OnStoreInit { - readonly ngrxOnStoreInit: () => void; -} - -export interface OnStateInit { - readonly ngrxOnStateInit: () => void; -} - export const INITIAL_STATE_TOKEN = new InjectionToken( '@ngrx/component-store Initial State' ); @@ -354,47 +344,3 @@ function processSelectorArgs< config, }; } - -function isOnStoreInitDefined(cs: unknown): cs is OnStoreInit { - return typeof (cs as OnStoreInit).ngrxOnStoreInit === 'function'; -} - -function isOnStateInitDefined(cs: unknown): cs is OnStateInit { - return typeof (cs as OnStateInit).ngrxOnStateInit === 'function'; -} - -const WITH_HOOKS = new InjectionToken[]>( - '@ngrx/component-store: ComponentStores with Hooks' -); - -export function provideComponentStore( - componentStoreClass: Type> -) { - return [ - { provide: WITH_HOOKS, multi: true, useClass: componentStoreClass }, - { - provide: componentStoreClass, - useFactory: () => { - const componentStores = inject(WITH_HOOKS); - let instance; - componentStores.forEach((componentStore) => { - if (componentStore instanceof componentStoreClass) { - instance = componentStore; - - if (isOnStoreInitDefined(componentStore)) { - componentStore.ngrxOnStoreInit(); - } - - if (isOnStateInitDefined(componentStore)) { - componentStore.state$ - .pipe(take(1)) - .subscribe(() => componentStore.ngrxOnStateInit()); - } - } - }); - - return instance; - }, - }, - ]; -} diff --git a/modules/component-store/src/index.ts b/modules/component-store/src/index.ts index 853d621ab8..552b245cce 100644 --- a/modules/component-store/src/index.ts +++ b/modules/component-store/src/index.ts @@ -1,2 +1,3 @@ export * from './component-store'; export * from './tap-response'; +export * from './lifecycle_hooks'; diff --git a/modules/component-store/src/lifecycle_hooks.ts b/modules/component-store/src/lifecycle_hooks.ts new file mode 100644 index 0000000000..3ce1ff8706 --- /dev/null +++ b/modules/component-store/src/lifecycle_hooks.ts @@ -0,0 +1,49 @@ +import { Provider, InjectionToken, Type, inject } from '@angular/core'; +import { take } from 'rxjs'; +import { ComponentStore } from './component-store'; + +export interface OnStoreInit { + readonly ngrxOnStoreInit: () => void; +} + +export interface OnStateInit { + readonly ngrxOnStateInit: () => void; +} + +export function isOnStoreInitDefined(cs: unknown): cs is OnStoreInit { + return typeof (cs as OnStoreInit).ngrxOnStoreInit === 'function'; +} + +export function isOnStateInitDefined(cs: unknown): cs is OnStateInit { + return typeof (cs as OnStateInit).ngrxOnStateInit === 'function'; +} + +export function provideComponentStore( + componentStoreClass: Type> +): Provider[] { + const CS_WITH_HOOKS = new InjectionToken>( + '@ngrx/component-store ComponentStore with Hooks' + ); + + return [ + { provide: CS_WITH_HOOKS, useClass: componentStoreClass }, + { + provide: componentStoreClass, + useFactory: () => { + const componentStore = inject(CS_WITH_HOOKS); + + if (isOnStoreInitDefined(componentStore)) { + componentStore.ngrxOnStoreInit(); + } + + if (isOnStateInitDefined(componentStore)) { + componentStore.state$ + .pipe(take(1)) + .subscribe(() => componentStore.ngrxOnStateInit()); + } + + return componentStore; + }, + }, + ]; +} From 8c109c7ebd803d9bb17380773098e3e0065224db Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Wed, 18 May 2022 07:13:22 -0500 Subject: [PATCH 09/10] chore: make hook checks private --- modules/component-store/src/lifecycle_hooks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/component-store/src/lifecycle_hooks.ts b/modules/component-store/src/lifecycle_hooks.ts index 3ce1ff8706..1446ba1456 100644 --- a/modules/component-store/src/lifecycle_hooks.ts +++ b/modules/component-store/src/lifecycle_hooks.ts @@ -10,11 +10,11 @@ export interface OnStateInit { readonly ngrxOnStateInit: () => void; } -export function isOnStoreInitDefined(cs: unknown): cs is OnStoreInit { +function isOnStoreInitDefined(cs: unknown): cs is OnStoreInit { return typeof (cs as OnStoreInit).ngrxOnStoreInit === 'function'; } -export function isOnStateInitDefined(cs: unknown): cs is OnStateInit { +function isOnStateInitDefined(cs: unknown): cs is OnStateInit { return typeof (cs as OnStateInit).ngrxOnStateInit === 'function'; } From 247f6e089a1d955e61d9841f80a73ed32d840564 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Thu, 19 May 2022 06:13:31 -0500 Subject: [PATCH 10/10] chore: cleaned up types and added docs --- .../component-store/src/lifecycle_hooks.ts | 73 ++++++++++++++++++- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/modules/component-store/src/lifecycle_hooks.ts b/modules/component-store/src/lifecycle_hooks.ts index 1446ba1456..026c2aae5e 100644 --- a/modules/component-store/src/lifecycle_hooks.ts +++ b/modules/component-store/src/lifecycle_hooks.ts @@ -2,26 +2,93 @@ import { Provider, InjectionToken, Type, inject } from '@angular/core'; import { take } from 'rxjs'; import { ComponentStore } from './component-store'; +/** + * The interface for the lifecycle hook + * called after the ComponentStore is instantiated. + */ export interface OnStoreInit { readonly ngrxOnStoreInit: () => void; } +/** + * The interface for the lifecycle hook + * called only once after the ComponentStore + * state is first initialized. + */ export interface OnStateInit { readonly ngrxOnStateInit: () => void; } +/** + * Checks to see if the OnInitStore lifecycle hook + * is defined on the ComponentStore. + * + * @param cs ComponentStore type + * @returns boolean + */ function isOnStoreInitDefined(cs: unknown): cs is OnStoreInit { return typeof (cs as OnStoreInit).ngrxOnStoreInit === 'function'; } +/** + * Checks to see if the OnInitState lifecycle hook + * is defined on the ComponentStore. + * + * @param cs ComponentStore type + * @returns boolean + */ function isOnStateInitDefined(cs: unknown): cs is OnStateInit { return typeof (cs as OnStateInit).ngrxOnStateInit === 'function'; } -export function provideComponentStore( - componentStoreClass: Type> +/** + * @description + * + * Function that returns the ComponentStore + * class registered as a provider, + * and uses a factory provider to instantiate the + * ComponentStore and run the lifecycle hooks + * defined on the ComponentStore. + * + * @param componentStoreClass The ComponentStore with lifecycle hooks + * @returns Provider[] + * + * @usageNotes + * + * ```ts + * @Injectable() + * export class MyStore + * extends ComponentStore<{ init: boolean }> + * implements OnStoreInit, OnStateInit + * { + * + * constructor() { + * super({ init: true }); + * } + * + * ngrxOnStoreInit() { + * // runs once after store has been instantiated + * } + * + * ngrxOnStateInit() { + * // runs once after store state has been initialized + * } + * } + * + * @Component({ + * providers: [ + * provideComponentStore(MyStore) + * ] + * }) + * export class MyComponent { + * constructor(private myStore: MyStore) {} + * } + * ``` + */ +export function provideComponentStore( + componentStoreClass: Type> ): Provider[] { - const CS_WITH_HOOKS = new InjectionToken>( + const CS_WITH_HOOKS = new InjectionToken>( '@ngrx/component-store ComponentStore with Hooks' );