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.
Let's say my routing flow is:
/home → /user → /edit
/edit with unsaved changes.CanDeactivate./home, skipping /user.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.
Working image - https://s6.imgcdn.dev/YjXe3M.gif
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
Here is a less complicate example of canDeactivate. We can reuse the same canDeactivate across multiple components using the decorator pattern.
We can define the decorator to add the canActivate function to our class.
function CanDeactivate(value = CanDeactivateContact) {
  return function decorator(target) {
    target.prototype.canDeactivate = value.prototype.canDeactivate;
  };
}
As you can see in this code, we use CanDeactivateContact as the default canDeactivate class, but we can customize this by passing in another class.
In the decorator function, we simply add the canDeactivate method from one class to another.
After doing this, we leverage HostListener specifically for the event window:beforeunload, we cannot call canDeactivate since we have not added the property in the class. So we can use dot syntax (this['canDeactivate'](this);) and call this method.
@Component({
  templateUrl: './contact.component.html',
})
@CanDeactivate()
export class ContactComponent {
  @ViewChild('contactForm') public contactForm: NgForm;
  changed = false;
  @HostListener('window:beforeunload', ['$event'])
  onBeforeUnload() {
    return this['canDeactivate'](this);
  }
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With