1

I'm trying to make a minimal but fancy AngularJS tutorial example, and I am running into an issue where after updating the entire tree for a model (inside the scope of an ng-change update), a template that is driven by a top-level ng-repeat is not re-rendered at all.

However, if I add the code $scope.data = {} at a strategic place, it starts working; but then the display flashes instead of being nice and smooth. And it's not a great example of how AngularJS automatic data binding works.

What am I missing; and what would be the right fix?

Exact code - select a country from the dropdown - This jsFiddle does not work: http://jsfiddle.net/f9zxt36g/ This jsFiddle works but flickers: http://jsfiddle.net/y090my10/

var app = angular.module('factbook', []);
app.controller('loadfact', function($scope, $http) {
  $scope.country = 'europe/uk';
  $scope.safe = function safe(name) { // Makes a safe CSS class name
    return name.replace(/[_\W]+/g, '_').toLowerCase();
  };
  $scope.trunc = function trunc(text) { // Truncates text to 500 chars
    return (text.length < 500) ? text : text.substr(0, 500) + "...";
  };
  $scope.update = function() { // Handles country selection
    // $scope.data = {}; // uncomment to force rednering; an angular bug?
    $http.get('https://rawgit.com/opendatajson/factbook.json/master/' +
        $scope.country + '.json').then(function(response) {
      $scope.data = response.data;
    });
  };
  $scope.countries = [
    {id: 'europe/uk', name: 'UK'},
    {id: 'africa/eg', name: 'Egypt'},
    {id: 'east-n-southeast-asia/ch', name: 'China'}
  ];
  $scope.update();
});

The template is driven by ng-repeat:

<div ng-app="factbook" ng-controller="loadfact">
  <select ng-model="country" ng-change="update()"
      ng-options="item.id as item.name for item in countries">
  </select>
  <div ng-repeat="(heading, section) in data"
       ng-init="depth = 1"
       ng-include="'recurse.template'"></div>
  <!-- A template for nested sections with heading and body parts -->
  <script type="text/ng-template" id="recurse.template">
    <div ng-if="section.text"
         class="level{{depth}} section fact ng-class:safe(heading);">
      <div class="level{{depth}} heading factname">{{heading}}</div>
      <div class="level{{depth}} body factvalue">{{trunc(section.text)}}</div>
    </div>
    <div ng-if="!section.text"
         class="level{{depth}} section ng-class:safe(heading);">
      <div class="level{{depth}} heading">{{heading}}</div>
      <div ng-repeat="(heading, body) in section"
           ng-init="depth = depth+1; section = body;"
           ng-include="'recurse.template'"
           class="level{{depth-1}} body"></div>
    </div>
  </script>
</div>

What am I missing?

David Bau
  • 3,681
  • 2
  • 18
  • 13
  • It should be issue with `ng-include`, as It creates new scope.If you just render your json data `{{data}}` you will notice it is smoothly running. I suggest to use prototypical chain model (like `$scope.data.data`) to preserve scope inside template. – anoop Mar 28 '17 at 15:42
  • Thanks anoop and Aperion. I am still not sure what caused the problem, but I found a fix that does not require me to eliminate recursion or the use of `ng-include`. It looks like it was a bad interaction between `ng-init` and `ng-repeat`. If I change it as follows, http://jsfiddle.net/fL951g83/ to eliminate use of ng-init on the loop variable in ng-repeat, it works fine. Could it be related to http://stackoverflow.com/questions/15355122/angularjs-ngrepeat-with-nginit-ngrepeat-doesnt-refresh-rendered-value? – David Bau Mar 28 '17 at 20:00

3 Answers3

1

You changed reference of section property by executing section = body; inside of ng-if directives $scope. What happened in details (https://docs.angularjs.org/api/ng/directive/ngIf):

  1. ng-repeat on data created $scope for ng-repeat with properties heading and section;
  2. Template from ng-include $compile'd with $scope from 1st step;
  3. According to documentation ng-if created own $scope using inheritance and duplicated heading and section;
  4. ng-repeat inside of template executed section = body and changed reference to which will point section property inside ngIf.$scope;
  5. As section is inherited property, you directed are displaying section property from another $scope, different from initial $scope of parent of ngIf.

This is easily traced - just add:

...
<script type="text/ng-template" id="recurse.template">
  {{section.Background.text}}
...

and you will notice that section.Background.text actually appoints to proper value and changed accordingly while section.text under ngIf.$scope is not changed ever.

Whatever you update $scope.data reference, ng-if does not cares as it's own section still referencing to previous object that was not cleared by garbage collector.

Reccomdendation: Do not use recursion in templates. Serialize your response and create flat object that will be displayed without need of recursion. As your template desired to display static titles and dynamic texts. That's why you have lagging rendering - you did not used one-way-binding for such static things like section titles. Some performance tips.

P.S. Just do recursion not in template but at business logic place when you manage your data. ECMAScript is very sensitive to references and best practice is to keep templates simple - no assignments, no mutating, no business logic in templates. Also Angular goes wild with $watcher's when you updating every of your section so many times without end.

Community
  • 1
  • 1
Appeiron
  • 1,063
  • 7
  • 14
  • Thanks for the analysis. But why does it work when `$scope.data = {}` first? If the stale references are hanging around then shouldn't they also be hanging around there? Which part of recursion isn't working? Things look like they are designed to work with recursion, e.g., ng-repeat also sets up a copy of the $scope too, so it shouldn't be ng-if's `section` but ng-repeat's `section` which is getting changed. The same thing happens with `depth`, which works correctly. – David Bau Mar 28 '17 at 18:08
  • Don't forget that depth is not a reference to object but primitive value. I recommend you to dig in about what are object references in JavaScript for better understanding – Appeiron Mar 28 '17 at 18:10
  • When you assigning data property to new empty object, ngRepeat clearing own scope as data have no keys, templates are removed, ngIf are removed and gets own scopes cleared as described in documentation. Think from parent perspective. Section would be same after assigning new object if you had ngShow for example that does not get cleared when hidden. – Appeiron Mar 28 '17 at 18:14
  • Issue is reached as soon as you pointed section in every ngIf to new reference that is different from parents section. Major question for you: what does sign "=" really do in your case? No - it is not updating section value, it is changing reference named section to different object. – Appeiron Mar 28 '17 at 18:21
0

Thanks to Apperion and anoop for their analysis. I have narrowed down the problem, and the upshot is that there seems to be a buggy interaction between ng-repeat and ng-init which prevents updates from being applied when a repeated variable is copied in ng-init. Here is a minimized example that shows the problem without using any recursion or includes or shadowing. https://jsfiddle.net/7sqk02m6/

<div ng-app="app" ng-controller="c">
  <select ng-model="choice" ng-change="update()">
    <option value="">Choose X or Y</option>
    <option value="X">X</option>
    <option value="Y">Y</option>
  </select>
  <div ng-repeat="(key, val) in data" ng-init="copy = val">
    <span>{{key}}:</span> <span>val is {{val}}</span>  <span>copy is {{copy}}</span>
  </div>
</div>

The controller code just switches the data between "X" and "Y" and empty versions:

var app = angular.module('app', []);
app.controller('c', function($scope) {
  $scope.choice = '';
  $scope.update = function() {
    $scope.data = {
      X: { first: 'X1', second: 'X2' },
      Y: { first: 'Y1', second: 'Y2' },
      "": {}
    }[$scope.choice];
  };
  $scope.update();
});

Notice that {{copy}} and {{val}} should behave the same inside the loop, because copy is just a copy of val. They are just strings like 'X1'. And indeed, the first time you select 'X', it works great - the copies are made, they follow the looping variable and change values through the loop. The val and the copy are the same.

first: val is X1 copy is X1
second: val is X2 copy is X2

But when you update to the 'Y' version of the data, the {{val}} variables update to the Y version but the {{copy}} values do not update: they stay as X versions.

first: val is Y1 copy is X1
second: val is Y2 copy is X2

Similarly, if you clear everything and start with 'Y', then update to 'X', the copies get stuck as the Y versions.

The upshot is: ng-init seems to fail to set up watchers correctly somehow when looped variables are copied in this situation. I could not follow Angular internals well enough to understand where the bug is. But avoiding ng-init solves the problem. A version of the original example that works well with no flicker is here: http://jsfiddle.net/cjtuyw5q/

David Bau
  • 3,681
  • 2
  • 18
  • 13
0

If you want to control what keys are being tracked by ng-repeat you can use a trackby statement: https://docs.angularjs.org/api/ng/directive/ngRepeat

<div ng-repeat="model in collection track by model.id">
  {{model.name}}
</div>

modifying other properties won't fire the refresh, which can be very positive for performance, or painful if you do a search/filter across all the properties of an object.