5

To put my problem simply, I have an element in component's template. This element has an ngIf condition and a (click) handler. It is not rendered from the very beginning, because the ngIf condition evaluates to false.

Now comes the interesting part: A code running outside the angular zone changes that condition to true, and after executing detectChanges on the change detector ref manually, this element gets rendered and the click handler ofc becomes active.

It all seems ok so far, but the problem is that when the (click) callback is run upon user's click, change detection is not triggered for the component.

Here is the reproduction https://stackblitz.com/edit/angular-kea4wi

Steps to reproduce it there:

  1. Click at the beige area
  2. Button appears, click it too
  3. Nothing happens, although message should have appeared below

Description:

  1. The beige area has a click event handler registered via addEventListener, and this event listener's callback is running outside the angular zone. Inside it a component's showButton property is set from false to true and I trigger change detection there manually by calling detectChanges(), otherwise the change in the showButton property wouldn't be registered. The code looks like this:

    this.zone.runOutsideAngular(() => {
       const el = this.eventTarget.nativeElement as HTMLElement;
       el.addEventListener('click', e => {
         this.showButton = true;
         this.cd.detectChanges();
       })
     })
    
  2. Now button appears, which thanks to *ngIf="showButton" wasn't rendered initially, and it has a click even handler declared in the template. This handler again changes component's property, this time showMessage to true.

    <button *ngIf="showButton" (click)="onButtonClick()">Click me!</button>
    
    onButtonClick() {
      this.showMessage = true;
    }
    
  3. When I click it, the handler obviously runs and changes component's showMessage to true, but it doesn't trigger change detection and message below doesn't appear. To make the example work, just set showButton to true from the very beginning, and the scenario above works.

The question is: How is this possible? Since I declared the (click) event handler in the template, shouldn't it always trigger change detection when called?

Dan Macak
  • 16,109
  • 3
  • 26
  • 43
  • reproduction steps with stackblitz demo would be great! – alt255 May 10 '20 at 13:46
  • My guess is the change detection is triggered when clicked, but as it starts from the root component and root component has `OnPush` so it stops there and does not check further components down the component tree. – alt255 May 10 '20 at 14:43
  • @alt255 the click event should bypass the `OnPush` hierarchy, and trigger change detection in place - in the component where it bubbled from (DOM-wise). Otherwise it would be a rather common problem and a design flaw in Angular. – Dan Macak May 10 '20 at 15:51
  • [see this for details](https://indepth.dev/everything-you-need-to-know-about-change-detection-in-angular/) this validates my point above – alt255 May 10 '20 at 15:54
  • @alt255 I read the article and it validates your point about the hierarchy of the change detection checks, but there is nothing about ng event handlers. I found [this article](https://netbasal.com/a-comprehensive-guide-to-angular-onpush-change-detection-strategy-5bac493074a4) which goes more in detail on events. Do you think point 2 in **OnPush Change Detection Strategy** might apply to my situation? – Dan Macak May 10 '20 at 16:17
  • Point 2 still applies in your case, it does trigger change detection and as change detection is started from the root of and when it reaches `GalleryComponent` it stops right there because `GalleryComponent` has `OnPush` set and does not reach the code where counter is updated. – alt255 May 10 '20 at 16:42
  • 1
    @alt255 if that's the case, why does clicking the arrows in other than described scenarios work then? Like when I refresh the page with the gallery and just click next and prev arrows multiple times. It updates the counter alright. – Dan Macak May 10 '20 at 16:47
  • thats a very good question to which unfortunately I have no answer :( – alt255 May 10 '20 at 17:33

2 Answers2

8

I created an issue in Angular's repo, and as it turns out, this behavior is logical, although perhaps unexpected. To rephrase what was written there by Angular team:

The code which causes the element with (click) handler to render is running outside the Angular zone as stated in the question. Now, although I execute detectChanges() manually there, it doesn't mean that the code magically runs in angular zone all of a sudden. It runs the change detection all right, but it "stays" in a different zone. And as a result, when the element is about to be rendered, the element's click callback is created in and bound to non-angular zone. This in turn means that when it is triggered by user clicking, it is still called, but doesn't trigger change detection.

The solution is to wrap code, which runs outside the angular zone, but which needs to perform some changes in the component, in zone.run(() => {...}).

So in my stackblitz reproduction, the code running outside the angular zone would look like this:

    this.zone.runOutsideAngular(() => {
      const el = this.eventTarget.nativeElement as HTMLElement;
      el.addEventListener('click', e => {
        this.zone.run(() => this.showButton = true);
      })      
    })

This, unlike calling detectChanges(), makes the this.showButton = true run in the correct zone, so that also elements created as a result of running that code with their event handlers are bound to the angular zone. This way, the event handlers always trigger change detection when reacting to DOM events.

This all boils down to a following takeaway: Declaring event handlers in a template doesn't automatically guarantee change detection in all scenarios.

Dan Macak
  • 16,109
  • 3
  • 26
  • 43
  • doing ngZone everywhere doesnt impact peformance or application if its a bit big? – Phaneendra Charyulu Kanduri Sep 02 '21 at 07:27
  • @PhaneendraCharyuluKanduri using `runOutsideAngular` can impact your app's performance in a very positive way, be it big or small. Especially if used to handle events which Angular doesn't have to "know" about and which would otherwise cause it to run change detection for each event. – Dan Macak Sep 02 '21 at 09:57
0

In case someone wants to do tasks that don't trigger change detection, here is how:

import { NgZone }from '@angular/core';

taskSelection;

constructor
// paramenters
(
  private _ngZone: NgZone,
)
// code block
{}


  /*
      Angular Lifecycle hooks
  */
ngOnInit() {
    this.processOutsideOfAngularZone();
}

processOutsideOfAngularZone () {
    var _this = this;

    this._ngZone.runOutsideAngular(() => {
    
      document.onselectionchange = function() {
          console.log('Outside ngZone Done!'); 

          let selection = document.getSelection();
          _this.taskSelection["anchorNode"] = selection.anchorNode.parentElement;
          _this.taskSelection["anchorOffset"] = selection.anchorOffset;
          _this.taskSelection["focusOffset"] = selection.focusOffset;
          _this.taskSelection["focusNode"] = selection.focusNode;
          _this.taskSelection["rangeObj"] = selection.getRangeAt(0);
      }

    });
}

https://angular.io/api/core/NgZone#runOutsideAngular

Marian07
  • 2,303
  • 4
  • 27
  • 48