javascriptangulartypescriptangular19candeactivate

CanDeactivate + Browser Back Button causes double navigation or skips route -- Angular browser back prevent


Problem

I'm using a CanDeactivate guard in Angular to prompt the user before navigating away from a form with unsaved changes. It works fine for normal route changes (e.g., clicking a link), but it breaks when the user presses the browser back button.

Scenario

Let's say my routing flow is:

/home → /user → /edit

What I Tried

I implemented a CanDeactivate guard like this:

export class YourFormComponent implements CanComponentDeactivate, OnInit, AfterViewInit {
    hasUnsavedChanges = false;

    // Implement the canDeactivate method
    canDeactivate(): Observable<boolean> | boolean {
      if (!this.hasUnsavedChanges) {
        return true;
      }

      const confirmLeave = window.confirm('You have unsaved changes. Leave anyway?');
      return confirmLeave;
    }
}

route.ts

import { Routes } from '@angular/router';
import { YourFormComponent } from './your-form.component';
import { ConfirmLeaveGuard } from './confirm-leave.guard';

const routes: Routes = [
  {
    path: 'form',
    component: YourFormComponent,
    canDeactivate: [ConfirmLeaveGuard]
  }
];

confrim-leave.guard.ts

import { inject } from '@angular/core';
import { CanDeactivateFn } from '@angular/router';
import { Observable } from 'rxjs';
import { Location } from '@angular/common';
export interface CanComponentDeactivate {
  canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
}

export const ConfirmLeaveGuard: CanDeactivateFn<CanComponentDeactivate> = (component: any, currentRoute, currentState, nextState) => {
  const location = inject(Location);
  const result = component.canDeactivate();

  // If it's a boolean value
  if (typeof result === 'boolean') {
    if (!result) {
      location.replaceState(window.location.pathname); // Restore URL
    }
    return result;
  }

  // If it's an Observable or Promise
  if (result instanceof Observable || result instanceof Promise) {
    return new Promise(resolve => {
      Promise.resolve(result).then(confirmed => {
        if (!confirmed) {
          location.replaceState(window.location.pathname); // Restore URL
        }
        resolve(confirmed);
      });
    });
  }

  return true;
};

I also tried using Location.replaceState() or Location.go() inside the guard to restore the history stack, but it still misbehaves when using the back button.

Question How can I correctly handle the browser back button with CanDeactivate to prevent double navigation or skipped routes?

Any advice or examples would be appreciated.

Stackblitz Example

Working image - https://s6.imgcdn.dev/YjXe3M.gif


Solution

  • Using your stackblitz I may have a solution. Try replacing your canDeactivateGuard with this version. Angular’s CanDeactivate guard does not directly prevent the back navigation in the way we want. The guard only cancels the navigation to the new route, but it doesn't stop the back navigation. So it seems that the browser is "holding onto" the previous back, and then when you cancel back again it adds another back onto it, which is why its going back two steps.

    This solution is probably not perfect, but hopefully might help you get to the right place.

    import { inject } from '@angular/core';
    import { CanDeactivateFn } from '@angular/router';
    import { Observable, of } from 'rxjs';
    import { Location } from '@angular/common';
    
    export interface CanComponentDeactivate {
      canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
    }
    
    export const canDeactivateGuard: CanDeactivateFn<CanComponentDeactivate> = (
      component: CanComponentDeactivate,
      currentRoute,
      currentState,
      nextState
    ) => {
      const location = inject(Location);
      const result = component.canDeactivate();
    
      if (typeof result === 'boolean') {
        if (!result) {
          // Restore the current URL if the user cancels
          window.history.pushState(null, '', currentState.url);
        }
        return of(result); // Wrap the result in an Observable
      }
    
      // If it's an Observable or Promise, handle it properly
      if (result instanceof Observable) {
        return new Observable<boolean>((observer) => {
          result.subscribe((confirmed) => {
            if (!confirmed) {
              // Restore the current URL if the user cancels
              window.history.pushState(null, '', currentState.url);
            }
            observer.next(confirmed);
            observer.complete();
          });
        });
      }
    
      if (result instanceof Promise) {
        return new Observable<boolean>((observer) => {
          result.then((confirmed) => {
            if (!confirmed) {
              // Restore the current URL if the user cancels
              window.history.pushState(null, '', currentState.url);
            }
            observer.next(confirmed);
            observer.complete();
          });
        });
      }
    
      return of(true); // Default case if no action needed
    };
    

    Working stackblitz