title: Angular 4 strange rendering behavior
body: <p>I develop complex Angular web application with deep component tree. The main idea of this application is to show different tasks (some kind of trivia game) over video stream. Some tasks appear while video continues to play, while other tasks pause video until task finished. But every task should appear exactly in specified time.</p>
  
  <p>The problem is that in some circumstances (I can't figure out what exactly this behavior depends on) some tasks appear with significant lag (5-10 sec). This behavior is not regular, so it's hard to catch and debug what the cause of it. It's seems to be this behavior more often occurs while page "cold start", while any attempt to reproduce it on the same page doesn't bring any success.</p>
  
  <p>Here are reasons, which were considered and discarded:</p>
  
  <ol>
  <li><p>Change detection. I thought, that Angular doesn't detect new task appearance and doesn't run rendering code. But it's not the case, because:</p>
  
  <ul>
  <li>there is explicit ChangeDetector.detectChanges() call on task appear; </li>
  <li>task init code also plays sound (via WebAudioApi) and I always hear sound in specified time without any lag, but visually elements appear with lag.</li>
  </ul></li>
  <li><p>Browser is heavily loaded with some job and can't show task in time. I suppose it's not the case, because I spent few hours on recording and analyzing Chrome's performance profile and haven't found any heavy load there. Conversely, there is suspicious idleness in problem moments. Here is example:</p></li>
  </ol>
  
  <p><a href="https://i.stack.imgur.com/mbTdm.png" rel="nofollow noreferrer"><img src="https://i.stack.imgur.com/mbTdm.png" alt="perfomance profile"></a></p>
  
  <ol start="3">
  <li>Browser engine specific issues. I discarded this option, because I saw this behavior in Chrome, Safari and Firefox (on Windows, MacOS, iOS)</li>
  </ol>
  
  <p>Here are code fragments, related to task appearance:</p>
  
  <p><strong>quest.component.ts</strong></p>
  
  <pre><code>@Component({
      selector: 'quest',
      templateUrl: 'quest.component.html',
      providers: [
          HintsManagerService,
          UserHintService,
          HearNoteSyncService
      ]
  })
  export class QuestComponent implements OnInit, OnDestroy, AfterViewInit {
      private questPaused = false;
      private subscriptions: Subscription[] = new Array&lt;Subscription&gt;();
      private currentTime: number;
      private maxReward = 0;
      private currentReward = 0;
      private videoProportion: [number, number];
      private wrappedTasks: TaskWrapper[];
      private lastActivatedTask = -1;
      private questPass: QuestPass = new QuestPass();
      private forciblyClosed = false;
      private isFullScreenActivated = false;
      private anticipationTime: number = 0.05;
      private rewindThresholdTime: number = 1;
      private anyTaskTutorial: boolean = false;
  
      @Input() settings: QuestSettings;
      @Input() quest: Quest;
      @Input() config: QuestConfig;
      @Input() scoreUnit = 'coin';
      @Input() battleId: number;
      @Input() videoChallengeRoundId: number;
      @Output() questFinished = new EventEmitter&lt;{ score: number, forciblyClosed: boolean, questPass: QuestPass }&gt;();
      @ViewChild('player') videoPlayer: PlayerComponent;
      @ViewChild('topPlayer') topPlayer: ElementRef;
  
      constructor(
          private store: Store&lt;AppState&gt;,
          private playerTimerService: PlayerTimerService,
          private changeDetector: ChangeDetectorRef,
          private hintManagerService: HintsManagerService,
          private tutorialService: TutorialService,
          private soundService: SoundService) {
      }
  
      ngOnInit() {
          this.subscriptions.push(this.store.select(state =&gt; state.gameControl.questEnd)
              .subscribe(questEnd =&gt; { if (questEnd) { this.stopQuest(); } }));
          this.subscriptions.push(this.store.select(state =&gt; state.gameControl.pause)
              .subscribe(pause =&gt; { this.questPaused = pause; }));
  
          this.wrappedTasks = new Array&lt;TaskWrapper&gt;();
          this.quest.tasks.forEach(value =&gt; {
              this.wrappedTasks.push({
                  startTime: value.startTime,
                  active: false,
                  activated: false,
                  task: value,
                  showTaskTutorial: this.tutorialService.shouldShowTaskTutorial(this.quest, value)
              } as TaskWrapper);
              this.maxReward += value.getTotalReward();
          });
          this.wrappedTasks.sort((a: TaskWrapper, b: TaskWrapper) =&gt; a.startTime - b.startTime);
          this.anyTaskTutorial = this.wrappedTasks.some(wt =&gt; wt.showTaskTutorial);
          this.hintManagerService.initialize(this.wrappedTasks.map(wt =&gt; wt.task), this.quest, this.settings, this.battleId, this.videoChallengeRoundId);
      }
  
      ngAfterViewInit() {
          this.subscriptions.push(this.videoPlayer.onUserRequestPlayPause
              .subscribe(value =&gt; this.tryPlayPause(value)));
          this.subscriptions.push(this.playerTimerService.timerUpdated
              .subscribe(value =&gt; {
                  if (!this.questPaused) {
                      this.currentTime = value;
                      this.checkActiveTasks();
                  }
              }));
      }
  
      answerClick(task: QuestTask, result: TaskPassResult) {
          if (result.isCorrect) {
              this.currentReward += task.reward;
          }
  
          let currentScore = 0;
          switch (this.scoreUnit) {
              case 'coin':
                  currentScore = Math.round(this.quest.coinReward * this.currentReward / this.maxReward);
                  break;
              case 'percent':
                  currentScore = Math.round(100 * this.currentReward / this.maxReward);
                  break;
          }
          this.videoPlayer.updateScore(currentScore);
  
          if (result.isCorrect) {
              this.soundService.play('right-answer');
          } else {
              this.soundService.play('wrong-answer');
          }
      }
  
      checkActiveTasks() {
          this.hintManagerService.checkTasksIntersection(this.currentTime);
          for (let i = this.lastActivatedTask + 1; i &lt; this.wrappedTasks.length; ++i) {
              if (this.wrappedTasks[i].startTime - this.anticipationTime &gt; this.currentTime) {
                  break;
              } else if (this.wrappedTasks[i].startTime - this.anticipationTime &lt;= this.currentTime &amp;&amp; !this.wrappedTasks[i].activated &amp;&amp; !this.wrappedTasks[i].active) {
                  this.wrappedTasks[i].tutorials = this.settings.tutorialEnabled
                      ? this.tutorialService.getTutorialsForTask(this.anyTaskTutorial, this.wrappedTasks[i].showTaskTutorial, this.wrappedTasks[i].task)
                      : [];
                  this.wrappedTasks[i].active = true;
                  this.wrappedTasks[i].activated = true;
                  this.lastActivatedTask = i;
                  if (this.wrappedTasks[i].task.pauseRequired) {
                      this.store.dispatch({ type: VIDEO_PAUSE, payload: true });
                      this.videoPlayer.setPlaybackTime(this.wrappedTasks[i].startTime);
                  }
                  this.changeDetector.detectChanges();
                  // if we skipped too much time, then do rewind and stop cycle to prevent simultaneous task activation
                  if (this.currentTime - this.wrappedTasks[i].startTime &gt; this.rewindThresholdTime) {
                      // if not yet rewinded
                      if (!this.wrappedTasks[i].task.pauseRequired) {
                          this.videoPlayer.setPlaybackTime(this.wrappedTasks[i].startTime);
                      }
                      break;
                  }
              }
          }
      }
  
      deactivateTask(wrappedTask: TaskWrapper) {
          wrappedTask.active = false;
      }
  
      closeQuest() {
          this.forciblyClosed = true;
          this.videoPlayer.close();
      }
  
      stopQuest() {
          this.questFinished.emit({
              score : this.currentReward / this.maxReward,
              forciblyClosed: this.forciblyClosed,
              questPass: this.questPass
          });
      }
  
      tryPlayPause(paused: boolean): void {
          const canPause = this.wrappedTasks.filter(wt =&gt; wt.active &amp;&amp; wt.task.type !== TaskType.hear &amp;&amp; wt.task.type !== TaskType.note).length === 0;
          if (canPause) {
              this.questPaused = !paused;
              this.store.dispatch({ type: PAUSE });
          }
      }
  
      ngOnDestroy() {
          this.subscriptions.forEach((subscription: Subscription) =&gt; {
              subscription.unsubscribe();
          });
          this.hintManagerService.destroyService();
          this.store.dispatch({ type: RESET });
      }
  }
  </code></pre>
  
  <p><strong>quest.component.html</strong></p>
  
  <pre><code>&lt;ng-container *ngIf="quest"&gt;
      &lt;div class="top__player" #topPlayer [ngClass]="topPlayer.offsetHeight | questSize : applySizePipe"&gt;
          &lt;player class="player" #player
              [quest]="quest"
              [startTime]="quest?.startTime" 
              [duration]="quest?.duration" 
              [scoreUnit]="scoreUnit" 
              [hintsEnabled]="settings?.hintsEnabled" 
              (onQuestInterrupted)="stopQuest()"&gt;
              &lt;ng-container *ngFor="let wrappedTask of wrappedTasks"&gt;
                  &lt;ng-container [ngSwitch]="wrappedTask.task.type" *ngIf="wrappedTask.active"&gt;
                      &lt;quiz-task *ngSwitchCase="'quiz'"
                          [tutorialTypes]="wrappedTask.tutorials"
                          [task]="wrappedTask.task" 
                          (onAnswer)="answerClick(wrappedTask.task, $event)" 
                          (onDeactivate)="deactivateTask(wrappedTask)"&gt;
                      &lt;/quiz-task&gt;
                      &lt;hidden-area-task *ngSwitchCase="'hidden-area'"
                          [tutorialTypes]="wrappedTask.tutorials"
                          [task]="wrappedTask.task" 
                          (onAnswer)="answerClick(wrappedTask.task, $event)" 
                          (onDeactivate)="deactivateTask(wrappedTask)"&gt;
                      &lt;/hidden-area-task&gt;
                      &lt;whats-next-task *ngSwitchCase="'whats-next'"
                          [tutorialTypes]="wrappedTask.tutorials"
                          [task]="wrappedTask.task" 
                          (onAnswer)="answerClick(wrappedTask.task, $event)" 
                          (onDeactivate)="deactivateTask(wrappedTask)"&gt;
                      &lt;/whats-next-task&gt;
                      &lt;hear-note-task *ngSwitchCase="'hear'"
                          [tutorialTypes]="wrappedTask.tutorials"
                          [task]="wrappedTask.task"
                          [taskType]="'hear_box'"
                          [reactionTime]="config.hearReactionTime"
                          (onAnswer)="answerClick(wrappedTask.task, $event)" 
                          (onDeactivate)="deactivateTask(wrappedTask)"&gt;
                      &lt;/hear-note-task&gt;
                      &lt;hear-note-task *ngSwitchCase="'note'"
                          [tutorialTypes]="wrappedTask.tutorials"
                          [task]="wrappedTask.task" 
                          [taskType]="'note_box'"
                          [reactionTime]="config.noteReactionTime"
                          (onAnswer)="answerClick(wrappedTask.task, $event)" 
                          (onDeactivate)="deactivateTask(wrappedTask)"&gt;
                      &lt;/hear-note-task&gt;
                      &lt;div *ngSwitchDefault&gt;Unknown type of task&lt;/div&gt;
                  &lt;/ng-container&gt;
              &lt;/ng-container&gt;
          &lt;/player&gt;
      &lt;/div&gt;
  &lt;/ng-container&gt;
  </code></pre>