3

So i am trying to better understand Angulars ChangeDetection and stumbled into a problem: https://plnkr.co/edit/M8d6FhmDhGWIvSWNVpPm?p=preview

This Plunkr is a simplified version of my applications code and basically has a parent and a child component. Both having ChangeDetectionStrategy.OnPush enabled.

parent.component.ts

@Component({
    selector: 'parent',
    template: `
            <button (click)="click()">Load data</button>
            {{stats.dataSize > 0}}
            <span *ngIf="stats.dataSize > 0">Works</span>
            <child [data]="data" [stats]="stats" (stats)="handleStatsChange()"></child>
        `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class ParentComponent implements OnCheck, OnChanges {

    data = [];
    stats = {
        dataSize: 0
    };

   constructor(private cdr: ChangeDetectorRef) {
   }

    click() {
        console.log("parent: loading data");
        setTimeout(() => {
            this.data = ["Data1", "Data2"];
            this.cdr.markForCheck();
        });
    }

    handleStatsChange() {
        console.log('parent: stats change');
        this.cdr.markForCheck();
    }
}

child.component.ts

import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, SimpleChanges } from "@angular/core";

@Component({
    selector: 'child',
    template: `
        <div *ngFor="let item of data">{{item}}</div>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildComponent implements OnInit, OnChanges {

    @Input() data;
    @Input() stats;
    @Output('stats') statsEmitter = new EventEmitter();

    constructor() {
    }

    ngOnInit() {
    }


    ngOnChanges(changes: SimpleChanges): void {
        console.log("child changes: ", changes);

        this.stats.dataSize = changes['data'].currentValue.length;
        this.statsEmitter.emit(this.stats);
    }
}

So parent updates data on button click which triggers ngOnChanges in child. Everytime data changes, child.component changes a value in stats. I want this value, dataSize, to be used in the <span *ngIf="stats.dataSize > 0">Works</span> in parent. For some reason the *ngIf wont be updated. The template {{stats.dataSize > 0}} otherwise updates no problem.

What i noticed: If i remove OnPush on parent, Angular will throw a exception Expression has changed after it was checked. Previous value: 'false'. Current value: 'true'.. I guess this comes from *ngIf="stats.dataSize > 0" being false first and now true after the second iteration of change detection in dev mode.

So thats why i tried setting this.cdr.markForCheck(); in parent in handleStatsChange. handleStatsChange will be called in child. This has no consequences thought, exception thrown anyway.

I guess change detection on parent doesnt get triggered because no @Input changed in parent, therefore ngIf doesnt update?? Clicking the button two times will actually show Works. I this because a new digest cycle does now start (triggered by a Event) and parents ChangeDetectorRef is now updating the template?

So why does Angular update {{stats.dataSize > 0}} and throw an error at ngIf?

Any help much appreciated :)

Wagner Michael
  • 2,172
  • 1
  • 15
  • 29

3 Answers3

2

I came across this issue today with intersection observer where scrolling an item into view was supposed to do something and 99% of the time it did but 1% of the time it didn't fire.

Important: The async pipe actually calls markForCheck internally. You can look at the source code (near the end).

One solution I've used to this in the past with observables is creating a pipe that creates a 'delay'. This has helped in 'expression changed' errors because it effectively causes a new change detector cycle.

This should be a last resort - but I've used it in a few times when I just couldn't get the timing right elsewhere.

<div *ngIf="model.showPanel | delayTime | async">...</div>

This will have a default of 0ms and the delay() RxJS pipe uses setTimeout:

@Pipe({
    name: 'delayTime'
})
export class DelayTimePipe implements PipeTransform {

    constructor() {}

    transform(value?: Observable<any> | any, ms?: number): Observable<any> {
        if (isObservable(value)) {
            return value.pipe(delay(ms || 0));
        } else {
            throw debuggerError('[DelayTimePipe] Needs to be an observable');
        }
    }
}
Simon_Weaver
  • 140,023
  • 84
  • 646
  • 689
1

In Angular, you can't change a value during the change detection cycle. So,

ngOnChanges(changes: SimpleChanges): void {
        console.log("child changes: ", changes);

        this.stats.dataSize = changes['data'].currentValue.length;  <-- this is not allowed
        this.statsEmitter.emit(this.stats);
    }

That's why you're getting the error about a value being changed after it was checked.

snorkpete
  • 14,278
  • 3
  • 40
  • 57
1

Digging a little more into Lifecycle Hooks Documentation i noticed they provide really nice examples about handling change detection.

In their counter example in CounterParentComponent they have to update their LoggerService by running a new 'tick', which means delaying execution with setTimeout.

counter:

updateCounter() {
    this.value += 1;
    this.logger.tick();
}

logger:

tick() {  this.tick_then(() => { }); }
tick_then(fn: () => any) { setTimeout(fn, 0); }

Thats exactlly what i had to do to get my code working. They also mention this in their docs:

Angular's unidirectional data flow rule forbids updates to the view after it has been composed. Both of these hooks fire after the component's view has been composed. Angular throws an error if the hook updates the component's data-bound comment property immediately (try it!). The LoggerService.tick_then() postpones the log update for one turn of the browser's JavaScript cycle and that's just long enough.

Wagner Michael
  • 2,172
  • 1
  • 15
  • 29
  • 1
    https://plnkr.co/edit/tAZvxaXKaJKqoproQW7U?p=preview setTimeout will run change detection cycle from root component. You just need to update current view – yurzui Mar 18 '17 at 10:17