balteo
balteo

Reputation: 24699

Asynchronicity issue with an Angular 2 app and the Angular 2 router

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:

  1. FIRST: the reloadPersonalInfo method on sessionService is called by root component ngOnInit. The execution flow enters this method.
  2. SECOND: in the meantime, the router guard is called and sees that the state of authenticated is false (because reloadPersonalInfo has not completed yet and therefore not set the authenticated state to true.
  3. THIRD: 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

Answers (3)

Olaf Horstmann
Olaf Horstmann

Reputation: 16892

There should be two possible ways to solve this:

Solution 1

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'])}));
}

Solution 2

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

Madhu Ranjan
Madhu Ranjan

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

Martin
Martin

Reputation: 16300

canActivate itself can return an Observable.

Instead of returning the boolean result in canActivate, return the isAuthenticated Observable.

Upvotes: 0

Related Questions