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): add OnStoreInit and OnStateInit lifecycle hooks #3368

Merged
merged 10 commits into from
May 20, 2022
128 changes: 127 additions & 1 deletion modules/component-store/spec/component-store.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ComponentStore } from '@ngrx/component-store';
import {
ComponentStore,
OnStateInit,
OnStoreInit,
provideComponentStore,
} from '@ngrx/component-store';
import { fakeSchedulers, marbles } from 'rxjs-marbles/jest';
import {
of,
Expand All @@ -24,6 +29,13 @@ import {
concatMap,
} from 'rxjs/operators';
import { createSelector } from '@ngrx/store';
import {
Inject,
Injectable,
InjectionToken,
Injector,
Provider,
} from '@angular/core';

describe('Component Store', () => {
describe('initialization', () => {
Expand Down Expand Up @@ -1447,4 +1459,118 @@ 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';

const INIT_STATE = new InjectionToken('Init State');

@Injectable()
class LifecycleStore
extends ComponentStore<LifeCycle>
implements OnStoreInit, OnStateInit
{
logs: string[] = [];
constructor(@Inject(INIT_STATE) state?: LifeCycle) {
super(state);
}

logEffect = this.effect(
tap<void>(() => {
this.logs.push('effect');
})
);

ngrxOnStoreInit() {
this.logs.push(onStoreInitMessage);
}

ngrxOnStateInit() {
this.logs.push(onStateInitMessage);
}
}

@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({ initialState: { init: true } });

expect(state.store.logs[0]).toBe(onStoreInitMessage);
});

it('should only call the OnInitStore lifecycle hook once', () => {
const state = setup({ initialState: { init: true } });
expect(state.store.logs[0]).toBe(onStoreInitMessage);

state.store.logs = [];
state.store.setState({ init: false });

expect(state.store.logs.length).toBe(0);
});

it('should call the OnInitState lifecycle hook if defined and state is set eagerly', () => {
const state = setup({ initialState: { init: true } });

expect(state.store.logs[1]).toBe(onStateInitMessage);
});

it('should call the OnInitState lifecycle hook if defined and after state is set lazily', () => {
const state = setup();
expect(state.store.logs.length).toBe(1);

state.store.setState({ init: true });

expect(state.store.logs[1]).toBe(onStateInitMessage);
});

it('should only call the OnInitStore lifecycle hook once', () => {
const state = setup({ initialState: { init: true } });

expect(state.store.logs[1]).toBe(onStateInitMessage);
state.store.logs = [];
state.store.setState({ init: false });

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();
});
});
});
1 change: 1 addition & 0 deletions modules/component-store/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './component-store';
export * from './tap-response';
export * from './lifecycle_hooks';
116 changes: 116 additions & 0 deletions modules/component-store/src/lifecycle_hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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';
}

/**
* @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<T extends object>(
componentStoreClass: Type<ComponentStore<T>>
): Provider[] {
const CS_WITH_HOOKS = new InjectionToken<ComponentStore<T>>(
'@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;
},
},
];
}