186

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.

Whelch
  • 2,003
  • 2
  • 11
  • 14
  • 3
    It does work for single page applications, when you try to close the page/tab. So any answers to the question would be only a partial solution if they ignore that fact. – 9ilsdx 9rvj 0lo Nov 22 '16 at 08:47

7 Answers7

363

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.

CularBytes
  • 9,924
  • 8
  • 76
  • 101
stewdebaker
  • 4,001
  • 3
  • 12
  • 10
  • 3
    This works really well @stewdebaker! I have one addition to this solution, see my answer below. – Jasper Risseeuw Feb 08 '17 at 08:42
  • 1
    import { Observable } from 'rxjs/Observable'; is missing from ComponentCanDeactivate – Mahmoud Kassem Jun 19 '17 at 21:42
  • 2
    I had to add `@Injectable()` to the PendingChangesGuard class. Also, I had to add PendingChangesGuard to my providers in `@NgModule` – spottedmahn Jun 30 '17 at 00:02
  • I had to add `import { HostListener } from '@angular/core';` – fidev Jul 19 '17 at 13:08
  • 1
    How can this solution be updated to just prevent the user from going back? – Anthony Oct 09 '17 at 21:08
  • fantastic twice cooked approach - works like a charm with the addition by @JasperRisseeuw - thanks stewdebaker ! – luchaos Jan 17 '18 at 14:33
  • Awesome piece of code, it works perfectly (I only used the `host` property in the `@Component` decorator instead of the `@HostListener` decorator) ... but I cannot figure out the part `implements CanDeactivate`. Why do you specify a subtype to the CanDeactivate type ? Is it important ? Is it for the method `canDeactivate(component: ComponentCanDeactivate)` defined below ? – M'sieur Toph' Feb 07 '18 at 13:21
  • 1
    @M'sieurToph', yes, I believe the subtype for `CanDeactivate` is supplied because it is used as--and should match--the type in the `PendingChangesGuard`'s `canDeactivate(component: ComponentCanDeactivate)` function. This way, the `PendingChangesGuard`'s `canDeactivate(component: ComponentCanDeactivate)` function knows that its `component` argument will have a `canDeactivate()` function of its own, since that component must implement the `ComponentCanDeactivate` interface. This follows the same pattern used in the Angular docs: https://angular.io/api/router/CanDeactivate – stewdebaker Feb 13 '18 at 23:13
  • It works well but now how can I pass an external parameter to the guard? I have to pass the user language in order to manage the message translation... – DarioN1 Jun 27 '18 at 16:21
  • 7
    It's worth noticing that you must return a boolean in case you are being navigated away with `beforeunload`. If you return an Observable, it will not work. You may want to change your interface for something like `canDeactivate: (internalNavigation: true | undefined)` and call your component like this: `return component.canDeactivate(true)`. This way, you can check if you are not navigating away internally to return `false` instead of an Observable. – jsgoupil Aug 12 '18 at 19:43
  • thanks for your solution! Is it possible to show the confirm dialog elsewhere in the code? I tried calling `this.canDeactivate()` from my component inside `ngOnInit()` to see if the dialog shows up elsewhere, besides when changing routes, but no success. – chris Aug 21 '18 at 15:13
  • @chris I believe you could accomplish that by adding the `PendingChangesGuard` to your component's constructor (e.g., `private pendingChangesGuard: PendingChangesGuard`) and then add a function to your component such as this: `checkCanDeactivate(){ this.pendingChangesGuard.canDeactivate(this); }` Then, calling `checkCanDeactivate()` will present the `confirm` dialog, if there are pending changes. Keep in mind that this isn't a standard way of using a guard, and there may be better ways to accomplish what you want. – stewdebaker Aug 21 '18 at 15:55
  • For some reason the HostListener didn't work for me (to guard against page refreshes), and yes I imported HostListener in my component. I had a working canDeactivate guard in place that worked when trying to route to another page but that didn't work with browser refreshes or when closing the browser. Using was the only thing that worked for me - see https://stackoverflow.com/a/51145053/2525272. – sfors says reinstate Monica Feb 13 '19 at 16:31
  • Sorry for the thread necro, this was perfect for what we needed. However, Safari on iOS deprecated the beforeunload event and the workarounds I've tried haven't worked - you get no confirmations of any kind. Any thoughts on how this could be made to work for iOS Safari? – Cyntech Aug 07 '19 at 07:17
  • I am getting component as null in PendingChangesGuard – PAA Oct 04 '19 at 12:56
  • I have implemented the code as the same above. but while I am trying to navigate away from the route. Inside canDeactive guard. The component received as null. So i get a error of cannot read property 'canDeactivate' of null. anyone has the same error? – Johnathan Li Feb 06 '20 at 00:19
  • 3
    I did everything as above, but it only works for route change but doesn't work for browser event `window:beforeunload`. How can I make this work only when user tries to close or refresh their browser? – Raj Baral Apr 22 '20 at 23:23
  • Can we import the guard into our component to reuse the `canDeactivate` function? I run into circular dependency. Any suggestions on how to make this more dry? – Austen Stone Jul 10 '20 at 14:02
  • When routing away, the `beforeunload` method runs twice. In my app, i have an Auth0 logout button and when i click it, it promts once and if i cancel it, it prompts again. Is anyone having a fix for it? Thanks. – Huzaifa Aug 11 '20 at 22:47
  • Is this possible when the "canDeactivate" function returns an observable? Can I do `canDeactivateResult$.subscribe(x => event.returnValue = x)` or something? – Ran Lottem May 25 '21 at 21:37
  • 1
    This answer should be the accepted one since it covers all the edge cases. Nicely done! thank you. – A.G. Nov 12 '21 at 16:46
  • Note : 'import { Observable } from 'rxjs/Observable';' should be: import { Observable } from 'rxjs'; – Jamie Nicholl-Shelley Jun 28 '22 at 14:28
  • Can I use something else, other than `confirm` to check if the user wants to leave? I wanted to show a personalized MatDialog – Awybin May 09 '23 at 20:11
  • @stewdebaker, we are using the same solution and it works for each page. But as I need to implement across all edit pages we are looking for solution to make it reusable code. Any suggestion here if we can achieve through directive which check form dirty – Mukesh Jun 23 '23 at 10:53
  • 1
    @Awybin I don't think you can use anything except 'confirm' with this solution because the essence of this solution is that it is based on a blocking call to 'confirm' function, which prevents navigation from the page only by accident (because 'confirm' blocks and freezes everything on the page). This solution does not properly intercept any unload event and does not deal with it like it should. If you start using anything else, e.g. MatDialog, it simply will not work. – Kirill G. Jul 09 '23 at 13:40
85

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<boolean>|Promise<boolean>|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<boolean> {
    return this.permissions.canActivate(this.currentUser, this.route.params.id);
  }
}
bootstrap(AppComponent, [
  CanActivateTeam,
  provideRouter([{
    path: 'team/:id',
    component: Team,
    canActivate: [CanActivateTeam]
  }])
);
Stephen Turner
  • 7,125
  • 4
  • 51
  • 68
Günter Zöchbauer
  • 623,577
  • 216
  • 2,003
  • 1,567
  • 42
    Unlike what the OP asked for, CanDeactivate is -currently- not hooking on the onbeforeunload event (unfortunately). Meaning that if the user tries to navigate to an external URL, close the window, etc. CanDeactivate will not be triggered. It seems to work only when the user is staying within the app. – Christophe Vidal Jul 20 '16 at 12:04
  • 3
    @ChristopheVidal is correct. Please see my answer for a solution that also covers navigating to an external URL, closing the window, reloading the page, etc. – stewdebaker Jan 20 '17 at 15:35
  • This works when changing routes. What if it's SPA ? Any other way to achieve this ? – sujay kodamala Jan 30 '17 at 21:40
  • http://stackoverflow.com/questions/36763141/is-there-any-lifecycle-hook-like-window-onbeforeunload-in-angular2 You would this need with routes as well. If the window is closed or navigated away from the current site `canDeactivate` won't work. – Günter Zöchbauer Jan 30 '17 at 21:42
71

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)";
    }
  }
}
Jasper Risseeuw
  • 1,207
  • 11
  • 11
  • 5
    Good catch @JasperRisseeuw! I didn't realize that IE/Edge handled this differently. This is a very useful solution for those who need to support IE/Edge and don't want the `false` to show in the confirm dialog. I made a small edit to your answer to include the `'$event'` in the `@HostListener` annotation, since that is required in order to be able to access it in the `unloadNotification` function. – stewdebaker Feb 08 '17 at 15:21
  • 1
    Thanks, I forgot to copy ", ['$event']" from my own code so good catch from you as well! – Jasper Risseeuw Feb 09 '17 at 08:57
  • the only solution the works was this one (using Edge). all others works but only shows default dialog message (Chrome/Firefox), not my text... I even [asked a question](http://stackoverflow.com/questions/42142053/candeactivate-confirm-message) to understand what is happening – Elmer Dantas Feb 10 '17 at 12:23
  • @ElmerDantas please see [my answer](http://stackoverflow.com/a/42207299/7307355) to your question for an explanation of why the default dialog message shows in Chrome/Firefox. – stewdebaker Feb 13 '17 at 15:17
  • This solution works only with generic confirmation dialog that `beforeunload` event executes, what if I want to show a custom dialog? I have tried this solution but it didn't worked because of it. Look at https://stackoverflow.com/questions/42142053/candeactivate-confirm-message for more details. Because of it, how can I accomplish it? – Byron Lopez Dec 14 '17 at 00:45
  • @ByronLopez as described in my example IE and Edge will display the message you set in the returnValue property of $event in the generic dialog. The other browers do not allow you to set a custom message afaik. You could ofcourse show a custom message in a popup (or whatever method you want), but you need to handle the window:beforeunload event and the browser will show you a generic error you need to close first. – Jasper Risseeuw Dec 15 '17 at 10:20
  • @JasperRisseeuw see my answer below :) – Byron Lopez Dec 16 '17 at 17:04
  • @ByronLopez, yes I know, but if the user navigates away from your app (type in another URL, closes the browser tab) and you still want to warn the user you still need to fall back on the `window:beforeuload` event – Jasper Risseeuw Dec 17 '17 at 23:07
  • It doesn't work for me, it still displays the 'false' message in IE. Am I missing something? The method 'unloadNotification' doesn't get hit (IE)... – tudor.iliescu Mar 07 '18 at 07:48
  • 2
    Actually, it works, sorry! I had to reference the guard in the module providers. – tudor.iliescu Mar 07 '18 at 07:56
  • I am not sure this is relevant but I am using angular 9+ and both messages show up when I try to navigate away clicking on a routerlink within the application. I get the modal first and then the generic Chrome message about leaving the site. Anyone has any idea? Thank you. – user906573 Apr 08 '20 at 23:09
  • If `canDeactivate` returns an observable, how do I get a synchronous value to return to the `@HostListener`? – Ran Lottem May 25 '21 at 21:33
20

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
yankee
  • 38,872
  • 15
  • 103
  • 162
Chris Halcrow
  • 28,994
  • 18
  • 176
  • 206
  • 1
    is there any chance of showing custom model for browser refresh ? – k11k2 Aug 10 '18 at 10:01
  • There must be a way, although this solution was OK for my needs. If I time I'll develop things further, although I won't be able to update this answer for quite a while sorry! – Chris Halcrow Aug 12 '18 at 23:12
19

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.

Stephen Paul
  • 37,253
  • 15
  • 92
  • 74
  • Currently from ng 12.1.x forward there is an option in router https://angular.io/api/router/ExtraOptions#canceledNavigationResolution that allows us to get rid of this hack. – ToM Feb 24 '22 at 14:28
6

For Angular 15, class-based route guards have been deprecated and replaced by function-based route guards. For more details, see this link.

I took @stewdebaker's excellent solution and made the necessary changes. The only changes are to the guard itself and that you don't need any module updates.

Component (no change from @stewdebaker's)

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 { CanDeactivateFn, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';

// Full solution found here: https://stackoverflow.com/a/41187919/74276
// and then changed to use the function-based method of doing route guards
// Updated solution found here: https://stackoverflow.com/a/75769104/74276

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

export const PendingChangesGuard: CanDeactivateFn<ComponentCanDeactivate> = (
  component: ComponentCanDeactivate
): Observable<boolean | UrlTree> => {
  return new Observable<boolean | UrlTree>((obs) => {
    // if there are no pending changes, just allow deactivation; else confirm first
    return component.canDeactivate()
      ? obs.next(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
        obs.next(
          confirm(
            'WARNING: You have unsaved changes. Press Cancel to go back and save these changes, or OK to lose these changes.'
          )
        );
  });
};

Routes (no changes from @stewdebaker's)

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

(no module changes needed for a function-based route guard)

Kirk Liemohn
  • 7,733
  • 9
  • 46
  • 57
-2

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

Byron Lopez
  • 193
  • 2
  • 8