Skip to content

Commit db35bfe

Browse files
feat(effects): add provideEffects function (#3524)
Closes #3522
1 parent 6b0db4e commit db35bfe

11 files changed

+285
-32
lines changed

modules/effects/spec/effects_root_module.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
22
import { INIT, Store, StoreModule } from '@ngrx/store';
33

44
import { EffectsModule } from '../src/effects_module';
5-
import { ROOT_EFFECTS_INIT } from '../src/effects_root_module';
5+
import { ROOT_EFFECTS_INIT } from '../src/effects_actions';
66

77
describe('Effects Root Module', () => {
88
const foo = 'foo';
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { ENVIRONMENT_INITIALIZER, inject, Injectable } from '@angular/core';
2+
import { TestBed } from '@angular/core/testing';
3+
import { map } from 'rxjs';
4+
import {
5+
createAction,
6+
createFeatureSelector,
7+
createReducer,
8+
props,
9+
provideState,
10+
provideStore,
11+
Store,
12+
} from '@ngrx/store';
13+
import {
14+
Actions,
15+
concatLatestFrom,
16+
createEffect,
17+
EffectsRunner,
18+
ofType,
19+
provideEffects,
20+
rootEffectsInit,
21+
} from '../src/index';
22+
23+
describe('provideEffects', () => {
24+
it('starts effects runner when called first time', () => {
25+
TestBed.configureTestingModule({
26+
providers: [
27+
{
28+
provide: ENVIRONMENT_INITIALIZER,
29+
multi: true,
30+
useValue: () => jest.spyOn(inject(EffectsRunner), 'start'),
31+
},
32+
provideStore({}).ɵproviders,
33+
// provide effects twice
34+
provideEffects([]).ɵproviders,
35+
provideEffects([]).ɵproviders,
36+
],
37+
});
38+
39+
const effectsRunner = TestBed.inject(EffectsRunner);
40+
expect(effectsRunner.start).toHaveBeenCalledTimes(1);
41+
});
42+
43+
it('dispatches effects init action when called first time', () => {
44+
TestBed.configureTestingModule({
45+
providers: [
46+
{
47+
provide: ENVIRONMENT_INITIALIZER,
48+
multi: true,
49+
useValue: () => jest.spyOn(inject(Store), 'dispatch'),
50+
},
51+
provideStore().ɵproviders,
52+
// provide effects twice
53+
provideEffects([]).ɵproviders,
54+
provideEffects([]).ɵproviders,
55+
],
56+
});
57+
58+
const store = TestBed.inject(Store);
59+
expect(store.dispatch).toHaveBeenCalledWith(rootEffectsInit());
60+
expect(store.dispatch).toHaveBeenCalledTimes(1);
61+
});
62+
63+
it('throws an error when store is not provided', () => {
64+
TestBed.configureTestingModule({
65+
// provide only effects
66+
providers: [provideEffects([TestEffects]).ɵproviders],
67+
});
68+
69+
expect(() => TestBed.inject(TestEffects)).toThrowError();
70+
});
71+
72+
it('runs provided effects', (done) => {
73+
TestBed.configureTestingModule({
74+
providers: [
75+
provideStore().ɵproviders,
76+
provideEffects([TestEffects]).ɵproviders,
77+
],
78+
});
79+
80+
const store = TestBed.inject(Store);
81+
const effects = TestBed.inject(TestEffects);
82+
83+
effects.simpleEffect$.subscribe((action) => {
84+
expect(action).toEqual(simpleEffectDone());
85+
done();
86+
});
87+
88+
store.dispatch(simpleEffectTest());
89+
});
90+
91+
it('runs provided effects after root state registration', (done) => {
92+
TestBed.configureTestingModule({
93+
providers: [
94+
provideEffects([TestEffects]).ɵproviders,
95+
// provide store after effects
96+
provideStore({ [rootSliceKey]: createReducer('ngrx') }).ɵproviders,
97+
],
98+
});
99+
100+
const store = TestBed.inject(Store);
101+
const effects = TestBed.inject(TestEffects);
102+
103+
effects.effectWithRootState$.subscribe((action) => {
104+
expect(action).toEqual(
105+
effectWithRootStateDone({ [rootSliceKey]: 'ngrx' })
106+
);
107+
done();
108+
});
109+
110+
store.dispatch(effectWithRootStateTest());
111+
});
112+
113+
it('runs provided effects after feature state registration', (done) => {
114+
TestBed.configureTestingModule({
115+
providers: [
116+
provideStore().ɵproviders,
117+
provideEffects([TestEffects]).ɵproviders,
118+
// provide feature state after effects
119+
provideState(featureSliceKey, createReducer('effects')).ɵproviders,
120+
],
121+
});
122+
123+
const store = TestBed.inject(Store);
124+
const effects = TestBed.inject(TestEffects);
125+
126+
effects.effectWithFeatureState$.subscribe((action) => {
127+
expect(action).toEqual(
128+
effectWithFeatureStateDone({ [featureSliceKey]: 'effects' })
129+
);
130+
done();
131+
});
132+
133+
store.dispatch(effectWithFeatureStateTest());
134+
});
135+
});
136+
137+
const rootSliceKey = 'rootSlice';
138+
const featureSliceKey = 'featureSlice';
139+
const selectRootSlice = createFeatureSelector<string>(rootSliceKey);
140+
const selectFeatureSlice = createFeatureSelector<string>(featureSliceKey);
141+
142+
const simpleEffectTest = createAction('simpleEffectTest');
143+
const simpleEffectDone = createAction('simpleEffectDone');
144+
const effectWithRootStateTest = createAction('effectWithRootStateTest');
145+
const effectWithRootStateDone = createAction(
146+
'effectWithRootStateDone',
147+
props<{ [rootSliceKey]: string }>()
148+
);
149+
const effectWithFeatureStateTest = createAction('effectWithFeatureStateTest');
150+
const effectWithFeatureStateDone = createAction(
151+
'effectWithFeatureStateDone',
152+
props<{ [featureSliceKey]: string }>()
153+
);
154+
155+
@Injectable()
156+
class TestEffects {
157+
constructor(
158+
private readonly actions$: Actions,
159+
private readonly store: Store
160+
) {}
161+
162+
readonly simpleEffect$ = createEffect(() => {
163+
return this.actions$.pipe(
164+
ofType(simpleEffectTest),
165+
map(() => simpleEffectDone())
166+
);
167+
});
168+
169+
readonly effectWithRootState$ = createEffect(() => {
170+
return this.actions$.pipe(
171+
ofType(effectWithRootStateTest),
172+
concatLatestFrom(() => this.store.select(selectRootSlice)),
173+
map(([, rootSlice]) => effectWithRootStateDone({ rootSlice }))
174+
);
175+
});
176+
177+
readonly effectWithFeatureState$ = createEffect(() => {
178+
return this.actions$.pipe(
179+
ofType(effectWithFeatureStateTest),
180+
concatLatestFrom(() => this.store.select(selectFeatureSlice)),
181+
map(([, featureSlice]) => effectWithFeatureStateDone({ featureSlice }))
182+
);
183+
});
184+
}

modules/effects/src/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import { Observable, OperatorFunction, Operator } from 'rxjs';
99
import { filter } from 'rxjs/operators';
1010

11-
@Injectable()
11+
@Injectable({ providedIn: 'root' })
1212
export class Actions<V = Action> extends Observable<V> {
1313
constructor(@Inject(ScannedActionsSubject) source?: Observable<V>) {
1414
super();

modules/effects/src/effect_sources.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
import { EFFECTS_ERROR_HANDLER } from './tokens';
3030
import { getSourceForInstance, ObservableNotification } from './utils';
3131

32-
@Injectable()
32+
@Injectable({ providedIn: 'root' })
3333
export class EffectSources extends Subject<any> {
3434
constructor(
3535
private errorHandler: ErrorHandler,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { createAction } from '@ngrx/store';
2+
3+
export const ROOT_EFFECTS_INIT = '@ngrx/effects/init';
4+
export const rootEffectsInit = createAction(ROOT_EFFECTS_INIT);

modules/effects/src/effects_module.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,13 @@ import {
77
SkipSelf,
88
Type,
99
} from '@angular/core';
10-
import { Actions } from './actions';
11-
import { EffectSources } from './effect_sources';
1210
import { EffectsFeatureModule } from './effects_feature_module';
13-
import { defaultEffectsErrorHandler } from './effects_error_handler';
1411
import { EffectsRootModule } from './effects_root_module';
1512
import { EffectsRunner } from './effects_runner';
1613
import {
1714
_FEATURE_EFFECTS,
1815
_ROOT_EFFECTS,
1916
_ROOT_EFFECTS_GUARD,
20-
EFFECTS_ERROR_HANDLER,
2117
FEATURE_EFFECTS,
2218
ROOT_EFFECTS,
2319
USER_PROVIDED_EFFECTS,
@@ -58,13 +54,6 @@ export class EffectsModule {
5854
return {
5955
ngModule: EffectsRootModule,
6056
providers: [
61-
{
62-
provide: EFFECTS_ERROR_HANDLER,
63-
useValue: defaultEffectsErrorHandler,
64-
},
65-
EffectsRunner,
66-
EffectSources,
67-
Actions,
6857
rootEffects,
6958
{
7059
provide: _ROOT_EFFECTS,

modules/effects/src/effects_root_module.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
11
import { NgModule, Inject, Optional } from '@angular/core';
2-
import {
3-
createAction,
4-
StoreModule,
5-
Store,
6-
StoreRootModule,
7-
StoreFeatureModule,
8-
} from '@ngrx/store';
2+
import { Store, StoreRootModule, StoreFeatureModule } from '@ngrx/store';
93
import { EffectsRunner } from './effects_runner';
104
import { EffectSources } from './effect_sources';
115
import { ROOT_EFFECTS, _ROOT_EFFECTS_GUARD } from './tokens';
12-
13-
export const ROOT_EFFECTS_INIT = '@ngrx/effects/init';
14-
export const rootEffectsInit = createAction(ROOT_EFFECTS_INIT);
6+
import { ROOT_EFFECTS_INIT } from './effects_actions';
157

168
@NgModule({})
179
export class EffectsRootModule {

modules/effects/src/effects_runner.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import { Subscription } from 'rxjs';
44

55
import { EffectSources } from './effect_sources';
66

7-
@Injectable()
7+
@Injectable({ providedIn: 'root' })
88
export class EffectsRunner implements OnDestroy {
99
private effectsSubscription: Subscription | null = null;
1010

11+
get isStarted(): boolean {
12+
return !!this.effectsSubscription;
13+
}
14+
1115
constructor(
1216
private effectSources: EffectSources,
1317
private store: Store<any>

modules/effects/src/index.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,11 @@ export { EffectsMetadata, CreateEffectMetadata } from './models';
1111
export { Actions, ofType } from './actions';
1212
export { EffectsModule } from './effects_module';
1313
export { EffectSources } from './effect_sources';
14+
export { ROOT_EFFECTS_INIT, rootEffectsInit } from './effects_actions';
1415
export { EffectsRunner } from './effects_runner';
1516
export { EffectNotification } from './effect_notification';
1617
export { EffectsFeatureModule } from './effects_feature_module';
17-
export {
18-
ROOT_EFFECTS_INIT,
19-
rootEffectsInit,
20-
EffectsRootModule,
21-
} from './effects_root_module';
18+
export { EffectsRootModule } from './effects_root_module';
2219
export { EFFECTS_ERROR_HANDLER } from './tokens';
2320
export { act } from './act';
2421
export {
@@ -28,3 +25,4 @@ export {
2825
} from './lifecycle_hooks';
2926
export { USER_PROVIDED_EFFECTS } from './tokens';
3027
export { concatLatestFrom } from './concat_latest_from';
28+
export { provideEffects } from './provide_effects';
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
ENVIRONMENT_INITIALIZER,
3+
inject,
4+
InjectFlags,
5+
Type,
6+
} from '@angular/core';
7+
import {
8+
EnvironmentProviders,
9+
FEATURE_STATE_PROVIDER,
10+
ROOT_STORE_PROVIDER,
11+
Store,
12+
} from '@ngrx/store';
13+
import { EffectsRunner } from './effects_runner';
14+
import { EffectSources } from './effect_sources';
15+
import { rootEffectsInit as effectsInit } from './effects_actions';
16+
17+
/**
18+
* Runs the provided effects.
19+
* Can be called at the root and feature levels.
20+
*
21+
* @usageNotes
22+
*
23+
* ### Providing effects at the root level
24+
*
25+
* ```ts
26+
* bootstrapApplication(AppComponent, {
27+
* providers: [provideEffects([RouterEffects])],
28+
* });
29+
* ```
30+
*
31+
* ### Providing effects at the feature level
32+
*
33+
* ```ts
34+
* const booksRoutes: Route[] = [
35+
* {
36+
* path: '',
37+
* providers: [provideEffects([BooksApiEffects])],
38+
* children: [
39+
* { path: '', component: BookListComponent },
40+
* { path: ':id', component: BookDetailsComponent },
41+
* ],
42+
* },
43+
* ];
44+
* ```
45+
*/
46+
export function provideEffects(effects: Type<unknown>[]): EnvironmentProviders {
47+
return {
48+
ɵproviders: [
49+
effects,
50+
{
51+
provide: ENVIRONMENT_INITIALIZER,
52+
multi: true,
53+
useValue: () => {
54+
inject(ROOT_STORE_PROVIDER);
55+
inject(FEATURE_STATE_PROVIDER, InjectFlags.Optional);
56+
57+
const effectsRunner = inject(EffectsRunner);
58+
const effectSources = inject(EffectSources);
59+
const shouldInitEffects = !effectsRunner.isStarted;
60+
61+
if (shouldInitEffects) {
62+
effectsRunner.start();
63+
}
64+
65+
for (const effectsClass of effects) {
66+
const effectsInstance = inject(effectsClass);
67+
effectSources.addEffects(effectsInstance);
68+
}
69+
70+
if (shouldInitEffects) {
71+
const store = inject(Store);
72+
store.dispatch(effectsInit());
73+
}
74+
},
75+
},
76+
],
77+
};
78+
}

0 commit comments

Comments
 (0)