Forcing RouterLinkActive To Update Using An Inputs Hack In Angular 5.0.2
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
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:
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.
Want to use code from this post? Check out the license.
Reader Comments
FYI, there is an Angular Issue relating to this: https://github.com/angular/angular/issues/18469
I have posted this solution / hack over there.
Man, great write-up/video. I spent the last 2 hours trying to find a workaround.
FYI, My issue was slightly different: I was using *ngFor's trackBy against a list of items that is sorted. When you change sort order, the 'activated' item would move, but the same index would remain active.
So, if the item at index 2 was set to active, re-ordering the list (which in my case happens via an observable against the original data stream) would MOVE the item at index 2 to, say, index 4, but whatever other item moved into index 2 would keep the 'active' state, despite no change in route. Super bizarre and I knew it was related to change detection but your hack solved my issue as well. If you want, you could mention that in your post.
Bravo! A good find/workaround.
Hello there,
Nice article,,,I am having the same issue,, but In my case there is no ID,, and I tried to add a fake property,, such as fakeID and i fill it on init,, but still nothing happens,, I am using angular 7...
In my parent navigation also noting gets activated ,,, unless I click on the link,, but on load it wont activate,, now the FUN part.. is even when I click on the link,, routerLink.isActive is always false,, but the routerLinkActive class is applied... that is driving me crazy for a couple of days right now...
Can you help me with what I am doing wrong???
By the way: my routes are all derived from the app root into lazy loaded routes
here are the routes:
export const rootRouterConfig: Routes = [
{
path: '',
redirectTo: 'dashboard',
pathMatch: 'full'
},
{
path: '',
component: AuthLayoutComponent,
children: [
{
path: 'sessions',
loadChildren: './views/sessions/sessions.module#SessionsModule',
data: { title: 'Session' }
}
]
},
{
path: 'dashboard',
loadChildren: './views/dashboard/dashboard.module#DashboardModule',
data: { title: 'Dashboard', breadcrumb: 'DASHBOARD' }
},
{
path: 'profile',
loadChildren: './views/profile/profile.module#ProfileModule',
data: { title: 'Profile', breadcrumb: 'PROFILE' }
},
{
path: 'account',
loadChildren: './modules/account/account.module#AccountModule',
data: { title: 'Account', breadcrumb: 'ACCOUNT' }
},
{
path: 'others',
loadChildren: './views/others/others.module#OthersModule',
data: { title: 'Others', breadcrumb: 'OTHERS' }
},
{
path: '**',
redirectTo: 'sessions/404'
}
];