Using a directive inside an ng-repeat, and a mysterious power of scope '@'

JavascriptAngularjsAngularjs Directive

Javascript Problem Overview


If you prefer to see the question in working code, start here: http://jsbin.com/ayigub/2/edit

Consider this almost equivalent ways to write a simple direcive:

app.directive("drinkShortcut", function() {
  return {
    scope: { flavor: '@'},
    template: '<div>{{flavor}}</div>'
  };
});

app.directive("drinkLonghand", function() {
  return {
    scope: {},
    template: '<div>{{flavor}}</div>',
    link: function(scope, element, attrs) {
      scope.flavor = attrs.flavor;
    }
  };
});

When used by themselves, the two directives work and behave identically:

  <!-- This works -->
  <div drink-shortcut flavor="blueberry"></div>
  <hr/>

  <!-- This works -->
  <div drink-longhand flavor="strawberry"></div>
  <hr/>

However, when used within an ng-repeat, only the shortcut version works:

  <!-- Using the shortcut inside a repeat also works -->
  <div ng-repeat="flav in ['cherry', 'grape']">
    <div drink-shortcut flavor="{{flav}}"></div>
  </div>
  <hr/>
  
  <!-- HOWEVER: using the longhand inside a repeat DOESN'T WORK -->      
  <div ng-repeat="flav in ['cherry', 'grape']">
    <div drink-longhand flavor="{{flav}}"></div>
  </div>
  

My questions are:

  1. Why does the longhand version not work inside an ng-repeat?
  2. How could you make the longhand version work inside an ng-repeat?

Javascript Solutions


Solution 1 - Javascript

In drinkLonghand, you use the code

scope.flavor = attrs.flavor;

During the linking phase, interpolated attributes haven't yet been evaluated, so their values are undefined. (They work outside of the ng-repeat because in those instances you aren't using string interpolation; you're just passing in a regular ordinary string, e.g. "strawberry".) This is mentioned in the Directives developer guide, along with a method on Attributes that is not present in the API documentation called $observe:

> Use $observe to observe the value changes of attributes that contain interpolation (e.g. src="{{bar}}"). Not only is this very efficient but it's also the only way to easily get the actual value because during the linking phase the interpolation hasn't been evaluated yet and so the value is at this time set to undefined.

So, to fix this problem, your drinkLonghand directive should look like this:

app.directive("drinkLonghand", function() {
  return {
    template: '<div>{{flavor}}</div>',
    link: function(scope, element, attrs) {
      attrs.$observe('flavor', function(flavor) {
        scope.flavor = flavor;
      });
    }
  };
});

However, the problem with this is that it doesn't use an isolate scope; thus, the line

scope.flavor = flavor;

has the potential to overwrite a pre-existing variable on the scope named flavor. Adding a blank isolate scope also doesn't work; this is because Angular attempts to interpolate the string on based on the directive's scope, upon which there is no attribute called flav. (You can test this by adding scope.flav = 'test'; above the call to attrs.$observe.)

Of course, you could fix this with an isolate scope definition like

scope: { flav: '@flavor' }

or by creating a non-isolate child scope

scope: true

or by not relying on a template with {{flavor}} and instead do some direct DOM manipulation like

attrs.$observe('flavor', function(flavor) {
  element.text(flavor);
});

but that defeats the purpose of the exercise (e.g. it'd be easier to just use the drinkShortcut method). So, to make this directive work, we'll break out the $interpolate service to do the interpolation ourself on the directive's $parent scope:

app.directive("drinkLonghand", function($interpolate) {
  return {
    scope: {},
    template: '<div>{{flavor}}</div>',
    link: function(scope, element, attrs) {
      // element.attr('flavor') == '{{flav}}'
      // `flav` is defined on `scope.$parent` from the ng-repeat
      var fn = $interpolate(element.attr('flavor'));
      scope.flavor = fn(scope.$parent);
    }
  };
});

Of course, this only works for the initial value of scope.$parent.flav; if the value is able to change, you'd have to use $watch and reevaluate the result of the interpolate function fn (I'm not positive off the top of my head how you'd know what to $watch; you might just have to pass in a function). scope: { flavor: '@' } is a nice shortcut to avoid having to manage all this complexity.

[Update]

To answer the question from the comments:

> How is the shortcut method solving this problem behind the scenes? Is it using the $interpolate service as you did, or is it doing something else?

I wasn't sure about this, so I looked in the source. I found the following in compile.js:

forEach(newIsolateScopeDirective.scope, function(definiton, scopeName) {
   var match = definiton.match(LOCAL_REGEXP) || [],
       attrName = match[2]|| scopeName,
       mode = match[1], // @, =, or &
       lastValue,
       parentGet, parentSet;

   switch (mode) {

     case '@': {
       attrs.$observe(attrName, function(value) {
         scope[scopeName] = value;
       });
       attrs.$$observers[attrName].$$scope = parentScope;
       break;
     }

So it seems that attrs.$observe can be told internally to use a different scope than the current one to base the attribute observation on (the next to last line, above the break). While it may be tempting to use this yourself, keep in mind that anything with the double-dollar $$ prefix should be considered private to Angular's private API, and is subject to change without warning (not to mention you get this for free anyway when using the @ mode).

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionJonahView Question on Stackoverflow
Solution 1 - JavascriptMichelle TilleyView Answer on Stackoverflow