DanHeidel
DanHeidel

Reputation: 671

Databinding not updating when called from $on

I've got a weird bug that I can't quite figure out. I recently refactored my angular app to be a bit more 'angular-ish'. Part of that was moving a window resize event handler out of a directive and putting it into a .run block where it can broadcast Angular events to the whole application.

That part seems to work fine. It watches the window and events propagate properly. However, when I call my model manipulation code from the $on listener, the databindings don't seem to fire.

.controller('channelListController', function ($scope, $rootScope, $http, $filter){
  $http.get('api/v1/channels').success(function(data){
    $scope.baseChannels = data;

    filterChannelData($scope.filter);
    //call manually since initial window resize occurs before async return
    splitChannelData($rootScope.windowAttr.columns);
  });

  //*************
  //this part doesn't trigger databinding update
  //*************
  $scope.$on('columnChange', function(){
    //window resize requires changing column number
    console.log('change detected');
    splitChannelData($rootScope.windowAttr.columns);
  });

  //*************
  //but this does
  //*************
  $scope.$watch('filter', function(newVal){
  //the filters are changed
    filterChannelData(newVal);
    //re-run split for new data
    splitChannelData($rootScope.windowAttr.columns);
  }, true);

  function filterChannelData(filter){
    if(filter){
      $scope.filteredChannels = $filter('looseCreatorComparator')($scope.baseChannels, filter.creators);
      $scope.filteredChannels = $filter('looseTagComparator')($scope.filteredChannels, filter.tags);
    } else {
      $scope.filteredChannels = $scope.baseChannels;
    }
  }

  function splitChannelData(columns){
    if(columns){
      $scope.splitChannels = [];
      for(var rep=0;rep<columns;rep++){
        $scope.splitChannels.push([]);
      }
      _.forEach($scope.filteredChannels, function(channel, index){
        $scope.splitChannels[index % columns].push(channel);
      });
    }
  }
});

So, when I alter the contents of my filter input fields, the $watch catches that, runs my filters and then fires splitChannelData. The latter refactors my dataset into a set of separate arrays for display. The view is databound to $scope.splitChannels. This works fine.

However, when the broadcast 'columnChange' event is caught by $on, the splitChannelData function fires, properly refactors $scope.splitChannels but the view does not refresh to reflect the change.

Also, if I then subsequently make another change to the filter input fields and trigger the $watch handler, it properly updates the view, including the dataset changes that the $on made but that didn't update to the view earlier.

WUT.

So, can anyone tell me why calling splitDataChannel from $scope.$watch makes the databinding work properly but the same call triggered by $scope.$on doesn't?

EDIT: I just solved the bug but still don't really understand why this solution is necessary. I would still love to get feedback.

I added a $apply call to the $on handler:

  $scope.$on('columnChange', function(){
    //window resize requires changing column number
    console.log('change detected');
    splitChannelData($rootScope.windowAttr.columns);
    //added this
    $scope.$apply();
  });

Why does $watch trigger the databinding update while $on does not and the latte requires a manual $apply to be called?

Upvotes: 3

Views: 747

Answers (1)

tasseKATT
tasseKATT

Reputation: 38490

General explanation:

"The boundaries of Angular's world" is not as complex as it sounds.

A bit simplified, Angular's dirty checking works by storing watchers in an array. A watcher has a watchExpression and a listenerFunction. When the digest cycle is triggered the watchers are iterated. If the returned value of a watchExpression has changed since last time, the associated listenerFunction is executed.

For example a watchExpression returns the string hello, while last time it returned hell. The listenerFunction is executed to update a specific DOM element's text value with the returned value hello.

If you change a variable that is watched but don't trigger the digest cycle, the update will be not reflected in the UI.

If the action triggers the digest cycle - You are inside the boundaries.

If the action doesn't trigger the digest cycle - You are outside the boundaries.

Most of the commonly used functionalities in Angular automatically trigger the digest loop internally for you. For example the directive ngClick and the $http service.

Specific explanation:

$broadcast, $emit and $on does not trigger the digest cycle. The problem is not with your handler attached to $on, but most likely with where $broadcast is used.

My guess is that it looks something like this:

angular.element($window).on('resize', function () {
  $rootScope.$broadcast('columnChange');
});

The on function is a jqLite/jQuery function. It does not trigger the digest cycle, which means it lives outside the boundaries of Angular's world.

You should wrap it in a call to $apply:

angular.element($window).on('resize', function() {
  $rootScope.$apply(function () { 
    $rootScope.$broadcast('columnChange');
  });
});

The reason not to call $apply in the handler attached to $on is because if something both calls $broadcast('columnChange') and triggers the digest loop you will get the $digest already in progress error.

Upvotes: 3

Related Questions