javascriptangulartypescriptangular-routercandeactivate

Angular CanDeactivate + browser back button breaks history after canceling navigation twice


I have an Angular (standalone) application with a CanDeactivate guard that prevents navigation when a form is dirty.

Reproduction steps:

  1. Navigate to User Management

  2. Click on a user (e.g. “Alice”).

  3. Click Edit roles.

  4. Modify the form so it becomes dirty.

  5. Click the browser back button (Chrome).

A confirmation dialog is shown by the CanDeactivate guard.

Expected behavior:
Canceling navigation via CanDeactivate should not consume browser history entries, even when done multiple times.

Actual behavior:
Each canceled browser back navigation seems to consume one history entry.

Guard implementation:

const canLeaveRoles: CanDeactivateFn<RolesComponent> = (component) => {
  if (!component.dirty) return true;
  return confirm('You have unsaved changes. Leave anyway?');
};

Notes:

Question:
Is this expected behavior of Angular Router / browser history?
If so, what is the recommended way to prevent browser history from being consumed when navigation is canceled?

stackblitz demo

import { Component } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import {
  Routes,
  provideRouter,
  RouterOutlet,
  RouterLink,
  ActivatedRoute,
  CanDeactivateFn,
} from '@angular/router';
import { Location, NgFor } from '@angular/common';
import { FormsModule } from '@angular/forms';

/* -------------------- App Root + Sidenav -------------------- */

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, RouterLink],
  template: `
    <div class="layout">
      <nav class="sidenav">
        <h3>Admin</h3>
        <a routerLink="/management" routerLinkActive="active">
          User Management
        </a>
      </nav>

      <main class="content">
        <router-outlet />
      </main>
    </div>
  `,
  styles: [
    `
    .layout { display: flex; height: 100vh; font-family: Arial; }
    .sidenav { width: 200px; padding: 16px; border-right: 1px solid #ddd; background: #f7f7f7; }
    .sidenav a { display: block; padding: 8px 0; text-decoration: none; color: #333; }
    .sidenav a.active { font-weight: bold; }
    .content { padding: 16px; flex: 1; }
  `,
  ],
})
class AppComponent {}

/* -------------------- Management -------------------- */

@Component({
  standalone: true,
  imports: [NgFor, RouterLink],
  template: `
    <h2>User Management</h2>

    <ul>
      <li *ngFor="let user of users">
        <a [routerLink]="['/management', user.id]">
          {{ user.name }}
        </a>
      </li>
    </ul>
  `,
})
class ManagementComponent {
  users = [
    { id: '1', name: 'Alice' },
    { id: '2', name: 'Bob' },
    { id: '3', name: 'Charlie' },
  ];
}

/* -------------------- User -------------------- */

@Component({
  standalone: true,
  imports: [RouterLink],
  template: `
    <button (click)="back()">⬅ Back</button>

    <h2>User {{ userId }}</h2>

    <section>
      <h3>Roles</h3>
      <a [routerLink]="['roles']">Edit roles</a>
    </section>
  `,
})
class UserComponent {
  userId = this.route.snapshot.paramMap.get('userId');

  constructor(private route: ActivatedRoute, private location: Location) {}

  back() {
    this.location.back();
  }
}

/* -------------------- Roles -------------------- */

@Component({
  standalone: true,
  imports: [FormsModule],
  template: `
    <h2>Edit Roles</h2>

    <form>
      <label>
        <input
          type="checkbox"
          [(ngModel)]="roles.admin"
          name="admin"
          (ngModelChange)="markDirty()"
        />
        Admin
      </label>
      <br />
      <label>
        <input
          type="checkbox"
          [(ngModel)]="roles.editor"
          name="editor"
          (ngModelChange)="markDirty()"
        />
        Editor
      </label>
    </form>

    <br />

    <button (click)="save()">Save</button>
    <button (click)="cancel()">Cancel</button>
  `,
})
class RolesComponent {
  dirty = false;

  roles = {
    admin: false,
    editor: true,
  };

  constructor(private location: Location) {}

  markDirty() {
    this.dirty = true;
  }

  save() {
    this.dirty = false;
    this.location.back();
  }

  cancel() {
    this.location.back();
  }
}

/* -------------------- Guard -------------------- */

const canLeaveRoles: CanDeactivateFn<RolesComponent> = (component) =>
  !component.dirty || confirm('You have unsaved changes. Leave anyway?');

/* -------------------- Routes (with titles) -------------------- */

const routes: Routes = [
  {
    path: '',
    redirectTo: 'management',
    pathMatch: 'full',
  },
  {
    path: 'management',
    component: ManagementComponent,
    title: 'User Management',
  },
  {
    path: 'management/:userId',
    component: UserComponent,
    title: (route) => `User ${route.paramMap.get('userId')}`,
  },
  {
    path: 'management/:userId/roles',
    component: RolesComponent,
    title: 'Edit User Roles',
    canDeactivate: [canLeaveRoles],
  },
];

/* -------------------- Bootstrap -------------------- */

bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes)],
});

Solution

  • You need to pushState the last URL, during can deactivate cancel action, below is an example of how it might look.

    const canLeaveRoles: CanDeactivateFn<RolesComponent> = (
      component,
      currentRoute: ActivatedRouteSnapshot,
      currentState: RouterStateSnapshot,
      nextState: RouterStateSnapshot
    ) => {
      if (component.dirty) {
        if (confirm('You have unsaved changes. Leave anyway?')) {
          return true;
        } else {
          // console.log(
          //   component.getLocation(),
          //   window.history,
          //   currentRoute,
          //   currentState,
          //   nextState
          // );
          // add the last route to the browser history.
          window.history.pushState(
            {},
            `${window.history.length + 1}`, // ensure the last position.
            currentState.url
          );
          return false;
        }
      }
      return true;
    };
    

    Stackblitz Demo