Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

ng-transclude inside ng-repeat is losing access to $transclude function

I have a list-like container directive that transcludes content into an ng-repeat.

The template looks like this:

<div ng-repeat='item in items'>
    <div ng-transclude></div>
</div>

and a usage looks like this

<my-container>
    foo
</my-container>

This works as expected. As soon as I have any directives above the ng-repeat in my template, however, such as

<div ng-style='{}'>
    <div ng-repeat='item in items'>
        <div ng-transclude></div>
    </div>
</div>

my code throws

"Illegal use of ngTransclude directive in the template! No parent directive that requires a transclusion found. Element: <div ng-transclude="">"

Taking a first peek into the Angular code, it looks Angular isn't setting up the $transclude injection correctly into the ngTransclude controller. I'll start digging through Angular code to try and figure out why this is, but if anyone already knows what's going on and/or how to resolve or workaround it, I'd be awfully grateful.

Here is a fully-functional fiddle for the positive case: http://jsfiddle.net/7BuNj/1/

Here is a fully-nonfunctional fiddle for the negative case: http://jsfiddle.net/8BLYG/

like image 682
bryant.robby Avatar asked Mar 04 '14 02:03

bryant.robby


1 Answers

tl;dr tl;dr tl;dr

It looks like the Angular recursive compile cycle doesn't thread transclusion functions down through directives with linking functions. My ngStyle directive had a linking function, so the transclusion function was lost by the time ngRepeat compiled its template. It's not clear if this is intended behavior or a bug; I'll follow-up with the Angular team later. For now, I've temporarily monkey-patched my copy of Angular v.1.2.0 to replace line 5593-5594 with

: compileNodes(childNodes,
  (nodeLinkFn && nodeLinkFn.transclude) ? nodeLinkFn.transclude : transcludeFn);

and everything is working fine.

The Research

All right. For this to make any sense for non-Angular experts (e.g., me :), I'm going to have get a little down-and-dirty with the code for Angular's compile/link cycle. I've provided a super-stripped down version of the relevant bits from v1.2.0 here for reference (sorry Angular team, I broke what I imagine are fairly religious style guidelines to try and make the code as short a snippet as possible ;):

function compileNodes(nodeList, transcludeFn, $rootElement, maxPriority, ignoreDirective, previousCompileContext) {
  ...
  for (var i = 0; i < nodeList.length; i++) {
    ...
    nodeLinkFn = (directives.length)
                 ? applyDirectivesToNode(directives, nodeList[i], attrs, transcludeFn, $rootElement, null, [], [], previousCompileContext)
                 : null;
    ...
    childLinkFn = (nodeLinkFn && nodeLinkFn.terminal || !(childNodes = nodeList[i].childNodes) || !childNodes.length)
                  ? null
                  : compileNodes(childNodes, nodeLinkFn ? nodeLinkFn.transclude : transcludeFn);
    ...
    linkFns.push(nodeLinkFn, childLinkFn);
    ...
  }
  return linkFnFound ? compositeLinkFn : null;

  function compositeLinkFn(scope, nodeList, $rootElement, boundTranscludeFn) {
    ...
    for(i = 0, n = 0, ii = linkFns.length; i < ii; n++) {
      ...
      nodeLinkFn = linkFns[i++];
      childLinkFn = linkFns[i++];
      if (nodeLinkFn) {
        ...
        childTranscludeFn = nodeLinkFn.transclude;
        if (childTranscludeFn || (!boundTranscludeFn && transcludeFn)) {
          nodeLinkFn(childLinkFn, childScope, node, $rootElement, createBoundTranscludeFn(scope, childTranscludeFn || transcludeFn));
        } else {
          nodeLinkFn(childLinkFn, childScope, node, $rootElement, boundTranscludeFn);
        }
      } else if (childLinkFn) {
        childLinkFn(scope, node.childNodes, undefined, boundTranscludeFn);
      }
    }
  }
}

function applyDirectivesToNode(directives, compileNode, templateAttrs, transcludeFn, jqCollection, originalReplaceDirective, preLinkFns, postLinkFns, previousCompileContext) {
  ...
  if (directiveValue = directive.transclude) {
    hasTranscludeDirective = true;
    if (directiveValue == 'element') {
      ...
      $template = groupScan(compileNode, attrStart, attrEnd);
      ...
      childTranscludeFn = compile($template, transcludeFn, terminalPriority, replaceDirective && replaceDirective.name, { ... });
    } else {
      $template = jqLite(jqLiteClone(compileNode)).contents();
      $compileNode.empty(); // clear contents
      childTranscludeFn = compile($template, transcludeFn);
    }
  }
  ...
  // setup preLinkFns and postLinkFns
  ...
  nodeLinkFn.transclude = hasTranscludeDirective && childTranscludeFn;
  return nodeLinkFn;

  function nodeLinkFn(childLinkFn, scope, linkNode, $rootElement, boundTranscludeFn) {
    ...
    forEach(controllerDirectives, function(directive) {
      ...
      transcludeFn = boundTranscludeFn && controllersBoundTransclude;
      // puts transcludeFn into controller locals for $transclude access
      ...
    }
    ...
    // PRELINKING: call all preLinkFns with boundTranscludeFn
    ...
    // RECURSION
    ...
    childLinkFn && childLinkFn(scopeToChild, linkNode.childNodes, undefined, boundTranscludeFn);
    ...
    // POSTLINKING: call all preLinkFns with boundTranscludeFn
    ... 
  }
}

Compile/Link Without Transclusion

Essentially the compile cycle does a depth-first search of the DOM via compileNodes. At each node,

  1. it compiles any directives on that node via applyDirectivesToNode into a nodeLinkFn that has access to any configured prelink or postlink functions and that accepts a child compositeLinkFn for recursion
  2. recursively compiles the node's subtree into a compositeLinkFn
  3. packages the nodeLinkFn up with the subtree compositeLinkFn into a node-level compositeLinkFn (skipping nodeLinkFn if empty)

Once compilation is complete, you have a top-level compositeLinkFn whose execution recurses depth-first exactly parallel to the compile process, and that at each node executes all prelink functions, recurses, and then executes all postlink functions.

Compile/Link With 1 Level Of Transclusion

Whenever applyDirectivesToNode hits a directive with the transclude flag set, it fully compiles the directive's element's content into a "transclude linking function" separate from the current compile recursion, and annotates the directive's nodeLinkFn with it. When the enclosing compositeLinkFn is eventually executed, this annotation gets read and passed into the nodeLinkFn as boundTranscludeFn, where it will eventually be setup for controller $transclude injection, passed to all prelink and postlink functions, and passed into the recursive call of the child linking function. This recursive threading of boundTranscludeFn is key so that you can access $transclude anywhere inside a transcluding directive's template.

Compile/Link With Multiple Levels Of Transclusion

Now what if we have transcluding directive B inside transcluding directive A? What we want to happen (I assume) is have directive A's transclude function somehow made available to directive B's transclude content. After all, B's transclude content should for all intents and purposes live inside A's template. The problem is that B's transclude content is compiled separately from A's compile recursion into its own tranclude linking function, which won't be a part of A's linking recursion and therefore won't receive A's translude function at link time.

Angular resolves this by:

  1. additionally threading transclude functions through the compileNodes recursion (not just the link recursion)
  2. passing transcludeFn directly into the transclude content compile cycle at compile-time
  3. allowing compositeLinkFn to pull a transclude function off its enclosing compileNodes call

Conclusion

The problem I was hitting in my original question was in this threading of transclusion functions through the compile recursion. The linking recursion always correctly passes the transclusion function down to child linking functions, but the compile recursion only passes the transclusion function down if the the current node's directive doesn't have a linking function, regardless of whether that directive transcludes or not:

compileNodes(childNodes, nodeLinkFn ? nodeLinkFn.transclude : transcludeFn);

The explosion from my question was due to the fact that ngStyle creates a linking function but doesn't transclude. So all of a sudden, the transclude function stopped being threaded down the compile cycle where it could be consumed when ngRepeat compiles its child content. The "fix" is to change the offending line to:

compileNodes(childNodes, (nodeLinkFn && nodeLinkFn.transclude) ? nodeLinkFn.transclude : transcludeFn);

Basically, now it only stops threading the transclude function if it is being replaced by a new, more deeply nested transclude function (which is consistent with the Angular docs saying that content should be transcluded to the nearest transcluding parent directive). Again, I'm not sure if all of this is intended behavior or a bug, but hopefully it's a useful look at transclusion regardless? :)

like image 143
bryant.robby Avatar answered Sep 23 '22 05:09

bryant.robby