author avatar
By Zach Tindall Senior Software Engineer

*Views, thoughts, and opinions expressed in this post belong solely to the author, and not necessarily to SemanticBits.

I recently picked up a seemingly simple user story from our sprint board to create a universal “Back” link that would appear at the top of most of our pages in the current application I’m working on here at SemanticBits. My initial thought was to just track the previous URL using router events and then use that URL when constructing the anchor tag. I found plenty of examples on StackOverflow and a few other blog posts that were doing just that. As I started to implement and test the feature, I quickly realized the proposed and widely-accepted solutions are actually incorrect.

The currently accepted, but incorrect, solutions

There are two solutions that I want to review first, both of which are easily found at the top of a simple web search. The first solution uses the NavigationEnd event, and the second solution uses the RoutesRecognized event, both fired by the Angular router.

Using NavigationEnd

previousUrl$ = new BehaviorSubject<string>(null);
currentUrl$ = new BehaviorSubject<string>(null);

constructor(
   router: Router
 ) {
   this.currentUrl$.next(router.url);
   router.events.subscribe(event => {
     if (event instanceof NavigationEnd) {
       this.previousUrl$.next(
         this.currentUrl$.value
       );
       this.currentUrl$.next(event.urlAfterRedirects);
     }
   });
 }

Using RoutesRecognized

previousUrl$ = new BehaviorSubject<string>(null);
currentUrl$ = new BehaviorSubject<string>(null);

constructor(
   router: Router
 ) {
   router.events
     .pipe(
       filter(evt => evt instanceof RoutesRecognized),
       pairwise()
     )
     .subscribe(
       ([previous, current]: [RoutesRecognized, RoutesRecognized]) => {
         this.previousUrl$.next(previous.urlAfterRedirects);
         this.currentUrl$.next(current.urlAfterRedirects);
       }
     );
 }

Why it’s incorrect

In order to show the issue with both of these solutions, I’ve created a very simple Angular application with a very basic navigation structure.

const routes: Routes = [
 { path: '', pathMatch: 'full', redirectTo: 'home' },
 { path: 'home', component: HomeComponent },
 { path: 'about', component: AboutComponent },
 { path: 'company', component: CompanyComponent },
 { path: 'products', component: ProductsComponent }
];

In order to test the code, I performed the below navigation with each solution:

  1. Navigate to the root path ‘/’, which redirects me to the homepage
  2. Click a menu link to navigate to the About page
  3. Click a menu link to navigate to the Company page
  4. Click the back button
  5. Click a menu link to navigate to the Products page

The issue really surfaces after step 4 in the above scenario. What would you expect the previous URL to be after that step? Maybe it’s a matter of opinion, but for the feature I’m working on, I need the previous URL after step 4 to be ‘/home’, when, in actuality, with these solutions, the previous URL is ‘/company’.

The issue really comes down to how Angular treats navigation. Any navigation event that occurs, either through routing in the application (an imperative event) or using the browser’s navigation (a popstate event), generates a new history event on the stack. Here is an example of the events triggered by following the above test scenario.

NavigationStart {"id":1,"url":"/","navigationTrigger":"imperative","restoredState":null}
NavigationEnd {"id":1,"url":"/","urlAfterRedirects":"/home"}

NavigationStart {"id":2,"url":"/about","navigationTrigger":"imperative","restoredState":null}
NavigationEnd {"id":2,"url":"/about","urlAfterRedirects":"/about"}

NavigationStart {"id":3,"url":"/company","navigationTrigger":"imperative","restoredState":null}
NavigationEnd {"id":3,"url":"/company","urlAfterRedirects":"/company"}

NavigationStart {"id":4,"url":"/about","navigationTrigger":"popstate","restoredState":{"navigationId":2}}
NavigationEnd {"id":4,"url":"/about","urlAfterRedirects":"/about"}

NavigationStart {"id":5,"url":"/products","navigationTrigger":"imperative","restoredState":null}
NavigationEnd {"id":5,"url":"/products","urlAfterRedirects":"/products"}

Since this is linear, the previous URL after you go back to a previous page will be incorrect until you navigate forward again. This means the accepted solutions will actually go forward to the page you were just on, rather than further back up the history stack.

A better solution

In order to consistently get the correct previous URL, regardless of forward/backward navigation, we need to track navigation similar to the way the history API tracks navigation. Then, our Backlink triggers window.history.back() rather than router.navigate(), so that in our service we can treat our Backlinks just like the browser’s Back button, as popstate events.

**Side note: I actually could have used the browser’s history API to make the feature work correctly just by calling window.history.back() inside a click event handler on the anchor element. However, an anchor element without an href attribute, though semantically correct, won’t allow the user to right-click and open the link in a new tab. Links in our project have this requirement.

By creating a router history service to track the navigation history, we can achieve this goal. Let me first show you the service and then I’ll explain how the service works.

export class RouterHistoryService {
 previousUrl$ = new BehaviorSubject<string>(null);
 currentUrl$ = new BehaviorSubject<string>(null);

 constructor(router: Router) {
   router.events
     .pipe(
       // only include NavigationStart and NavigationEnd events
       filter(
         event =>
           event instanceof NavigationStart || event instanceof NavigationEnd
       ),
       scan<NavigationStart | NavigationEnd, RouterHistory>(
         (acc, event) => {
           if (event instanceof NavigationStart) {
             // We need to track the trigger, id, and idToRestore from the NavigationStart events
             return {
               ...acc,
               event,
               trigger: event.navigationTrigger,
               id: event.id,
               idToRestore:
                 (event.restoredState && event.restoredState.navigationId) ||
                 undefined
             };
           }

           // NavigationEnd events
           const history = [...acc.history];
           let currentIndex = acc.currentIndex;

           // router events are imperative (router.navigate or routerLink)
           if (acc.trigger === 'imperative') {
             // remove all events in history that come after the current index
             history.splice(currentIndex + 1);

             // add the new event to the end of the history and set that as our current index
             history.push({ id: acc.id, url: event.urlAfterRedirects });
             currentIndex = history.length - 1;
           }

           // browser events (back/forward) are popstate events
           if (acc.trigger === 'popstate') {
             // get the history item that references the idToRestore
             const idx = history.findIndex(x => x.id === acc.idToRestore);

             // if found, set the current index to that history item and update the id
             if (idx > -1) {
               currentIndex = idx;
               history[idx].id = acc.id;
             } else {
               currentIndex = 0;
             }
           }

           return {
             ...acc,
             event,
             history,
             currentIndex
           };
         },
         {
           event: null,
           history: [],
           trigger: null,
           id: 0,
           idToRestore: 0,
           currentIndex: 0
         }
       ),
       // filter out so we only act when navigation is done
       filter(
         ({ event, trigger }) => event instanceof NavigationEnd && !!trigger
       )
     )
     .subscribe(({ history, currentIndex }) => {
       const previous = history[currentIndex - 1];
       const current = history[currentIndex];

       // update current and previous urls
       this.previousUrl$.next(previous ? previous.url : null);
       this.currentUrl$.next(current.url);
     });
 }
}

A big thanks to RxJS for the scan operator. If you’re not familiar with this operator, it works the same way as the Javascript array method ‘reduce’. This allows us to track the user’s navigation history by accumulating the router events and plucking the bits we need from the NavigationStart and NavigationEnd events.  

Whenever a NavigationStart event is fired, we need to track a few things—the event itself for filtering, the navigation trigger, the id of the event, and the id of the event to restore. The id of the event to restore is only available for popstate events and notifies us which event in our history should be restored. This allows us to traverse our history to find out if the back or forward button was pressed on the browser.

Once a NavigationEnd event is fired, we need to start making some decisions on how to track the history event.

1. Imperative Events: These are the events that were triggered through the application, like clicking on an anchor element with a RouterLink directive. This one is actually pretty simple. First, we remove all events after the current one (e.g., to clean up after any rapid popstate events), and then add the current event to the history stack. Finally, we set the current index to the last item in the history stack.

This follows the same pattern as the history API. If you take a look at the browser’s forward button, it’s only enabled after you use the browser’s back button and remains enabled until 1 of 2 things happen:

  • you click the forward button enough times to get to the end of the pages you’ve visited, or
  • you click on any navigation element on any page

Imperative events represent the second option here.

2. Popstate Events: These are the events that were triggered through the use of the browser’s back and forward buttons or using the history API. Since popstate events include an id to restore, we can use that value to find the history event in our history array that matches. If the history event is found, then we set the current index to that history event and update the id on that history event to the current id.

This, at first, was very confusing. Why would we update the id of the history event in our history array to the id of the current router event? This goes back to the linear fashion of how the Angular router fires navigation events. If a user navigates back one page, then forward one page, then back again, the events will look like this:

NavigationStart {"id":8,"url":"/home","navigationTrigger":"popstate","restoredState":{"navigationId":6}}
NavigationEnd {"id":8,"url":"/home","urlAfterRedirects":"/home"}

NavigationStart {"id":9,"url":"/about","navigationTrigger":"popstate","restoredState":{"navigationId":7}}
NavigationEnd {"id":9,"url":"/about","urlAfterRedirects":"/about"}

NavigationStart {"id":10,"url":"/home","navigationTrigger":"popstate","restoredState":{"navigationId":8}}
NavigationEnd {"id":10,"url":"/home","urlAfterRedirects":"/home"}

The first back click here caused the router to fire an event that signals we are going back to a history event with id 6. Next, we go forward, and then back again.  However, even though we are returning to the same page, the event now signals that we are going back to a history event with id 8. Our service needs to expect this new value 8 when we next try to go back in history. Since only imperative events are actually pushed onto the history array, we need to update the ids of the history events to accommodate the router events fired by Angular.

Finally, since we only want to update the previous URL once navigation is finished, we are going to pipe the returned router history from the scan operator through an RxJS filter to wait for NavigationEnd.

Now, as long as we have implemented our Backlink click handler correctly to use window.history.back() rather than router.navigate(), we can use the service to always access the correct previous URL. We use this to populate the Backlink href for open-in-new-tab events, and to trigger conditional text based on the URL, for example.

previousUrl$ = this.routerHistoryService.previousUrl$;
currentUrl$ = this.routerHistoryService.currentUrl$;

 constructor(
   private routerHistoryService: RouterHistoryService
 ) { }

You can access a full working example here:  https://github.com/semanticbits/previous-url-example