Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

  • User is on /edit with unsaved changes.
  • Presses the browser back button.
  • A confirmation dialog is shown via CanDeactivate.
  • If user cancels, the route stays the same (correct).
  • But when they press back again and confirm, it navigates two steps back to /home, skipping /user.

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

like image 593
Karthik S Avatar asked Oct 31 '25 14:10

Karthik S


2 Answers

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

like image 152
David Avatar answered Nov 03 '25 05:11

David


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);
  }
}

Stackblitz Demo

like image 20
Naren Murali Avatar answered Nov 03 '25 05:11

Naren Murali



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!