Search code examples
angularparent-childangular2-changedetection

Angular ChangeDetection Mystery: child event / parent CD trigger logic


My ultimate goal is to prevent triggering CD in ancestor/parent comps when a DOM click event is registered in a child comp. From what I understand, this is not possible, ancestors/parents of the child comp will always be marked for check. Still, with that in mind, I encounter inconsistent behavior.

Consider this comp tree:

  R
  |
  M
 / \
/   \
A    B
|    |
|    |
C1   C2     

R is app root.
M is the main comp, it's a dynamically created section comp, therefore does NOT use @Input vars.
A and B are both child comps of M.
C is a child comp used by both A and B, each have it's own dedicated instance of C.
All comps (R, M, A, B, C) use ChangeDetectionStrategy.OnPush.
Each child comp instance (A, B, C1, C2) has a unique html id in the comp template tag.
Each child comp instance (A, B, C1, C2) receives data via @Input vars.
M comp subscribes to Ng Store, and a store update (received by M) then creates a new layout of A and B in the template of M - but it does NOT update the @Input vars of A or B.

Mystery #1
Once a store update was already received by M comp (and the M template layout of the A and B comps has been changed), any DOM click event that is registered in C1 or C2 triggers ngOnChanges() in M and then both A and B.

(Q1.1) why is ngOnChanges() triggered in the parent/ancestor comps (for a child DOM click event) when no @Input data was changed in the app ?

(Q1.2) the actual @Input data that ngOnChanges() receives (during this process) in M, A, or B has NOT changed, i.e. SimpleChanges.currentValue equals SimpleChanges.previousValue.
again: why is ngOnChanges() "falsely" triggered in M, A, or B ?
to elaborate: in my app, in each child comp (A, B, C1, C2), ngOnChanges() will invoke certain data processing. With the behavior described here, this unnecessarily wastes a lot of resources. I now have to explicitly check and compare current vs. previous @Input data to prevent unnecessary processing.
Why is Ng behaving this way ? I was under the impression that ngOnChanges() only gets triggered when the @Input data has actually changed... (?)

(Q1.3) why does a click event in C1 trigger ngOnChanges() in comp B ? B is NOT an ancestor of C1.
This leads me to believe that the reason is b/c B has it's own C child instance C2, but I don't understand why Ng would behave this way... C2 did not fire an event.

Mystery #2
When no store update has been received yet by M (and M is using it's default template layout), a click event in C1 or C2 does NOT trigger ngOnChanges() in both M, A or B.
(Q2.1) why is that and how can I keep this preferred behavior ?

Mystery #3
In attempting to prevent CD being triggered in parent/ancestor comps, I employed in the C1/C2 comps:
(1) CD.detach() in the constructor
(2) ngZone.runOutsideAngular() in the click handler function
None of that worked.
(Q3.1) what is the recommended way to prevent CD being triggered in ancestors (when a child DOM event occurs) ?

Tech stack:
Angular: 13.3.11
Win 10 x64

Solution

  • It's a lot of questions. I probably won't answer them all. But will start with the basic, which might give clues to multiple questions.

    How CD works

    Whenever DOM event happens in a component, that has a callback attached to it in the template (e.g. <button (click)="onClick()">), Angular internally calls markForCheck for this component.

    It marks this component instance as "dirty", and then traverses the components tree upwards all the way to the root, marking all visited components as dirty as well. This doesn't perform any change detection on its own - but it will have effect on the next cycle of CD, whenever it happens.

    Thanks to Zone.js, that same click event (patched by Zone) is also intercepted by the Angular Zone - independently on event handlers in your component. This actually schedules a CD cycle. I believe it's scheduled as microtask, via Promise.resolve() - so by the time this CD cycle starts, the markForCheck call above has updated the affected subtree of components.

    Now, the CD starts with the root, and traverses the tree downwards. For any given component, it checks whether it needs to be processed:

    • if a component is not OnPush, that it's always processed
    • if a component is OnPush, it's processed if any of the below is true:
      • component is marked as dirty (thanks to the markForCheck call)
      • component's Inputs changed

    If Angular decided a component should not be processed, based on the logic above, it stops here and won't go any deeper to its children.

    There is no magic in that. If you see a component is affected by CD, it means it was marked as dirty (explicitly or implicitly by its children) or its Inputs changed. Note that input change is checked by reference. For primitive types it's trivial. But if you pass an object or an array as an input, and if you re-create this object/array in the parent component's ngOnChanges, it means the equality check treats it as a new value, re-rendering the child component this data is passed to.

    How to avoid CD for a DOM event

    Don't use event binding in the template. Instead, set event listeners programmatically. E.g

    ngAfterViewInit() {
      fromEvent(this.buttonRef.nativeElement, "click")
        .pipe(takeUntil(this.onDestroy))
        .subscribe(() => this.onClick())
    }
    

    This way a click event won't mark the component (and its ancestors) as dirty, and because of OnPush, they will not re-render.

    Note that this click event patched by Zone will still schedule a CD cycle. In order to avoid this (it's redundant work for the framework, because nothing was marked as dirty anyway), you can wrap the event listener in runOutsideAngular:

    this.ngZone.runOutsideAngular(() => {
      fromEvent(this.buttonRef.nativeElement, "click")
        .pipe(takeUntil(this.onDestroy))
        .subscribe(() => this.onClick())
    })