Using Router Events To Detect Back And Forward Browser Navigation In Angular 7.0.4
This morning, while digging into the "retain scroll" feature that was released with Angular 6, I discovered that the Angular team added a "navigationTrigger" property and a "restoredState" property to the NavigationStart Router event. This is an exciting addition to the Router as it finally gives us the ability to differentiate between an imperative navigation (ex, the user clicked a router-link) and a location-change navigation (ex, the user clicked the Back or Forward buttons in the browser chrome). This insight is something that I struggled with in Angular 5 when building my "restore scroll position" polyfill; and, is something that will make custom behaviors like my polyfill much easier to implement.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
Every NavigationStart event in the Router is given a unique, monotonically increasing ID. Even navigation events that are considering "going back" or "going forward" within the Browser's history are given a unique ID. What's great about the Router updates in Angular 6 is that the NavigationStart event now also includes the unique ID of the navigation event being restored by a "popstate" event (ie, a non-imperative navigation event).
This restored state ID is provided in a property called, "restoredState". And, we can use the existence of this property to determine if the navigation was triggered in attempt to move to a completely new browser state (the "restoredState" will be null); or, if the navigation was triggered in an attempt to move to an historical browser state.
ASIDE: The NavigationStart event also includes a "navigationTrigger" property to provide a more technical indication of what triggered the navigation; at this time, however, I am not yet sure what additional value there is to knowing why the navigation occurred. I am sure that there are use-cases for its consumption; but, for the time-being, knowing the restored state seems like the most valuable facet of this update.
To explore this Router behavior, I created a simple demo in which you can navigate between three routes, each of which has three anchor links. As you navigate through the app, the details of the NavigationStart event are logged to the console. And, if you go "back" and "forward" through your Browser's history, you will see how the IDs of previous navigation events are presented as the "restoredState" IDs:
// Import the core angular services.
import { Component } from "@angular/core";
import { Event as NavigationEvent } from "@angular/router";
import { filter } from "rxjs/operators";
import { NavigationStart } from "@angular/router";
import { Router } from "@angular/router";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template: `
<nav class="nav">
<a routerLink="./section-a" class="nav__item">Section A</a>
<a routerLink="./section-b" class="nav__item">Section B</a>
<a routerLink="./section-c" class="nav__item">Section C</a>
</nav>
<router-outlet></router-outlet>
`
})
export class AppComponent {
// I initialize the app component.
constructor( router: Router ) {
router.events
.pipe(
// The "events" stream contains all the navigation events. For this demo,
// though, we only care about the NavigationStart event as it contains
// information about what initiated the navigation sequence.
filter(
( event: NavigationEvent ) => {
return( event instanceof NavigationStart );
}
)
)
.subscribe(
( event: NavigationStart ) => {
console.group( "NavigationStart Event" );
// Every navigation sequence is given a unique ID. Even "popstate"
// navigations are really just "roll forward" navigations that get
// a new, unique ID.
console.log( "navigation id:", event.id );
console.log( "route:", event.url );
// The "navigationTrigger" will be one of:
// --
// - imperative (ie, user clicked a link).
// - popstate (ie, browser controlled change such as Back button).
// - hashchange
// --
// NOTE: I am not sure what triggers the "hashchange" type.
console.log( "trigger:", event.navigationTrigger );
// This "restoredState" property is defined when the navigation
// event is triggered by a "popstate" event (ex, back / forward
// buttons). It will contain the ID of the earlier navigation event
// to which the browser is returning.
// --
// CAUTION: This ID may not be part of the current page rendering.
// This value is pulled out of the browser; and, may exist across
// page refreshes.
if ( event.restoredState ) {
console.warn(
"restoring navigation id:",
event.restoredState.navigationId
);
}
console.groupEnd();
}
)
;
}
}
With this code, loaded if we execute the following navigation steps:
- Click "Section A" link.
- Click "Section B" link.
- Click "Section C" link.
- Click browser's "Back Button".
... we will get the following browser output:
As you can see, as we navigate through the application, each NavigationStart event is listed as an "imperative" navigation action that has no "restoredState" property. However, when we click the Browser's Back button, the trigger is listed as "popstate"; and, there is a "restoredState" property that points back to the ID of the original "/section-b" navigation event.
While it is not really relevant to the demo, here is my App Module for completeness:
// Import the core angular services.
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
// Import the application components and services.
import { AppComponent } from "./app.component";
import { SectionAComponent } from "./section-a.component";
import { SectionBComponent } from "./section-b.component";
import { SectionCComponent } from "./section-c.component";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot(
[
{
path: "section-a",
component: SectionAComponent
},
{
path: "section-b",
component: SectionBComponent
},
{
path: "section-c",
component: SectionCComponent
}
],
{
// Tell the router to use the hash instead of HTML5 pushstate.
useHash: true,
// These aren't necessary for this demo - they are just here to provide
// a more natural experience and test harness.
scrollPositionRestoration: "enabled",
anchorScrolling: "enabled",
enableTracing: false
}
)
],
declarations: [
AppComponent,
SectionAComponent,
SectionBComponent,
SectionCComponent
],
providers: [
// CAUTION: We don't need to specify the LocationStrategy because we are setting
// the "useHash" property in the Router module above (which will be setting the
// strategy for us).
// --
// {
// provide: LocationStrategy,
// useClass: HashLocationStrategy
// }
],
bootstrap: [
AppComponent
]
})
export class AppModule {
// ...
}
This is really cool! Obviously, the Angular team added this functionality to enable their own "restore scroll" functionality. However, said functionality only works on the primary viewport as far as I understand. As such, I believe that I can use this functionality to greatly improve the stability of my own "restore scroll" polyfill, which works on an arbitrary collection of scrollable containers.
But, that's an exploration for another day.
Want to use code from this post? Check out the license.
Reader Comments
This is cool.
router.navigate() changes the previous state of browser.
lets say you have JSP page A which where a button click sends POST request to server and then you render page B where you have angular app.
when you hit the router. navigate line previous POST request is vanished which has some disadvantages.
do you have any suggestions to avoid this.
details question is on : https://stackoverflow.com/questions/55647198/angular-router-naviagate-changes-the-previous-request
@Maurer,
Glad you found it interesting :D
@Akshay,
I am not sure I understand what you are saying. In my experience, when I use the Router to navigate, the History is kept in tact, unless I explicitly tell it to replace the
replaceUrl
property to overwrite the current history item:www.bennadel.com/blog/3614-using-replaceurl-to-persist-search-filters-in-the-url-without-messing-up-the-browser-history-in-angular-7-2-14.htm
... but, as you will see in the linked post, I have to tell the Router specifically to alter the History object rather than pushing a new item onto the stack. By default, the Router seems to work naturally with the History object.
Hi Ben,
I have used same component for tabbed navigation. i want to handle the browser back button to go back to the previous tab in same component, can you please suggest me how to implement this?
Thanks,
Venkatesh P
@Venkatesh,
For what it's worth, I 100% agree with your approach. Tabbed navigation is very much like any other navigation - it only differs in some of the implementation details. That said, the easiest way do to this would be use the Tab-links to set the URL hash / fragment. Then, in your View components, you can subscribe to the Fragment and use it to change the View (ie, render the appropriate Tab). This way, changing the tab changes the URL, which is stored in the History. And hitting the Back / Forward button will change the URL, which can then be reflected in the View state.
This is actually something I've been meaning to try and put a demo together for. I will try to prioritize this.
Also, you could take it one step further and even use the Router to render the Tab. So, just like you would render any other View with the Router, you could have something like (pseudo-code):
As you can see here, I'm actually using
<router-outlet>
to render the Tab content. Of course, that would require that you use the[routerLink]
directive in your Tab nav; but, that's easy enough.Anyway, several options to try.
Hi, as soon as I am pressing back button on landing page, it navigates away to default browser page. The app exits without hitting router events, router guard, ngDestroy, ionViewWillLeave or window:onbeforeunload event. Please suggest. I have to restrict that navigation without disabling the back button. I have to show a pop up there customized.
@Urvi,
That is surprising that not even
window:onbeforeunload
isn't working. Perhaps it's the naming of the event. When using the host binding, you typically remove the on portion of the event-name. I would trying usingwindow:beforeunload
and see if that makes any difference? Also, you probably have to callevent.preventDefault();
to actually stop the user from leaving? I can't remember off-hand. But, hopefully that helps.@Maurer,
I noticed that you use different URLs every time you move between pages, but what about when we want to keep the same URL when we are moving between pages? I am using skiplocation to avoid changing the URL.
The comment avatar is cool! Just trying...
Hi Ben,
how to give an alert, when the user clicks on browser back/forward/reload/close buttons
@Kumar,
Some of those are very different kinds of events. For things like Reload and Close, you probably want to look into the
beforeunload
browser event:https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
It allows you to be able to cancel the user's intent to unload the current page.
Hi Ben,
Is there a way to differentiate between back and forward button press? In my scenario, I want to perform different actions for back and forward browser button press.
Hi, I tried to follow your instructions in my app, but when I click Forward or Back button in Chrome, the restoredState is always null (trigger is popstate). In which condition can this happen?
@ETiake,
Hmm, that's a tough-one, since both the Back button and Forward button are
popstate
events. Especially since routes can have cycles. Imagine going to the following routes in order: A B A B. If you then this the Back button to get toA
, both a Back and Forwardpopstate
action would lead you routeB
. It's a tough challenge!Out of curiosity, what are you trying to do? Maybe we can think of a different approach?
Hi @Ben,
thank you very much for the answer. After some researches, I found out that a dynamic route as
{ path: "section-a/:id/:version", component: SectionAComponent},
makes the browser enter in a completely new state. This means that if I want to go to section-a/1/1 from section-b/2/2 and then return to section-a/1/1 using the back button, the restoredState is null, because every navigation is set to a completely new state. If I use a static route, as you did in the demo, the restoredState is not null, it works!
I need the restoredState because my app has other elements to store and restore. If the user clicks the back button, he wants not only navigate to the previous route (the navigation works), but also restore some other configurations, that I stored with the particular navigationId in a custom navigation history.
I cannot understand if it's a feature or a bug or I am wrong somewhere.
Thank you for sharing this. Your comments are incredibly easy to understand.