Approximately a year ago, I have implemented the first e2e tests on a project. It was a rather big application using JAVA SpringBoot on the back-end and Angular on the front-end. We used Protractor as a testing tool, which uses Selenium. In the front-end code there was a service, which had an error handler method. When that method was called, a modal dialog popped up and the user could see the details of the errors and the stacktrace.
The problem was that while it has tracked every error that happened on the back-end, the front-end failed silently. TypeErrors
, ReferenceErrors
and other uncaught exceptions were logged only to the console. When something went wrong during e2e test runs the screenshot, which was taken when the test step has failed, has shown absolutely nothing. Have fun debugging that!
Luckily Angular has a built-in way of handling errors and it is extremely easy to use. We just have to create our own service, which implements Angular's ErrorHandler
interface:
import { ErrorHandler, Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler{
constructor() {}
handleError(error: any) {
// Implement your own way of handling errors
}
}
While we could easily provide our service in our AppModule
, it might be a good idea to provide this service in a separate module. This way we could create our own library and use it in our future projects as well:
// ERROR HANDLER MODULE
import {ErrorHandler, ModuleWithProviders, NgModule} from '@angular/core';
import {ErrorHandlerComponent} from './components/error-handler.component';
import {FullscreenOverlayContainer, OverlayContainer, OverlayModule} from '@angular/cdk/overlay';
import {ErrorHandlerService} from './error-handler.service';
import {A11yModule} from '@angular/cdk/a11y';
@NgModule({
declarations: [ErrorHandlerComponent],
imports: [CommonModule, OverlayModule, A11yModule],
entryComponents: [ErrorHandlerComponent]
})
export class ErrorHandlerModule {
public static forRoot(): ModuleWithProviders {
return {
ngModule: ErrorHandlerModule,
providers: [
{provide: ErrorHandler, useClass: ErrorHandlerService},
{provide: OverlayContainer, useClass: FullscreenOverlayContainer},
]
};
}
}
We used the Angular CLI
for generating the ErrorHandlerModule, so we already have a component generated which can be our modal dialog's content. In order for us to be able to put it inside an Angular CDK overlay, it needs to be an entryComponent. That is why we have put it into the ErrorHandlerModule
's entryComponents array.
We also added some imports. OverlayModule
and A11yModule
comes from the CDK module. They are needed for creating our overlay and to trap focus when our error dialog is opened. As you can see, we provide OverlayContainer
using the FullscreenOverlayContainer
class because if an error occurs, we want to restrict our users' interactions to our error modal. If we don't have a fullscreen backdrop, the users might be able to interact with the application and cause further errors. Let's add our newly created module to our AppModule
:
// APP MODULE
import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component';
import {MainComponent} from './main/main.component';
import {ErrorHandlerModule} from '@btapai/ng-error-handler';
import {HttpClientModule} from '@angular/common/http';
@NgModule({
declarations: [ AppComponent, MainComponent ],
imports: [
BrowserModule,
HttpClientModule,
ErrorHandlerModule.forRoot(),
AppRoutingModule,
],
bootstrap: [AppComponent]
})
export class AppModule {
}
Now that we have our ErrorHandlerService
in place, we can start implementing the logic. We are going to create a modal dialog, which displays the error in a clean, readable way. This dialog will have an overlay/backdrop and it will be dynamically placed into the DOM with the help of the Angular CDK. Let's install it:
npm install @angular/cdk --save
According to the documentation, the Overlay
component needs some pre-built css files. Now if we would use Angular Material in our project it wouldn't be necessary, but that is not always the case. Let's import the overlay css in our styles.css
file. Note, that if you already use Angular Material in your app, you don't need to import this css.
@import '~@angular/cdk/overlay-prebuilt.css';
Let's use our handleError
method to create our modal dialog. It is important to know, that the ErrorHandler
service is part of the application initialisation phase of Angular. In order to avoid a rather nasty cyclic dependency error, we use the injector as its only constructor parameter. We use Angular's dependency injection system when the actual method is called. Let's import the overlay from the CDK and attach our ErrorHandlerComponent
into the DOM:
// ... imports
@Injectable({
providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler {
constructor(private injector: Injector) {}
handleError(error: any) {
const overlay: Overlay = this.injector.get(Overlay);
const overlayRef: OverlayRef = overlay.create();
const ErrorHandlerPortal: ComponentPortal<ErrorHandlerComponent> = new ComponentPortal(ErrorHandlerComponent);
const compRef: ComponentRef<ErrorHandlerComponent> = overlayRef.attach(ErrorHandlerPortal);
}
}
Let's turn our attention towards our error handler modal. A pretty simple working solution would be displaying the error message and the stacktrace. Let's also add a 'dismiss' button to the bottom.
// imports
export const ERROR_INJECTOR_TOKEN: InjectionToken<any> = new InjectionToken('ErrorInjectorToken');
@Component({
selector: 'btp-error-handler',
// TODO: template will be implemented later
template: `${error.message}<br><button (click)="dismiss()">DISMISS</button>`
styleUrls: ['./error-handler.component.css'],
})
export class ErrorHandlerComponent {
private isVisible = new Subject();
dismiss$: Observable<{}> = this.isVisible.asObservable();
constructor(@Inject(ERROR_INJECTOR_TOKEN) public error) {
}
dismiss() {
this.isVisible.next();
this.isVisible.complete();
}
}
As you can see, the component itself is pretty simple. We are going to use two rather important directives in the template, to make the dialog accessible. The first one is the cdkTrapFocus
which will trap the focus when the dialog is rendered. This means that the user cannot focus elements behind our modal dialog. The second directive is the cdkTrapFocusAutoCapture
which will automatically focus the first focusable element inside our focus trap. Also, it will automatically restore the focus to the previously focused element, when our dialog is closed.
In order to be able to display the error's properties, we need to inject it using the constructor. For that we need our own injectionToken. We also created a rather simple logic for emitting a dismiss event using a subject and the dismiss$
property. Let's connect this with our handleError
method in our service and do some refactoring.
// imports
export const DEFAULT_OVERLAY_CONFIG: OverlayConfig = {
hasBackdrop: true,
};
@Injectable({
providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler {
private overlay: Overlay;
constructor(private injector: Injector) {
this.overlay = this.injector.get(Overlay);
}
handleError(error: any): void {
const overlayRef = this.overlay.create(DEFAULT_OVERLAY_CONFIG);
this.attachPortal(overlayRef, error).subscribe(() => {
overlayRef.dispose();
});
}
private attachPortal(overlayRef: OverlayRef, error: any): Observable<{}> {
const ErrorHandlerPortal: ComponentPortal<ErrorHandlerComponent> = new ComponentPortal(
ErrorHandlerComponent,
null,
this.createInjector(error)
);
const compRef: ComponentRef<ErrorHandlerComponent> = overlayRef.attach(ErrorHandlerPortal);
return compRef.instance.dismiss$;
}
private createInjector(error: any): PortalInjector {
const injectorTokens = new WeakMap<any, any>([
[ERROR_INJECTOR_TOKEN, error]
]);
return new PortalInjector(this.injector, injectorTokens);
}
}
Let's focus on providing the error as an injected parameter first. As you can see, the ComponentPortal
class expects one must-have parameter, which is the component itself. The second parameter is a ViewContainerRef
which would have an effect of the component's logical place of the component tree. The third parameter is our createInejctor
method. As you can see it returns a new PortalInjector
instance. Let's take a quick look at its underlying implementation:
export class PortalInjector implements Injector {
constructor(
private _parentInjector: Injector,
private _customTokens: WeakMap<any, any>) { }
get(token: any, notFoundValue?: any): any {
const value = this._customTokens.get(token);
if (typeof value !== 'undefined') {
return value;
}
return this._parentInjector.get<any>(token, notFoundValue);
}
}
As you can see, it expects an Injector
as a first parameter and a WeakMap for custom tokens. We did exactly that using our ERROR_INJECTOR_TOKEN
which is associated with our error itself. The created PortalInjector
is used for the proper instantiation of our ErrorHandlerComponent
, it will make sure that the error itself will be present in the component.
At last, our attachPortal
method returns the recently instantiated component's dismiss$
property. We subscribe to it, and when it changes we call the .dispose()
on our overlayRef
. And our error modal dialog is dismissed. Note, that we also call complete on our subject inside the component, therefore, we don't need to unsubscribe from it.
Now, this is excellent for errors that are thrown when there's an issue in the clinet side code. But we are creating web applications and we use API endpoints. So what happens when a REST endpint gives back an error?
We can handle every error in its own service, but do we really want to? If everything is alright errors won't be thrown. If there are specific requirements, for example to handle 418 status code with a flying unicorn you could implement its handler in its service. But when we face rather common errors, like 404 or 503 we might want to display that in this same error dialog.
Let's just quickly gather what happens when an HttpErrorResponse
is thrown. It is going to happen async, so probably we are going to face some change detection issues. This error type has different properties than a simple error, therefore, we might need a sanitiser method. Now let's get into it by creating a rather simple interface for the SanitisedError
:
export interface SanitizedError {
message: string;
details: string[];
}
Let's create a template for our ErrorHandlerComponent
:
// Imports
@Component({
selector: 'btp-error-handler',
template: `
<section cdkTrapFocus [cdkTrapFocusAutoCapture]="true" class="btp-error-handler__container">
<h2>Error</h2>
<p>{{error.message}}</p>
<div class="btp-error-handler__scrollable">
<ng-container *ngFor="let detail of error.details">
<div>{{detail}}</div>
</ng-container>
</div>
<button class="btp-error-handler__dismiss button red" (click)="dismiss()">DISMISS</button>
</section>`,
styleUrls: ['./error-handler.component.css'],
})
export class ErrorHandlerComponent implements OnInit {
// ...
}
We wrapped the whole modal into a <section>
and we added the cdkTrapFocus
directive to it. This directive will prevent the user from navigating in the DOM behind our overlay/modal. The [cdkTrapFocusAutoCapture]="true"
makes sure that the dismiss button is focused immediately. When the modal is closed the previously focused element will get back the focus. We simply display the error message and the details using *ngFor. Let's jump back into our ErrorHandlerService
:
// Imports
@Injectable({
providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler {
// Constructor
handleError(error: any): void {
const sanitised = this.sanitiseError(error);
const ngZone = this.injector.get(NgZone);
const overlayRef = this.overlay.create(DEFAULT_OVERLAY_CONFIG);
ngZone.run(() => {
this.attachPortal(overlayRef, sanitised).subscribe(() => {
overlayRef.dispose();
});
});
}
// ...
private sanitiseError(error: Error | HttpErrorResponse): SanitizedError {
const sanitisedError: SanitizedError = {
message: error.message,
details: []
};
if (error instanceof Error) {
sanitisedError.details.push(error.stack);
} else if (error instanceof HttpErrorResponse) {
sanitisedError.details = Object.keys(error)
.map((key: string) => `${key}: ${error[key]}`);
} else {
sanitisedError.details.push(JSON.stringify(error));
}
return sanitisedError;
}
// ...
}
With a rather simple sanitiseError
method we create an object which is based on our previously defined interface. We check for error types and populate the data accordingly. The more interesting part is using the injector to get ngZone. When an error happens asynchronously, it usually happens outside change detection. We wrap our attachPortal
with ngZone.run(/* ... */)
, so when an HttpErrorResponse
is caught, it is rendered properly in our modal.
While the current state works nicely, it still lacks customisation. We use the Overlay from the CDK module, so exposing an injection token for custom configurations would be nice. Another important shortcoming of this module is that when this module is used, another module can't be used for error handling. For example, integrating Sentry would require you to implement a similar, but lightweight ErrorHandler module. In order to be able to use both, we should implement the possibility of using hooks inside our error handler. First, let's create our InjectionToken
and our default configuration:
import {InjectionToken} from '@angular/core';
import {DEFAULT_OVERLAY_CONFIG} from './constants/error-handler.constants';
import {ErrorHandlerConfig} from './interfaces/error-handler.interfaces';
export const DEFAULT_ERROR_HANDLER_CONFIG: ErrorHandlerConfig = {
overlayConfig: DEFAULT_OVERLAY_CONFIG,
errorHandlerHooks: []
};
export const ERROR_HANDLER_CONFIG: InjectionToken<ErrorHandlerConfig> = new InjectionToken('btp-eh-conf');
Then provide it with our module, using our existing forRoot
method:
@NgModule({
declarations: [ErrorHandlerComponent],
imports: [CommonModule, OverlayModule, A11yModule],
entryComponents: [ErrorHandlerComponent]
})
export class ErrorHandlerModule {
public static forRoot(): ModuleWithProviders {
return {
ngModule: ErrorHandlerModule,
providers: [
{provide: ErrorHandler, useClass: ErrorHandlerService},
{provide: OverlayContainer, useClass: FullscreenOverlayContainer},
{provide: ERROR_HANDLER_CONFIG, useValue: DEFAULT_ERROR_HANDLER_CONFIG}
]
};
}
}
Then integrate this config handling into our ErrorHandlerService
as well:
// Imports
@Injectable({
providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler {
// ...
handleError(error: any): void {
const sanitised = this.sanitiseError(error);
const {overlayConfig, errorHandlerHooks} = this.injector.get(ERROR_HANDLER_CONFIG);
const ngZone = this.injector.get(NgZone);
this.runHooks(errorHandlerHooks, error);
const overlayRef = this.createOverlayReference(overlayConfig);
ngZone.run(() => {
this.attachPortal(overlayRef, sanitised).subscribe(() => {
overlayRef.dispose();
});
});
}
// ...
private runHooks(errorHandlerHooks: Array<(error: any) => void> = [], error): void {
errorHandlerHooks.forEach((hook) => hook(error));
}
private createOverlayReference(overlayConfig: OverlayConfig): OverlayRef {
const overlaySettings: OverlayConfig = {...DEFAULT_OVERLAY_CONFIG, ...overlayConfig};
return this.overlay.create(overlaySettings);
}
// ...
}
And we are almost ready. Let's integrate a third-party error handler hook into our application:
// Imports
const CustomErrorHandlerConfig: ErrorHandlerConfig = {
errorHandlerHooks: [
ThirdPartyErrorLogger.logErrorMessage,
LoadingIndicatorControl.stopLoadingIndicator,
]
};
@NgModule({
declarations: [
AppComponent,
MainComponent
],
imports: [
BrowserModule,
HttpClientModule,
ErrorHandlerModule.forRoot(),
AppRoutingModule,
],
providers: [
{provide: ERROR_HANDLER_CONFIG, useValue: CustomErrorHandlerConfig}
],
bootstrap: [AppComponent]
})
export class AppModule {
}
As you can see, handling errors is an extremely important part of software development, but it can also be fun.
Thank you very much for reading this blog post. If you prefer reading code, please check out my ng-reusables git repository. You can also try out the implementation as well using this npm package.
Top comments (0)