Skip to content

Commit 0ffed02

Browse files
feat(component-store): add OnStoreInit and OnStateInit lifecycle hooks (#3368)
Closes #3335
1 parent 345ee53 commit 0ffed02

File tree

3 files changed

+244
-1
lines changed

3 files changed

+244
-1
lines changed

modules/component-store/spec/component-store.spec.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
/* eslint-disable @typescript-eslint/no-non-null-assertion */
2-
import { ComponentStore } from '@ngrx/component-store';
2+
import {
3+
ComponentStore,
4+
OnStateInit,
5+
OnStoreInit,
6+
provideComponentStore,
7+
} from '@ngrx/component-store';
38
import { fakeSchedulers, marbles } from 'rxjs-marbles/jest';
49
import {
510
of,
@@ -24,6 +29,13 @@ import {
2429
concatMap,
2530
} from 'rxjs/operators';
2631
import { createSelector } from '@ngrx/store';
32+
import {
33+
Inject,
34+
Injectable,
35+
InjectionToken,
36+
Injector,
37+
Provider,
38+
} from '@angular/core';
2739

2840
describe('Component Store', () => {
2941
describe('initialization', () => {
@@ -1447,4 +1459,118 @@ describe('Component Store', () => {
14471459
expect(componentStore.get()).toEqual({ value: 'updated' });
14481460
});
14491461
});
1462+
1463+
describe('lifecycle hooks', () => {
1464+
interface LifeCycle {
1465+
init: boolean;
1466+
}
1467+
1468+
const onStoreInitMessage = 'on store init called';
1469+
const onStateInitMessage = 'on state init called';
1470+
1471+
const INIT_STATE = new InjectionToken('Init State');
1472+
1473+
@Injectable()
1474+
class LifecycleStore
1475+
extends ComponentStore<LifeCycle>
1476+
implements OnStoreInit, OnStateInit
1477+
{
1478+
logs: string[] = [];
1479+
constructor(@Inject(INIT_STATE) state?: LifeCycle) {
1480+
super(state);
1481+
}
1482+
1483+
logEffect = this.effect(
1484+
tap<void>(() => {
1485+
this.logs.push('effect');
1486+
})
1487+
);
1488+
1489+
ngrxOnStoreInit() {
1490+
this.logs.push(onStoreInitMessage);
1491+
}
1492+
1493+
ngrxOnStateInit() {
1494+
this.logs.push(onStateInitMessage);
1495+
}
1496+
}
1497+
1498+
@Injectable()
1499+
class ExtraStore extends LifecycleStore {
1500+
constructor() {
1501+
super();
1502+
}
1503+
}
1504+
1505+
function setup({
1506+
initialState,
1507+
providers = [],
1508+
}: { initialState?: LifeCycle; providers?: Provider[] } = {}) {
1509+
const injector = Injector.create({
1510+
providers: [
1511+
{ provide: INIT_STATE, useValue: initialState },
1512+
provideComponentStore(LifecycleStore),
1513+
providers,
1514+
],
1515+
});
1516+
1517+
return {
1518+
store: injector.get(LifecycleStore),
1519+
injector,
1520+
};
1521+
}
1522+
1523+
it('should call the OnInitStore lifecycle hook if defined', () => {
1524+
const state = setup({ initialState: { init: true } });
1525+
1526+
expect(state.store.logs[0]).toBe(onStoreInitMessage);
1527+
});
1528+
1529+
it('should only call the OnInitStore lifecycle hook once', () => {
1530+
const state = setup({ initialState: { init: true } });
1531+
expect(state.store.logs[0]).toBe(onStoreInitMessage);
1532+
1533+
state.store.logs = [];
1534+
state.store.setState({ init: false });
1535+
1536+
expect(state.store.logs.length).toBe(0);
1537+
});
1538+
1539+
it('should call the OnInitState lifecycle hook if defined and state is set eagerly', () => {
1540+
const state = setup({ initialState: { init: true } });
1541+
1542+
expect(state.store.logs[1]).toBe(onStateInitMessage);
1543+
});
1544+
1545+
it('should call the OnInitState lifecycle hook if defined and after state is set lazily', () => {
1546+
const state = setup();
1547+
expect(state.store.logs.length).toBe(1);
1548+
1549+
state.store.setState({ init: true });
1550+
1551+
expect(state.store.logs[1]).toBe(onStateInitMessage);
1552+
});
1553+
1554+
it('should only call the OnInitStore lifecycle hook once', () => {
1555+
const state = setup({ initialState: { init: true } });
1556+
1557+
expect(state.store.logs[1]).toBe(onStateInitMessage);
1558+
state.store.logs = [];
1559+
state.store.setState({ init: false });
1560+
1561+
expect(state.store.logs.length).toBe(0);
1562+
});
1563+
1564+
it('works with multiple stores where one extends the other', () => {
1565+
const state = setup({
1566+
providers: [provideComponentStore(ExtraStore)],
1567+
});
1568+
1569+
const lifecycleStore = state.store;
1570+
const extraStore = state.injector.get(ExtraStore);
1571+
1572+
expect(lifecycleStore).toBeDefined();
1573+
expect(extraStore).toBeDefined();
1574+
});
1575+
});
14501576
});

modules/component-store/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './component-store';
22
export * from './tap-response';
3+
export * from './lifecycle_hooks';
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { Provider, InjectionToken, Type, inject } from '@angular/core';
2+
import { take } from 'rxjs';
3+
import { ComponentStore } from './component-store';
4+
5+
/**
6+
* The interface for the lifecycle hook
7+
* called after the ComponentStore is instantiated.
8+
*/
9+
export interface OnStoreInit {
10+
readonly ngrxOnStoreInit: () => void;
11+
}
12+
13+
/**
14+
* The interface for the lifecycle hook
15+
* called only once after the ComponentStore
16+
* state is first initialized.
17+
*/
18+
export interface OnStateInit {
19+
readonly ngrxOnStateInit: () => void;
20+
}
21+
22+
/**
23+
* Checks to see if the OnInitStore lifecycle hook
24+
* is defined on the ComponentStore.
25+
*
26+
* @param cs ComponentStore type
27+
* @returns boolean
28+
*/
29+
function isOnStoreInitDefined(cs: unknown): cs is OnStoreInit {
30+
return typeof (cs as OnStoreInit).ngrxOnStoreInit === 'function';
31+
}
32+
33+
/**
34+
* Checks to see if the OnInitState lifecycle hook
35+
* is defined on the ComponentStore.
36+
*
37+
* @param cs ComponentStore type
38+
* @returns boolean
39+
*/
40+
function isOnStateInitDefined(cs: unknown): cs is OnStateInit {
41+
return typeof (cs as OnStateInit).ngrxOnStateInit === 'function';
42+
}
43+
44+
/**
45+
* @description
46+
*
47+
* Function that returns the ComponentStore
48+
* class registered as a provider,
49+
* and uses a factory provider to instantiate the
50+
* ComponentStore and run the lifecycle hooks
51+
* defined on the ComponentStore.
52+
*
53+
* @param componentStoreClass The ComponentStore with lifecycle hooks
54+
* @returns Provider[]
55+
*
56+
* @usageNotes
57+
*
58+
* ```ts
59+
* @Injectable()
60+
* export class MyStore
61+
* extends ComponentStore<{ init: boolean }>
62+
* implements OnStoreInit, OnStateInit
63+
* {
64+
*
65+
* constructor() {
66+
* super({ init: true });
67+
* }
68+
*
69+
* ngrxOnStoreInit() {
70+
* // runs once after store has been instantiated
71+
* }
72+
*
73+
* ngrxOnStateInit() {
74+
* // runs once after store state has been initialized
75+
* }
76+
* }
77+
*
78+
* @Component({
79+
* providers: [
80+
* provideComponentStore(MyStore)
81+
* ]
82+
* })
83+
* export class MyComponent {
84+
* constructor(private myStore: MyStore) {}
85+
* }
86+
* ```
87+
*/
88+
export function provideComponentStore<T extends object>(
89+
componentStoreClass: Type<ComponentStore<T>>
90+
): Provider[] {
91+
const CS_WITH_HOOKS = new InjectionToken<ComponentStore<T>>(
92+
'@ngrx/component-store ComponentStore with Hooks'
93+
);
94+
95+
return [
96+
{ provide: CS_WITH_HOOKS, useClass: componentStoreClass },
97+
{
98+
provide: componentStoreClass,
99+
useFactory: () => {
100+
const componentStore = inject(CS_WITH_HOOKS);
101+
102+
if (isOnStoreInitDefined(componentStore)) {
103+
componentStore.ngrxOnStoreInit();
104+
}
105+
106+
if (isOnStateInitDefined(componentStore)) {
107+
componentStore.state$
108+
.pipe(take(1))
109+
.subscribe(() => componentStore.ngrxOnStateInit());
110+
}
111+
112+
return componentStore;
113+
},
114+
},
115+
];
116+
}

0 commit comments

Comments
 (0)