I have an Angular (standalone) application with a CanDeactivate guard that prevents navigation when a form is dirty.
Reproduction steps:
Navigate to User Management
Click on a user (e.g. “Alice”).
Click Edit roles.
Modify the form so it becomes dirty.
Click the browser back button (Chrome).
A confirmation dialog is shown by the CanDeactivate guard.
If I click Cancel, navigation is correctly prevented and I stay on the page.
If I repeat the same action (browser back → Cancel) one more time, the browser history is now empty.
After that, the browser back button no longer works (history length is 0).
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:
In-app back buttons use Location.back()
The issue occurs only when using the browser back button
This happens consistently in Chrome
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)],
});
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;
};