angular: Deactivation Guard breaks the routing history

I’m submitting a … (check one with “x”)

[X ] bug report => search github for a similar issue or PR before submitting
[ ] feature request
[ ] support request => Please do not submit support request here, instead see https://github.com/angular/angular/blob/master/CONTRIBUTING.md#question

Current behavior Currently, I have a deactivation guard on a route which will return false or true depending on a condition. To get to this guarded route, the user must pass though 3 navigation step. Now, once on the guarded route, when using location.back(), the guard is called. If it returns true, the previous route is loaded. If the guard returns false, the navigation is cancelled. But if we redo a Location.back() after a failed navigation, the previous route that will be loaded will be 2 steps in the history instead of 1 (user perception).

Workflow

Main    -- navigate to     --> Route 1
Route 1 -- navigate to     --> Route 2
Route 2 -- navigate to     --> Route 3
Route 3 -- location.back() --> guard returns true  --> Route 2
Route 2 -- navigate to     --> Route 3
Route 3 -- location.back() --> guard returns false --> Route 3
Route 3 -- location.back() --> guard returns true  --> Route 1  (should be Route 2)

Expected behavior An expected behavior for a user would be that navigating back brings back to the previous routed page. Workflow

Main    -- navigate to     --> Route 1
Route 1 -- navigate to     --> Route 2
Route 2 -- navigate to     --> Route 3
Route 3 -- location.back() --> guard returns true  --> Route 2
Route 2 -- navigate to     --> Route 3
Route 3 -- location.back() --> guard returns false --> Route 3
Route 3 -- location.back() --> guard returns true  --> Route 2  (expected)

Minimal reproduction of the problem with instructions Plnkr

  1. Click button Nav to route1
  2. Click button Nav to route2
  3. Click button Nav to route3
  4. Click button Block Nav Back
  5. Click button Nav back
    • BOGUE: The location.back() routed on Route1 instead of Route2

Personnal investigation After some investigation, I saw that in routerState$.then (router.ts line 752) this logic used when navigationIsSuccessful == false is pretty simply but it is the actual cause of this bug. Basically, when a deactivation guard is hit, the location of the browser is already changed to the previous route. Which means that when the guard returns false, the routerState$ runs his logic and calls resetUrlToCurrentUrlTree(). At this point we can see that we replace the state of the current location. But by doing this, we loose that route in the history which means that in my plunker, if we click the block nav back 3 times and then click the nav back we will actually kill the application.

What is the motivation / use case for changing the behavior? This is for me a pretty big bug since a guard that returns false breaks alters the current routing history. In the case of our application this breaks the workflow and brings wrong business scopes to a user.

Please tell us about your environment:

Windows 10, NPM, Nodejs, Visual Studio 2015 (using nodejs for typescript compilation)

  • Angular version: 2.3.3

  • Browser: [ all ]

  • Language: [TypeScript 2.0.10 | ES5]

About this issue

  • Original URL
  • State: closed
  • Created 8 years ago
  • Reactions: 143
  • Comments: 112 (16 by maintainers)

Commits related to this issue

Most upvoted comments

As a workaround, I tried to put the active url back to the history, and this approach seems to work:

export class CanDeactivateGuard implements CanDeactivate<any> {
    constructor(
        private readonly location: Location,
        private readonly router: Router
    ) {}

    canDeactivate(component: any, currentRoute: ActivatedRouteSnapshot): boolean {
        if (myCondition) {
            const currentUrlTree = this.router.createUrlTree([], currentRoute);
            const currentUrl = currentUrlTree.toString();
            this.location.go(currentUrl);
            return false;
        } else {
            return true;
        }
    }
}

I can´t believe a feature like this is broken. Lets hope 2020 is the year they fix it.

I would love to see this issue fixed! ❤️ I am experiencing it in 7.2.1.

Timing-wise, the browser’s location and history has changed before the Deactivate Guard fires. If a false-like value is returned from canDeactivate() Angular will revert the browser’s location but will not modify the history. Since the navigation was canceled, not correcting the history presents a problem.

At this time I’m not coming up with a workaround that solves all of the cases. The best I have so far, based somewhat on this comment, handles the browser back button, Location.back(), and imperative navigation. Browser forward button, and likely Location.forward(), do not work. Also, it results in new state being pushed on the browser history stack, so entries in the history stack that you could forward navigate to will be lost. 🙁

import { Location } from "@angular/common";
import { Injectable } from "@angular/core";
import { ActivatedRouteSnapshot, CanDeactivate, Router, RouterStateSnapshot } from "@angular/router";

@Injectable({
  providedIn: "root"
})
export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {

  constructor(
    private location: Location,
    private router: Router
  ) { }

  canDeactivate(component: CanComponentDeactivate, currentRoute: ActivatedRouteSnapshot, currentState: RouterStateSnapshot, nextState: RouterStateSnapshot) {
    return component.canDeactivate ? component.canDeactivate().then(canDeactivate => {
      if (!canDeactivate && this.router.getCurrentNavigation().trigger === "popstate") {
        this.location.go(currentState.url);
      }

      return canDeactivate;
    }) : true;
  }
}

export interface CanComponentDeactivate {
  canDeactivate: () => Promise<boolean>;
}

I don’t know why it’s not fixed after 3 years. Angular guards are not working properly in a normal case to close a dialog when back button is pressed because the history is changing even if the guard returns false. it’s funny that the frequency label is set to low for a core functionality!! and the PRs are closed because they were just resolving the back button issue and not forward!!

I am surprised this is still an issue. 😃 Any updates guys about this issue?

We’re rooting for you @aahmedayed ! Thank you for working on a fix!

Thank you for the encouraging words, i was away for a while dealing with some personal stuff, now I’m fully back, and I promise to try my best to finish the thread ASAP.

Opened since 2016, I can confirm that the issue still exists in 9.0.0-next.7, any update on this?

I cannot believe that such a horrendous bug has gone unfixed for 4 years.

The known issue mentioned above was fixed in the 12.1.2 release this week. There are currently no known issues and it would be great if those interested in the fix could give it a quick test in their applications using the instructions above.

With the increase in popularity of PWA applications, users of android device use a lot the back button to close dialog or to go back, this really should be fix

Hi all, what’s the status on this issue? Or is there any workaround?

I guess I have to tell my client not to use the back button!

2020 nearing its end and this is still broken in Angular 10 11.

@jotatoledo What do you mean. The plunkr is still working and the step are still reproductible. If you update the config.js in the plunkr to use the latest version of Angular, you’ll see that it still is reproductible.

We’re rooting for you @aahmedayed ! Thank you for working on a fix!

Any update on this issue?

The behavior of the Angular Guide’s primary router example (Crisis Center) is broken. It does not match the user-reported behavior described above, and also does not appear to match intended expected behavior. In the Crisis Center example/stackblitz:

  1. Clicking the simulated browser Back/Forward buttons does not appear to trigger the CanDeactivate guard.
  2. Clicking a button within the app (e.g. going from “crisis-center/1” to “crisis-center/2”) does trigger the CanDeactivate guard, then selecting Cancel (e.g. discard changes) works as expected, i.e. does not impact the URL history.

See: https://angular.io/guide/router#routing--navigation https://angular.io/generated/live-examples/router/stackblitz.html


In my app, I don’t believe CanDeactivate guards can be used at all while this behavior is broken.

If there is an accurate workaround, can the Angular Guide (and its example) be updated to include it? Or at least, add a Known Issues section describing why users following the tutorial will not see expected behavior?

I’m facing the same behaviour here, someone know’s when it will be fixed? 😦

Another update on this: I’m trying to address at least part of this issue with imperative navigations (ones which are triggered by router.navigate) that get cancelled/blocked by guards and are followed by a popstate/hashchange (browser back/forward or manual url change) in #37408. The change initially appears to resolve one of the tests that was added in the previous attempts to correct this issue. I’ve actually encountered this in the past week when investigating another report: https://github.com/angular/angular/issues/16710#issuecomment-634869739. If the presubmits look good as well, we can hopefully get this submitted as a non-breaking change bug fix that doesn’t require a new router config option.

In order to address the issue with history and browser back/forward navigation, I think there could be an opportunity to add a beforeUnload listener that executes synchronous canDeactivate guards to potentially prevent any browser navigation. This feels like a decent option that would have somewhat well-defined behavior and expectations that could be documented. This change would need much more thought and design work, but I’m documenting the option here for future reference. This wouldn’t work for SPAs in all cases except for when the back/forward is navigating to an external page.

Still not solved? 🙄

Also chiming in that this is an issue that is affecting our app.

Our team develops an application that uses browser history heavily to navigate through pages. Unfortunately, we have faced the same issue which causes a bit weird navigation experience for users. The fix for this issue would be much appreciated!

The main problem, in my view, is that the router by itself has no way of knowing whether a popstate event is a forward or back click (imperative navigation is a separate issue, but more easily dealt with–see below). If the router knew whether forward or back was clicked, it could simply call location.forward() when a back-click navigation is cancelled by a deactivation route guard, and location.back() when a forward-click navigation is cancelled.

However, because the router doesn’t have that information, when a deactivation gaurd cancels a popstate navigation, the router restores the prior URL by simply calling replaceState() to overwrite the current state (which was set to the now-cancelled URL as soon as the popstate event fired) with the prior URL. When that happens, history gets corrupted because there are now 2 entries in the history stack containing the prior URL: the original, which was the current state prior to the cancelled navigation, and the now current state, which was overwritten with the prior URL when the navigation was cancelled.

The solution I’ve implemented to work around this is to utilize a global service RouterHistoryTrackerService, injected in the root component, that tracks all navigation changes by subscribing to router events (NavigationStart, NavigationEnd, NavigationCancel).

It tracks the router event id of the last navigation and the direction of the last navigation relative to the current state. Then, on a popstate event, it compares the restoredState NavigationId of that popstate navigation to the id of the last navigation. If they match, then the user is going in the stored direction. If not, the user is going the other way.

Once direction has been determined, if the navigation is canceled by a routeGuard, all that’s left to do is to reverse the replaceState() performed by the router (by calling replaceState() again, this time with the cancelled url so it goes back on the stack in its place) and then call location.back() (if the cancelled popstate was forward) or location.forward() (if the cancelled popstate was back).

Imperative navigation are also tracked, but those are handled differently by the router when cancelled. State never changes when an imperative navigation is canceled, so does not need to be restored. However, the cancelled URL still gets stored as the lastNavigation within the router (this is the rawUrl value within the transitions object in the router). To prevent a subsequent popstate to the cancelled URL from being ignored, I use a non-state-changing navigation (skipLocation) to the current URL. (Note: I only deal with imperative and popstate navigation, not hashchange).

While RouterHistoryTrackerService is always running to track history, its cancellation handler only gets invoked when guardInvoked is set to true, which is done in the routeGuard by calling setGuardInvoked(true) for guarded routes. I use a global generic routeGuard service (CanDeactivateComponentService), which makes this call and then defers to the canDeactivate() method of each guarded component to set conditions for deactivation.

If navigation tracking logic of this type could be built into the router, it could avoid using the replaceState() calls when a routeGuard blocks deactivation and instead just call location.forwad() or location.back() as appropriate.

router-history-tracker-service.ts

/*
 * RouterHistoryTrackerService: maintains record of current and prior navigation.
 * When injected into a CanDeactivate guard and invoked in that guard,
 * prevents corruption of history stack for both imperative and popstate navigation
 * 
 * //todo: consider migrating to localstorage for tracking variables for more
 * robustness on refresh.
 * 
 * Copyright (c) 2020 Adam Cohen redyarisor [at] gmail.com
 * 
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this 
 * software and associated documentation files (the "Software"), to deal in the Software 
 * without restriction, including without limitation the rights to use, copy, modify, merge, 
 * publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons 
 * to whom the Software is furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in all 
 * copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 
 * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 
 * PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 
 * FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 
 * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 
 * OTHER DEALINGS IN THE SOFTWARE.
 * 
 */


import { Injectable, OnDestroy } from '@angular/core';
import { Router, NavigationEnd, NavigationStart, NavigationCancel, PRIMARY_OUTLET, RouterEvent } from '@angular/router';
import { Subscription } from 'rxjs';
import { Location } from '@angular/common';
import { filter } from 'rxjs/operators';

//singleton service
@Injectable({
  providedIn: 'root'
})

export class RouterHistoryTrackerService implements OnDestroy {

  // subscription to router events to track changes
  private routerSubscription : Subscription;

  // url of current page (last successful navigation)
  private urlCurrent: string;
  // url of current page, parsed into segments (e.g. ["home","samplepage","1"])
  private urlCurrentSegmented : Array<string>;
  // id of current url
  private navIdCurrent : number; 
  // state of current page
  private stateCurrent : any;  

  
  //event id of most recent PRIOR navigation. Set by NavigationEnd handler.
  //If current page was reached imperatively or by clicking forward, id of page reached by going back
  //If current page was reached by going back, id of page reached by going forward  
  private navIdHistorical : number;
  
  // direction to get to navIdHistorical (forward or back)
  // used in conjunction with navIdHistorical to determine
  // whether a popstate event is forward or back
  // if a popstate is navigating to an id that matches navIdHistorical
  // then we know we're going in navDirectionHistorical, otherwise, we're going the other way
  // set to "back" after an imperative navigation because only historical option is "back" after an imperative
  // navigation event, which becomes the new top-of-the stack
  private navDirectionHistorical : string; 
  
  // holds restoredState nav id of current navigation. Populated whenever a popstate occurs
  private restoredStateNavId : number;

  // tracks whether currently active navigation was imperative or the result of a popstate
  private poppedState : boolean = false;

  // direction of current navigation
  // set each time a NavigationStart event occurs if triggered by popstate 
  // determined by comparing destination of NavigationStart event to
  // navDirectionHistorical and navIdHistorical
  // initialized by first popstate NavigationStart
  private direction : string; 

  //information needed to restore an item in the history stack
  //captured with each NavigationStart event
  private restoreState : any;
  private restoreUrl : string;

  // was cancellation invoked by a routeGuard?  If so, apply special procedures (below)
  // set and managed by each routeGuard; allows tracking to continue for any navigation
  // to preserve history, but only triggers restorative behavior when a guard is invoked 
  // and navigation cancelled.
  private guardInvoked : boolean;

  constructor(private router : Router, private location : Location) {
    this.urlCurrent = this.router.url;
    
    // set up subscription to router Start, End, and Cancel events for tracking and handling
    this.routerSubscription = router.events.pipe(
      filter((event : RouterEvent) => event instanceof NavigationStart || event instanceof NavigationEnd || event instanceof NavigationCancel))
      .subscribe(event =>      
      {
      if (event instanceof NavigationStart){
        this.navigationStartHandler(event);
      }      
      else if (event instanceof NavigationEnd) {
        this.navigationEndHandler(event)
      }  
      else if (event instanceof NavigationCancel){
        this.navigationCancelHandler(event);
      }
    });
  }

  /**
  * Handler for navigationStartEvents
  * Determines direction of travel if popstate-triggered
  * Captures destination state info for use in event of cancellation
  * @param event a NavigationStart event
  */
  private navigationStartHandler(event: NavigationStart){
    // popstate = back or forward clicked
    // if popstate, need to determine if it was back for forward pressed 
    if (event.navigationTrigger == 'popstate'){
      this.poppedState = true;

      // restored state id is part of the NavigationStart event on a popstate event; capture it 
      this.restoredStateNavId = event.restoredState ? event.restoredState.navigationId : null;
      
      // if restored state matches stored, then we're going in stored direction
      if (this.restoredStateNavId == this.navIdHistorical)
      { 
        this.direction = this.navDirectionHistorical;
      }
      // otherwise, we're going in the opposite direction
      else{
        this.direction = this.navDirectionHistorical == 'back' ? 'forward' : 'back';
      }
    }
    //no popstate = imperative;
    else{
      this.direction = "imperative";
    }
    // on any navigation start, capture the details of the website we're starting to go to
    // in case it needs to be replaced in the stack (in the event of a popstate cancellation)
    this.setRestoreParams(this.location.path(), history.state);

  }

  /**
   * Handler for nagiationEnd Events
   * after a completed navigation:
   * 1. sets navDirectionHistorical which is the direction to get to the last-visited page.
   * 2. sets navIdHistorical which is the navigationId of the last-visited page
   * 3. captures current page info
   * 
   * @param event a NavigationEnd Event
   */
  private navigationEndHandler(event: NavigationEnd){
      // if navigating imperatively, the only available direction is back because top 
      // of history stack is being replaced and the prior page is now accessible by going back
      if (!this.poppedState){ // imperative navigation
        this.navDirectionHistorical = "back";
      } 
      // if navigation was triggered by popstate
      // and we just went forward, prior page is now accessible by clicking back
      // reset popped state for next navigation
      else if (this.direction == "forward"){
        this.navDirectionHistorical = "back";
        this.poppedState = false;          
      } 
      // if navigation was triggered by popstate and we just went back,
      // prior page is now accessible by clicking forward       
      // reset popped state for next navigation        
      else {
        this.navDirectionHistorical = "forward";
        this.poppedState = false;
      }
      // capture PRIOR navigation event Id 
      // (which was stored in current after last navigation end)
      // and store it for comparison on next navigation 
      this.navIdHistorical = this.navIdCurrent;      
      
      // update current id, url, and state
      this.urlCurrent = event.url;   
      this.navIdCurrent = event.id;        
      this.stateCurrent = history.state;   

      // parse current URL into segments to allow use of router.navigate to clean up after an imperative navigation below
      this.urlCurrentSegmented = new Array<string>()        
      let k = this.router.parseUrl(event.url).root.children[PRIMARY_OUTLET]
      if (k){
        k.segments.forEach(element => this.urlCurrentSegmented.push(element.path));
      }
      
      // after successful navigation, clear restoredId and direction
      this.clearDirectionAndRestoredId();    
  }

  /**
   * handle cancellations for imperative and popstate navigation
   * @param event 
   */
  private navigationCancelHandler(event: NavigationCancel){
      // early exit if cancellation was not guard-invoked (set by authGuard)
      if (!this.guardInvoked){
        return; 
      }
      /*
      Special case for an imperative navigation
      A cancelled imperative naviagtion still gets stored as the lastNavigation 
      within the router (this is the rawUrl value within the transitions object in the router).
      Because cancelled imperative navigations do not change the router 
      state (url never changes; replaceState never called), we do not need to adjust state.
      However having the lastNavigation set to the cancelled URL is a problem, 
      if the lastNavigation is the same as what we are going back or forward to 
      to (i.e. page1 -[imperative]-> page2 -[imperative]-> page1[cancel] -[back]-> page1
      Under those circumstances, the router sees the lastNavigation and the actual previous 
      navigation url as the same and skips the navigation by default.
      To get around this, we fire a non-state-changing navigation to the current URL, 
      which doesn't touch state, but updates the lastNavigation stored by the router to
      reflect the current URL.  Enclosed in a timeout to prevent a navigation ID mismatch error
      when the router is changed multiple times in the same cycle
      */
      if (!this.poppedState){
        setTimeout(() => {
          this.router.navigate(this.urlCurrentSegmented, {skipLocationChange: true});
        });
      }

      /* Popstate cancellation:
      On a popstate event, state (URL) changes as soon as navigation begins.
      On a popped state cancellation, the Angular will restore the state by calling replaceState
      on the new (cancelled) URL, replacing it with the URL that we started at.  
      This action breaks history because the URL we cancelled gets removed from the stack 
      (clobbered by the URL we started at during the replaceState) and there are now 2 entries 
      on the stack for the URL we started at (one as a result of the replaceState and the original)
      To fix the issue, we reverse the router's replaceState by calling replaceState 
      with the URL we cancelled so that it is put back in the correct stack position, 
      and then we roll back the popstate by navigating back (if forward was clicked)
      or forward (if back was clicked)
      */

      // must have a restoreUrl (set in navigationStartHandler) to proceed
      if (this.poppedState && this.restoreUrl != undefined){
        // reverse router's replaceState call by putting the cancelled destination URL back in the stack
        this.location.replaceState(this.restoreUrl, undefined, this.restoreState);

        if (this.direction == "forward"){
          this.location.back();
        }
        else {
          this.location.forward();
        }
        // clear restore params for next navigation
        this.resetRestoreParams();  
        this.setGuardInvoked(false);
      }
      //reset popstate tracker for next navigation
      this.poppedState = false;

    }
  
  // getters/helper methods
  public getNavIdStored(){
    return this.navIdHistorical;
  }

  public getNavDirectionStored(){
    return this.navDirectionHistorical;
  }

  public getNavIdIdRestored(){
    return this.restoredStateNavId ? this.restoredStateNavId : null;
  }

  public getDirection(){
    return this.direction;
  }

  // called from routeGuard to invoke cancellation handler
  public setGuardInvoked(guardStatus : boolean){
    this.guardInvoked = guardStatus;
  }

  // clear direction and restoredStateNavId for next run
  private clearDirectionAndRestoredId(){
    this.direction, this.restoredStateNavId = undefined;
  }

  // sets restore parameters for restoring stack after nav cancellation
  private setRestoreParams( restoreUrl : string, restoreState : any){    
      this.restoreUrl = restoreUrl;
      this.restoreState = restoreState;        
  }

  // resets restore parameters
  private resetRestoreParams(){
        this.restoreUrl, this.restoreState = undefined;
  }
  
  public ngOnDestroy(){
    this.routerSubscription.unsubscribe();
  }
  
}

can-deactivate-component-service.ts

/** 
 * 
 * CanDeactivateComponentService: a basic deactivation routeGuard.  Used in conjunction
 * with RouterHistoryTracker.  When added as a routeGuard to a component, it enables
 * the history protection features of RouterHistoryTracker so that if deactivation is disallowed
 * the historical stack is preserved.
 * 
 * Copyright (c) 2020 Adam Cohen redyarisor [at] gmail.com
 * 
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this 
 * software and associated documentation files (the "Software"), to deal in the Software 
 * without restriction, including without limitation the rights to use, copy, modify, merge, 
 * publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons 
 * to whom the Software is furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in all 
 * copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 
 * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 
 * PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 
 * FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 
 * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 
 * OTHER DEALINGS IN THE SOFTWARE.
 * 
 */


import { Injectable } from '@angular/core';
import { CanDeactivate, ActivatedRouteSnapshot, RouterStateSnapshot} from '@angular/router';
import { Observable } from 'rxjs';
import { RouterHistoryTrackerService } from './router-history-tracker.service';

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

@Injectable({
  providedIn: 'root'
})
export class CanDeactivateComponentService implements CanDeactivate<CanDeactivateComponent>{

  constructor(private routerTracker : RouterHistoryTrackerService){
  }

  // if canDeactivate method (which is a property of the CanDeactivateComponent interface) is defined for component,
  // return result of component's canDeactivate method, otherwise allow deactivation.
  canDeactivate(component : CanDeactivateComponent, currentRoute : ActivatedRouteSnapshot, currentState : RouterStateSnapshot){
    if (component.canDeactivate == undefined){    
      return; 
    }

    else {     
      //invoke history protection features of Router History Tracker Service to allow restoration
      //if navigation is cancelled.
      this.routerTracker.setGuardInvoked(true);      
      return component.canDeactivate();
    }
  }  
}

Confirmed in v9: https://stackblitz.com/edit/angular-ivy-tefqm1?file=src%2Findex.html

Edit: example also extended to show that a similar issue exists with guards with Location#forward so we can close #15664 as a duplicate.

Skaiser, its purpose is what you described, but there is a bug currently. A PR has been made to solve the issue, but until then the CanDeactivate guard breaks your history.

I couldn’t find any workaround inside my candeactivate class. Still waiting for the PR

Now is 2020/07 , this issue is very important , but no solution last for 4 years?

Update after some investigation:

While those who have attempted to address this issue in the past are no longer on the team, I can at least make a reasonable guess as to why this hasn’t been fixed. I don’t really have an update for what can/will be done to address these issues. I think it’s worth documenting the difficulty with this, though; I haven’t really seen anything from the previous attempts at it so I had to do my own investigation.

Many people in this thread have responded with reasonable solutions to the particular issue with Location#back()/the browser back button. It’s worth noting that there is nothing that can address both back and forward actions.

  • The browser back button will change the URL and popstate before the router can respond to the navigation and this isn’t behavior we can override. This means that we always have to respond to a navigation rather than being able to truly “prevent” a deactivation/history change.
  • There’s no reliable way to tell the difference between the back and forward - both of these emit a popstate event with no distinct characteristics.
  • Handling all events as if they were back and ignoring forward could arguably make things worse for forward navigations (forward history gets clobbered)

Because of the limitations with the browser history and events, any potential fix for this would be a bit messy and still not work for all scenarios. So there’s maybe no “right” way to fix this issue so it’s difficult to determine what direction to go in making a change.

Wow, this bug has a long history. There is a PR for it at #18135 but it is not merged because it does not implement a bugfix for forward navigation. @jasonaden maybe you want to reopen the PR as more than one year hasn’t brought up a fix for forward navigation? Not fixing a bug because we could potentially fix another one is, in my mind, a bad excuse for not letting the bug fix pass.

I also am experiencing this issue in production. Is there any way we can have a ball park range of when this is going to be fixed?

Hi, quick update here:

  • We have the fix for this in the router code (#38884), but it’s not yet exposed through the public API.
  • We’re still running through tests and there is one known issue at the moment: https://github.com/angular/angular/pull/38884#issuecomment-863767152
  • If you’d like to help test things out, you can enable the fix by putting this bit of code in the app component
// @ts-ignore: private option not yet exposed for public use
router.canceledNavigationResolution = 'computed';

Why hasn’t this been fixed yet? Is there any sign from the angular dev team?

Even 2020 has no power on this bug! 😈

This same problem is also affecting also our product. It’s a bit odd that this isn’t resolved. Seems like a common use case. Maybe now that Ivy is out the team can concentrate on improving the existing features.


Many good solutions presented before. I’ve tried various ways of implementing a possible way around the issue, but all seem to have some problems / limitations.

I wanted to chime in with my own hacky PoC, that seems to work and gather feedback on potential pitfalls.

Note:

  • We still need some other code to determine if the navigation was done using forward / back button
  • The solution also doesn’t take in account that you could still navigate, while the dialog / prompt is up

The main idea is to temporarily ignore and prevent the propagation of history change events to the router. We don’t want the router to react to our manual popstate events that will restore history state once a guard is cancelled.

To do this, we want to block the LocationStrategy’s replaceState and pushState calls. So we will create a new location strategy that will extend the default path strategy (HistoryBlockingLocationStrategy).

Continuing where KevinKelchen left off.

import { Location } from "@angular/common";
import { Injectable } from "@angular/core";
import { ActivatedRouteSnapshot, CanDeactivate, RouterStateSnapshot } from "@angular/router";

@Injectable({
  providedIn: "root"
})
export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {

  constructor(
    private location: Location,
    private locationStrategy: HistoryBlockingLocationStrategy 
  ) { }

  canDeactivate(component: CanComponentDeactivate, currentRoute: ActivatedRouteSnapshot, currentState: RouterStateSnapshot, nextState: RouterStateSnapshot) {
    return component.canDeactivate ? component.canDeactivate().then(canDeactivate => {
      if (!canDeactivate && this.router.getCurrentNavigation().trigger === "popstate") {
       // Assuming it's a back navigation (forward not in scope of this PoC)
        this.locationStrategy.stopNextPopstatePropagation(); // Stops the next popstate event from being propagated to router
        this.location.forward(); // Now navigate forward in history state
      }

      return canDeactivate;
    }) : true;
  }
}

export interface CanComponentDeactivate {
  canDeactivate: () => Promise<boolean>;
}

New blocking location strategy that should be provided for the app

export class HistoryBlockingLocationStrategy extends PathLocationStrategy {
    private historyBlocked: boolean = false;
   
    /* Stops next popstate call from being passed on to the router */
    public stopNextPopstatePropagation(): void {
      this.historyBlocked = true;
    }

    public unblockHistory(): void {
      this.historyBlocked = false;
    }
    
   /* Wrapping all incoming popstate listener events. This is how router gets it's updates */
    onPopState(fn: LocationChangeListener): void {
        const wrappedFn: LocationChangeListener = (event: LocationChangeEvent) => {
            if (this.historyBlocked) { // Popstate call was received, but we don't want the router to react to it
                this.unblockHistory(); // The propagation was stopped, now return back to normal

                return () => {} // noop event listener
            }

            return fn(event);
        }
        super.onPopState(wrappedFn);
    }
    
    /* Replace state is being called after guards reject routing. Results in duplicate url calls if back button is pressed */
    replaceState(state: any, title: string, url: string, queryParams: string): void {
        if (this.historyBlocked) { 
          return;
        }

        return super.replaceState(state, title, url, queryParams);
    }
}

Again, this is just a PoC that could be improved in many ways. My main concern is that blocking popstate events might cause some issues with the router / location syncing. However my quick testing didn’t seem to have problems with this.

Is there some downside in blocking popstate calls?

can reproduce issue with angular 7.0.0 with canactivate

Hey Guys I am facing the same issue, can someone update, when it can be fixed. I am using angular 4.0 and router 4.0

At first sight the fix seems to work perfectly, I couldn’t reproduce a problematic case in our scenario anymore.

issue still persisting on angular router 7.2.6!!

This issue is even worse if you use replace state. E.g. route to list/{id}/edit call location.replaceState(‘list/{modifiedID}/edit’) try to route away, return false from canDeactivate location is updated to list/{id}/edit.

@KevinKelchen 's solution still has list/{id}/edit as the currentState.url. None of the snapshots have the replaced state of list/{modifiedID}/edit

Any idea if a fix will be put in for this anytime soon, seems like its been open for a while…

This is my workaround with a custom dialog component, based on @icesmith solution:

// can-deactivate-guard.ts
import { Injectable } from '@angular/core';
import { CanDeactivate, ActivatedRouteSnapshot, Router } from '@angular/router';
import { DeactivationGuarded } from '../deactivation-guarded';
import { Observable, of } from 'rxjs';
import { Location } from '@angular/common';
import { switchMap } from 'rxjs/operators';

@Injectable({
	providedIn: 'root'
})
export class CanDeactivateGuard implements CanDeactivate<DeactivationGuarded> {

	constructor(
		private readonly router: Router,
		private readonly location: Location,
	) {}

	canDeactivate(component: DeactivationGuarded, currentRoute: ActivatedRouteSnapshot): Observable<boolean> {

		return component.canDeactivate().pipe(
			switchMap((resultFromConfirmDialog: boolean) => {
			if (!resultFromConfirmDialog) {
				const currentUrlTree = this.router.createUrlTree([], currentRoute);
				const currentUrl = currentUrlTree.toString();
				this.location.go(currentUrl);
				return of(false);
			}
			return of(true);
		}));
	}
}

Guarded interface:

import { Observable } from 'rxjs';

export interface DeactivationGuarded {
	canDeactivate(): Observable<boolean>;
}

And I have this in my component which I want to protect(component has to implement DeactivationGuarded):

canDeactivate(): Observable<boolean> {
	console.log('canDeactivate has fired in the component!');
	const dialogRef = this.dialog.open(ConfirmDialogComponent);
	return dialogRef.afterClosed();
}

Can someone give the state of this issue?

I am on 12.2.1 and I have added

// @ts-ignore: private option not yet exposed for public use
router.canceledNavigationResolution = 'computed';

to the constructor in my app.component.ts.

Is there anything further I need to look at or do at this time?

Any updates on this? Inquiring minds want to know…

@leekFreak if you inject the RouterHistoryTracker in your root component, that error should not occur because urlCurrentSegmented gets initialized when the first routed component loads. I’m working on a StackBlitz demo project, which I think will be helpful, both to show the above and because there are lot of different ways to implement the canDeactivate method in a component. I’ll post that here when it’s done.

Building a PWA today and facing this same issue…

I am facing the same issue in Angular 6.2.8, imperative to popstate navigation can-deactivate, not working

my code

export class CanDeactivateGuard implements CanDeactivate<UserFormComponent> {
  canDeactivate(component: UserFormComponent): boolean {
   
    if(component.hasUnsavedData()){
        if (confirm(You have unsaved changes! If you leave, your changes will be lost.)) {
            return true;
        } else {
            return false;
        }
    }
    return true;
  }
}

The use case for reproducing: If I click navigation menu show prompt, now I clicked cancel button(stay on the page) and click browser back button "CanDeactivateGuard " not working, if I click browser back button more than one it’s worked

We have the same issue in our application. We have confirmation dialog on deactivate guard and when a user clicks back button multiple times we have several problems:

  • canDeactivate invokes per each back button
  • as a result confirmation dialog is recreated per each back button click
  • router pops history item per each back button click, even if the user hasn’t selected anything in the confirmation dialog
  • as a result, history is corrupted.

Also experiencing this. As @WillEllis pointed out in regards to @KevinKelchen solution and solutions that update history state, changing parameters does break functionality. The workaround by hooking up onbeforeunload when the route is loaded does its job, but CanDeactivates behavior is not as documented. An update/status on this would be appreciated.

@patrickracicot @DzmitryShylovich i’m using angular 5.x and history gets lost after some by guard cancels. seems issue has not been fixed. has it?

I am also using Angular 4.0 and Router 4.0. Having this issue as well. This is a major bug and is a big setback for our application. Would be great if a workaround could be found or fix be made to this.

Can @aahmedayed say in what version this fix will be coming out? 12?

I tried this workaround and I think it works. I was facing this issue in my angular app so I created a variable in one global service and stored last popped state object in it. I captured this last popped state object in “popstate” event listner as given below.

window.addEventListener(‘popstate’, (popstateEvent) => { console.log(popstateEvent.state); this.lastPoppedState = popstateEvent.state; });

Now every time when user cancel to go away from the current page, I push this state in the history as given below.

pushLastHistoryState() { window.history.pushState(this.lastPoppedState, ‘’); }

Hope this will help.

Glad that this is finally getting traction. Great solution from @redyaris! We tackled the problem of back / forward detection using a generated timestamp on history.state.

For most cases this should resolve the problem.

One edge case that we found hard to tackle is right clicking the forward / back button and choosing an entry that is more than one step away from the currently active state. Simply using back/forward is not going to cut it. We did some additional steps for restoring these types of navigations, but they wound up breaking the history stack.

Perhaps someone else can come up with an elegant solution 😃

@redyaris - thanks for this excellent comment. I think that this is the direction [sic] that @atscott is thinking of going to solve this problem. Watch this space…

{ provide: LocationStrategy, useClass: HistoryBlockingLocationStrategy }, in NgModule

It’s close enough, but it will still break on parameters change. To prevent refreshes and route changes, the approach taken is to prompt the user with a browser message. This is done in the component on which your guard acts upon.

@HostListener('window:beforeunload') preventLeavingPage(): Observable<boolean> | boolean { return yourConditionForLeaving; }

Finding more information on this should not be a problem, a large number of people are settled with this and can give better advice on how to go about it.

Sad it’s been 3 years, but @KevinKelchen’s solution is close enough for me to use it.

@icesmith: sir in which section we have put above code i am also facing same issue?

The only person who had attempted a PR on this was @DzmitryShylovich, who has been banned from the project due to multiple Code of Conduct violations.

I really hope this is on the Angular core team’s radar. We have a production application that uses CanDeactivate to detect unsaved changes. This bug can lead to significant data issues and a handful of unpredictable behaviors throughout the app.

Before returning false , add this.Location.go(this.route.url) . it should fix the issue.

Issue still persists on angular version 6

I came up with the following workaround. I doesn’t fix forward button navigation (actually, it kinda makes it worse) and I’m sure there are many other problems with it. I haven’t done a lot of testing either, so I highly discourage using it, only if you’re really desperate.

export class AppModule {
    constructor(location: Location, router: Router) {
        location.subscribe(locationEvent => {
            router.events
                .pipe(
                    filter(event => event instanceof NavigationCancel || event instanceof NavigationEnd),
                    first(),
                    filter(event => event instanceof NavigationCancel),
                    filter((event: NavigationCancel) => event.url === locationEvent.url)
                )
                .subscribe(cancelEvent => {
                    location.replaceState(cancelEvent.url);
                    location.forward();
                });
        });
    }
}