diff --git a/libs/state/selections/src/lib/accumulation-observable.ts b/libs/state/selections/src/lib/accumulation-observable.ts index 0243734f24..7e132bc5cd 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: unknown) => console.error(e), +} = {}): Accumulator { const signal$ = merge( stateObservables.pipe( distinctUntilChanged(), @@ -45,10 +46,10 @@ 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), + 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 0c818e7c01..af20a53af9 100644 --- a/libs/state/spec/rx-state.component.spec.ts +++ b/libs/state/spec/rx-state.component.spec.ts @@ -1,10 +1,17 @@ -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 { + Component, + ErrorHandler, + Input, + Output, + Type, + ViewChild, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; 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' }; @@ -99,35 +106,45 @@ export class RxStateGlueContainerComponent extends RxState { describe('LocalProviderTestComponent', () => { let component: RxStateInjectionComponent; - let fixture: ComponentFixture; + let errorHandlerSpy: jest.Mock; beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [RxStateInjectionComponent], - teardown: { destroyAfterEach: true }, + const { component: c, errorHandler: e } = setupTestComponent({ + testComponent: RxStateInjectionComponent, + providers: [ + { provide: ErrorHandler, useValue: { handleError: jest.fn() } }, + ], }); - fixture = TestBed.createComponent(RxStateInjectionComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + component = c; + errorHandlerSpy = e.handleError as jest.Mock; }); 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: jest.Mock; beforeEach(() => { - 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; - fixture.detectChanges(); + component = c; + errorHandlerSpy = e.handleError as jest.Mock; }); it('should create', () => { @@ -135,4 +152,68 @@ 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')); + }); + }); }); + +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/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..addc97dcfa 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 errorHandler = inject(ErrorHandler); + private accumulator = createAccumulationObservable({ + handleError: this.errorHandler.handleError.bind(this.errorHandler), + }); private effectObservable = createSideEffectObservable(); /**