Core Operations During Change Detection

Earlier, I published a deep dive into the numerous steps Angular's change detection performed. That material became outdated once Angular adopted the Ivy rendering engine in v12. Now I want to walk through the complete set of operations that the new engine executes.

This article is an excerpt from my Angular Deep Dive course series

Every time Angular checks a component view, it executes a sequence of operations. In computer science terms, these are often described as side effects — actions that produce observable changes beyond merely returning a value:

In computer science, an operation, function or expression is said to have a side effect if it modifies some state variable value(s) outside its local environment, which is to say if it has any observable effect other than its primary effect of returning a value to the invoker of the operation.

Within Angular, the principal side effect of change detection is projecting application state onto the target platform. In typical browser scenarios, that state consists of component properties and the projection involves modifying the DOM.

Several additional operations happen during component checks. These can be identified through the [refreshView](https://github.com/angular/angular/blob/02f3d12a0dc2c1b6f5ae06fff019058036fa5edc/packages/core/src/render3/instructions/shared.ts#L357%3E) function. Here's an abbreviated version of its body with my annotations:

function refreshView(tView, lView, templateFn, context) {
  enterView(lView);

  try {
    if (templateFn !== null) {
      // update input bindings on child components
      // execute ngOnInit, ngOnChanges and ngDoCheck hooks
      // update DOM on the current component
      executeTemplate(tView, lView, templateFn, RenderFlags.Update, context);
    }

    // execute ngOnInit, ngOnChanges and ngDoCheck hooks
    // if they haven't been executed from the template function
    const preOrderCheckHooks = tView.preOrderCheckHooks;
    if (preOrderCheckHooks !== null) {
      executeCheckHooks(lView, preOrderCheckHooks, null);
    }

    // First mark transplanted views that are declared in this lView as needing a refresh at their
    // insertion points. This is needed to avoid the situation where the template is defined in this
    // `LView` but its declaration appears after the insertion component.
    markTransplantedViewsForRefresh(lView);

    // Refresh views added through ViewContainerRef.createEmbeddedView()
    refreshEmbeddedViews(lView);

    // Content query results must be refreshed before content hooks are called.
    if (tView.contentQueries !== null) {
      refreshContentQueries(tView, lView);
    }

    // execute content hooks (AfterContentInit, AfterContentChecked)
    const contentCheckHooks = tView.contentCheckHooks;
    if (contentCheckHooks !== null) {
      executeCheckHooks(lView, contentCheckHooks);
    }

    // execute logic added through @HostBinding()
    processHostBindingOpCodes(tView, lView);

    // Refresh child component views.
    const components = tView.components;
    if (components !== null) {
      refreshChildComponents(lView, components);
    }

    // View queries must execute after refreshing child components because a template in this view
    // could be inserted in a child component. If the view query executes before child component
    // refresh, the template might not yet be inserted.
    const viewQuery = tView.viewQuery;
    if (viewQuery !== null) {
      executeViewQueryFn<T>(RenderFlags.Update, viewQuery, context);
    }

    // execute view hooks (AfterViewInit, AfterViewChecked)
    const viewCheckHooks = tView.viewCheckHooks;
    if (viewCheckHooks !== null) {
      executeCheckHooks(lView, viewCheckHooks);
    }

    // reset the dirty state after the component is checked
    if (!isInCheckNoChangesPass) {
      lView[FLAGS] &= ~(LViewFlags.Dirty | LViewFlags.FirstLViewPass);
    }

    // this one is tricky :) requires its own section, we'll explore it later
    if (lView[FLAGS] & LViewFlags.RefreshTransplantedView) {
      lView[FLAGS] &= ~LViewFlags.RefreshTransplantedView;
      updateTransplantedViewCount(lView[PARENT] as LContainer, -1);
    }

  } finally {
    leaveView();
  }
}

Each operation will be examined in detail in the "Inside Rendering Engine" section.

For now, let's survey the core change detection operations derived from the function above, listed in their execution order:

  1. executing a template function in update mode for the current view
    – validates and updates bindings on child components/directives
    – triggers ngOnInit, ngDoCheck, and ngOnChanges on the child when bindings have changed
    refreshes DOM interpolations for the current view when its component properties have changed
  2. executeCheckHooks if they weren't run in the prior step
    invokes the OnChanges lifecycle hook on the child component upon binding changes
    invokes ngDoCheck on the child component (OnInit is limited to the first check)
  3. markTransplantedViewsForRefresh
    – locates transplanted views further down the Lview chain that need refreshing
  4. refreshEmbeddedViews
    executes change detection for views established through ViewContainerRef APIs (largely repeating the steps in this list)
  5. refreshContentQueries
    refreshes the ContentChildren query list on the child view component
  6. execute Content CheckHooks
    triggers AfterContentChecked lifecycle hooks on the child component (AfterContentInit is called only during the first check)
  7. processHostBindingOpCodes
    – validates and modifies host DOM properties linked through @HostBinding() declarations within the component class
  8. refreshChildComponents
    runs change detection for child components referenced in the current template. OnPush components are bypassed unless marked dirty
  9. executeViewQueryFn
    refreshes the ViewChildren query list on the current view's component
  10. execute View CheckHooks (AfterViewInit, AfterViewChecked)
    triggers AfterViewChecked lifecycle hooks on the child component (AfterViewInit is called only during the first check)

Key Observations

Several insights emerge from these operations.

Change detection for the current view serves as the trigger for child view change detection. This is evident from the refreshChildComponents operation (#8). Angular invokes [refreshComponent](https://github.com/angular/angular/blob/02f3d12a0dc2c1b6f5ae06fff019058036fa5edc/packages/core/src/render3/instructions/shared.ts#L1687) for every child component:

function refreshComponent(hostLView, componentHostIdx) {
  const componentView = getComponentLViewByIndex(componentHostIdx, hostLView);
  // Only attached components that are CheckAlways 
  // or OnPush and dirty should be refreshed
  if (viewAttachedToChangeDetector(componentView)) {
    const tView = componentView[TVIEW];
    if (componentView[FLAGS] & (LViewFlags.CheckAlways | LViewFlags.Dirty)) {
      refreshView(tView, componentView, tView.template, componentView[CONTEXT]);
    } else if (componentView[TRANSPLANTED_VIEWS_TO_REFRESH] > 0) {
      // Only attached components that are CheckAlways 
      // or OnPush and dirty should be refreshed
      refreshContainsDirtyView(componentView);
    }
  }
}

A condition dictates whether a component gets checked:

 if (viewAttachedToChangeDetector(componentView)) { ... }
 if (componentView[FLAGS] & (LViewFlags.CheckAlways | LViewFlags.Dirty)) {...}

The key requirement is that the component's [changeDetectorRef](https://github.com/angular/angular/blob/c14c701775c900ce9ac8781c08fc76da067910c5/packages/core/src/change_detection/change_detector_ref.ts#L63) must be connected to the component tree. Without that connection, the component itself, its children, and any transplanted views it contains are all skipped.

Provided the primary requirement holds, a component is considered for checking when it's not using OnPush, or when it is an OnPush component marked as dirty. The refreshView function concludes by resetting the dirty flag on an OnPush component:

// reset the dirty state after the component is checked
if (!isInCheckNoChangesPass) {
  lView[FLAGS] &= ~(LViewFlags.Dirty | LViewFlags.FirstLViewPass);
}

Finally, any transplanted views associated with the component are also subject to checking:

if (componentView[TRANSPLANTED_VIEWS_TO_REFRESH] > 0) {
  // Only attached components that are CheckAlways or OnPush and dirty should be refreshed
  refreshContainsDirtyView(componentView);
}

Template execution

The executeTemplate function, which Angular runs at the start of change detection, is responsible for invoking the template function from the component's definition. The compiler generates this template function for each component individually. Taking component A as an example:

@Component({
  selector: 'a-cmp',
  template: `<b-cmp [b]="1"></b-cmp> {{updateTemplate()}}`,
})
export class A {
  ngDoCheck() {
    console.log('A: ngDoCheck');
  }
  ngAfterContentChecked() {
    console.log('A: ngAfterContentChecked');
  }
  ngAfterViewChecked() {
    console.log('A: ngAfterViewChecked');
  }
  updateTemplate() {
    console.log('A: updateTemplate');
  }
}

its definition appears as:

import {
  ɵɵdefineComponent as defineComponent,
  ɵɵelement as element,
  ɵɵtext as text,
  ɵɵproperty as property,
  ɵɵadvance as advance,
  ɵɵtextInterpolate1 as textInterpolate1
} from '@angular/core';

export class A {}
export class B {}

A.ɵfac = function A_Factory(t) { return new (t || A)(); };
A.ɵcmp = defineComponent({
    type: A,
    selectors: [["a-cmp"]],
    decls: 2,
    vars: 2,
    consts: [[3, "b"]],
    template: function A_Template(rf, ctx) {
      if (rf & 1) {
        element(0, "b-cmp", 0);
        text(1);
      }
      if (rf & 2) {
        property("b", 1);
        advance(1);
        textInterpolate1(" ", ctx.updateTemplate(), "");
      }
    },
    dependencies: function() { return [B]; },
    encapsulation: 2
  }
);

Every function imported from the module carries the __ɵɵ__ prefix, marking it as private.

The template may contain various instructions. In this case, we see the creational instructions element and text during initialization, along with property, advance, and textInterpolate1 during the change detection phase:

template: function A_Template(rf, ctx) {
  if (rf & 1) {
    element(0, "b-cmp", 0);
    text(1);
  }
  if (rf & 2) {
    property("b", 1);
    advance(1);
    textInterpolate1(" ", ctx.updateTemplate(), "");
  }
}

Lifecycle hooks

A crucial detail is that the majority of lifecycle hooks are invoked on the child component during the current component's change detection pass. The ngAfterViewChecked hook stands out as the exception.

With a component hierarchy like A -> B -> C, the sequence of hook calls and binding updates is:

Entering view: A
    B: updateBinding
    B: ngOnChanges
    B: ngDoCheck
        A: updateTemplate
    B: ngAfterContentChecked
    Entering view: B
        С: updateBinding
        C: ngOnChanges
        С: ngDoCheck
            B: updateTemplate
        С: ngAfterContentChecked
        Entering view: C
            С: updateTemplate
            С: ngAfterViewChecked
    B: ngAfterViewChecked
A: ngAfterViewChecked

That concludes this overview. I'm regularly adding new material to the course, including free content such as what I've presented here. Check out the course by clicking here, or read "Early bird option for the most in-depth Angular course" for more details about the content and who it's designed for.