triggering $onChanges for updated one way binding - angularjs

I'm really happy with the "new" $onChanges method you can implement in a component's controller. However it only seems to be triggered when the bound variable is overwritten from outside my component, not (for instance) when an item is added to an existing array
It this intended behaviour or a bug? Is there another way of listening to updates to my input bindings, besides doing a $scope.$watch on it?
I'm using Angular 1.5.3

First TL;DR
For an array that is bounded via one-way binding, a watch expression is added that does not check for object equality but uses reference checking. This means that adding an element to the array will never fire the '$onChanges' method, since the watcher will never be 'dirty'.
I've created a plnkr that demonstrates this:
http://plnkr.co/edit/25pdLE?p=preview
Click the 'add vegetable in outer' and 'change array reference in outer' and look at the 'Number of $onChanges invocation'. It will only change with the latter button.
Complete explanation
To fully grasp what is going on, we should check the angular code base. When a '<' binding is found, the following code is used to set up a watch expression.
case '<':
if (!hasOwnProperty.call(attrs, attrName)) {
if (optional) break;
attrs[attrName] = void 0;
}
if (optional && !attrs[attrName]) break;
parentGet = $parse(attrs[attrName]);
destination[scopeName] = parentGet(scope);
// IMPORTANT PART //
removeWatch = scope.$watch(parentGet, function parentValueWatchAction(newParentValue) {
var oldValue = destination[scopeName];
recordChanges(scopeName, newParentValue, oldValue);
destination[scopeName] = newParentValue;
}, parentGet.literal);
// ------------- //
removeWatchCollection.push(removeWatch);
break;
The important part here is how the 'scope.$watch' expression is set up. The only parameters passed are the parsed expression and the listener function. The listener function is fired once the '$watch' is found dirty in the digest cycle. If it is fired, the listener will execute the 'recordChanges' method. This records an '$onChanges' callback task that will be executed in the '$postDigest' phase and notify all components that are listening for the '$onChanges' lifecycle hook to tell them if the value has changed.
What's important to keep in mind here, if the '$watcher' is never dirty, the '$onChanges' callback is not triggered. But even more importantly, by the way the '$watch' expression is created, it will NEVER be dirty, UNLESS the reference changes. If you wanted to check for equality between objects instead of reference, you should pass an extra third parameter that asks for this:
$watch: function(watchExp, listener, objectEquality, prettyPrintExpression)
As this is not the case here with the way the one way binding is set up, it will ALWAYS check for reference.
This means, if you add an element to an array, the reference is not changed. Meaning the '$watcher' will never be dirty, meaning the '$onChanges' method will not be called for changes to the array.
To demonstrate this, I've created a plnkr:
http://plnkr.co/edit/25pdLE?p=preview
It contains two components, outer and inner.
Outer has primitive string value that can be changed through an input box and an array that can be extended by adding an element or have its reference changed.
Inner has two one-way bounded variables, the value and the array. It listens for all changes.
this.$onChanges = setType;
function setType() {
console.log("called");
vm.callCounter++;
}
If you type into the input field, the '$onChanges' callback is fired every time. This is logical and expected, since a string is primitive so it cannot be compared by reference, meaning the '$watcher' will be dirty, and the '$onChanges' lifecycle hook fired.
If you click the 'Add vegetable in outer', it will execute the following code:
this.changeValueArray = function() {
vm.valueArray.push("tomato");
};
Here we just add a value to the existing bounded array. We're working by reference here, so the '$watcher' is not fired and there is no callback. You will not see the counter increment or the 'called' statement in your console.
Note: If you click the 'Add something to the array' inside the inner component, the array in outer component also changes. This is logical, since we are updating the exact same array by reference. So even though it is a one-way binding, the array can be updated from inside the inner component.
If you change the reference in the outer component by clicking 'Change array reference in outer', the '$onChanges' callback is fired as expected.
As to answer your question: Is this intended behaviour or a bug? I guess this is intended behaviour. Otherwise they would have given you the option to define your '<' binding in a way that it would check for object equality. You can always create an issue on github and just ask the question if you'd like.

Related

Mutating object properties within an array with Polymer

I not sure how to solve this issue. I am sure someone will know this very quickly.
I have an array of objects and modifying a property. I have a firebase listener 'child_changed'. When firebase is updated need to update the array. Here is the code below.
dbRefList.on('child_changed', function(snap) {
var len = this.grocerylist.length;
for(var i=len; i--;) {
if(this.grocerylist[i].key === snap.key) {
this.set(['grocerylist', i, 'selected'], snap.val().selected);
}
}
this.notifyPath('grocerylist', this.grocerylist.slice());
}.bind(this));
When the array is modified I want the template repeat-dom to trigger. I know this.set will not trigger array mutation sub properties but again I am not sure how to solve this. I done research and tried so many solutions.
I can force a render on the template dom-repeat but I would prefer the data binding way.
So this code (just the this.set you have in there now) should cause the value of grocerylist.i.selected to update inside the dom-repeat (assuming it's bound in there so it's actually showing up).
What behavior are you seeing? Are you trying to filter or sort the list based on the selected value? In that case, you might need to add observe="selected" on the dom-repeat.
(Also—have you confirmed that the child-changed callback is being called with the this value you expect—the element—rather than window or something else?)
You should be able to force a refresh by doing this.grocerylist = this.grocerylist.slice() or this.set('grocerylist', this.grocerylist.slice()); ... notifyPath doesn't work here because notifyPath doesn't change the value, it notifies the element about a prior change (the second argument is effectively ignored).

Why does binding trigger a $watch although no data was changed?

I have an entity in the scope that is being edited by the user. Each time it gets modified, I want to trigger some custom validation. So I have:
// validate the position if anything has changed
$scope.$watch("entity", function() {
if ($scope.entity.Id) {
$scope.validate();
}
}, true /* watch "by value" (see: https://docs.angularjs.org/guide/scope#scope-life-cycle) */);
So far so good. Now, since this is a big entity with quite some fields, not all of the fields participate initially in data-binding. Only portions of the fields are visible to the user by using a tab control. When the user switches tabs, another portion of the entity is shown.
However, when the additional controls get bound to the corresponding fields of the entity, the $watch gets triggered even though the binding doesn't change any value on the entity.
Why is this the case and how could I prevent it?
Note:
I thought that the data-binding is possibly adding some internal $... fields to entity but these are hopefully disregarded (at least this is the case in angular.equals so they should probably be disregarded in the $watch too, I assume).
$watch with object equality flag true compares the properties of the old object copy with the current object. Therefore the listener fires when a property name is changed, added, or removed. You can try something like the following:
$scope.$watch("entity", function(newVal, oldVal) {
if(Object.keys(newVal).length !== Object.keys(oldVal).length)
return; //Detect added properties
if ($scope.entity.Id) {
$scope.validate();
}
}, true

Set model value programmatically in Angular.js

I'm an author of angular-input-modified directive.
This directive is used to track model's value and allows to check whether the value was modified and also provides reset() function to change value back to the initial state.
Right now, model's initial value is stored in the ngModelController.masterValue property and ngModelController.reset() function is provided. Please see the implementation.
I'm using the following statement: eval('$scope.' + modelPath + ' = modelCtrl.masterValue;'); in order to revert value back to it's initial state. modelPath here is actually a value of ng-model attribute. This was developed a way back and I don't like this approach, cause ng-model value can be a complex one and also nested scopes will break this functionality.
What is the best way to refactor this statement? How do I update model's value directly through the ngModel controller's interface?
The best solution I've found so far is to use the $parse service in order to parse the Angular's expression in the ng-model attribute and retrieve the setter function for it. Then we can change the model's value by calling this setter function with a new value.
Example:
function reset () {
var modelValueSetter = $parse(attrs.ngModel).assign;
modelValueSetter($scope, 'Some new value');
}
This works much more reliably than eval().
If you have a better idea please provide another answer or just comment this one. Thank you!
[previous answer]
I had trouble with this issue today, and I solved it by triggering and sort of hijacking the $parsers pipeline using a closure.
const hijack = {trigger: false; model: null};
modelCtrl.$parsers.push( val => {
if (hijack.trigger){
hijack.trigger = false;
return hijack.model;
}
else {
// .. do something else ...
})
Then for resetting the model you need to trigger the pipeline by changing the $viewValue with modelCtrl.$setViewValue('newViewValue').
const $setModelValue = function(model){
// trigger the hijack and pass along your new model
hijack.trigger = true;
hijack.model = model;
// assuming you have some logic in getViewValue to output a viewValue string
modelCtrl.$setViewValue( getViewValue(model) );
}
By using $setViewValue(), you will trigger the $parsers pipeline. The function I wrote in the first code block will then be executed with val = getViewValue(model), at which point it would try to parse it into something to use for your $modelValue according the logic in there. But at this point, the variable in the closure hijacks the parser and uses it to completely overwrite the current $modelValue.
At this point, val is not used in the $parser, but it will still be the actual value that is displayed in the DOM, so pick a nice one.
Let me know if this approach works for you.
[edit]
It seems that ngModel.$commitViewValue should trigger the $parsers pipeline as well, I tried quickly but couldn't get it to work.

Controller returning un rendered view

I am facing an issue with my controllers refs and the returned object being invalid. And by invalid it returning me an object that says it has not been rendered even though i am in the process of using it.
In my controller I have a ref defined as the following.
refs : [ {
ref : 'selectDeltaView',
selector : '[itemId=selectDeltaView]'
}]
and later in my controller I access this view using the created accessor
this.getSelectDeltaView()
What is strange is that the first time this view is presented, inside of an Ext.Window it works just fine. However, on the second launch the view I get back is not rendered.
this.getSeletDeltaView().rendered === false
Reading the documentation over at Sencha it seems that this simply passes my selector to the Ext.ComponentQuery.query function. However, if I call Ext.ComponentQuery.query('[itemId=selectDeltaView]') I get an array with a single element of which rendered is true.
Am i missing something? Why is my controllers reference returning me invalid data.
Update
Some additional details not mentioned in the original post. My initial assumption was that my view was not being destroyed. However, I have log statements in my ondestory and beforedestory events and can confirm that on closing the window my view is being destroyed.
What is the most confusing is that if refs simply use Ext.ComponentQuery.query, why does Ext.ComponentQuery.query only return one view when the window is re-opened and it returns the correct view. I understand from the question below that refs will catch the view, but that view no longer exist.. or shouldn't.. If the view is destroyed, can I force the controller to clear its catch?
The controllers reference is cached after the first evaluation.
What happens is this:
You open your view a first time.
getSeletDeltaView() finds that view, and stores a reference to it in the reference lookup cache.
You close the view, but you do not destroy it completely.
You open the view a second time, but it is a different instance.
getSeletDeltaView() finds the first view, that is no longer rendered, but still exists, because it was not destroyed. You cannot use this reference, because it point to the wrong view.
Solution:
Make sure to destroy() your view, when you hide it. You can also use autoDestroy as a property. Usually, autoDestroy is true by default.

backbone model with an array/object property: infinite 'change' event triggered after sync()?

My backbone.js model has an array property. I bound the change event to save().
After sync() (triggered by save(), my app server returns an identical JSON, but backbone thinks the array has been changed (due to a different reference to the array I guess?), and trigger changes again. Then an infinite loop occurs.
save() -> sync() -> triggered `change` -> save()...
What shall I do?
Idea: I can bind the change event to a function that checks if the changed attributes are of type object/array, and do a deep comparison and call save only if the array/object really changed. If true then save()?
Thanks!
Try the Edge version of Backbone (master branch) this behavior changed after 0.9.9 - see https://github.com/documentcloud/backbone/pull/2004
Backbone has a special option on many methods to prevent just this sort of issue: silent:true. If you pass that option to your save method, the resulting sync won't trigger a change event.
So, if you want to set your change event handler to save silently, something like:
changeHandler: function() {
this.save({silent:true});
}
should do the trick.

Resources