Reputation: 24699
I am facing a tricky issue with asynchronicity in an angular 2 app.
I am basically trying to rehydrate/reload information from the backend when the app is reloaded/bootstrapped in the user's browser (think F5/refresh). The issue is that before the backend async method returns the result, a router guard is called and blocks...
I reload the information from the root component's ngOnInit
method as follows:
from root component:
ngOnInit() {
//reloadPersonalInfo returns an Observable
this.sessionService.reloadPersonalInfo()
.subscribe();
}
from sessionService
:
reloadPersonalInfo() {
//FIRST: the app execution flow gets here
let someCondition: boolean = JSON.parse(localStorage.getItem('someCondition'));
if (someCondition) {
return this.userAccountService.retrieveCurrentUserAccount()
.switchMap(currentUserAccount => {
//THIRD: and finally, the execution flow will get there and set the authenticated state to true (unfortunately too late)...
this.store.dispatch({type: SET_AUTHENTICATED});
this.store.dispatch({type: SET_CURRENT_USER_ACCOUNT, payload: currentUserAccount});
return Observable.of('');
});
}
return Observable.empty();
}
The trouble is that I have a router CanActivate
guard as follows:
canActivate() {
//SECOND: then the execution flow get here and because reloadPersonalInfo has not completed yet, isAuthenticated will return false and the guard will block and redirect to '/signin'
const isAuthenticated = this.sessionService.isAuthenticated();
if (!isAuthenticated) {
this.router.navigate(['/signin']);
}
return isAuthenticated;
}
isAuthenticated method from sessionService
:
isAuthenticated(): boolean {
let isAuthenticated = false;
this.store.select(s => s.authenticated)
.subscribe(authenticated => isAuthenticated = authenticated);
return isAuthenticated;
}
So to recap:
reloadPersonalInfo
method on sessionService
is called by root component ngOnInit
. The execution flow enters this method.authenticated
is false
(because reloadPersonalInfo
has not completed yet and therefore not set the authenticated
state to true
.reloadPersonalInfo
completes too late and sets the authenticated
state to true
(but the router guard has already blocked).Can anyone please help?
edit 1: Let me stress that the authenticated
state that matters is the one in the store; it is set by this line: this.store.dispatch({type: SET_AUTHENTICATED});
.
edit 2: I changed the condition from authenticated
to someCondition
in order to reduce confusion. Previously, there was another state variable called authenticated
...
edit 3: I have changed the isAuthenticated()
method return type to Observable<boolean>
instead of boolean
(to follow Martin's advice) and adapted the canActivate
method as follows:
canActivate() {
return this.sessionService.isAuthenticated().map(isAuthenticated => {
if (isAuthenticated) {
return true;
}
this.router.navigate(['/signin']);
return false;
});
}
from sessionService
:
isAuthenticated(): Observable<boolean> {
return this.store.select(s => s.authenticated);
}
It makes no difference unfortunately...
Can someone please advise as to how to sort this asynchronicity issue?
Upvotes: 1
Views: 476
Reputation: 16892
There should be two possible ways to solve this:
The quickest, would be to distinguish your isAuthenticated
not into 2 but 3 states, this way you can encode one more cruical piece of information into the state: At the time of bootstrapping(when no response from the server has been received), there is no way the client can know for sure if its credentials/tokens are valid or not, thus the state should correctly be "unknown".
First you have to change the initial state of authenticated
in your store to null
(you also may choose undefined
or even use numbers depending on your personal taste). And then you just have to add a .filter
to your guard, that renders the guard practically "motionless":
canActivate() {
return this.sessionService.isAuthenticated()
.filter(isAuthenticated => isAuthenticated !== null) // this will cause the guard to halt until the isAuthenticated-question/request has been resolved
.do(isAuth => (if (!isAuth) {this.router.navigate(['/signin'])}));
}
The second solution would be very similar, but instead of encoding a 3rd state into authenticated
you'd add a new flag to your store called authRequestRunning
, that is set to true
while the auth-request is being made, and set to false
after it completes. Your guard would then look only slightly different:
canActivate() {
return this.sessionService.isAuthenticated()
.switchMap(isAuth => this.sessionService.isAuthRequestRunning()
.filter(running => !running) // don't emit any data, while the request is running
.mapTo(isAuth);
)
.do(isAuth => (if (!isAuth) {this.router.navigate(['/signin'])}));
}
With solution #2 you might have some more code. and you have to be careful that the authRequestRunning
is set to false
first before the authenticated
-state is updated.
Edit: I have edited the code in solution #2, so the order of setting the running-status and the auth-status does not matter any more.
The reason why I would use solution #2 is, because in most cases such a state-flag already exists and is being used to display a loading-indicator or something like that.
Upvotes: 2
Reputation: 17944
why are you not setting Authenticated before you give a retrieveCurrentUserAccount
call? IT seems you already know if your user is authenticated or not based upon the value inside localStorage
if (isAuthenticated) {
// set before you give a async call.
this.store.dispatch({type: SET_AUTHENTICATED});
return this.userAccountService.retrieveCurrentUserAccount()
.switchMap(currentUserAccount => {
//THIRD: and finally, the execution flow will get there and set the authenticated state to true (unfortunately too late)...
this.store.dispatch({type: SET_CURRENT_USER_ACCOUNT, payload: currentUserAccount});
return Observable.of('');
});
}
Update
Try below,
import { Component, Injectable } from '@angular/core';
import { Router, Routes, RouterModule, CanActivate } from '@angular/router';
import { Subject } from 'rxjs/Subject';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/take';
@Injectable()
export class SessionService{
private _isAuthenticated: Subject<boolean> = new Subject<boolean>();
public get isAuthenticated(){
return this._isAuthenticated.asObservable();
}
reloadPersonalInfo(){
setTimeout(() => {
this._isAuthenticated.next(true);
// do something else too...
}, 2000);
}
}
@Component({
selector: 'my-app',
template: `<h3>Angular CanActivate observable</h3>
<hr />
<router-outlet></router-outlet>
`
})
export class AppComponent {
constructor(private router: Router,
private sessionService : SessionService) { }
ngOnInit() {
this.sessionService.reloadPersonalInfo();
}
}
@Component({
template: '<h3>Dashboard</h3>'
})
export class DashboardComponent { }
@Component({
template: '<h3>Login</h3>'
})
export class LoginComponent { }
@Injectable()
export class DashboardAuthGuard implements CanActivate {
constructor(private router: Router, private sessionService : SessionService) { }
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot){
return this.sessionService.isAuthenticated.map(res => {
if(res){
return true;
}
this.router.navigate(['login']);
}).take(1);
}
}
let routes: Routes = [
{
path: '',
redirectTo: '/dashboard',
pathMatch: 'full'
},
{
path: 'dashboard',
canActivate: [DashboardAuthGuard],
component: DashboardComponent
},
{
path: 'login',
component: LoginComponent
}
]
export const APP_ROUTER_PROVIDERS = [
DashboardAuthGuard
];
export const routing: ModuleWithProviders
= RouterModule.forRoot(routes);
Here is the Plunker!!
Hope this helps!!
Upvotes: 0
Reputation: 16300
canActivate
itself can return an Observable.
Instead of returning the boolean result in canActivate
, return the isAuthenticated
Observable.
Upvotes: 0