From 246f7e614695836fb757eb5419697c602a3613bd Mon Sep 17 00:00:00 2001 From: Edouard Bozon Date: Wed, 26 Apr 2023 10:34:59 +0200 Subject: [PATCH 1/2] fix(state): handle error emission in `connect` using `ErrorHandler` --- .../src/lib/accumulation-observable.ts | 9 +-- libs/state/spec/rx-state.component.spec.ts | 44 ++++++++++++-- libs/state/spec/rx-state.service.spec.ts | 58 ++++++++++--------- libs/state/src/lib/rx-state.service.ts | 8 ++- 4 files changed, 80 insertions(+), 39 deletions(-) diff --git a/libs/state/selections/src/lib/accumulation-observable.ts b/libs/state/selections/src/lib/accumulation-observable.ts index 0243734f24..fb4c0657b3 100644 --- a/libs/state/selections/src/lib/accumulation-observable.ts +++ b/libs/state/selections/src/lib/accumulation-observable.ts @@ -25,11 +25,12 @@ const defaultAccumulator: AccumulationFn = (st: T, sl: Partial): T => { return { ...st, ...sl }; }; -export function createAccumulationObservable( +export function createAccumulationObservable({ stateObservables = new Subject>>(), stateSlices = new Subject>(), - accumulatorObservable = new BehaviorSubject(defaultAccumulator) -): Accumulator { + accumulatorObservable = new BehaviorSubject(defaultAccumulator), + handleError = (e: any) => console.error(e), +} = {}): Accumulator { const signal$ = merge( stateObservables.pipe( distinctUntilChanged(), @@ -45,7 +46,7 @@ export function createAccumulationObservable( ), tap( (newState) => (compositionObservable.state = newState), - (error) => console.error(error) + (error) => handleError(error) ), // @Notice We catch the error here as it get lost in between `publish` and `publishReplay`. We return empty to catchError((e) => EMPTY), diff --git a/libs/state/spec/rx-state.component.spec.ts b/libs/state/spec/rx-state.component.spec.ts index 0c818e7c01..b984995018 100644 --- a/libs/state/spec/rx-state.component.spec.ts +++ b/libs/state/spec/rx-state.component.spec.ts @@ -1,10 +1,16 @@ +import { + Component, + ErrorHandler, + Input, + Output, + ViewChild, +} from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, Input, Output, ViewChild } from '@angular/core'; -import { PrimitiveState } from '@test-helpers'; -import { createStateChecker } from './fixtures'; -import { Observable, Subject } from 'rxjs'; import { RxState } from '@rx-angular/state'; import { select } from '@rx-angular/state/selections'; +import { PrimitiveState } from '@test-helpers'; +import { Observable, Subject, throwError } from 'rxjs'; +import { createStateChecker } from './fixtures'; const initialChildState = { str: 'initialChildState' }; @@ -100,33 +106,51 @@ export class RxStateGlueContainerComponent extends RxState { describe('LocalProviderTestComponent', () => { let component: RxStateInjectionComponent; let fixture: ComponentFixture; + let errorHandlerSpy: any; beforeEach(() => { - TestBed.configureTestingModule({ + const testBed = TestBed.configureTestingModule({ declarations: [RxStateInjectionComponent], + providers: [ + { provide: ErrorHandler, useValue: { handleError: jest.fn() } }, + ], teardown: { destroyAfterEach: true }, }); fixture = TestBed.createComponent(RxStateInjectionComponent); component = fixture.componentInstance; + errorHandlerSpy = testBed.inject(ErrorHandler).handleError; fixture.detectChanges(); }); it('should create', () => { stateChecker.checkSubscriptions(component.state, 1); }); + + describe('state.connect', () => { + it('should handle error through global ErrorHandler', () => { + const error$ = throwError(() => new Error('whoops')); + component.state.connect(error$); + expect(errorHandlerSpy).toHaveBeenCalledWith(new Error('whoops')); + }); + }); }); describe('InheritanceTestComponent', () => { let component: RxStateInheritanceComponent; let fixture: ComponentFixture; + let errorHandlerSpy: any; beforeEach(() => { - TestBed.configureTestingModule({ + const testBed = TestBed.configureTestingModule({ declarations: [RxStateInheritanceComponent], teardown: { destroyAfterEach: true }, + providers: [ + { provide: ErrorHandler, useValue: { handleError: jest.fn() } }, + ], }); fixture = TestBed.createComponent(RxStateInheritanceComponent); component = fixture.componentInstance; + errorHandlerSpy = testBed.inject(ErrorHandler).handleError; fixture.detectChanges(); }); @@ -135,4 +159,12 @@ describe('InheritanceTestComponent', () => { component.ngOnDestroy(); stateChecker.checkSubscriptions(component, 0); }); + + describe('state.connect', () => { + it('should handle error through global ErrorHandler', () => { + const error$ = throwError(() => new Error('whoops')); + component.connect(error$); + expect(errorHandlerSpy).toHaveBeenCalledWith(new Error('whoops')); + }); + }); }); diff --git a/libs/state/spec/rx-state.service.spec.ts b/libs/state/spec/rx-state.service.spec.ts index 6988963113..23aaed6907 100644 --- a/libs/state/spec/rx-state.service.spec.ts +++ b/libs/state/spec/rx-state.service.spec.ts @@ -13,8 +13,11 @@ import { TestScheduler } from 'rxjs/testing'; import { createStateChecker } from './fixtures'; function setupState(cfg: { initialState?: T }) { + const testBed = TestBed.configureTestingModule({ + providers: [RxState], + }); + const state = testBed.inject(RxState); const { initialState } = { ...cfg }; - const state = new RxState(); if (initialState) { state.set(initialState); } @@ -36,56 +39,56 @@ beforeEach(() => { }); describe('RxStateService', () => { - let service: RxState; - - beforeEach(() => { - TestBed.configureTestingModule({ - teardown: { destroyAfterEach: true }, - }); - service = setupState({}); - }); + let state: RxState; it('should be created', () => { - expect(service).toBeTruthy(); + state = setupState({}); + expect(state).toBeTruthy(); }); it('should be hot on instantiation', () => { - stateChecker.checkSubscriptions(service, 1); + state = setupState({}); + stateChecker.checkSubscriptions(state, 1); }); it('should unsubscribe on ngOnDestroy call', () => { - stateChecker.checkSubscriptions(service, 1); - service.ngOnDestroy(); - stateChecker.checkSubscriptions(service, 0); + state = setupState({}); + + stateChecker.checkSubscriptions(state, 1); + state.ngOnDestroy(); + stateChecker.checkSubscriptions(state, 0); }); describe('State', () => { + beforeEach(() => { + state = setupState({}); + }); + it('should create new instance', () => { - const state = new RxState(); - expect(state).toBeDefined(); + expect(state).toBeInstanceOf(RxState); }); }); describe('$', () => { + beforeEach(() => { + state = setupState({}); + }); + it('should return NO empty state after init when subscribing late', () => { testScheduler.run(({ expectObservable }) => { - const state = setupState({}); expectObservable(state.$).toBe(''); }); }); it('should return No changes when subscribing late', () => { testScheduler.run(({ expectObservable }) => { - const state = new RxState(); state.subscribe(); - state.set({ num: 42 }); expectObservable(state.$.pipe(pluck('num'))).toBe(''); }); }); it('should return new changes', () => { - const state = new RxState(); state.subscribe(); state.set({ num: 42 }); const slice$ = state.$.pipe(select('num')); @@ -97,26 +100,26 @@ describe('RxStateService', () => { }); describe('stateful with select', () => { + beforeEach(() => { + state = setupState({}); + }); + it('should return empty state after init when subscribing late', () => { testScheduler.run(({ expectObservable }) => { - const state = setupState({}); expectObservable(state.select()).toBe(''); }); }); it('should return changes when subscribing late', () => { testScheduler.run(({ expectObservable }) => { - const state = new RxState(); state.subscribe(); - state.set({ num: 42 }); expectObservable(state.select('num')).toBe('n', { n: 42 }); }); }); it('should return new changes', () => { - testScheduler.run(({ expectObservable }) => { - const state = new RxState(); + testScheduler.run(() => { state.subscribe(); state.set({ num: 42 }); const slice$ = state.select('num'); @@ -189,7 +192,7 @@ describe('RxStateService', () => { it('should return initial state', () => { testScheduler.run(({ expectObservable }) => { - const state = new RxState(); + const state = setupState({}); state.subscribe(); state.set({ num: 42 }); @@ -282,6 +285,7 @@ describe('RxStateService', () => { state.set(initialPrimitiveState); state.select().subscribe((s) => expect(s).toBe(initialPrimitiveState)); }); + it('should override previous state slices', () => { const state = setupState({ initialState: initialPrimitiveState }); state.select().subscribe((s) => { @@ -301,6 +305,7 @@ describe('RxStateService', () => { ).toThrowError('wrong param'); }); }); + describe('with state project partial', () => { it('should add new slices', () => { const state = setupState({}); @@ -319,6 +324,7 @@ describe('RxStateService', () => { state.select().subscribe((s) => expect(state).toBe({ num: 43 })); }); }); + describe('with state key and value partial', () => { it('should add new slices', () => { const state = setupState({}); diff --git a/libs/state/src/lib/rx-state.service.ts b/libs/state/src/lib/rx-state.service.ts index 82f3b9e826..6fe0449e27 100644 --- a/libs/state/src/lib/rx-state.service.ts +++ b/libs/state/src/lib/rx-state.service.ts @@ -1,4 +1,4 @@ -import { Injectable, OnDestroy } from '@angular/core'; +import { ErrorHandler, inject, Injectable, OnDestroy } from '@angular/core'; // eslint-disable-next-line @nrwl/nx/enforce-module-boundaries import { AccumulationFn, @@ -53,8 +53,10 @@ export type ProjectValueReducer = ( @Injectable() export class RxState implements OnDestroy, Subscribable { private subscription = new Subscription(); - - private accumulator = createAccumulationObservable(); + private handleError = inject(ErrorHandler).handleError; + private accumulator = createAccumulationObservable({ + handleError: this.handleError, + }); private effectObservable = createSideEffectObservable(); /** From fa72532aa36c314185170c213ea3d106d17a206b Mon Sep 17 00:00:00 2001 From: Edouard Bozon Date: Thu, 25 May 2023 22:01:41 +0200 Subject: [PATCH 2/2] fix(state): handle error emission in `connect` using `ErrorHandler` --- .../src/lib/accumulation-observable.ts | 4 +- libs/state/spec/rx-state.component.spec.ts | 87 +++++++++++++++---- libs/state/src/lib/rx-state.service.ts | 4 +- 3 files changed, 72 insertions(+), 23 deletions(-) diff --git a/libs/state/selections/src/lib/accumulation-observable.ts b/libs/state/selections/src/lib/accumulation-observable.ts index fb4c0657b3..7e132bc5cd 100644 --- a/libs/state/selections/src/lib/accumulation-observable.ts +++ b/libs/state/selections/src/lib/accumulation-observable.ts @@ -29,7 +29,7 @@ export function createAccumulationObservable({ stateObservables = new Subject>>(), stateSlices = new Subject>(), accumulatorObservable = new BehaviorSubject(defaultAccumulator), - handleError = (e: any) => console.error(e), + handleError = (e: unknown) => console.error(e), } = {}): Accumulator { const signal$ = merge( stateObservables.pipe( @@ -49,7 +49,7 @@ export function createAccumulationObservable({ (error) => handleError(error) ), // @Notice We catch the error here as it get lost in between `publish` and `publishReplay`. We return empty to - catchError((e) => EMPTY), + catchError(() => EMPTY), publish() ); const state$: Observable = signal$.pipe(publishReplay(1)); diff --git a/libs/state/spec/rx-state.component.spec.ts b/libs/state/spec/rx-state.component.spec.ts index b984995018..af20a53af9 100644 --- a/libs/state/spec/rx-state.component.spec.ts +++ b/libs/state/spec/rx-state.component.spec.ts @@ -3,9 +3,10 @@ import { ErrorHandler, Input, Output, + Type, ViewChild, } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { RxState } from '@rx-angular/state'; import { select } from '@rx-angular/state/selections'; import { PrimitiveState } from '@test-helpers'; @@ -105,21 +106,17 @@ export class RxStateGlueContainerComponent extends RxState { describe('LocalProviderTestComponent', () => { let component: RxStateInjectionComponent; - let fixture: ComponentFixture; - let errorHandlerSpy: any; + let errorHandlerSpy: jest.Mock; beforeEach(() => { - const testBed = TestBed.configureTestingModule({ - declarations: [RxStateInjectionComponent], + const { component: c, errorHandler: e } = setupTestComponent({ + testComponent: RxStateInjectionComponent, providers: [ { provide: ErrorHandler, useValue: { handleError: jest.fn() } }, ], - teardown: { destroyAfterEach: true }, }); - fixture = TestBed.createComponent(RxStateInjectionComponent); - component = fixture.componentInstance; - errorHandlerSpy = testBed.inject(ErrorHandler).handleError; - fixture.detectChanges(); + component = c; + errorHandlerSpy = e.handleError as jest.Mock; }); it('should create', () => { @@ -137,21 +134,17 @@ describe('LocalProviderTestComponent', () => { describe('InheritanceTestComponent', () => { let component: RxStateInheritanceComponent; - let fixture: ComponentFixture; - let errorHandlerSpy: any; + let errorHandlerSpy: jest.Mock; beforeEach(() => { - const testBed = TestBed.configureTestingModule({ - declarations: [RxStateInheritanceComponent], - teardown: { destroyAfterEach: true }, + const { component: c, errorHandler: e } = setupTestComponent({ + testComponent: RxStateInheritanceComponent, providers: [ { provide: ErrorHandler, useValue: { handleError: jest.fn() } }, ], }); - fixture = TestBed.createComponent(RxStateInheritanceComponent); - component = fixture.componentInstance; - errorHandlerSpy = testBed.inject(ErrorHandler).handleError; - fixture.detectChanges(); + component = c; + errorHandlerSpy = e.handleError as jest.Mock; }); it('should create', () => { @@ -168,3 +161,59 @@ describe('InheritanceTestComponent', () => { }); }); }); + +describe('CustomErrorHandler', () => { + let component: RxStateInheritanceComponent; + let errorHandlerSpy: jest.SpyInstance; + + class CustomErrorHandler implements ErrorHandler { + private prod = true; + handleError() { + if (this.prod) { + throw new Error('Prod error'); + } + throw new Error('Dev error'); + } + } + + beforeEach(() => { + const { component: c, errorHandler: e } = setupTestComponent({ + testComponent: RxStateInheritanceComponent, + providers: [ + { + provide: ErrorHandler, + useClass: CustomErrorHandler, + }, + ], + }); + component = c; + errorHandlerSpy = jest.spyOn(e, 'handleError'); + }); + + describe('state.connect', () => { + it('should handle error through CustomErrorHandler', () => { + const error$ = throwError(() => new Error('whoops')); + component.connect('str', error$); + expect(errorHandlerSpy).toThrow(new Error('Prod error')); + }); + }); +}); + +function setupTestComponent({ + testComponent, + providers, +}: { + testComponent: Type; + providers: any[]; +}) { + const testBed = TestBed.configureTestingModule({ + declarations: [testComponent], + providers: [...providers], + teardown: { destroyAfterEach: true }, + }); + const fixture = TestBed.createComponent(testComponent); + const component = fixture.componentInstance; + const errorHandler = testBed.inject(ErrorHandler); + + return { fixture, component, errorHandler }; +} diff --git a/libs/state/src/lib/rx-state.service.ts b/libs/state/src/lib/rx-state.service.ts index 6fe0449e27..addc97dcfa 100644 --- a/libs/state/src/lib/rx-state.service.ts +++ b/libs/state/src/lib/rx-state.service.ts @@ -53,9 +53,9 @@ export type ProjectValueReducer = ( @Injectable() export class RxState implements OnDestroy, Subscribable { private subscription = new Subscription(); - private handleError = inject(ErrorHandler).handleError; + private errorHandler = inject(ErrorHandler); private accumulator = createAccumulationObservable({ - handleError: this.handleError, + handleError: this.errorHandler.handleError.bind(this.errorHandler), }); private effectObservable = createSideEffectObservable();