Skip to content

Commit 71137e5

Browse files
alex-okrushkobrandonroberts
authored andcommitted
feat(effects): resubscribe to effects on error (#1881)
BREAKING CHANGE: Prior to introduction of automatic resubscriptions on errors, all effects had effectively {resubscribeOnError: false} behavior. For the rare cases when this is still wanted please add {resubscribeOnError: false} to the effect metadata. BEFORE: ```ts login$ = createEffect(() => this.actions$.pipe( ofType(LoginPageActions.login), mapToAction( // Happy path callback action => this.authService.login(action.credentials).pipe( map(user => AuthApiActions.loginSuccess({ user }))), // error callback error => AuthApiActions.loginFailure({ error }), ) )); ``` AFTER: ```ts login$ = createEffect(() => this.actions$.pipe( ofType(LoginPageActions.login), mapToAction( // Happy path callback action => this.authService.login(action.credentials).pipe( map(user => AuthApiActions.loginSuccess({ user }))), // error callback error => AuthApiActions.loginFailure({ error }), ) // Errors are handled and it is safe to disable resubscription ), {resubscribeOnError: false }); ```
1 parent 478b225 commit 71137e5

File tree

14 files changed

+345
-83
lines changed

14 files changed

+345
-83
lines changed

modules/effects/spec/effect_creator.spec.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ describe('createEffect()', () => {
5858
expectSnippet(`
5959
const effect = createEffect(() => ({ foo: 'a' }), { dispatch: false });
6060
`).toFail(
61-
/Type '{ foo: string; }' is not assignable to type 'Observable<Action> | ((...args: any[]) => Observable<Action>)'./
61+
/Type '{ foo: string; }' is not assignable to type 'Observable<unknown> | ((...args: any[]) => Observable<unknown>)'./
6262
);
6363
});
6464
});
@@ -73,31 +73,39 @@ describe('createEffect()', () => {
7373
it('should dispatch by default', () => {
7474
const effect: any = createEffect(() => of({ type: 'a' }));
7575

76-
expect(effect['__@ngrx/effects_create__']).toEqual({ dispatch: true });
76+
expect(effect['__@ngrx/effects_create__']).toEqual(
77+
jasmine.objectContaining({ dispatch: true })
78+
);
7779
});
7880

7981
it('should be possible to explicitly create a dispatching effect', () => {
8082
const effect: any = createEffect(() => of({ type: 'a' }), {
8183
dispatch: true,
8284
});
8385

84-
expect(effect['__@ngrx/effects_create__']).toEqual({ dispatch: true });
86+
expect(effect['__@ngrx/effects_create__']).toEqual(
87+
jasmine.objectContaining({ dispatch: true })
88+
);
8589
});
8690

8791
it('should be possible to create a non-dispatching effect', () => {
8892
const effect: any = createEffect(() => of({ type: 'a' }), {
8993
dispatch: false,
9094
});
9195

92-
expect(effect['__@ngrx/effects_create__']).toEqual({ dispatch: false });
96+
expect(effect['__@ngrx/effects_create__']).toEqual(
97+
jasmine.objectContaining({ dispatch: false })
98+
);
9399
});
94100

95101
it('should be possible to create a non-dispatching effect returning a non-action', () => {
96102
const effect: any = createEffect(() => of('foo'), {
97103
dispatch: false,
98104
});
99105

100-
expect(effect['__@ngrx/effects_create__']).toEqual({ dispatch: false });
106+
expect(effect['__@ngrx/effects_create__']).toEqual(
107+
jasmine.objectContaining({ dispatch: false })
108+
);
101109
});
102110

103111
describe('getCreateEffectMetadata', () => {
@@ -106,14 +114,30 @@ describe('createEffect()', () => {
106114
a = createEffect(() => of({ type: 'a' }));
107115
b = createEffect(() => of({ type: 'b' }), { dispatch: true });
108116
c = createEffect(() => of({ type: 'c' }), { dispatch: false });
117+
d = createEffect(() => of({ type: 'd' }), { resubscribeOnError: true });
118+
e = createEffect(() => of({ type: 'd' }), {
119+
resubscribeOnError: false,
120+
});
121+
f = createEffect(() => of({ type: 'e' }), {
122+
dispatch: false,
123+
resubscribeOnError: false,
124+
});
125+
g = createEffect(() => of({ type: 'e' }), {
126+
dispatch: true,
127+
resubscribeOnError: false,
128+
});
109129
}
110130

111131
const mock = new Fixture();
112132

113133
expect(getCreateEffectMetadata(mock)).toEqual([
114-
{ propertyName: 'a', dispatch: true },
115-
{ propertyName: 'b', dispatch: true },
116-
{ propertyName: 'c', dispatch: false },
134+
{ propertyName: 'a', dispatch: true, resubscribeOnError: true },
135+
{ propertyName: 'b', dispatch: true, resubscribeOnError: true },
136+
{ propertyName: 'c', dispatch: false, resubscribeOnError: true },
137+
{ propertyName: 'd', dispatch: true, resubscribeOnError: true },
138+
{ propertyName: 'e', dispatch: true, resubscribeOnError: false },
139+
{ propertyName: 'f', dispatch: false, resubscribeOnError: false },
140+
{ propertyName: 'g', dispatch: true, resubscribeOnError: false },
117141
]);
118142
});
119143

modules/effects/spec/effect_decorator.spec.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,30 @@ describe('@Effect()', () => {
55
it('should get the effects metadata for a class instance', () => {
66
class Fixture {
77
@Effect() a: any;
8-
@Effect() b: any;
8+
@Effect({ dispatch: true })
9+
b: any;
910
@Effect({ dispatch: false })
1011
c: any;
12+
@Effect({ resubscribeOnError: true })
13+
d: any;
14+
@Effect({ resubscribeOnError: false })
15+
e: any;
16+
@Effect({ dispatch: false, resubscribeOnError: false })
17+
f: any;
18+
@Effect({ dispatch: true, resubscribeOnError: false })
19+
g: any;
1120
}
1221

1322
const mock = new Fixture();
1423

1524
expect(getEffectDecoratorMetadata(mock)).toEqual([
16-
{ propertyName: 'a', dispatch: true },
17-
{ propertyName: 'b', dispatch: true },
18-
{ propertyName: 'c', dispatch: false },
25+
{ propertyName: 'a', dispatch: true, resubscribeOnError: true },
26+
{ propertyName: 'b', dispatch: true, resubscribeOnError: true },
27+
{ propertyName: 'c', dispatch: false, resubscribeOnError: true },
28+
{ propertyName: 'd', dispatch: true, resubscribeOnError: true },
29+
{ propertyName: 'e', dispatch: true, resubscribeOnError: false },
30+
{ propertyName: 'f', dispatch: false, resubscribeOnError: false },
31+
{ propertyName: 'g', dispatch: true, resubscribeOnError: false },
1932
]);
2033
});
2134

modules/effects/spec/effect_sources.spec.ts

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { ErrorHandler } from '@angular/core';
22
import { TestBed } from '@angular/core/testing';
3-
import { cold, getTestScheduler } from 'jasmine-marbles';
3+
import { cold, hot, getTestScheduler } from 'jasmine-marbles';
44
import { concat, NEVER, Observable, of, throwError, timer } from 'rxjs';
5-
import { map } from 'rxjs/operators';
5+
import { concatMap, map } from 'rxjs/operators';
66

77
import {
88
Effect,
@@ -235,6 +235,54 @@ describe('EffectSources', () => {
235235
);
236236
});
237237

238+
it('should report an error if an effect throws one', () => {
239+
const sources$ = of(new SourceError());
240+
241+
toActions(sources$).subscribe();
242+
243+
expect(mockErrorReporter.handleError).toHaveBeenCalledWith(
244+
new Error('An Error')
245+
);
246+
});
247+
248+
it('should resubscribe on error by default', () => {
249+
class Eff {
250+
@Effect()
251+
b$ = hot('a--b--c--d').pipe(
252+
map(v => {
253+
if (v == 'b') throw new Error('An Error');
254+
return v;
255+
})
256+
);
257+
}
258+
259+
const sources$ = of(new Eff());
260+
261+
// 👇 'b' is ignored.
262+
const expected = cold('a-----c--d');
263+
264+
expect(toActions(sources$)).toBeObservable(expected);
265+
});
266+
267+
it('should not resubscribe on error when resubscribeOnError is false', () => {
268+
class Eff {
269+
@Effect({ resubscribeOnError: false })
270+
b$ = hot('a--b--c--d').pipe(
271+
map(v => {
272+
if (v == 'b') throw new Error('An Error');
273+
return v;
274+
})
275+
);
276+
}
277+
278+
const sources$ = of(new Eff());
279+
280+
// 👇 completes.
281+
const expected = cold('a--|');
282+
283+
expect(toActions(sources$)).toBeObservable(expected);
284+
});
285+
238286
it(`should not break when the action in the error message can't be stringified`, () => {
239287
const sources$ = of(new SourceG());
240288

@@ -454,6 +502,77 @@ describe('EffectSources', () => {
454502
);
455503
});
456504

505+
it('should report an error if an effect throws one', () => {
506+
const sources$ = of(new SourceError());
507+
508+
toActions(sources$).subscribe();
509+
510+
expect(mockErrorReporter.handleError).toHaveBeenCalledWith(
511+
new Error('An Error')
512+
);
513+
});
514+
515+
it('should resubscribe on error by default', () => {
516+
const sources$ = of(
517+
new class {
518+
b$ = createEffect(() =>
519+
hot('a--b--c--d').pipe(
520+
map(v => {
521+
if (v == 'b') throw new Error('An Error');
522+
return v;
523+
})
524+
)
525+
);
526+
}()
527+
);
528+
// 👇 'b' is ignored.
529+
const expected = cold('a-----c--d');
530+
531+
expect(toActions(sources$)).toBeObservable(expected);
532+
});
533+
534+
it('should resubscribe on error by default when dispatch is false', () => {
535+
const sources$ = of(
536+
new class {
537+
b$ = createEffect(
538+
() =>
539+
hot('a--b--c--d').pipe(
540+
map(v => {
541+
if (v == 'b') throw new Error('An Error');
542+
return v;
543+
})
544+
),
545+
{ dispatch: false }
546+
);
547+
}()
548+
);
549+
// 👇 doesn't complete and doesn't dispatch
550+
const expected = cold('----------');
551+
552+
expect(toActions(sources$)).toBeObservable(expected);
553+
});
554+
555+
it('should not resubscribe on error when resubscribeOnError is false', () => {
556+
const sources$ = of(
557+
new class {
558+
b$ = createEffect(
559+
() =>
560+
hot('a--b--c--d').pipe(
561+
map(v => {
562+
if (v == 'b') throw new Error('An Error');
563+
return v;
564+
})
565+
),
566+
{ dispatch: false, resubscribeOnError: false }
567+
);
568+
}()
569+
);
570+
// 👇 errors with dispatch false
571+
const expected = cold('---#', undefined, new Error('An Error'));
572+
573+
expect(toActions(sources$)).toBeObservable(expected);
574+
});
575+
457576
it(`should not break when the action in the error message can't be stringified`, () => {
458577
const sources$ = of(new SourceG());
459578

modules/effects/spec/effects_metadata.spec.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getEffectsMetadata, getSourceMetadata } from '../src/effects_metadata';
22
import { of } from 'rxjs';
33
import { Effect, createEffect } from '..';
4+
import { EffectMetadata } from '../src/models';
45

56
describe('Effects metadata', () => {
67
describe('getSourceMetadata', () => {
@@ -11,17 +12,24 @@ describe('Effects metadata', () => {
1112
@Effect({ dispatch: false })
1213
c: any;
1314
d = createEffect(() => of({ type: 'a' }), { dispatch: false });
15+
@Effect({ dispatch: false, resubscribeOnError: false })
16+
e: any;
1417
z: any;
1518
}
1619

1720
const mock = new Fixture();
21+
const expected: EffectMetadata<Fixture>[] = [
22+
{ propertyName: 'a', dispatch: true, resubscribeOnError: true },
23+
{ propertyName: 'c', dispatch: false, resubscribeOnError: true },
24+
{ propertyName: 'b', dispatch: true, resubscribeOnError: true },
25+
{ propertyName: 'd', dispatch: false, resubscribeOnError: true },
26+
{ propertyName: 'e', dispatch: false, resubscribeOnError: false },
27+
];
1828

19-
expect(getSourceMetadata(mock)).toEqual([
20-
{ propertyName: 'a', dispatch: true },
21-
{ propertyName: 'c', dispatch: false },
22-
{ propertyName: 'b', dispatch: true },
23-
{ propertyName: 'd', dispatch: false },
24-
]);
29+
expect(getSourceMetadata(mock)).toEqual(
30+
jasmine.arrayContaining(expected)
31+
);
32+
expect(getSourceMetadata(mock).length).toEqual(expected.length);
2533
});
2634
});
2735

@@ -36,17 +44,21 @@ describe('Effects metadata', () => {
3644
@Effect({ dispatch: false })
3745
e: any;
3846
f = createEffect(() => of({ type: 'f' }), { dispatch: false });
47+
g = createEffect(() => of({ type: 'g' }), {
48+
resubscribeOnError: false,
49+
});
3950
}
4051

4152
const mock = new Fixture();
4253

4354
expect(getEffectsMetadata(mock)).toEqual({
44-
a: { dispatch: true },
45-
c: { dispatch: true },
46-
e: { dispatch: false },
47-
b: { dispatch: true },
48-
d: { dispatch: true },
49-
f: { dispatch: false },
55+
a: { dispatch: true, resubscribeOnError: true },
56+
c: { dispatch: true, resubscribeOnError: true },
57+
e: { dispatch: false, resubscribeOnError: true },
58+
b: { dispatch: true, resubscribeOnError: true },
59+
d: { dispatch: true, resubscribeOnError: true },
60+
f: { dispatch: false, resubscribeOnError: true },
61+
g: { dispatch: true, resubscribeOnError: false },
5062
});
5163
});
5264

modules/effects/src/effect_creator.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,27 @@
11
import { Observable } from 'rxjs';
22
import { Action } from '@ngrx/store';
3-
import { EffectMetadata } from './models';
3+
import { EffectMetadata, EffectConfig } from './models';
44

55
const CREATE_EFFECT_METADATA_KEY = '__@ngrx/effects_create__';
66

7+
type DispatchType<T> = T extends { dispatch: infer U } ? U : unknown;
78
export function createEffect<
8-
R extends Observable<unknown> | ((...args: any[]) => Observable<unknown>)
9-
>(source: () => R, options: { dispatch: false }): R;
10-
export function createEffect<
11-
T extends Action,
12-
R extends Observable<T> | ((...args: any[]) => Observable<T>)
13-
>(source: () => R, options?: { dispatch: true }): R;
14-
export function createEffect<
15-
T extends Action,
16-
R extends Observable<T> | ((...args: any[]) => Observable<T>)
17-
>(source: () => R, { dispatch = true } = {}): R {
9+
C extends EffectConfig,
10+
T extends DispatchType<C>,
11+
O extends T extends false ? Observable<unknown> : Observable<Action>,
12+
R extends O | ((...args: any[]) => O)
13+
>(source: () => R, config?: Partial<C>): R {
1814
const effect = source();
15+
// Right now both createEffect and @Effect decorator set default values.
16+
// Ideally that should only be done in one place that aggregates that info,
17+
// for example in mergeEffects().
18+
const value: EffectConfig = {
19+
dispatch: true,
20+
resubscribeOnError: true,
21+
...config, // Overrides any defaults if values are provided
22+
};
1923
Object.defineProperty(effect, CREATE_EFFECT_METADATA_KEY, {
20-
value: {
21-
dispatch,
22-
},
24+
value,
2325
});
2426
return effect;
2527
}

0 commit comments

Comments
 (0)