Recently, I encounter two very interesting issues when using ng-repeat in AngularJS. Not completely understanding the $watch and $digest() is the root cause.
Requirement
I am making some workout entries as a list and one special requirement is to group the records by the date.
In order to break the entries to different groups, I use a scope level variable $scope.lastActionDate to keep track of the last actionDate of the entry to decide whether I should add the actionDateGroup DIV. The source is as below. The debug messages are used to explain the issues I encountered. You can safely ignore them now. Actually, you may already guess what one of the issues is after seeing them. Yes, only one. I bet you can never guess the second one and why.
<style>
body {font-family: 'Lucida Grande', 'Lucida Sans', Arial, sans-serif;}
ul li {list-style-type: none;}
.actionDateGroup {font-weight: bold; color: red}
</style>
<div id="content" ng-controller="EntryCtrl">
<div id="entries">
<ul>
<li ng-repeat="entry in entries">
<div ng-switch on="isNewDateGroup(entry.actionDate)">
<div ng-switch-when="true" class="actionDateGroup">{{entry.actionDate}}</div>
</div>
<span>{{entry.desc}}</span>
</li>
</ul>
</div>
</div>
<script type="text/javascript" src="./js/angular/angular.js"></script>
<script>
function EntryCtrl($scope, $location) {
$scope.entries = [{
desc: 'Rope jumping count 1000',
actionDate: '2012-01-31',
},{
desc: 'Jogging 3000M',
actionDate: '2012-01-31'
},{
desc: 'Situp 40 * 3',
actionDate: '2012-01-30'
}];
$scope.lastActionDate = null;
$scope.calledCount = 0;
$scope.isNewDateGroup = function(actionDate) {
$scope.calledCount++;
console.log('Function called count: ' + $scope.calledCount);
console.log('Entry date vs Scope date: ' + actionDate + ' vs ' + $scope.lastActionDate);
if ($scope.lastActionDate === null || $scope.lastActionDate !== actionDate) {
$scope.lastActionDate = actionDate;
return true;
}
return false;
};
}
</script>
Expectation
- actionDate of the first entry will always be shown as it’s the first group.
- actionDate of the remaining entries will be shown if its value is not the same as the previous one.
Phenomenon
When the sample data is as above (case #1), the effect looks like it’s behaving correctly as below:
- 2012-01-31 Rope jumping count 1000
- Jogging 3000M
- 2012-01-30 Situp 40 * 3
However, if you change the actionDate of the last entry to be also 2012-01-31 (case #2), you will find the result is that no date group is shown. Why? Isn’t it supposed to show only the first one as all entries have the same actionDate?
Expected result:
- 2012-01-31 Rope jumping count 1000
- Jogging 3000M
- Situp 40 * 3
Actual result:
- Rope jumping count 1000
- Jogging 3000M
- Situp 40 * 3
Now if you check the calledCount in the debug message, you will find that it’s called 6 times (double the entry count) in case #1 and 9 times in case #2. There are two issues I never thought they should happen:
- The isNewDateGroup function is called more than the entries’ count. (Guess this, right?)
- The called count is different when the data is different. (how about this?)
Causes
In AngularJS $watch API:
- Since $digest() reruns when it detects changes the watchExpression can execute multiple times per $digest() and should be idempotent.
- The listener is called only when the value from the current watchExpression and the previous call to watchExpression are not equal (with the exception of the initial run, see below) …
- The watch listener may change the model, which may trigger other listeners to fire. This is achieved by rerunning the watchers until no changes are detected. The rerun iteration limit is 10 to prevent an infinite loop deadlock.
…
(Since watchExpression can execute multiple times per $digest cycle when a change is detected, be prepared for multiple calls to your listener.)
Issue #1
The isNewDateGroup being watched whose calculation relies on value of lastActionDate is not idempotent and so during initial run stage, lastActionDate is set to 2012-01-30 at the end of case #1 which causes the illusion of working, while it is set to 2012-01-31 at the end of case #2 which illustrates the error.
Issue #2
In below code, if I comment out $scope.lastActionDate = actionDate; or change the return true; to return false;, the called count will be 6, same as case #1. This implies that the return value of the expression is the cause.
if ($scope.lastActionDate === null || $scope.lastActionDate !== actionDate) {
$scope.lastActionDate = actionDate;
return true;
}
Remember what the API states: rerunning the watchers until no changes are detected? Let’s see what the return value is for watch expression isNewDateGroup after each run.
If the actionDate of the last entry is 2012-01-30:
Rope jumping | Jogging | Situp | |
1st (initial) | true ($scope.lastActionDate === null) | false | true (‘2012-01-31’ !== ‘2012-01-30’; $scope.lastActionDate = ‘2012-01-30’) |
2nd | true ($scope.lastActionDate !== ‘2012-01-31’) | false | true |
If the actionDate of the last entry is 2012-01-31:
Rope jumping | Jogging | Situp | |
1st (initial) | true ($scope.lastActionDate === null) | false | false |
2nd | false (change compared to last run) | false | false |
3rd | false | false | false |
So now you see why changing the last entry to 2012-01-31 causes the 3rd time to evaluate the expression again.