Skip to content

Commit

Permalink
feat(effects): add provideEffects function (#3524)
Browse files Browse the repository at this point in the history
Closes #3522
  • Loading branch information
markostanimirovic committed Aug 24, 2022
1 parent 6b0db4e commit db35bfe
Show file tree
Hide file tree
Showing 11 changed files with 285 additions and 32 deletions.
2 changes: 1 addition & 1 deletion modules/effects/spec/effects_root_module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { INIT, Store, StoreModule } from '@ngrx/store';

import { EffectsModule } from '../src/effects_module';
import { ROOT_EFFECTS_INIT } from '../src/effects_root_module';
import { ROOT_EFFECTS_INIT } from '../src/effects_actions';

describe('Effects Root Module', () => {
const foo = 'foo';
Expand Down
184 changes: 184 additions & 0 deletions modules/effects/spec/provide_effects.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { ENVIRONMENT_INITIALIZER, inject, Injectable } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { map } from 'rxjs';
import {
createAction,
createFeatureSelector,
createReducer,
props,
provideState,
provideStore,
Store,
} from '@ngrx/store';
import {
Actions,
concatLatestFrom,
createEffect,
EffectsRunner,
ofType,
provideEffects,
rootEffectsInit,
} from '../src/index';

describe('provideEffects', () => {
it('starts effects runner when called first time', () => {
TestBed.configureTestingModule({
providers: [
{
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => jest.spyOn(inject(EffectsRunner), 'start'),
},
provideStore({}).ɵproviders,
// provide effects twice
provideEffects([]).ɵproviders,
provideEffects([]).ɵproviders,
],
});

const effectsRunner = TestBed.inject(EffectsRunner);
expect(effectsRunner.start).toHaveBeenCalledTimes(1);
});

it('dispatches effects init action when called first time', () => {
TestBed.configureTestingModule({
providers: [
{
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => jest.spyOn(inject(Store), 'dispatch'),
},
provideStore().ɵproviders,
// provide effects twice
provideEffects([]).ɵproviders,
provideEffects([]).ɵproviders,
],
});

const store = TestBed.inject(Store);
expect(store.dispatch).toHaveBeenCalledWith(rootEffectsInit());
expect(store.dispatch).toHaveBeenCalledTimes(1);
});

it('throws an error when store is not provided', () => {
TestBed.configureTestingModule({
// provide only effects
providers: [provideEffects([TestEffects]).ɵproviders],
});

expect(() => TestBed.inject(TestEffects)).toThrowError();
});

it('runs provided effects', (done) => {
TestBed.configureTestingModule({
providers: [
provideStore().ɵproviders,
provideEffects([TestEffects]).ɵproviders,
],
});

const store = TestBed.inject(Store);
const effects = TestBed.inject(TestEffects);

effects.simpleEffect$.subscribe((action) => {
expect(action).toEqual(simpleEffectDone());
done();
});

store.dispatch(simpleEffectTest());
});

it('runs provided effects after root state registration', (done) => {
TestBed.configureTestingModule({
providers: [
provideEffects([TestEffects]).ɵproviders,
// provide store after effects
provideStore({ [rootSliceKey]: createReducer('ngrx') }).ɵproviders,
],
});

const store = TestBed.inject(Store);
const effects = TestBed.inject(TestEffects);

effects.effectWithRootState$.subscribe((action) => {
expect(action).toEqual(
effectWithRootStateDone({ [rootSliceKey]: 'ngrx' })
);
done();
});

store.dispatch(effectWithRootStateTest());
});

it('runs provided effects after feature state registration', (done) => {
TestBed.configureTestingModule({
providers: [
provideStore().ɵproviders,
provideEffects([TestEffects]).ɵproviders,
// provide feature state after effects
provideState(featureSliceKey, createReducer('effects')).ɵproviders,
],
});

const store = TestBed.inject(Store);
const effects = TestBed.inject(TestEffects);

effects.effectWithFeatureState$.subscribe((action) => {
expect(action).toEqual(
effectWithFeatureStateDone({ [featureSliceKey]: 'effects' })
);
done();
});

store.dispatch(effectWithFeatureStateTest());
});
});

const rootSliceKey = 'rootSlice';
const featureSliceKey = 'featureSlice';
const selectRootSlice = createFeatureSelector<string>(rootSliceKey);
const selectFeatureSlice = createFeatureSelector<string>(featureSliceKey);

const simpleEffectTest = createAction('simpleEffectTest');
const simpleEffectDone = createAction('simpleEffectDone');
const effectWithRootStateTest = createAction('effectWithRootStateTest');
const effectWithRootStateDone = createAction(
'effectWithRootStateDone',
props<{ [rootSliceKey]: string }>()
);
const effectWithFeatureStateTest = createAction('effectWithFeatureStateTest');
const effectWithFeatureStateDone = createAction(
'effectWithFeatureStateDone',
props<{ [featureSliceKey]: string }>()
);

@Injectable()
class TestEffects {
constructor(
private readonly actions$: Actions,
private readonly store: Store
) {}

readonly simpleEffect$ = createEffect(() => {
return this.actions$.pipe(
ofType(simpleEffectTest),
map(() => simpleEffectDone())
);
});

readonly effectWithRootState$ = createEffect(() => {
return this.actions$.pipe(
ofType(effectWithRootStateTest),
concatLatestFrom(() => this.store.select(selectRootSlice)),
map(([, rootSlice]) => effectWithRootStateDone({ rootSlice }))
);
});

readonly effectWithFeatureState$ = createEffect(() => {
return this.actions$.pipe(
ofType(effectWithFeatureStateTest),
concatLatestFrom(() => this.store.select(selectFeatureSlice)),
map(([, featureSlice]) => effectWithFeatureStateDone({ featureSlice }))
);
});
}
2 changes: 1 addition & 1 deletion modules/effects/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import { Observable, OperatorFunction, Operator } from 'rxjs';
import { filter } from 'rxjs/operators';

@Injectable()
@Injectable({ providedIn: 'root' })
export class Actions<V = Action> extends Observable<V> {
constructor(@Inject(ScannedActionsSubject) source?: Observable<V>) {
super();
Expand Down
2 changes: 1 addition & 1 deletion modules/effects/src/effect_sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
import { EFFECTS_ERROR_HANDLER } from './tokens';
import { getSourceForInstance, ObservableNotification } from './utils';

@Injectable()
@Injectable({ providedIn: 'root' })
export class EffectSources extends Subject<any> {
constructor(
private errorHandler: ErrorHandler,
Expand Down
4 changes: 4 additions & 0 deletions modules/effects/src/effects_actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createAction } from '@ngrx/store';

export const ROOT_EFFECTS_INIT = '@ngrx/effects/init';
export const rootEffectsInit = createAction(ROOT_EFFECTS_INIT);
11 changes: 0 additions & 11 deletions modules/effects/src/effects_module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,13 @@ import {
SkipSelf,
Type,
} from '@angular/core';
import { Actions } from './actions';
import { EffectSources } from './effect_sources';
import { EffectsFeatureModule } from './effects_feature_module';
import { defaultEffectsErrorHandler } from './effects_error_handler';
import { EffectsRootModule } from './effects_root_module';
import { EffectsRunner } from './effects_runner';
import {
_FEATURE_EFFECTS,
_ROOT_EFFECTS,
_ROOT_EFFECTS_GUARD,
EFFECTS_ERROR_HANDLER,
FEATURE_EFFECTS,
ROOT_EFFECTS,
USER_PROVIDED_EFFECTS,
Expand Down Expand Up @@ -58,13 +54,6 @@ export class EffectsModule {
return {
ngModule: EffectsRootModule,
providers: [
{
provide: EFFECTS_ERROR_HANDLER,
useValue: defaultEffectsErrorHandler,
},
EffectsRunner,
EffectSources,
Actions,
rootEffects,
{
provide: _ROOT_EFFECTS,
Expand Down
12 changes: 2 additions & 10 deletions modules/effects/src/effects_root_module.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,9 @@
import { NgModule, Inject, Optional } from '@angular/core';
import {
createAction,
StoreModule,
Store,
StoreRootModule,
StoreFeatureModule,
} from '@ngrx/store';
import { Store, StoreRootModule, StoreFeatureModule } from '@ngrx/store';
import { EffectsRunner } from './effects_runner';
import { EffectSources } from './effect_sources';
import { ROOT_EFFECTS, _ROOT_EFFECTS_GUARD } from './tokens';

export const ROOT_EFFECTS_INIT = '@ngrx/effects/init';
export const rootEffectsInit = createAction(ROOT_EFFECTS_INIT);
import { ROOT_EFFECTS_INIT } from './effects_actions';

@NgModule({})
export class EffectsRootModule {
Expand Down
6 changes: 5 additions & 1 deletion modules/effects/src/effects_runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import { Subscription } from 'rxjs';

import { EffectSources } from './effect_sources';

@Injectable()
@Injectable({ providedIn: 'root' })
export class EffectsRunner implements OnDestroy {
private effectsSubscription: Subscription | null = null;

get isStarted(): boolean {
return !!this.effectsSubscription;
}

constructor(
private effectSources: EffectSources,
private store: Store<any>
Expand Down
8 changes: 3 additions & 5 deletions modules/effects/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,11 @@ export { EffectsMetadata, CreateEffectMetadata } from './models';
export { Actions, ofType } from './actions';
export { EffectsModule } from './effects_module';
export { EffectSources } from './effect_sources';
export { ROOT_EFFECTS_INIT, rootEffectsInit } from './effects_actions';
export { EffectsRunner } from './effects_runner';
export { EffectNotification } from './effect_notification';
export { EffectsFeatureModule } from './effects_feature_module';
export {
ROOT_EFFECTS_INIT,
rootEffectsInit,
EffectsRootModule,
} from './effects_root_module';
export { EffectsRootModule } from './effects_root_module';
export { EFFECTS_ERROR_HANDLER } from './tokens';
export { act } from './act';
export {
Expand All @@ -28,3 +25,4 @@ export {
} from './lifecycle_hooks';
export { USER_PROVIDED_EFFECTS } from './tokens';
export { concatLatestFrom } from './concat_latest_from';
export { provideEffects } from './provide_effects';
78 changes: 78 additions & 0 deletions modules/effects/src/provide_effects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
ENVIRONMENT_INITIALIZER,
inject,
InjectFlags,
Type,
} from '@angular/core';
import {
EnvironmentProviders,
FEATURE_STATE_PROVIDER,
ROOT_STORE_PROVIDER,
Store,
} from '@ngrx/store';
import { EffectsRunner } from './effects_runner';
import { EffectSources } from './effect_sources';
import { rootEffectsInit as effectsInit } from './effects_actions';

/**
* Runs the provided effects.
* Can be called at the root and feature levels.
*
* @usageNotes
*
* ### Providing effects at the root level
*
* ```ts
* bootstrapApplication(AppComponent, {
* providers: [provideEffects([RouterEffects])],
* });
* ```
*
* ### Providing effects at the feature level
*
* ```ts
* const booksRoutes: Route[] = [
* {
* path: '',
* providers: [provideEffects([BooksApiEffects])],
* children: [
* { path: '', component: BookListComponent },
* { path: ':id', component: BookDetailsComponent },
* ],
* },
* ];
* ```
*/
export function provideEffects(effects: Type<unknown>[]): EnvironmentProviders {
return {
ɵproviders: [
effects,
{
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
inject(ROOT_STORE_PROVIDER);
inject(FEATURE_STATE_PROVIDER, InjectFlags.Optional);

const effectsRunner = inject(EffectsRunner);
const effectSources = inject(EffectSources);
const shouldInitEffects = !effectsRunner.isStarted;

if (shouldInitEffects) {
effectsRunner.start();
}

for (const effectsClass of effects) {
const effectsInstance = inject(effectsClass);
effectSources.addEffects(effectsInstance);
}

if (shouldInitEffects) {
const store = inject(Store);
store.dispatch(effectsInit());
}
},
},
],
};
}

0 comments on commit db35bfe

Please sign in to comment.