mark entire app dirty and force changedetection

Viewed 443

Lets imagine an app where

  • all Components (including app.component) are onPush.

how can I call a function forceAppWideChangeDetection() in for eg app.component that will ensure run changeDetection in EACH AND EVERY component of the app.

I imagine I have to tree-walk the internal tree of components and call markForCheck on every component.

How can this be done?

Please note

  • my app is still on viewEngine. In case that this might me relevant.

  • the reason im asking this is: when the language is changed in the UI from lang-a to lang-b. Almost each and every component needs a changeDetection in order to update the displayed texts-language. Currently we have an own @Input() for that, that goes through the whole component tree and forces the components to rerender as "an input property has changed". I personally don't like this approach and was looking for a simpler solution. So as you can see this expensive method forceAppWideChangeDetection() has only to run when the language changes.

4 Answers

Unless there is an out of box solution that I am not aware of, you can use a service and a base class:

@Injectable({ providedIn: 'root' })
export class ChangeDetectionTriggerService {
  readonly trigger$ = new Subject<void>();
}

Then base components:

@Directive()
export class BaseComponent implements OnDestroy {

  readonly onDestroy$ = new Subject<void>();

  ngOnDestroy(): void {
    this.onDestroy$.next();
  }

}
@Directive() // https://angular.io/guide/migration-undecorated-classes
export class BaseChangeDetectionComponent implements OnInit extends BaseComponent {

  constructor(private changeDetectorRef: ChangeDetectorRef,
              private changeDetectionTriggerService: ChangeDetectionTriggerService) {
    super();
  }

  ngOnInit(): void {
    this.changeDetectionSub = changeDetectionTriggerService.trigger$
      .pipe(takeUntil(this.onDestroy$))
      .subscribe(() => this.changeDetectorRef.markForCheck());
  }

}

Usage in target component:

@Component(/** ... **/)
export class MyComponent extends BaseChangeDetectionComponent {

  constructor(private changeDetectorRef: ChangeDetectorRef,
              private changeDetectionTriggerService: ChangeDetectionTriggerService) {
    super(this.changeDetectorRef, this.changeDetectionTriggerService);
  }

}

Then use it in anywhere by simply emitting in the subject:

changeDetectionTriggerService.trigger$.next();

This would probably work. But keep it as a last resort. Hopefully there is a more simple and elegant solution.

In case you're not afraid of using private API then you can traverse all component views and mark them as dirty

ViewEngine

import { ApplicationRef, Component } from '@angular/core';

@Component({
  ...
})
export class AnyComponent {
  constructor(private appRef: ApplicationRef) {}

  runCd() {
    forceAppWideChangeDetection(this.appRef);
  }  
} 

function markParentViewsForCheck(view) {
  var currView = view;
  while (currView) {
      if (currView.def.flags & 2 /* OnPush */) { 
          currView.state |= 8 /* ChecksEnabled */;
      }
      currView = currView.viewContainerParent || currView.parent;
  }
}

function forEachEmbeddedViews(view, visitorVn: (view) => void) {
  const def = view.def;

  if (!(def.nodeFlags & 16777216 /* EmbeddedViews */)) {
    return;
  }
  for (var i = 0; i < def.nodes.length; i++) {
    var nodeDef = def.nodes[i];
    if (nodeDef.flags & 16777216 /* EmbeddedViews */) { 
      var embeddedViews = view.nodes[i].viewContainer._embeddedViews;
      for (var k = 0; k < embeddedViews.length; k++) {
        visitorVn(embeddedViews[k]);
      }
    } else if ((nodeDef.childFlags & 16777216) /* EmbeddedViews */ === 0) {
      i += nodeDef.childCount;
    }
  }
}

function forEachComponentViews(view, visitorVn: (view) => void) {
  const def = view.def;
  if (!(def.nodeFlags & 33554432 /* ComponentView */)) {
    return;
  }
  for (var i = 0; i < def.nodes.length; i++) {
    var nodeDef = def.nodes[i];
    if (nodeDef.flags & 33554432 /* ComponentView */) {
      visitorVn(view.nodes[i].componentView);
    }
    else if ((nodeDef.childFlags & 33554432 /* ComponentView */) === 0) {
        i += nodeDef.childCount;
    }
  }
}

function visitView(view) {
  markParentViewsForCheck(view);

  forEachEmbeddedViews(view, visitView);
  forEachComponentViews(view, visitView);
}

function forceAppWideChangeDetection(appRef: ApplicationRef) { 
  for (const view of (appRef as any)._views) {
    visitView(view._view);
  }
}

Stackblitz Angular 8

Ivy

In Ivy you can loop over all your rendered component's hosts and make use of __ngContext__ to mark corresponding view as dirty.

import { Component, ɵmarkDirty } from '@angular/core';

@Component({
  ...
})
export class AnyComponent {

  runCd() {
    forceAppWideChangeDetection();
  }
}

function forceAppWideChangeDetection() {
  const CONTEXT = 8;
  const PREFIX = 'app-'.toUpperCase();
  const allHosts = 
       Array.from(document.querySelectorAll<any>('*'))
            .filter(el => !!el.__ngContext__ && el.tagName.startsWith(PREFIX));

  for (const host of allHosts) {
    const elementWithinHost = host.firstElementChild;
    if (elementWithinHost && elementWithinHost.__ngContext__) {
      const component = elementWithinHost.__ngContext__[CONTEXT];
      ɵmarkDirty(component)
    }
  }
}

Ng-run Angular 11

You can load this on every component you want to monitor or just app component. this is just a loose example. Angular performs a life cycle check that can be called with OnChanges life cycle hook - you should create an input like data 0 and data 1 on every parent component.

To do this create a component to do this so it can listen to and import it into either appComponent or into each component parent component you want to monitor. This may not be exactly what you want but it will be very close.

from angular cli

ng g c viewportListener

import { Component, OnInit, OnChanges, SimpleChanges, Input } from '@angular/core';

@Component({
  selector: 'app-viewport-listener',
  template: '<div></div>',
  styleUrls: ['./viewport-listener.component.css']
})
export class ViewportListenerComponent implements OnInit, OnChanges {
 @Input() data: any;
  constructor() { }

  ngOnInit(): void {
  }
  ngOnChanges(changes: SimpleChanges){
    forceAppWideDetction()
  }

}
function forceAppWideDetction(){}

https://angular.io/api/core/OnChanges https://www.stackchief.com/blog/ngOnChanges%20Example%20%7C%20Angular https://dev.to/nickraphael/ngonchanges-best-practice-always-use-simplechanges-always-1feg

Easy and clean way to do it

If you're looking to change the language of the entire application when changing a language without having to reload the page, I would consider taking a look at the following https://github.com/ngx-translate/core.
ngx-translate let you use pipe to translate languages, when you do change the language inside the ngx translate service, then every pipe do upload themselves and your app do change their languages, without any other fuss

<h2>{{ 'WELCOME' | translate }}</h2>
import { TranslateService } from '@ngx-translate/core'

//..,
constructor(private _translateService: TranslateService){}

setLanguage(language: string) {
this._translateService.use(language)
}

And you're done, I let you read this for further details.

Response to the question

This is my own way of doing things if I didn't had the right to use ngx-translate. I would create a "base" file, with the logic you need, we'll talking here about observable I think

let's name it base.component.ts

import { Directive, OnDestroy, OnInit } from '@angular/core'
import { Subject } from 'rxjs'
import { filter, takeUntil } from 'rxjs/operators'
import { LanguageService } from 'some/where'

@Directive()
export class BaseComponent implements OnInit, OnDestroy {
  protected _unsubscribeAll: Subject<any> = new Subject()

  constructor(public languageService: LanguageService) {}

  ngOnInit(): void {
    this.languageService.onLanguageChanged$
      .pipe(
        filter((langChange) => (langChange?.firstChange ? false : true)), // Avoid subscribing to the first change
        takeUntil(this._unsubscribeAll),
      )
      .subscribe((value) => {
        // Do the reload you need to
      })

    this._extendedInit()
  }

  protected _extendedInit() {
    // For your default ngOnInit
  }

  ngOnDestroy(): void {
    this._unsubscribeAll.next()
    this._unsubscribeAll.complete()

    this._extendedDestroy()
  }

  protected _extendedDestroy() {
    // For your default ngOnDestroy
  }
}

You then, extends every component that need to be updated on language change, (or anything else, if you need to)

import { Directive } from '@angular/core'
import { LanguageService } from 'some/where'
import { BaseComponent } from 'some/where'

@Directive()
export class RandomComponent extends BaseComponent {
  constructor(public languageService: LanguageService) {
    super(languageService)
  }

  protected _extendedInit(): void {
    // For custom ngOnInit
  }
}

If you do not need to add other import to the constructor, you may ignore it, like the following

import { Directive } from '@angular/core'

@Directive()
export class RandomComponent extends BaseComponent {
  protected _extendedInit(): void {
    // For custom ngOnInit
  }
}

And if needed, this his how you could create your service.
I did add the same logic as angular do for the ngOnChange, maybe help you avoid reloading things if you do not want to, for example for the first load

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

export interface LangChange {
  previousValue: any
  currentValue: any
  firstChange: boolean
}

@Injectable({
  providedIn: 'root',
})
export class LanguageService {
  onSelectedLanguageChange$: BehaviorSubject<LangChange> = new BehaviorSubject(null)

  constructor() {}

  setLanguage(language: string) {
    this.onSelectedLanguageChange$.next({
      previousValue: this.onSelectedLanguageChange$.value,
      currentValue: language,
      firstChange: !this.onSelectedLanguageChange$.value,
    })
  }
}

then, into your app.component or anywhere you wants

  constructor(private _languageService: LanguageService) {}

  changeLang(language: string) {
    this._languageService.setLanguage('de')
  }
Related