ngChange fires before value makes it out of isolate scope
Asked Answered
L

2

8
//main controller
angular.module('myApp')
.controller('mainCtrl', function ($scope){
    $scope.loadResults = function (){
        console.log($scope.searchFilter);
    };
});

// directive
angular.module('myApp')
.directive('customSearch', function () {
    return {
        scope: {
            searchModel: '=ngModel',
            searchChange: '&ngChange',
        },
        require: 'ngModel',
        template: '<input type="text" ng-model="searchModel" ng-change="searchChange()"/>',
        restrict: 'E'
    };
});

// html
<custom-search ng-model="searchFilter" ng-change="loadResults()"></custom-search>

Here is a simplified directive to illustrate. When I type into the input, I expect the console.log in loadResults to log out exactly what I have already typed. It actually logs one character behind because loadResults is running just before the searchFilter var in the main controller is receiving the new value from the directive. Logging inside the directive however, everything works as expected. Why is this happening?

My Solution

After getting an understanding of what was happening with ngChange in my simple example, I realized my actual problem was complicated a bit more by the fact that the ngModel I am actually passing in is an object, whose properties i am changing, and also that I am using form validation with this directive as one of the inputs. I found that using $timeout and $eval inside the directive solved all of my problems:

//main controller
angular.module('myApp')
.controller('mainCtrl', function ($scope){
    $scope.loadResults = function (){
        console.log($scope.searchFilter);
    };
});

// directive
angular.module('myApp')
.directive('customSearch', function ($timeout) {
    return {
        scope: {
            searchModel: '=ngModel'
        },
        require: 'ngModel',
        template: '<input type="text" ng-model="searchModel.subProp" ng-change="valueChange()"/>',
        restrict: 'E',
        link: function ($scope, $element, $attrs, ngModel)
        {
            $scope.valueChange = function()
            {
                $timeout(function()
                {
                    if ($attrs.ngChange) $scope.$parent.$eval($attrs.ngChange);
                }, 0);
            };
        }
    };
});

// html
<custom-search ng-model="searchFilter" ng-change="loadResults()"></custom-search>
Lightness answered 16/10, 2015 at 19:27 Comment(0)
P
4

You answered your own question in the title! '=' is watched while '&' is not

  • Somewhere outside angular:

    input view value changes

  • next digest cycle:

    ng-model value changes and fires ng-change()

    ng-change adds a $viewChangeListener and is called this same cycle. See: ngModel.js#L714 and ngChange.js implementation.

    At that time $scope.searchFilter hasn't been updated. Console.log's old value

  • next digest cycle: searchFilter is updated by data binding.

UPDATE: Only as a POC that you need 1 extra cycle for the value to propagate you can do the following. See the other anwser (@NewDev for a cleaner approach).

.controller('mainCtrl', function ($scope, $timeout){
    $scope.loadResults = function (){
        $timeout(function(){
           console.log($scope.searchFilter);
        });
    };
});
Pilose answered 16/10, 2015 at 20:36 Comment(6)
Good answer, I was debugging Angular for the last hour to see what's up. Can you explain why it takes an additional digest cycle for the directive to update the parent scope? Is it because the digest starts from $rootScope and propagates down? If there was a 3rd ng-change along the way, would it take more digest cycles for the value to reach the controller?Robinet
The scope is isolated, directive doesn't modify parent's scope directly. They way to keep = vars synchronized is by using the famous angular 'data binding'.. which is basically a $watch. Each digest cycle angular will do the dirty check and when it detects a change, copies the value 'manually'. So, when the directive variable changes, it needs a $digest be copied to the 'parent' (while the & is a direct access)Pilose
To nitpick, input triggers the digest loop (it doesn't "happen" during a digest), and in its first iteration searchModel is set, and searchChange() (which results in loadResults()) is triggered... Then, in the 2nd iteration, searchFilter is set... But this workaround is very hacky.Grain
you are right about first cycle not being in a cycle! ( was thinking about ngModel $viewValue.. but ngModel is not that simple) I'll edit. About being hacky, it's just what the question was... "what's going on?"Pilose
@AlejandroCotroneo, yeah, I got it... Wording probably could have been better to say that this is POC, so as not to suggest that this is, in fact, a valid workaround.Grain
I used $timeout inside the directive, and it seems to have the added benefit of allowing outer validation to complete before firing, which I also found necessary for my situation. Thanks!Lightness
G
12

The reason for the behavior, as rightly pointed out in another answer, is because the two-way binding hasn't had a chance to change the outer searchFilter by the time searchChange(), and consequently, loadResults() was invoked.

The solution, however, is very hacky for two reasons.

One, the caller (the user of the directive), should not need to know about these workarounds with $timeout. If nothing else, the $timeout should have been done in the directive rather than in the View controller.

And two - a mistake also made by the OP - is that using ng-model comes with other "expectations" by users of such directives. Having ng-model means that other directives, like validators, parsers, formatters and view-change-listeners (like ng-change) could be used alongside it. To support it properly, one needs to require: "ngModel", rather than bind to its expression via scope: {}. Otherwise, things would not work as expected.

Here's how it's done - for another example, see the official documentation for creating a custom input control.

scope: true, // could also be {}, but I would avoid scope: false here
template: '<input ng-model="innerModel" ng-change="onChange()">',
require: "ngModel",
link: function(scope, element, attrs, ctrls){
  var ngModel = ctrls; // ngModelController

  // from model -> view
  ngModel.$render = function(){
    scope.innerModel = ngModel.$viewValue;
  }

  // from view -> model
  scope.onChange = function(){
    ngModel.$setViewValue(scope.innerModel);
  }
}

Then, ng-change just automatically works, and so do other directives that support ngModel, like ng-required.

Grain answered 16/10, 2015 at 23:46 Comment(6)
I realized I left some key info out of my example. My outer ngModel is actually an object, and I'm trying to use multiple inputs inside the directive to change properties of that object, but still treat the directive like a single input.Lightness
@RobbyAllsopp, ok, that's fine with the approach I gave you. But your approach with $timeout and binding directly to ngModel is wrong and could produce unexpected results. For example, suppose you wanted to use ng-model-options with debounce on your directive - it would not have any effect. Like I said, to use ng-model means that you support the ngModelController "framework".Grain
good point it would mess up some other things with ngModel. I can't see a way around the $timeout though if i still want validation to work, but i could probably swap out the $eval for ngModel.$setViewValue(angular.copy($scope.searchModel)). I may switch to that, although i'm not excited about having to copy the whole object every time anything changesLightness
@RobbyAllsopp, I'm not sure what you mean by validation. Validation is supported via ngModel. In fact, I'm fairly certain, that with your $timeout approach it wouldn't work, since a change directly via isolate scope binding bypasses parses and validators. And, you need to create a new object for the "change" to register.Grain
Is something changed in Angular? This isn't working for me. Changes to the ngModel would change your textbox, but changes to the textbox don't update the ngModel. Though, the onChange is definitely being called. Also, once the textbox value differs from the ngModel (via manual input), it no longer even updates when the ngModel is changed elsewhere. Lastly, wouldn't it be require: '^ngModel'? Can you do a Plunker?Pincenez
This should be accepted answerDiscolor
P
4

You answered your own question in the title! '=' is watched while '&' is not

  • Somewhere outside angular:

    input view value changes

  • next digest cycle:

    ng-model value changes and fires ng-change()

    ng-change adds a $viewChangeListener and is called this same cycle. See: ngModel.js#L714 and ngChange.js implementation.

    At that time $scope.searchFilter hasn't been updated. Console.log's old value

  • next digest cycle: searchFilter is updated by data binding.

UPDATE: Only as a POC that you need 1 extra cycle for the value to propagate you can do the following. See the other anwser (@NewDev for a cleaner approach).

.controller('mainCtrl', function ($scope, $timeout){
    $scope.loadResults = function (){
        $timeout(function(){
           console.log($scope.searchFilter);
        });
    };
});
Pilose answered 16/10, 2015 at 20:36 Comment(6)
Good answer, I was debugging Angular for the last hour to see what's up. Can you explain why it takes an additional digest cycle for the directive to update the parent scope? Is it because the digest starts from $rootScope and propagates down? If there was a 3rd ng-change along the way, would it take more digest cycles for the value to reach the controller?Robinet
The scope is isolated, directive doesn't modify parent's scope directly. They way to keep = vars synchronized is by using the famous angular 'data binding'.. which is basically a $watch. Each digest cycle angular will do the dirty check and when it detects a change, copies the value 'manually'. So, when the directive variable changes, it needs a $digest be copied to the 'parent' (while the & is a direct access)Pilose
To nitpick, input triggers the digest loop (it doesn't "happen" during a digest), and in its first iteration searchModel is set, and searchChange() (which results in loadResults()) is triggered... Then, in the 2nd iteration, searchFilter is set... But this workaround is very hacky.Grain
you are right about first cycle not being in a cycle! ( was thinking about ngModel $viewValue.. but ngModel is not that simple) I'll edit. About being hacky, it's just what the question was... "what's going on?"Pilose
@AlejandroCotroneo, yeah, I got it... Wording probably could have been better to say that this is POC, so as not to suggest that this is, in fact, a valid workaround.Grain
I used $timeout inside the directive, and it seems to have the added benefit of allowing outer validation to complete before firing, which I also found necessary for my situation. Thanks!Lightness

© 2022 - 2024 — McMap. All rights reserved.