Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Aaron Lerch
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Aaron Lerch@aaronlerch )

Forcing RouterLinkActive To Update Using An Inputs Hack In Angular 5.0.2

By Ben Nadel on

The RouterLinkActive directive in the Angular Router module allows us to add "active" CSS classes to a navigation item (or item container) when the current route subsumes the given RouterLink command. In other words, the RouterLinkActive directive lets us turn navigational elements "on" and "off" as the user navigates around the application. In the vast majority of cases, this "just works." However due to the way in which the RouterLinkActive directive queries for RouterLink instances, there are edge-cases in which the order of operations prevents navigational elements from turning on. In those edge-cases, at least in Angular 5.0.2, we can use an "Inputs" hack to force the RouterLinkActive directive to update.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

If we look at the RouterLinkActive implementation, we can see three important implementation details:

  • It listens for the NavigationEnd event, then updates its state accordingly.
  • It listens for Input changes, then updates its state accordingly.
  • It queries for RouterLink instances using ContentChildren().

Without trying to say too much here - since my grasp on the change-detection life-cycle is fairly shallow - there appears to be a race condition between when the NavigationEnd event fires and when the RouterLink ContentChildren() is updated. And, this kind of makes sense; since the ContentChildren() decorator provides a way to query the DOM (Document Object Model), it necessarily depends on the template being reconciled with the view-model. As such, one could probably assume that the ContentChildren() value will always be a step-behind the view-model state.

In fact, if we go into the in-browser Source of our RouterLinkActive directive and add a break-point to the method that compares the active URL to the RouterLink URL, we can see that the two can be out of sync. In the following screenshot, I've hit this break-point by performing the following navigation:

  • From: /go/1/view
  • To: /go/2/view

 
 
 

 
 RouterLinkActive breakpoint shows URL vs. DOM state in Angular 5.0.2. 
 
 
 

As you can see, the browser URL is "/go/2/view". However, our breakpoint illustrates that after the NavigationEnd event has fired, the RouterLinkActive's ContentChildren() RouterLink instances still present the "/go/1/view". This is because the template has not yet been updated to reflect the state of the view-model as changed by the navigation event.

In a practical sense, this means that certain types of navigation will not cause the RouterLinkActive directive to add the "active" class to the DOM Element. Because, at the time the links are checked, the DOM has not caught up to the view-model. This can cause your RouterLinkActive directives to "de-activate" even when they match the browser URL.

To "fix" this (ie, hack it to make it work as desired), let's refer back to the previous list of implementation details. From that list we can see that the RouterLinkActive directive updates its state if the "Input" bindings are changed. And, it just so happens, that one of the Input bindings - routerLinkActiveOptions - allows us to pass-in an arbitrary object. As such, if we can base the routerLinkActiveOptions binding on the state of the view-model, then it should cause the Input to change after the view-model changes, which should get RouterLinkActive to re-synchronize with the browser URL.

To demonstrate this, I've created a simple demo that has two tiers of navigation: Item navigation and Mode navigation. The items are defined by the following route:

/go/:id/:mode

... and can be linked to from within the root component:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "my-app",
  • styleUrls: [ "./app.component.less" ],
  • template:
  • `
  • <nav>
  • <a routerLink="/" class="item">Root</a>
  • <a routerLink="go/1" class="item" routerLinkActive="on">Item 1</a>
  • <a routerLink="go/2" class="item" routerLinkActive="on">Item 2</a>
  • <a routerLink="go/3" class="item" routerLinkActive="on">Item 3</a>
  • </nav>
  •  
  • <router-outlet></router-outlet>
  • `
  • })
  • export class AppComponent {
  • // ...
  • }

Within each ChildComponent, there is then a navigation to enter the View mode or the Edit mode. In the following ChildComponent template, I'm providing two sets of view-edit navigation: the first provides just the RouterLinkActive directives (which we know will break in some cases); and the second provides the additional RouterLinkActiveOptions input-binding which is based on the ":id" and ":mode" URL parameters.

  • // Import the core angular services.
  • import { ActivatedRoute } from "@angular/router";
  • import { Component } from "@angular/core";
  • import { OnDestroy } from "@angular/core";
  • import { OnInit } from "@angular/core";
  • import { ParamMap } from "@angular/router";
  • import { Subscription } from "rxjs/Subscription";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "child-view",
  • styleUrls: [ "./child.component.less" ],
  • template:
  • `
  • <nav>
  • <a routerLink="/go/{{ id }}/view" class="item" routerLinkActive="on">View</a>
  • <a routerLink="/go/{{ id }}/edit" class="item" routerLinkActive="on">Edit</a>
  • </nav>
  •  
  • <nav>
  • <a
  • routerLink="/go/{{ id }}/view"
  • class="item"
  • routerLinkActive="on"
  • [routerLinkActiveOptions]="{ __change_detection_hack__: [ id, mode ] }">
  • View
  • </a>
  • <a
  • routerLink="/go/{{ id }}/edit"
  • class="item"
  • routerLinkActive="on"
  • [routerLinkActiveOptions]="{ __change_detection_hack__: [ id, mode ] }">
  • Edit
  • </a>
  • </nav>
  •  
  • <p>
  • You are in mode <strong>{{ mode }}</strong> for child <strong>{{ id }}</strong>.
  • </p>
  • `
  • })
  • export class ChildComponent implements OnInit, OnDestroy {
  •  
  • public id: number;
  • public mode: string;
  •  
  • private activatedRoute: ActivatedRoute;
  • private paramMapSubscription: Subscription;
  •  
  • // I initialize the child-view component.
  • constructor( activatedRoute: ActivatedRoute ) {
  •  
  • this.activatedRoute = activatedRoute;
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I get called once when the component is being unmounted.
  • public ngOnDestroy() : void {
  •  
  • ( this.paramMapSubscription ) && this.paramMapSubscription.unsubscribe();
  •  
  • }
  •  
  •  
  • // I get called once after the inputs have been bound for the first time.
  • public ngOnInit() : void {
  •  
  • this.paramMapSubscription = this.activatedRoute.paramMap.subscribe(
  • ( paramMap: ParamMap ) : void => {
  •  
  • this.id = +paramMap.get( "id" );
  • this.mode = paramMap.get( "mode" );
  •  
  • }
  • );
  •  
  • }
  •  
  • }

Notice that the second set of navigation elements is using the Input binding:

[routerLinkActiveOptions]="{ __change_detection_hack__: [ id, mode ] }"

By making this object be, at least in part, dynamic, it will cause the ngOnChanges() life-cycle hook method to be invoked in the RouterLinkActive directive - but, only after the inputs have been updated. This will cause the RouterLinkActive directive to re-run its .update() method, which will re-check the RouterLink ContentChildren() against the browser URL. And, at that time, the two have been synchronized and the appropriate "active" CSS classes will be added to the navigational element.

Now, if we open this up in the browser and perform the 1-to-2 item navigation, we get the following output:


 
 
 

 
 Using RouterLinkActiveOptions input binding, we can force RouterLinkActive to update when the DOM updates in Angular 5.0.2. 
 
 
 

As you can see, even though both sets of navigation elements are using the same routes, only the second set - which is using the routerLinkActiveOptions input-binding hack - actually activates the DOM Element properly.

While this behavior is a bit unexpected, I am not sure that I would consider this a bug, especially since it only affects a certain edge-case of navigation conditions. The RouterLinkActive directive is querying the Document Object Model (DOM) for state; so, timing-quirks around template reconciliation are just a fact of life. That said, there may be something that the Angular team can do to make this a bit more useable. And, when / if this "hack" is fixed, it will be super easy to find all instances of "__change_detection_hack__" in your code-base.



Looking For A New Job?

Ooops, there are no jobs. Post one now for only $29 and own this real estate!

100% of job board revenue is donated to Kiva. Loans that change livesFind out more »

Reader Comments

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.