Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(state): handle error emission in connect using ErrorHandler #1553

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions libs/state/selections/src/lib/accumulation-observable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ const defaultAccumulator: AccumulationFn = <T>(st: T, sl: Partial<T>): T => {
return { ...st, ...sl };
};

export function createAccumulationObservable<T extends object>(
export function createAccumulationObservable<T extends object>({
stateObservables = new Subject<Observable<Partial<T>>>(),
stateSlices = new Subject<Partial<T>>(),
accumulatorObservable = new BehaviorSubject(defaultAccumulator)
): Accumulator<T> {
accumulatorObservable = new BehaviorSubject(defaultAccumulator),
handleError = (e: unknown) => console.error(e),
} = {}): Accumulator<T> {
const signal$ = merge(
stateObservables.pipe(
distinctUntilChanged(),
Expand All @@ -45,10 +46,10 @@ export function createAccumulationObservable<T extends object>(
),
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<T> = signal$.pipe(publishReplay(1));
Expand Down
119 changes: 100 additions & 19 deletions libs/state/spec/rx-state.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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' };

Expand Down Expand Up @@ -99,40 +106,114 @@ export class RxStateGlueContainerComponent extends RxState<PrimitiveState> {

describe('LocalProviderTestComponent', () => {
let component: RxStateInjectionComponent;
let fixture: ComponentFixture<RxStateInjectionComponent>;
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<RxStateInheritanceComponent>;
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', () => {
stateChecker.checkSubscriptions(component, 1);
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<any>;
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 };
}
58 changes: 32 additions & 26 deletions libs/state/spec/rx-state.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ import { TestScheduler } from 'rxjs/testing';
import { createStateChecker } from './fixtures';

function setupState<T extends object>(cfg: { initialState?: T }) {
const testBed = TestBed.configureTestingModule({
providers: [RxState],
});
const state = testBed.inject(RxState<T>);
const { initialState } = { ...cfg };
const state = new RxState<T>();
if (initialState) {
state.set(initialState);
}
Expand All @@ -36,56 +39,56 @@ beforeEach(() => {
});

describe('RxStateService', () => {
let service: RxState<PrimitiveState>;

beforeEach(() => {
TestBed.configureTestingModule({
teardown: { destroyAfterEach: true },
});
service = setupState({});
});
let state: RxState<PrimitiveState>;

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<PrimitiveState>();
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<PrimitiveState>();
state.subscribe();

state.set({ num: 42 });
expectObservable(state.$.pipe(pluck('num'))).toBe('');
});
});

it('should return new changes', () => {
const state = new RxState<PrimitiveState>();
state.subscribe();
state.set({ num: 42 });
const slice$ = state.$.pipe(select('num'));
Expand All @@ -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<PrimitiveState>();
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<PrimitiveState>();
testScheduler.run(() => {
state.subscribe();
state.set({ num: 42 });
const slice$ = state.select('num');
Expand Down Expand Up @@ -189,7 +192,7 @@ describe('RxStateService', () => {

it('should return initial state', () => {
testScheduler.run(({ expectObservable }) => {
const state = new RxState<PrimitiveState>();
const state = setupState({});
state.subscribe();

state.set({ num: 42 });
Expand Down Expand Up @@ -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) => {
Expand All @@ -301,6 +305,7 @@ describe('RxStateService', () => {
).toThrowError('wrong param');
});
});

describe('with state project partial', () => {
it('should add new slices', () => {
const state = setupState({});
Expand All @@ -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<PrimitiveState>({});
Expand Down
8 changes: 5 additions & 3 deletions libs/state/src/lib/rx-state.service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -53,8 +53,10 @@ export type ProjectValueReducer<T, K extends keyof T, V> = (
@Injectable()
export class RxState<T extends object> implements OnDestroy, Subscribable<T> {
private subscription = new Subscription();

private accumulator = createAccumulationObservable<T>();
private errorHandler = inject(ErrorHandler);
private accumulator = createAccumulationObservable<T>({
handleError: this.errorHandler.handleError.bind(this.errorHandler),
});
private effectObservable = createSideEffectObservable();

/**
Expand Down