Adding .delay(10) To ActivatedRoute Helps Prevent Unwanted Router Behaviors In Angular 4.4.6
To be very clear, before we get into this post, I am not saying that any of the following behaviors are "bugs" in the Angular 4.4.6 Router or ActivatedRoute services. For all I know, everything demonstrated here is the "expected" behavior for these services. The only claim that I am making is that these behaviors are, at least sometimes, "unwanted"; and, I'm simply sharing the approach that I'm taking to try and mitigate these behaviors in my application.
Furthermore, dealing with the Router is a tricky beast because you have several sources of information that all have to be reconciled: the current URL; the in-memory tree of ActivatedRoute objects; the router-outlet directives which manage the routable Views; and, the state of the components that conditionally-house the router-outlet directives. And, to make things even more complicated, the existence of a router-outlet depends on the state of the template which has to be reconciled with its component using change detection.
Not to mention that there are there are always interesting cross-browser differences to content with.
What I'm trying to do here is paint a picture that there are a lot of moving parts when it comes to a router-based application. As such, there is more going on here than I can keep in my head (at this time). Hopefully, as my mental model for the Angular Router becomes more robust, I'll start to see how the orchestration of all of these parts is executed. But, for now, I'm still feeling around in the dark.
Now, to get to the meat of the post, I am seeing two "unwanted" behaviors in my application: components seeing ActivatedRoute events right before they are destroyed; and, components being re-created right after being destroyed. In both of these cases, I have found that adding a .delay(10) to the ActivatedRoute stream removes these unwanted behaviors. I don't fully understand why; but, it seems to work consistently in both Chrome and Firefox.
CAUTION: The second issue seems to only be symptomatic in Chrome (not Firefox). And, adding a .delay(0) fixes Chrome by breaks Firefox. But, a .delay(10) makes both browsers work consistently.
Run this demo in my JavaScript Demos project on GitHub.
To see this in action, I have a root component that conditionally renders a Child View component, with simulated network latency. Admittedly, this demo would have been a bit easier if I had a routable Parent component; but, since I only have the root component and the Child View component, I have some janky code here to make sure the router-outlet isn't always on the page:
// Import the core angular services.
import { Component } from "@angular/core";
import { Event as NavigationEvent } from "@angular/router";
import { NavigationStart } from "@angular/router";
import { Router } from "@angular/router";
// Import these modules for their side-effects.
import "rxjs/add/operator/delay";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<h2>
Without <code>.delay(10)</code> In ActivatedRoute
</h2>
<ul>
<li>
<strong>Step 0</strong> —
<a routerLink="/">Show Root</a>
</li>
<li>
<strong>Step 1</strong> —
<a [routerLink]="[ 'bad-child', 1 ]">Show Child 1</a>
</li>
<li>
<strong>Step 2</strong> —
<a [routerLink]="[ 'bad-child', 2 ]">Show Child 2</a>
</li>
<li>
<strong>Step 3</strong> —
<a href="#/bad-child/3">Show Child 3</a>
</li>
</ul>
<h2>
With <code>.delay(10)</code> In ActivatedRoute
</h2>
<ul>
<li>
<strong>Step 0</strong> —
<a routerLink="/">Show Root</a>
</li>
<li>
<strong>Step 1</strong> —
<a [routerLink]="[ 'good-child', 1 ]">Show Child 1</a>
</li>
<li>
<strong>Step 2</strong> —
<a [routerLink]="[ 'good-child', 2 ]">Show Child 2</a>
</li>
<li>
<strong>Step 3</strong> —
<a href="#/good-child/3">Show Child 3</a>
</li>
</ul>
<ng-template [ngIf]="isLoading">
<p>
<em>No child being rendered yet.</em>
</p>
</ng-template>
<ng-template [ngIf]="! isLoading">
<router-outlet></router-outlet>
</ng-template>
`
})
export class AppComponent {
public isLoading: boolean;
constructor( router: Router ) {
var timer: number = null;
this.isLoading = true;
// NOTE: I am adding the delay here to give the template time to reconcile with
// the changes in the view-model. To be honest, I don't exactly understand the
// timing issues; but, I suspect that multiple things are taking place in series
// and the change detector is unhappy with it.
router.events.delay( 0 ).subscribe(
( event: NavigationEvent ) : void => {
if ( ! ( event instanceof NavigationStart ) ) {
return;
}
console.info( "Navigating to:", event.url );
clearTimeout( timer );
// We want to indicate a loading for all navigations EXCEPT for 2 ==> 3.
// This demonstrates another edge-case in which we navigate away from a
// component inside the ParamMap callback.
this.isLoading = ! /-child\/3/i.test( event.url );
// If we're navigating to one of the Child routes, let's pull the router-
// outlet out of the page briefly so that the Router will destroy the
// current Child component (if any is rendered). This will help simulate
// the kind of network latency we would see in production if we were
// loading data of a parent view.
if ( /-child\/[12]/i.test( event.url ) ) {
timer = setTimeout(
() : void => {
this.isLoading = false;
},
500
);
}
}
);
}
}
As you can see there, are two sets of demo steps to take. The first set uses the Bad Child Component and the second set uses the Good Child Component. The only difference between these two components is that they have different log statements and the Good Child Component adds a .delay(10) operator to the ActivatedRoute stream.
Here's the Bad Child Component:
// Import the core angular services.
import { ActivatedRoute } from "@angular/router";
import { Component } from "@angular/core";
import { ParamMap } from "@angular/router";
import { Router } from "@angular/router";
import { Subscription } from "rxjs/Subscription";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "bad-child-component",
styleUrls: [ "./child.component.less" ],
template:
`
This is the <strong>Bad-Child</strong> component.
`
})
export class BadChildComponent {
private activatedRoute: ActivatedRoute;
private paramMapSubscription: Subscription;
private router: Router;
// I initialize the child-view component.
constructor(
activatedRoute: ActivatedRoute,
router: Router
) {
this.activatedRoute = activatedRoute;
this.router = router;
console.group( "Bad Child Component Instantiated." );
}
// ---
// PUBLIC METHODS.
// ---
// I get called once when the component is being destroyed.
public ngOnDestroy() : void {
( this.paramMapSubscription ) && this.paramMapSubscription.unsubscribe();
console.log( "ngOnDestroy() called." );
console.groupEnd();
}
// 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 => {
console.warn( "Param-map value:", paramMap.get( "id" ) );
if ( paramMap.get( "id" ) === "3" ) {
console.warn( "Navigating back to root." );
this.router.navigate( [ "/" ] );
}
}
);
}
}
And, here's the Good Child Component:
// Import the core angular services.
import { ActivatedRoute } from "@angular/router";
import { Component } from "@angular/core";
import { ParamMap } from "@angular/router";
import { Router } from "@angular/router";
import { Subscription } from "rxjs/Subscription";
// Import these modules for their side-effects.
import "rxjs/add/operator/delay";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "good-child-component",
styleUrls: [ "./child.component.less" ],
template:
`
This is the <strong>Good-Child</strong> component.
`
})
export class GoodChildComponent {
private activatedRoute: ActivatedRoute;
private paramMapSubscription: Subscription;
private router: Router;
// I initialize the child-view component.
constructor(
activatedRoute: ActivatedRoute,
router: Router
) {
this.activatedRoute = activatedRoute;
this.router = router;
console.group( "Good Child Component Instantiated." );
}
// ---
// PUBLIC METHODS.
// ---
// I get called once when the component is being destroyed.
public ngOnDestroy() : void {
( this.paramMapSubscription ) && this.paramMapSubscription.unsubscribe();
console.log( "ngOnDestroy() called." );
console.groupEnd();
}
// I get called once, after the inputs have been bound for the first time.
public ngOnInit() : void {
this.paramMapSubscription = this.activatedRoute.paramMap
// CAUTION: Adding the .delay(10) to the ParamMap subscription helps prevent
// several unwanted behaviors in the Angular Router (as of 4.4.6). To be
// clear, I AM NOT SAYING THAT THESE ARE "BUGS"; only that there things are
// happening that I don't want to happen, and adding .delay(10) helps prevent
// those things. I've started adding this as the default behavior.
.delay( 10 )
.subscribe(
( paramMap: ParamMap ) : void => {
console.warn( "Param-map value:", paramMap.get( "id" ) );
if ( paramMap.get( "id" ) === "3" ) {
console.warn( "Navigating back to root." );
this.router.navigate( [ "/" ] );
}
}
)
;
}
}
As you can see, the only meaningful difference in the Good Child Component is that it adds a .delay(10) to the ActivatedRoute stream.
Now, if we run this demo in the browser and click through first set of links (giving each view time to render), we get the following page output:
As you can see, there are two "unwanted" (though, again, not necessarily "buggy") behaviors. First, the Child component is able to see the ParamMap event that triggers its own destruction. And, Second, the Child component is immediately re-created and then re-destroyed when it navigates away from itself too soon after ParamMap event.
Now, if we clear the console and run through the second series of steps - the one with the added .delay(10) on the ActivatedRoute stream - we get the following output:
As you can see, the two "unwanted" behaviors are no longer symptomatic in the browser once the .delay(10) is added to the ActivatedRoute. And, both Chrome and Firefox are now consistent (previously, they were symptomatic under different conditions). I don't fully grasp why this actually fixes anything. I believe it's a combination of all the moving parts, template reconciliation, change detection, race-conditions, and browser inconsistencies. But, this seems to work best for my personal brand of component design.
ASIDE: As a reminder, I would like to recommend that you always unsubscribe from your ActivatedRoute stream subscriptions. The documentation says that this is not necessary - that the Router manages it for you; but, in my experience, this is inconsistent. As such, you should treat the ActivatedRoute stream like any other stream and unsubscribe from it in your ngOnDestroy() life-cycle hood methods.
Want to use code from this post? Check out the license.
Reader Comments