Warn user of unsaved changes before leaving page

AngularAngular2 Routing

Angular Problem Overview


I would like to warn users of unsaved changes before they leave a particular page of my angular 2 app. Normally I would use window.onbeforeunload, but that doesn't work for single page applications.

I've found that in angular 1, you can hook into the $locationChangeStart event to throw up a confirm box for the user, but I haven't seen anything that shows how to get this working for angular 2, or if that event is even still present. I've also seen plugins for ag1 that provide functionality for onbeforeunload, but again, I haven't seen any way to use it for ag2.

I'm hoping someone else has found a solution to this problem; either method will work fine for my purposes.

Angular Solutions


Solution 1 - Angular

To also cover guards against browser refreshes, closing the window, etc. (see @ChristopheVidal's comment to Günter's answer for details on the issue), I have found it helpful to add the @HostListener decorator to your class's canDeactivate implementation to listen for the beforeunload window event. When configured correctly, this will guard against both in-app and external navigation at the same time.

For example:

Component:

import { ComponentCanDeactivate } from './pending-changes.guard';
import { HostListener } from '@angular/core';
import { Observable } from 'rxjs/Observable';

export class MyComponent implements ComponentCanDeactivate {
  // @HostListener allows us to also guard against browser refresh, close, etc.
  @HostListener('window:beforeunload')
  canDeactivate(): Observable<boolean> | boolean {
    // insert logic to check if there are pending changes here;
    // returning true will navigate without confirmation
    // returning false will show a confirm dialog before navigating away
  }
}

Guard:

import { CanDeactivate } from '@angular/router';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';

export interface ComponentCanDeactivate {
  canDeactivate: () => boolean | Observable<boolean>;
}

@Injectable()
export class PendingChangesGuard implements CanDeactivate<ComponentCanDeactivate> {
  canDeactivate(component: ComponentCanDeactivate): boolean | Observable<boolean> {
    // if there are no pending changes, just allow deactivation; else confirm first
    return component.canDeactivate() ?
      true :
      // NOTE: this warning message will only be shown when navigating elsewhere within your angular app;
      // when navigating away from your angular app, the browser will show a generic warning message
      // see http://stackoverflow.com/a/42207299/7307355
      confirm('WARNING: You have unsaved changes. Press Cancel to go back and save these changes, or OK to lose these changes.');
  }
}

Routes:

import { PendingChangesGuard } from './pending-changes.guard';
import { MyComponent } from './my.component';
import { Routes } from '@angular/router';

export const MY_ROUTES: Routes = [
  { path: '', component: MyComponent, canDeactivate: [PendingChangesGuard] },
];

Module:

import { PendingChangesGuard } from './pending-changes.guard';
import { NgModule } from '@angular/core';
  
@NgModule({
  // ...
  providers: [PendingChangesGuard],
  // ...
})
export class AppModule {}

NOTE: As @JasperRisseeuw pointed out, IE and Edge handle the beforeunload event differently from other browsers and will include the word false in the confirm dialog when the beforeunload event activates (e.g., browser refreshes, closing the window, etc.). Navigating away within the Angular app is unaffected and will properly show your designated confirmation warning message. Those who need to support IE/Edge and don't want false to show/want a more detailed message in the confirm dialog when the beforeunload event activates may also want to see @JasperRisseeuw's answer for a workaround.

Solution 2 - Angular

The router provides a lifecycle callback CanDeactivate

for more details see the guards tutorial

> class UserToken {} > class Permissions { > canActivate(user: UserToken, id: string): boolean { > return true; > } > } > @Injectable() > class CanActivateTeam implements CanActivate { > constructor(private permissions: Permissions, private currentUser: UserToken) {} > canActivate( > route: ActivatedRouteSnapshot, > state: RouterStateSnapshot > ): Observable|Promise|boolean { > return this.permissions.canActivate(this.currentUser, route.params.id); > } > } > @NgModule({ > imports: [ > RouterModule.forRoot([ > { > path: 'team/:id', > component: TeamCmp, > canActivate: [CanActivateTeam] > } > ]) > ], > providers: [CanActivateTeam, UserToken, Permissions] > }) > class AppModule {}

original (RC.x router) > class CanActivateTeam implements CanActivate { > constructor(private permissions: Permissions, private currentUser: UserToken) {} > canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):Observable { > return this.permissions.canActivate(this.currentUser, this.route.params.id); > } > } > bootstrap(AppComponent, [ > CanActivateTeam, > provideRouter([{ > path: 'team/:id', > component: Team, > canActivate: [CanActivateTeam] > }]) > );

Solution 3 - Angular

The example with the @Hostlistener from stewdebaker works really well, but I made one more change to it because IE and Edge display the "false" that is returned by the canDeactivate() method on the MyComponent class to the end user.

Component:

import {ComponentCanDeactivate} from "./pending-changes.guard";
import { Observable } from 'rxjs'; // add this line

export class MyComponent implements ComponentCanDeactivate {
  
  canDeactivate(): Observable<boolean> | boolean {
    // insert logic to check if there are pending changes here;
    // returning true will navigate without confirmation
    // returning false will show a confirm alert before navigating away
  }

  // @HostListener allows us to also guard against browser refresh, close, etc.
  @HostListener('window:beforeunload', ['$event'])
  unloadNotification($event: any) {
    if (!this.canDeactivate()) {
		$event.returnValue = "This message is displayed to the user in IE and Edge when they navigate without using Angular routing (type another URL/close the browser/etc)";
	}
  }
}

Solution 4 - Angular

I've implemented the solution from @stewdebaker which works really well, however I wanted a nice bootstrap popup instead of the clunky standard JavaScript confirm. Assuming you're already using ngx-bootstrap, you can use @stwedebaker's solution, but swap the 'Guard' for the one I'm showing here. You also need to introduce ngx-bootstrap/modal, and add a new ConfirmationComponent:

Guard

(replace 'confirm' with a function that will open a bootstrap modal - displaying a new, custom ConfirmationComponent):

import { Component, OnInit } from '@angular/core';
import { ConfirmationComponent } from './confirmation.component';

import { CanDeactivate } from '@angular/router';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { BsModalService } from 'ngx-bootstrap/modal';
import { BsModalRef } from 'ngx-bootstrap/modal';

export interface ComponentCanDeactivate {
  canDeactivate: () => boolean | Observable<boolean>;
}

@Injectable()
export class PendingChangesGuard implements CanDeactivate<ComponentCanDeactivate> {

  modalRef: BsModalRef;

  constructor(private modalService: BsModalService) {};

  canDeactivate(component: ComponentCanDeactivate): boolean | Observable<boolean> {
    // if there are no pending changes, just allow deactivation; else confirm first
    return component.canDeactivate() ?
      true :
      // NOTE: this warning message will only be shown when navigating elsewhere within your angular app;
      // when navigating away from your angular app, the browser will show a generic warning message
      // see http://stackoverflow.com/a/42207299/7307355
      this.openConfirmDialog();
  }

  openConfirmDialog() {
    this.modalRef = this.modalService.show(ConfirmationComponent);
    return this.modalRef.content.onClose.map(result => {
        return result;
    })
  }
}

confirmation.component.html

<div class="alert-box">
    <div class="modal-header">
        <h4 class="modal-title">Unsaved changes</h4>
    </div>
    <div class="modal-body">
        Navigate away and lose them?
    </div>
    <div class="modal-footer">
        <button type="button" class="btn btn-secondary" (click)="onConfirm()">Yes</button>
        <button type="button" class="btn btn-secondary" (click)="onCancel()">No</button>        
    </div>
</div>

confirmation.component.ts

import { Component } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { BsModalRef } from 'ngx-bootstrap/modal';

@Component({
    templateUrl: './confirmation.component.html'
})
export class ConfirmationComponent {

    public onClose: Subject<boolean>;

    constructor(private _bsModalRef: BsModalRef) {

    }

    public ngOnInit(): void {
        this.onClose = new Subject();
    }

    public onConfirm(): void {
        this.onClose.next(true);
        this._bsModalRef.hide();
    }

    public onCancel(): void {
        this.onClose.next(false);
        this._bsModalRef.hide();
    }
}

And since the new ConfirmationComponent will be displayed without using a selector in an html template, it needs to be declared in entryComponents (not needed anymore with Ivy) in your root app.module.ts (or whatever you name your root module). Make the following changes to app.module.ts:

app.module.ts

import { ModalModule } from 'ngx-bootstrap/modal';
import { ConfirmationComponent } from './confirmation.component';

@NgModule({
  declarations: [
     ...
     ConfirmationComponent
  ],
  imports: [
     ...
     ModalModule.forRoot()
  ],
  entryComponents: [ConfirmationComponent] // Only when using old ViewEngine

Solution 5 - Angular

June 2020 answer:

Note that all solutions proposed up until this point do not deal with a significant known flaw with Angular's canDeactivate guards:

  1. User clicks the 'back' button in the browser, dialog displays, and user clicks CANCEL.
  2. User clicks the 'back' button again, dialog displays, and user clicks CONFIRM.
  3. Note: user is navigated back 2 times, which could even take them out of the app altogether :(

This has been discussed here, here, and at length here


Please see my solution to the problem demonstrated here which safely works around this issue*. This has been tested on Chrome, Firefox, and Edge.


* IMPORTANT CAVEAT: At this stage, the above will clear the forward history when the back button is clicked, but preserve the back history. This solution will not be appropriate if preserving your forward history is vital. In my case, I typically use a master-detail routing strategy when it comes to forms, so maintaining forward history is not important.

Solution 6 - Angular

The solution was easier than expected, don't use href because this isn't handled by Angular Routing use routerLink directive instead.

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionWhelchView Question on Stackoverflow
Solution 1 - AngularstewdebakerView Answer on Stackoverflow
Solution 2 - AngularGünter ZöchbauerView Answer on Stackoverflow
Solution 3 - AngularJasper RisseeuwView Answer on Stackoverflow
Solution 4 - AngularChris HalcrowView Answer on Stackoverflow
Solution 5 - AngularStephen PaulView Answer on Stackoverflow
Solution 6 - AngularByron LopezView Answer on Stackoverflow