How to implement a global loader in Angular
Asked Answered
S

4

22

I have a global loader which is implemented like this:

CoreModule:

router.events.pipe(
  filter(x => x instanceof NavigationStart)
).subscribe(() => loaderService.show());

router.events.pipe(
  filter(x => x instanceof NavigationEnd || x instanceof NavigationCancel || x instanceof NavigationError)
).subscribe(() => loaderService.hide());

LoaderService:

@Injectable({
    providedIn: 'root'
})
export class LoaderService {

    overlayRef: OverlayRef;
    componentFactory: ComponentFactory<LoaderComponent>;
    componentPortal: ComponentPortal<LoaderComponent>;
    componentRef: ComponentRef<LoaderComponent>;

    constructor(
        private overlay: Overlay,
        private componentFactoryResolver: ComponentFactoryResolver
    ) {
        this.overlayRef = this.overlay.create(
            {
                hasBackdrop: true,
                positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically()
            }
        );

        this.componentFactory = this.componentFactoryResolver.resolveComponentFactory(LoaderComponent);

        this.componentPortal = new ComponentPortal(this.componentFactory.componentType);
    }

    show(message?: string) {
        this.componentRef = this.overlayRef.attach<LoaderComponent>(this.componentPortal);
        this.componentRef.instance.message = message;
    }

    hide() {
        this.overlayRef.detach();
    }
}

When running with Angular 7.0.2, the behavior (which I wanted) was:

  • Show loader while resolving data attached to a route, and while loading a lazy module
  • Don't show loader when navigating to a route without any resolver

I have updated to Angular 7.2, now the behavior is:

  • Show loader while resolving data attached to a route, and while loading a lazy module
  • Show the Overlay whithout the LoaderComponent when navigating to a route without any resolver

I have added some logs on the NavigationStart and NavigationEnd events and I found that NavigationEnd is triggered immediately after NavigationStart (which is normal), while Overlay disappears about 0.5s after.

I have read the CHANGELOG.md but I found nothing that might explain this problem. Any idea is welcome.

Edit:

After further research, I have restored the previous behavior by setting package.json like this:

"@angular/cdk": "~7.0.0",
"@angular/material": "~7.0.0",

instead of this:

"@angular/cdk": "~7.2.0",
"@angular/material": "~7.2.0",

I have identified the faulty commit which has been released in version 7.1.0 and I posted my problem on the related GitHub issue. It fixes the fade out animation of the Overlay.

What is the v7.1+ compliant way to get the desired behavior? According to me, the best thing to do would be: show the loader only when necessary, but NavigationStart doesn't hold the needed information. I'd like to avoid ending up with some debounce behavior.

Stately answered 10/1, 2019 at 19:34 Comment(10)
is it possible that loaderService.hide() is executed without trigger?Trevelyan
Are you asking if it is called from elsewhere?Stately
That could be an option I never considered but I meant, that it could just be executed without any trigger and that the notation you used is just interpreted as code to execute and not as OOP structure with functions.Trevelyan
@Trevelyan sorry I really don't get your pointStately
Don't mind, my assumption is probably wrong anyway. Try to debug why LoaderService::hide() is triggered or if Overlay is disappearing without involvement of LoaderService::hide().Trevelyan
I have identified the faulty pull request in Angular CDK: github.com/angular/material2/pull/10145Stately
Hey @YoukouleleY good detective work. I just +1'd your comment on that pull request. But it might be faster for you to get a resolution by submitting an issue and referencing that pull request. Not sure how much the Angular team is paying attention to comments on closed PRs and, generally, fresh issues are preferred for triage, etc. Just my $.02.Extrovert
@Extrovert correct me if I'm wrong but GitHub issues are for change requests. In my case I'd like to revert what has been done, but I presume that this fix is what most people needStately
Good point. I guess I'm not sure if you would classify this as a "bug" per se. Their contributing guide has a provision for submitting a bug and it sounds like this might actually be a bug.Extrovert
@Extrovert thanks for the insight. Will consider submitting an issue if SO doesn't helpStately
S
3

Here is what I ended up with, after realizing that a delay was a good solution in terms of UX because it allowed the loader to show only when the loading time is worth displaying a loader.

I don't like this solution because I prefer when the state is being encapsulated into Observable operators rather than in a shared variable but I couldn't achieve it.

counter = 0;

router.events.pipe(
  filter(x => x instanceof NavigationStart),
  delay(200),
).subscribe(() => {
  /*
  If this condition is true, then the event corresponding to the end of this NavigationStart
  has not passed yet so we show the loader
  */
  if (this.counter === 0) {
    loaderService.show();
  }
  this.counter++;
});

router.events.pipe(
  filter(x => x instanceof NavigationEnd || x instanceof NavigationCancel || x instanceof NavigationError)
).subscribe(() => {
  this.counter--;
  loaderService.hide();
});
Stately answered 22/5, 2019 at 20:4 Comment(1)
I think it's better create an unique observable. Using in html pipe async is not necessary subscribeAbstract
C
0

The way we implement loader in our system with exception list:

export class LoaderInterceptor implements HttpInterceptor {
  requestCount = 0;

  constructor(private loaderService: LoaderService) {
  }

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

    if (!(REQUEST_LOADER_EXCEPTIONS.find(r => request.url.includes(r)))) {
      this.loaderService.setLoading(true);
      this.requestCount++;
    }

    return next.handle(request).pipe(
      tap(res => {
        if (res instanceof HttpResponse) {
          if (!(REQUEST_LOADER_EXCEPTIONS.find(r => request.url.includes(r)))) {
            this.requestCount--;
          }
          if (this.requestCount <= 0) {
            this.loaderService.setLoading(false);
          }
        }
      }),
      catchError(err => {
        this.loaderService.setLoading(false);
        this.requestCount = 0;
        throw err;
      })
    );
  }
}

and loader service is just (300ms delay prevents loader from just flashing on the screen when response is fast):

export class LoaderService {
  loadingRequest = new BehaviorSubject(false);
  private timeout: any;

  setLoading(val: boolean): void {
    if (!val) {
      this.timeout = setTimeout(() => {
        this.loadingRequest.next(val);
      }, 300);
    } else {
      clearTimeout(this.timeout);
      this.loadingRequest.next(val);
    }
  }
}
Chiu answered 28/5, 2019 at 11:19 Comment(1)
That works only for http requests, not for lazy modulesStately
A
0

Trying improve the idea of @Guerric P, I suppose if you defined an observable like:

  loading$ = this.router.events.pipe(
    filter(
      x =>
        x instanceof NavigationStart ||
        x instanceof NavigationEnd ||
        x instanceof NavigationCancel ||
        x instanceof NavigationError
    ),
    map(x => (x instanceof NavigationStart ? true : false)),
    debounceTime(200),
    tap(x => console.log(x))
  );

And you has

<div *ngIf="loading$|async">Loadding....</div>

You should see loading... when start navigation and don't see then all is loaded.

This can be in a component and this component in main-app.component, it's not necessary a service or Factory in this stackblitz if you remove the debounce you will see true,false in console NOTE: You can remove the "tap" operator, it's only for check

Update making the same loader serve when we load data from a service.

Imagine we has a service with a property "loading" that was a Subject

@Injectable({
  providedIn: 'root',
})
export class DataService {
    loading:Subject<boolean>=new Subject<boolean>();
    ...
}

We can merge the before observable and this, so our loading$ in app.component becomes

  loading$ = merge(this.dataService.loading.pipe(startWith(false)),
    this.router.events.pipe(
    filter(
      x =>
        x instanceof NavigationStart ||
        x instanceof NavigationEnd ||
        x instanceof NavigationCancel ||
        x instanceof NavigationError
    ),
    map(x => (x instanceof NavigationStart ? true : false))
  )).pipe(
    debounceTime(200),
    tap(x => console.log(x))
  );

So, we can do, e.g.

this.dataService.loading.next(true)
this.dataService.getData(time).subscribe(res=>{
  this.dataService.loading.next(false)
  this.response=res;
})

NOTE: You can check the stackblitz, the one-component don't delay, so the "loading" is not showed, the two-component, delay because a CanActivate Guard and because call a service in the ngOnInit

NOTE2: In the example we call manually the "this.dataService.loading.next(true|false)". We can improve it creating an operator

Abstract answered 25/3, 2021 at 22:16 Comment(7)
Your example should show a loader that uses Angular's overlay because the initial problem is about the overlay briefly showing when not needed.Stately
Guerric, I put a simple div with an *ngIf, you can use any component or div with an overlay. Sincerily, I don't like the idea of use a "counter" or subscribe to two observables, but it's a personal opinion. Use an unique observable and a debounce, avoid it, but of course, your code it's a good aproach too.Abstract
I don't like it either, that's why I'm requesting another answer with my bounty. A div with a *ngIf doesn't behave like an overlay, because the overlay fades out since v7, on previous versions we could always show/hide it on very short intervals, it was not visible, but it is since v7Stately
Your debounceTime applies on trueand false events which could lead to skipping events that are however neededStately
@GuerricP, I update the stackblitz. I use a AuthGuard in the "second-component". You see the "loading" before chage the second component. I think that in case of Lazy loading should work. I hope the example help me to explain better.Abstract
One problem with your code, it hides the loader with a 200ms delayStately
true, I tryed replace the debounceTime(200) by a debounce(x=>x==true?timer(200):EMPTY) , but there are a fliker in the second component because there're a call to a service in the "ngOnInit"Abstract
A
0

The last update of my comment wait always 200 milliseconds. Of course we want not wait at the end of Navigation or at the end of the observable. So we can repalce the debounceTime(200) by some like debounce(x => x ? timer(200) : EMPTY), but this make that in we has in ngOnInit of a component with delay the search, the "loader" flick.

So I decided use "data" in the router to indicate what components has an ngOnInit

Imagine some like

const routes: Routes = [
  { path: 'one-component', component: OneComponent },
  { path: 'two-component', component: TwoComponent,
               canActivate:[FoolGuard],data:{initLoader:true}},
    { path: 'three-component', component: ThreeComponent,
               canActivate:[FoolGuard],data:{initLoader:true} },
  ];

I create a service that take account when the router has in data "initLoader" and include the nils' operator

/*Nils' operator: https://nils-mehlhorn.de/posts/indicating-loading-the-right-way-in-angular
*/
export function prepare<T>(callback: () => void): (source: Observable<T>) => Observable<T> {
  return (source: Observable<T>): Observable<T> => defer(() => {
    callback();
    return source;
  });
}
export function indicate<T>(indicator: Subject<boolean>): (source: Observable<T>) => Observable<T> {
  return (source: Observable<T>): Observable<T> => source.pipe(
    prepare(() => indicator.next(true)),
    finalize(() => indicator.next(false))
  )
}

The Nils' operator work that if you has an Subject and an observable

  myObservable.pipe(indicate(mySubject)).subscribe(res=>..)

send a true to the Subject at start of the call and false and the end of the call

Well, I can make a service like

/*Service*/
@Injectable({
  providedIn: "root"
})
export class DataService {
  loading: Subject<boolean> = new Subject<boolean>();
  constructor(private router: Router){}

  //I use the Nils' operator
  getData(time: number) {
    return of(new Date()).pipe(delay(time),indicate(this.loading));
  }

  getLoading(): Observable<any> {
    let wait=true;
    return merge(
      this.loading.pipe(map(x=>{
        wait=x
        return x
        })),
      this.router.events
        .pipe(
          filter(
            x =>
              x instanceof NavigationStart ||
              x instanceof ActivationEnd ||
              x instanceof NavigationCancel ||
              x instanceof NavigationError || 
              x instanceof NavigationEnd
          ),
          map(x => {
            if (x instanceof ActivationEnd) {
              wait=x.snapshot.data.wait|| false;
              return true;
            }
            return x instanceof NavigationStart ? true : false;
          })
        ))
        .pipe(
          debounce(x=>wait || x?timer(200):EMPTY),
        )
  }

This makes that the delay happens when StartNavigation or in the "path" has in data "wait:true". See in the stackblitz that "component-three" has in path data:{wait:true} (possibly we forget remove it) but has no ngOnInit. This makes that we has a delay

NOTE: another service can be use the loader, only inject the "dataService" and use the nils' operator

this.anotherService.getData().pipe(
   indicate(this.dataService.loading)
).subscribe(res=>{....})
Abstract answered 2/4, 2021 at 10:5 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.