Angular Router Protection Issues

Angular router is not perfect, yet. At least latest stable version, 4.3.6 which is a scope of this article. You will notice it while struggling to prototype more sophisticated routing architecture. Nested structure full of resolve and canActivation guards is a must sometimes, especially when your application grows. In this article, I will try shed light on some the difficulties I had to face lately while working with Angular Router.

But for now, let’s start with some basic stuff. Imagine we have an information page with two car brands: tesla and arrinera. Routing might look like:

/cars
/cars/tesla
/cars/arrinera

All right, let’s define routing for it:

{ 
path: 'cars',
component: CarsComponent,
children: [
{ path: ':cid', component: CarComponent }
]
}

Quiet simple, huh? Now, we need some information about cars, let’s create a CarsResolver, as a data provider:

@Injectable()
class CarsResolver implements Resolve<any> {
public resolve() {
return Observable.of([{name: 'tesla'}, {name: 'arrinera'}]);
}
}

And update route definition:

{ 
path: 'cars',
component: CarsComponent,
resolve: { cars: CarsResolver },
children: [
{ path: ':cid', component: CarComponent }
]
}

Imagine now, that we want to protect our :cid route to have only two values: tesla and arrinera, as provided by CarsResolver. Our primary objective:

/cars/tesla -> ok!
/cars/arrinera -> ok!
/cars/ford -> not allowed!

Simple, you might think, Let’s use CanActivate. Ok, let’s give it a try. It should check whether provided by user car brand (:cid) is available in resolved by parent route cars list. If so, allow component to be initialized, prohibit otherwise.

{ 
path: 'cars',
component: CarsComponent,
resolve: { cars: CarsResolve },
children: [
{
path: ':cid',
canActivate: [ CarGuard ],
component: CarComponent
}
]
}

Definition of CarGuard:

@Injectable()
class CarGuard implements CanActivate<boolean> {
    public resolve(route: ActivatedRouteSnapshot) {
const resolvedCars = route.parent.data.cars;
const carId = route.params.cid;
const car = resolvedCars.find(car => car.name === carId);

return Observable.of(!!car);
}
}

Quite simple: access cars data resolved by CarsResolver on parent route. Find in list car brand object, by provided by a user in URL cid, and resolve boolean value, representing car availability.

Saving, running and bang! It does not work. route.parent.data.cars is not defined. Why? Because CarsResolve has not started yet. Under the hood, Angular calls CanActivate guards before data resolution (resolve) process steps in, so we have no access to resolved data. canActivate approach is more like: Are we allowed to navigate route? If so, run resolvers, then initialize component. To make things worse, even when we have two canActivate guards (one defined in child route, another in parent route), both of them are being called at the same time. It’s well-known issue, which is already fixed in 5.0.0-beta.1, but leave it for now. The truth is, that in this specific circumstance, we can’t take advantages of Angular Guards capabilities.

What we need here is some hybrid of Resolve and canActivate guard. Resolve is allowed to access data resolved by parent route. It’s a huge advantage over canActivate. Guards, on the other hand, can prevent a component from being initialized, but how is it achieved? Let’s look through some framework innards…

When any user defined canActivate resolves to false (look here), under the hood, canLoadFails function is being called. This function, calls another shared function, navigationCancelingError which produces an Observable error, which emits interesting instance of Error object, with ngNavigationCancelingError property set to true. Such well-shaped Error forces Angular, to not initialize component. This is what we need to protect our route! We can use this knowledge, to define our custom resolve, which will act like a canActivate and resolve at once. Let’s name it CarResolve.

Replace non-working canActivate with our new CarResolve:

{ 
path: 'cars',
component: CarsComponent,
resolve: { cars: CarsResolve },
children: [
{
path: ':cid',
resolve: { car: CarResolve },
component: CarComponent
}
]
}

And define CarResolve as:

@Injectable()
class CarResolve implements Resolve<any> {
public resolve(route: ActivatedRouteSnapshot) {

const resolvedCars = route.parent.data.cars;
const cid = route.params.cid;
const car = resolvedCars.find(car => car.name === cid);
        if (car) {
return Observable.of(car);
} else {
return Observable.throw({
ngNavigationCancelingError: true
});
}
}
}

How does it works? As it’s aresolve, we have access to resolved by parent routecars list. On the other hand, while throwing object with ngNavigationCancelingError property set, we act likecanActivate guard. This forces Angular to reject component initialization. Eventually, NavigationCancel route event is being produced and sent. It will be helpful later, but for now, let’s test our routing transitions from source to target:

/cars -> /cars/tesla          // allowed!
/cars/tesla -> /cars/arrinera // allowed!
/cars/arrinera -> /cars/ford // not allowed!

It works like a charm for state transitions! As described above, we have already source state activated (/cars, /cars/tesla, /cars/arrinera), and then we are trying to navigate a target route. It might succeed or fail. But what happens internally when it fails? resetUrlToCurrentUrlTree function is being called. What it does is trivial URL replacement for source route URL. So, when have already state activated (let’s say: /cars) and we are trying to navigate the invalid route, /cars/ford, after canceling transition, this function reverts URL to latest activated route, which is /cars in this case. From user perspective, nothing has changed, we stay on source route.

But what happens, when we have no source state activated? Is it possible? Yes, it is. We can achieve it by opening new browser tab, and pasting http://localhost:4200/cars/ford in an address bar. Angular router is trying to activate /cars/ford state, and it fails… Unfortunately, as we have no source state activated, a blank page is displayed, at least the root<route-outlet> element is not filled at all. Not sure whether it’s intended behavior, what’s more, this problem occurs also for canActivate guards.

How can we bypass this issue? Just by watching source URL, after route activation rejection. Let’s apply subscription for NavigationCancel event like this:

this.router.events
.filter(event => event instanceof NavigationCancel)
.subscribe(event => {
const { url } = this.router.currentRouterState.snapshot;
if (url === '') {
this.router.navigate(['/cars']);
}
});

So, when we receive NavigationCancel event, and ActivatedRouteSnapshot url property points to an empty string it means, that a user opened url (which points to invalid application state) in new browser tab. We can redirect the user to specific route, preventing the blank page from being displayed. And yes, we can put this redirection inside CarResolve definition, but I think, that resolve should only resolve data (or throw an error) without additional tricky redirection logic.

Click here to open plunker with an example application embodying described issues. Review code and necessarily click Launch the preview in a separate window icon in order to play with routing capabilities in new browser tab. Observe console output.