Skip to content

Commit 089abdc

Browse files
elwynelwynMikeRyanDev
authored andcommitted
feat(Effects): Ensure effects are only subscribed to once
1 parent c40432d commit 089abdc

File tree

8 files changed

+115
-9
lines changed

8 files changed

+115
-9
lines changed

build/util.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export async function ignoreErrors<T>(promise: Promise<T>): Promise<T | null> {
8585
}
8686

8787
export function fromNpm(command: string) {
88-
return `./node_modules/.bin/${command}`;
88+
return path.normalize(`./node_modules/.bin/${command}`);
8989
}
9090

9191
export function getPackageFilePath(pkg: string, filename: string) {

docs/effects/README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,19 @@ store. The `ofType` operator lets you filter for actions of a certain type in wh
3636
want to use to perform a side effect.
3737

3838
## Example
39-
1. Create an AuthEffects service that describes a source of login actions:
39+
1. Register the EffectsModule in your application root imports:
40+
```ts
41+
import { EffectsModule } from '@ngrx/effects';
42+
43+
@NgModule({
44+
imports: [
45+
EffectsModule.forRoot()
46+
]
47+
})
48+
export class AppModule { }
49+
```
50+
51+
2. Create an AuthEffects service that describes a source of login actions:
4052

4153
```ts
4254
// ./effects/auth.ts
@@ -70,7 +82,7 @@ export class AuthEffects {
7082
}
7183
```
7284

73-
2. Register your effects via `EffectsModule.run` method in your module's `imports`:
85+
3. Register your effects via `EffectsModule.run` method in your module's `imports`:
7486

7587
```ts
7688
import { EffectsModule } from '@ngrx/effects';
@@ -81,7 +93,7 @@ import { AuthEffects } from './effects/auth';
8193
EffectsModule.run(AuthEffects)
8294
]
8395
})
84-
export class AppModule { }
96+
export class YourAuthModule { }
8597
```
8698

8799
## API Documentation

docs/effects/api.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,19 @@
44

55
Feature module for @ngrx/effects.
66

7+
### forRoot
8+
Registers internal @ngrx/effects services to run in your application.
9+
This is required once in your root app module.
10+
711
### run
812

913
Registers an effects class to run when the target module bootstraps.
1014
Call once per each effect class you want to run.
1115

16+
Note that running an effects class multiple times (for example via
17+
different lazy loaded modules) will not cause Effects to run multiple
18+
times.
19+
1220
Usage:
1321
```ts
1422
@NgModule({

modules/effects/spec/effects-subscription.spec.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,25 @@ import { ReflectiveInjector } from '@angular/core';
22
import { of } from 'rxjs/observable/of';
33
import { Effect } from '../src/effects';
44
import { EffectsSubscription } from '../src/effects-subscription';
5+
import { SingletonEffectsService } from '../src/singleton-effects.service';
56

67

78
describe('Effects Subscription', () => {
89
it('should add itself to a parent subscription if one exists', () => {
910
const observer: any = { next() { } };
10-
const root = new EffectsSubscription(observer, undefined, undefined);
11+
const singletonEffectsService = new SingletonEffectsService();
12+
const root = new EffectsSubscription(observer, singletonEffectsService, undefined, undefined);
1113

1214
spyOn(root, 'add');
13-
const child = new EffectsSubscription(observer, root, undefined);
15+
const child = new EffectsSubscription(observer, singletonEffectsService, root, undefined);
1416

1517
expect(root.add).toHaveBeenCalledWith(child);
1618
});
1719

1820
it('should unsubscribe for all effects when destroyed', () => {
1921
const observer: any = { next() { } };
20-
const subscription = new EffectsSubscription(observer, undefined, undefined);
22+
const singletonEffectsService = new SingletonEffectsService();
23+
const subscription = new EffectsSubscription(observer, singletonEffectsService, undefined, undefined);
2124

2225
spyOn(subscription, 'unsubscribe');
2326
subscription.ngOnDestroy();
@@ -33,12 +36,32 @@ describe('Effects Subscription', () => {
3336
}
3437
const instance = new Source();
3538
const observer: any = { next: jasmine.createSpy('next') };
39+
const singletonEffectsService = new SingletonEffectsService();
3640

37-
const subscription = new EffectsSubscription(observer, undefined, [ instance ]);
41+
const subscription = new EffectsSubscription(observer, singletonEffectsService, undefined, [ instance ]);
3842

3943
expect(observer.next).toHaveBeenCalledTimes(3);
4044
expect(observer.next).toHaveBeenCalledWith('a');
4145
expect(observer.next).toHaveBeenCalledWith('b');
4246
expect(observer.next).toHaveBeenCalledWith('c');
4347
});
44-
});
48+
49+
it('should not merge duplicate effects instances when a SingletonEffectsService is provided', () => {
50+
class Source {
51+
@Effect() a$ = of('a');
52+
@Effect() b$ = of('b');
53+
@Effect() c$ = of('c');
54+
}
55+
const instance = new Source();
56+
const observer: any = { next: jasmine.createSpy('next') };
57+
const singletonEffectsService = new SingletonEffectsService();
58+
singletonEffectsService.removeExistingAndRegisterNew([ instance ]);
59+
60+
const subscription = new EffectsSubscription(observer, singletonEffectsService, undefined, [ instance ]);
61+
62+
expect(observer.next).not.toHaveBeenCalled();
63+
expect(observer.next).not.toHaveBeenCalledWith('a');
64+
expect(observer.next).not.toHaveBeenCalledWith('b');
65+
expect(observer.next).not.toHaveBeenCalledWith('c');
66+
});
67+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { of } from 'rxjs/observable/of';
2+
import { Effect } from '../src/effects';
3+
import { SingletonEffectsService } from '../src/singleton-effects.service';
4+
5+
describe('SingletonEffectsService', () => {
6+
it('should filter out duplicate effect instances and register new ones', () => {
7+
class Source1 {
8+
@Effect() a$ = of('a');
9+
@Effect() b$ = of('b');
10+
@Effect() c$ = of('c');
11+
}
12+
class Source2 {
13+
@Effect() d$ = of('d');
14+
@Effect() e$ = of('e');
15+
@Effect() f$ = of('f');
16+
}
17+
const instance1 = new Source1();
18+
const instance2 = new Source2();
19+
let singletonEffectsService = new SingletonEffectsService();
20+
21+
let result = singletonEffectsService.removeExistingAndRegisterNew([ instance1 ]);
22+
expect(result).toContain(instance1);
23+
24+
result = singletonEffectsService.removeExistingAndRegisterNew([ instance1, instance2 ]);
25+
expect(result).not.toContain(instance1);
26+
expect(result).toContain(instance2);
27+
28+
result = singletonEffectsService.removeExistingAndRegisterNew([ instance1, instance2 ]);
29+
expect(result).not.toContain(instance1);
30+
expect(result).not.toContain(instance2);
31+
});
32+
});

modules/effects/src/effects-subscription.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Observer } from 'rxjs/Observer';
44
import { Subscription } from 'rxjs/Subscription';
55
import { merge } from 'rxjs/observable/merge';
66
import { mergeEffects } from './effects';
7+
import { SingletonEffectsService } from './singleton-effects.service';
78

89

910
export const effects = new OpaqueToken('ngrx/effects: Effects');
@@ -12,6 +13,7 @@ export const effects = new OpaqueToken('ngrx/effects: Effects');
1213
export class EffectsSubscription extends Subscription implements OnDestroy {
1314
constructor(
1415
@Inject(Store) private store: Observer<Action>,
16+
@Inject(SingletonEffectsService) private singletonEffectsService: SingletonEffectsService,
1517
@Optional() @SkipSelf() public parent?: EffectsSubscription,
1618
@Optional() @Inject(effects) effectInstances?: any[]
1719
) {
@@ -27,6 +29,8 @@ export class EffectsSubscription extends Subscription implements OnDestroy {
2729
}
2830

2931
addEffects(effectInstances: any[]) {
32+
effectInstances = this.singletonEffectsService.removeExistingAndRegisterNew(effectInstances);
33+
3034
const sources = effectInstances.map(mergeEffects);
3135
const merged = merge(...sources);
3236

modules/effects/src/effects.module.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NgModule, Injector, Type, APP_BOOTSTRAP_LISTENER, OpaqueToken } from '@
22
import { Actions } from './actions';
33
import { EffectsSubscription, effects } from './effects-subscription';
44
import { runAfterBootstrapEffects, afterBootstrapEffects } from './bootstrap-listener';
5+
import { SingletonEffectsService } from './singleton-effects.service';
56

67

78
@NgModule({
@@ -17,6 +18,15 @@ import { runAfterBootstrapEffects, afterBootstrapEffects } from './bootstrap-lis
1718
]
1819
})
1920
export class EffectsModule {
21+
static forRoot() {
22+
return {
23+
ngModule: EffectsModule,
24+
providers: [
25+
SingletonEffectsService
26+
]
27+
};
28+
}
29+
2030
static run(type: Type<any>) {
2131
return {
2232
ngModule: EffectsModule,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Injectable } from '@angular/core';
2+
3+
@Injectable()
4+
export class SingletonEffectsService {
5+
private registeredEffects: string[] = [];
6+
7+
removeExistingAndRegisterNew (effectInstances: any[]): any[] {
8+
return effectInstances.filter(instance => {
9+
const instanceAsString = instance.constructor.toString();
10+
if (this.registeredEffects.indexOf(instanceAsString) === -1) {
11+
this.registeredEffects.push(instanceAsString);
12+
return true;
13+
}
14+
return false;
15+
});
16+
}
17+
}

0 commit comments

Comments
 (0)