2

I am using Angular 5.2.1. I have a main page which has two components:

<proposal-expansion [proposallist]="proposalList" ... ></proposal-expansion>
<proposal-dashboard ... ></proposal-dashboard>

All the data coming from the server is loaded by the time the main page is rendered. Initially the expansion is empty (proposalList is []). The dashboard shows an abbreviated version of 5 proposals. Note that even though the display shows minimal data on the proposals, all the data is still there. I then have a button on the dashboard component that when clicked hides the dashboard and emits an event containing all the proposal data.

expandRecent(): void {
    console.log("expand clicked...");
    this.spinnerService.spinner(true);
    this.showRecent.emit(this.propList);
}

In the main page component, here is the function that is called when that event is emitted:

showRecent(event) {
    this.proposalList = event;
    this.showDashboard = false;
    this.spinnerService.spinner(false);
}

So at that point, the expansion component takes the data from proposalList and tries to render. There are a number of child components which are rendered within the expansion component, and it takes 4-5 seconds before the expansion component is actually displayed. Keep in mind that there is no interaction with the server for that time--the 4-5 seconds is all Angular.

Given that it takes so long for the expansion component to be displayed, I would like to have a spinner appear so that the user knows something is happening. I have a spinner service which works by passing in true to show the spinner or false to hide it. I pass true to the service as soon as the button on the dashboard is clicked (in expandRecent()). Here is where I run into problems.

Even though turning the spinner on is the first thing to happen (other than the console.log(), it doesn't actually happen first. Initially, I thought that the spinner was being turned on and off immediately. However, I found that if I never turned off the spinner, it would still wait until the expansion component was loaded before the spinner came on. Also, I found that if I manually turned on the spinner prior to clicking the button, then the spinner would not turn off until the expansion was loaded as desired. So it seems that I need to figure out when and where to turn the spinner on.

Based on the Angular documentation, ngOnChanges

Responds when Angular (re)sets data-bound input properties.

Based on that, I added this in the expansion component, thinking that as soon as the proposalList was updated, this would be called:

ngOnChanges() {
    this.spinnerService.spinner(true);
}

I also removed the line that sets the spinner to true in the expandRecent() method. However, when I did all that, there was still no spinner until after the expansion component was loaded, and worse--it turned it off before the ngOnChanges turned it on. So it showed no spinner for 4-5 seconds, then it came on and ran indefinitely.

What are the appropriate lifecycle hooks I should use, and in which components should I use them, in order to get the spinner to behave as expected?

Thanks!

UPDATE WITH LATEST ATTEMPTS:

I have now moved to using the router and a service to accomplish this, but still have the same problem.

expandRecent(): void {
    console.log("expand recent");
    this.spinnerService.spinner(true);
    this.changeDetectorRef.detectChanges();
    // essentially this just passes the data from one list to another in the service
    this.proposalService.proposalShortList$.take(1).subscribe(
        list => this.proposalService.setProposalList(list)
    );
    // this navigates to the ProposalExpansion component
    this.router.navigate(['../display'], { relativeTo: this.route });
}

I then tried adding this to the ProposalExpansion component:

ngAfterViewChecked() {
    this.spinnerService.spinner(false);
}

Now when expandRecent() runs, this is what I see in the console (The numbers are the time stamps for when each component's ngOnInit is run.):

expand recent
Should change spinner to true
Set up proposal list in expansion component
1520359500144
Initiate proposal for 2924
1520359500342
Inititiating revision for -
1520359500417
Initiating item 1
1520359500537
Initiate proposal for 2923
1520359500718
...
Initiating item 1
1520359502082
Should change spinner to false (8 x)
Should change spinner to false
Should change spinner to false (8 x)

Based on the console, one would think it is working. However, the spinner never as actually visible. But, if I put the router.navigate inside a setTimeout function, then the spinner works exactly as expected. (The setTimeout worked when the time was set to 100 ms or more. It did not work with 10 ms or less.) While this is a work-around that I could use, I would really like to understand what is happening, and it doesn't seem like I should have to use that kind of work-around.

I would love to have this in a Plunker, but I was having trouble even getting it to work with Angular Material before adding all my code, so that would be a significant time investment that I would prefer to avoid.

Here is the spinner code:

spinner.component.html

<div *ngIf="(spinner$ | async)">
    <i class="fas fa-spinner fa-pulse fa-2x fa-fw"></i>
</div>

spinner.component.ts

import { Component, OnInit } from '@angular/core';
import {Observable} from 'rxjs/Observable';

import { SpinnerService } from './spinner.service';

@Component({
  selector: 'spinner',
  templateUrl: './spinner.component.html',
  styleUrls: ['./spinner.component.scss']
})
export class SpinnerComponent implements OnInit {
    spinner$: Observable<boolean>;

    constructor(private spinnerService: SpinnerService) { }

    ngOnInit() {
        this.spinner$ = this.spinnerService.spinner$;
    }

    // to show the spinner
    showSpinner() {
        this.spinnerService.spinner(true);
    }

    // to hide the spinner
    hideSpinner() {
        this.spinnerService.spinner(false);
    }
}

spinner.service.ts

import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';

@Injectable()
export class SpinnerService {
    private spinnerSubject = new BehaviorSubject<boolean>(false);
    spinner$: Observable<boolean> = this.spinnerSubject.asObservable();

    constructor() {}

    spinner(spin:boolean) {
        console.log("Should change spinner to "+spin);
        this.spinnerSubject.next(spin);
    }
}
Tim
  • 1,605
  • 6
  • 31
  • 47
  • 1
    Personally, I would focus on why it's taking so long and correcting that instead of spending time on getting the spinner working. First thing to consider is *not* emitting your entire list back to the parent. Try moving the data property somewhere else (if there is no parent component that could hold it, then a service) and see if that improves your performance so you don't need the spinner. – DeborahK Feb 23 '18 at 18:56
  • And if you want to understand why the spinner is not turning on ... here is a great video on understanding JavaScript event loops: https://www.youtube.com/watch?v=8aGhZQkoFbQ&t=9s – DeborahK Feb 23 '18 at 19:02
  • 1
    If it is possible to build a sample of this performance issue in plunker or stackblitz (not the entire application) ... we can take a closer look. – DeborahK Feb 23 '18 at 19:04
  • @DeborahK I tried moving the data property into a service (which I like), but it didn't make any difference in the timing. Do you know of a way to add some timers or something so that I can figure out which components are taking the longest to load? – Tim Feb 23 '18 at 21:58
  • You could add a bunch of console.log statement to see what's happening. If you include the current time you'd be able to extrapolate how long between the log statements. – DeborahK Feb 23 '18 at 23:13
  • Do you have a `ngFor` in your template iterating over all of your values? If so, you can often improve performance by adding a `trackby` to the `ngFor` as shown here: https://netbasal.com/angular-2-improve-performance-with-trackby-cc147b5104e5 – DeborahK Feb 23 '18 at 23:16
  • @DeborahK I added some timers. Within the expansion component, I have 5 proposal components. Each proposal component has one or more revision components, and each revision component can have one or more item components. On average, it took about 375 milli-seconds to load a proposal component (x 5 = about 2 seconds to fully load, although sometimes it takes longer). Within that, revision components were taking about 110 milliseconds, and item components about 60 milliseconds. That seems pretty reasonable to me, but the total is still a long time with no feedback. – Tim Feb 26 '18 at 15:57
  • Did you see the `trackby` suggestion above? Did it help? – DeborahK Feb 26 '18 at 17:11
  • @DeborahK I did try the trackBy feature. I think these suggestions have helped improve the time, but it still is long enough that a spinner would be appropriate. – Tim Feb 26 '18 at 18:40
  • The spinners that I've done have been displayed/hidden by the router ... so I don't have an example for you in non-router cases. Unless you want to add all of the data retrieval as part of a route resolver? – DeborahK Feb 26 '18 at 22:27
  • @DeborahK Thanks for your help. I do have the data retrieval in a route resolver for the main page. However, I'm not following any routes right now in order to show the expansion component. Maybe I could look into that if I can't get this figured out. – Tim Feb 26 '18 at 23:36
  • If you test the spinner with a button to turn it on and a button to turn it off, does it work? – ConnorsFan Mar 06 '18 at 01:30
  • @ConnorsFan Yes, the spinner is working correctly. It works in other parts of the application, with a manual button, whenever a request is made to the server, etc. It is just the timing for when it turns on and off that I can't figure out for this scenario. – Tim Mar 06 '18 at 15:02
  • OK. You could trigger change detection after enabling the spinner in `expandRecent`, to force the view to update, for example with `this.changeDetectorRef.detectChanges()` (injecting `private changeDetectorRef: ChangeDetectorRef` in the constructor). – ConnorsFan Mar 06 '18 at 15:07
  • @ConnorsFan I am not familiar with `ChangeDetectorRef`, although from some brief research it looks like it could be the right answer. Could you elaborate a little more in an answer as to how that would work (such as what to do with the changeDecectorRef and when/how to turn the spinner off)? Thanks! – Tim Mar 06 '18 at 16:56
  • Can you try it first? I can explain why it works if it does, but I would prefer not spending time doing it if it ends up not working. Sorry for being so lazy... :-) – ConnorsFan Mar 06 '18 at 16:59
  • You can take a look at [this answer](https://stackoverflow.com/a/48179882/1009922) to see how to use `ChangeDetectorRef` and why it may solve your issue. – ConnorsFan Mar 06 '18 at 17:07
  • @ConnorsFan I added the changeDetectorRef. You can see where I had it in my update above. However, it worked exactly the same way when I did not include the changeDetectorRef, so I don't think that it is doing anything as I had it set up (I'm not sure that what I did with it was correct, though.) – Tim Mar 06 '18 at 18:30
  • Another method that you can try, with `ApplicationRef`: calling `applicationRef.tick()` (instead of `changeDetectorRef.detectChanges()`). By the way, I assume that you don't see any errors in the console when you make your tests. – ConnorsFan Mar 06 '18 at 19:03
  • @ConnorsFan `applicationRef.tick()` does not do the trick either. So far only the setTimeout seems to work. You are correct that I'm not seeing any errors. – Tim Mar 06 '18 at 22:53
  • Good to know that you found a solution. As to explain why the spinner needs a little time to start working, we would need to know what kind of spinner component it is, how it is included in the markup (or created in the code) and what command is used to start it. – ConnorsFan Mar 06 '18 at 23:00
  • @ConnorsFan I added the code for the spinner. What seems odd to me is that I don't see this problem anywhere else the spinner is used, so it doesn't seem like a problem with the spinner. But neither do I have an idea of where the problem is. – Tim Mar 06 '18 at 23:09
  • I am not very familiar with these FontAwesome icons but I see in some places that they add the class `fa-spin` to force the icon to spin. In your case, I assume that it should at least become visible, if not spinning. I suspect the browser to be busy getting your data, and not having time to devote to the spinner (unless you delay the data update with `setTimeout`). – ConnorsFan Mar 06 '18 at 23:27

3 Answers3

2

When using routing, you can turn on/off a spinner as follows:

App component

import { Component } from '@angular/core';
import { Router, Event, NavigationStart, NavigationEnd, NavigationError, NavigationCancel } from '@angular/router';

@Component({
    selector: 'mh-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})
export class AppComponent {
    loading: boolean = true;

    constructor(private router: Router) {
        router.events.subscribe((routerEvent: Event) => {
            this.checkRouterEvent(routerEvent);
        });
    }

    checkRouterEvent(routerEvent: Event): void {
        if (routerEvent instanceof NavigationStart) {
            this.loading = true;
        }

        if (routerEvent instanceof NavigationEnd ||
            routerEvent instanceof NavigationCancel ||
            routerEvent instanceof NavigationError) {
            this.loading = false;
        }
    }
}

App template

<span class="glyphicon glyphicon-refresh glyphicon-spin spinner" *ngIf="loading"></span>
<router-outlet></router-outlet>

You can define any desired image/graphic for the spinner. Just turn it on/off based on the loading flag.

Vega
  • 27,856
  • 27
  • 95
  • 103
DeborahK
  • 57,520
  • 12
  • 104
  • 129
  • Unfortunately, this also does not work the way I would expect. I changed everything to use routing, and I added a console.log statement for when navigation starts and ends. Now in the expandRecent() function I have `this.router.navigate(['../display'], { relativeTo: this.route });`. However, in the console I see that it starts navigation and then immediately says that navigation has ended. After that I see all the logs that show the loading of all the sub components. So the net effect is that it still takes a couple seconds to load with no indicator. – Tim Mar 05 '18 at 15:22
  • The above works best when the data retrieval is in a route resolver because those are executed *before* the routing completes. – DeborahK Mar 05 '18 at 22:31
  • Since the problem is not with data retrieval, then how does this apply to my problem? – Tim Mar 05 '18 at 22:46
  • My apologies, I'm at a conference and gave a quick answer without re-reviewing all of your issues. I'd like to go back to my suggestion of providing a plunker or stackblitz that can better demonstrate your problem. If we could better see your performance issues we may be able to provide more specific suggestions. – DeborahK Mar 06 '18 at 04:35
  • I spent a while trying to get a Plunker running, but was not having much success. I did add an update to what I have tried if you want to take a look at that. Thanks for your help. – Tim Mar 06 '18 at 18:32
1

Although this does not explain why the spinner was not working as I expected, it did do the trick to make it work as desired. Basically, I had to put the navigate inside a setTimeout function:

expandRecent(): void {
    console.log("expand recent");
    this.spinnerService.spinner(true);
    this.proposalService.proposalShortList$.take(1).subscribe(
        list => this.proposalService.setProposalList(list)
    );
    let myrouter = this.router;
    let myroute = this.route;
    setTimeout(function() {
        myrouter.navigate(['../display'], { relativeTo: myroute });
    },100);
}
Tim
  • 1,605
  • 6
  • 31
  • 47
0

Although this also does not answer the question, it does provide a potential solution to the primary problem of user interaction. According to the Material docs:

Lazy rendering

By default, the expansion panel content will be initialized even when the panel is closed. To instead defer initialization until the panel is open, the content should be provided as an ng-template:

<mat-expansion-panel>
  <mat-expansion-panel-header>
    This is the expansion title
  </mat-expansion-panel-header>

  <ng-template matExpansionPanelContent>
    Some deferred content
  </ng-template>
</mat-expansion-panel>

So instead of trying to render all the expansion panels at once, this will only render the header initially. This dramatically reduces the load time, and hence the demand for the spinner. (If using the setTimeout trick to make the spinner show, it barely flashes.) Although it takes a little longer when an expansion panel is expanded, it is still only a fraction of a second, so the overall experience for the user is better, in my opinion.

Tim
  • 1,605
  • 6
  • 31
  • 47