Change Detection Strategy Appears To Override The ChangeDetectorRef In Angular 2 RC 3
In Angular 2 RC 3, you can provide a change detection strategy in your Component meta-data. But, you can also inject the ChangeDetectorRef into your component as a way to gain finer control over how your component interacts with the change detection life-cycle. As it turns out, however, doing both of these things leads to possibly unexpected behavior. Well, unexpected to me, at the very least. From what I can see, it looks like the change detection strategy in the Component meta-data overrides the injected ChangeDetectorRef.
Run this demo in my JavaScript Demos project on GitHub.
I'm still trying to wrap my head around how change detection works in Angular 2. So, from my point of view, there's nothing obviously wrong in providing my component with both a change detection strategy and a ChangeDetectorRef. After all, I might want to detach my component from the change detection life-cycle, regardless of what strategy it is currently using. But, it doesn't quite work that way. From what I can see, the OnPush change detection strategy is overriding my ability to manually "detach" my component.
To demonstrate this, I have two Label components. Both of these components accept a [value] input. And, both of these components attempt to detach themselves from the change-detection life-cycle. The only real meaningful difference between the two is that the first one uses the Default change detection strategy while the second one using the OnPush change detection strategy.
First, let's look at the root component to see how these components are being consumed:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { MyLabelComponent } from "./my-label.component";
import { MyPushLabelComponent } from "./my-label.component";
@Component({
selector: "my-app",
directives: [ MyLabelComponent, MyPushLabelComponent ],
template:
`
<p>
<a (click)="cycleLabel()">Cycle current label input</a>
</p>
<my-label [value]="labels[ 0 ]"></my-label>
<my-push-label [value]="labels[ 0 ]"></my-push-label>
`
})
export class AppComponent {
// I hold the labels collection, which we will cycle when rendering.
public labels: string[];
// I initialize the component.
constructor() {
this.labels = [ "Use Caution", "All Systems Go", "Slippery When Wet" ];
}
// ---
// PUBLIC METHODS.
// ---
// I cycle the labels, moving the current one to the end of the collection.
public cycleLabel() : void {
this.labels.push( this.labels.shift() );
}
}
As you can see, both of the Label components are receiving a [value] input binding, which is being cycled by the root component. This means that the user has the ability to change the value property being piped into the Labels, which is where the change detection comes into play. And, the only thing different between these two components is which change detection strategy is defined in their respective meta-data.
// Import the core angular services.
import { ChangeDetectionStrategy } from "@angular/core";
import { ChangeDetectorRef } from "@angular/core";
import { Component } from "@angular/core";
import { OnChanges } from "@angular/core";
// The following components are exactly the same in terms of the internal
// functionality. The ONLY DIFFERENCE between the two is that they have different
// meta-data. Specifically, the former uses the Default change detection strategy
// while the latter uses the OnPush change detection strategy. The goal here is to
// see how, if at all, the change detection strategy affects the changeDetectorRef.
// Using Default change detection strategy.
@Component({
selector: "my-label",
inputs: [ "value" ],
changeDetection: ChangeDetectionStrategy.Default,
template:
`
<strong>Label</strong>: {{ value }}
`
})
export class MyLabelComponent implements OnChanges {
// I hold the value of the label.
public value: string;
// I initialize the component.
constructor( changeDetectorRef: ChangeDetectorRef ) {
// When the component is initialized, we want to immediately detach the
// change detector so that the component's view will not be updated.
changeDetectorRef.detach();
}
// ---
// PUBLIC METHODS.
// ---
// I handle changes to any of the bound inputs.
public ngOnChanges( changes: any ) : void {
console.log( "Inputs changed:", Object.keys( changes ) );
}
}
// Using OnPush change detection strategy.
@Component({
selector: "my-push-label",
inputs: [ "value" ],
changeDetection: ChangeDetectionStrategy.OnPush,
template:
`
<strong>Label</strong>: {{ value }}
`
})
export class MyPushLabelComponent implements OnChanges {
// I hold the value of the label.
public value: string;
// I initialize the component.
constructor( changeDetectorRef: ChangeDetectorRef ) {
// When the component is initialized, we want to immediately detach the
// change detector so that the component's view will not be updated.
// --
// CAUTION: This version does not work as you MIGHT EXPECT!
changeDetectorRef.detach();
}
// ---
// PUBLIC METHODS.
// ---
// I handle changes to any of the bound inputs.
public ngOnChanges( changes: any ) : void {
console.log( "Inputs changed:", Object.keys( changes ) );
}
}
As you can see, both Label components receive the ChangeDetectorRef and then immediately try to detach themselves. However, when we run this code, we get the following output:
From this output, we can see a few interesting things. First of all, the second component, which is using the OnPush change detection strategy, is continuing to update its view even after we call changeDetectorRef.detach(). So, clearly, the change detection strategy in the meta-data is overriding out explicit use of the ChangeDetectorRef injectable.
Second of all, the change detection strategy does not affect the piping of inputs into the component. As you can see from the console-logging, both components continue to receive inputs and trigger their ngOnChanges() life-cycle hooks, regardless of which change detection strategy they are using. This was very elucidating for me - I had just assumed that the ngOnChanges() life-cycle method would be skipped if the changeDetectorRef had been detached. I can now see, however, that these are two completely unrelated features of the platform.
If you are intimately aware of how change detection works in Angular 2, this interaction between the change detection strategy and the ChangeDetectorRef might be obvious. But for me, it wasn't. And, from what I can see, it looks like you have to use the Default change detection strategy if you want to also use the changeDetectorRef for finer-grained control of the change detection life-cycle.
Want to use code from this post? Check out the license.
Reader Comments
@Ben,
The whole logic of what happens is hidden, it is done in the generated factory for the view of a component.
This is expected, since the strategy is part of the component metadata and the metadata is used to generate view factory code.
Now, there are only 2 CD strategies: OnPush and Default. These 2 are user facing enums, the actual logic spins around a CD Status which can be:
CheckOnce, Checked, CheckAlways, Detached, Errored, Destroyed
OnPush is initially set as CheckOnce.
Default is initially set as CheckAlways.
When you detach a CD the mode change to Detached.
So, how come OnPush doesn't persist Detached mode?
Well, this is related to the 2nd anecdote you mentioned, ngOnChanges fires regardless of the CD status, even if Detached.
Since this part is generated by the compiler it's hard to see it in action.
This is the line that is responsible for this post :)
if (changed) { self._appEl_11.componentView.markAsCheckOnce(); }
changed an object representing the changes in the component, this is also the object being sent to ngOnChanges.
Since it runs even if Detached it will always revert back the CD status/mode to CheckOnce even if you detached it.
Why does it happen in OnPush and not on Default?
Simple, this line is only added by the codegen when the strategy is OnPush.
A closer look revels this:
if (changed) { self._appEl_11.componentView.markAsCheckOnce(); }
if ((changes !== null)) { self._MyPushLabelComponent_11_4.ngOnChanges(changes); }
So, we can see that right after setting CheckOnce, ngOnChanges get's invoked so you can override this by calling this.changeDetector.detach();
in the component.
BTW, I believe this is a bug so I opened an issue - tagged you.
https://github.com/angular/angular/issues/9720
@Shlomi,
Well, it's just that simple <head explodes> :P
While not in the post, I actually did try to put the .detach() in the ngOnChanges() life-cycle method and I was able to prevent the view from updating. But, I omitted it from the exploration because that felt really counter-intuitive; meaning, what's the point of .detach() if I have to call it all the time. I just figured that maybe I had to leave the Default ChangeDetection strategy if I wanted to get more granular with my control.
But, if you think this is a bug, I'm all for it. Interested to see what the Angular team says in your ticket.
Yo, and thank goodness for Chrome dev-tools and being able to see the compiled templates, right? I've spent some time poking around in the template code that actually gets generated. It's fascinating, if not incredibly hard to follow :D Many a break-point and "step into" buttons have been pushed on my part.
@Ben,
What helped me figuring this out is overriding the `cdMode` property on the View to be a get/set via defineProperty and add debugger; statement at the set function.
This was done in the console :)
Then follow where change occurs via the break point.
Really an offside approach but it got me there.
I have a question that might be related that I don't get to understand how it works fully, I'm trying to build an App where using Change Detection Strategy to OnPush everywhere. Imagine I have two components bound to the same JSON object (loaded once). This components do not have any input parameters. One of them makes a change to the data. How do I get the other component bound to the same data to redraw?.
Please let me know if it makes sense.
@Alexandro,
Since OnPush is really driven by Inputs changing or internal Events, having to coordinate "shared state" changes is tricky. I think in a case like that, you might want to make some sort of Observable that each component can listen to. Then, you can inject that Observable into each component, subscribe to changes, and then, on change events, manually call the changeDetectorRef.detectChanges().
Something like (pseudo-code):
constructor( jsonChanges, changeDetectorRef ) {
. . . . jsonChanges.subscribe( () => changeDetectorRef.detectChanges() );
}
I don't know all the details about how the data is available or how it changes over time; but, this might be the easiest way to go.
@ben this is now solved :)
https://github.com/angular/angular/issues/9720#event-1062929803