Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

Directive fails to insert/replace html when template root element is table row (tr) #1459

Closed
phaas opened this issue Oct 14, 2012 · 69 comments
Closed
Milestone

Comments

@phaas
Copy link

phaas commented Oct 14, 2012

In an attempt to automate some common table layouts I created a directive that emits the <tr> ...</tr>, including the table cells.

When this directive is applied, angular throws an exception indicating that the template does not have a single root element (it does).

When replace is set to false, this issue does not occur -- but the resulting markup will be invalid.

I suspect that the problem is partially due to the browser "cleaning up" the table HTML and moving unrelated or unknown tags outside of the table -- while preventing table tags from being inserted/replaced at the desired location.

Example

Url: http://jsfiddle.net/phaas/sL9gM/

<div ng-app="tables">
    <table>
        <row key="x" value="y"></row>
        <row key="1" value="2"></row>
        <row key="longer" value="very long!"></row>
    </table>
</div>
angular.module('tables',[])
    .directive('row', function() {
        return {
            restrict: 'E',
            replace: true,
            scope: { key: '@', value: '@' },
            template: '<tr><td>{{key}}</td><td>{{value}}</td></tr>'            
        }
    });

Stack Trace

Error: Template must have exactly one root element. was: <tr><td>{{key}}</td><td>{{value}}</td></tr>
    at applyDirectivesToNode (http://ajax.googleapis.com/ajax/libs/angularjs/1.0.2/angular.js:4010:21)
    at compileNodes (http://ajax.googleapis.com/ajax/libs/angularjs/1.0.2/angular.js:3790:14)
    at compileNodes (http://ajax.googleapis.com/ajax/libs/angularjs/1.0.2/angular.js:3795:14)
    at compile (http://ajax.googleapis.com/ajax/libs/angularjs/1.0.2/angular.js:3735:29)
    at http://ajax.googleapis.com/ajax/libs/angularjs/1.0.2/angular.js:932:9
    at Object.Scope.$eval (http://ajax.googleapis.com/ajax/libs/angularjs/1.0.2/angular.js:7808:28)
    at Object.Scope.$apply (http://ajax.googleapis.com/ajax/libs/angularjs/1.0.2/angular.js:7888:23)
    at http://ajax.googleapis.com/ajax/libs/angularjs/1.0.2/angular.js:930:13
    at Object.invoke (http://ajax.googleapis.com/ajax/libs/angularjs/1.0.2/angular.js:2788:25)
    at bootstrap (http://ajax.googleapis.com/ajax/libs/angularjs/1.0.2/angular.js:928:12) 
@phaas
Copy link
Author

phaas commented Oct 14, 2012

Workaround: Instead of replacing a non-standard element with a tr defined in the template, the directive can be applied as an attribute on an existing tr.
This avoids the exception but isn't quite as nice to read.

<div ng-app="tables">
    <table>
        <tr row key="x" value="y"></tr>
        <tr row key="1" value="2"></tr>
        <tr row key="longer" value="very long!"></tr>
    </table>
</div>
angular.module('tables',[])
    .directive('row', function() {
        return {
            restrict: 'A',
            replace: false,
            scope: { key: '@', value: '@' },
            template: '<td>{{key}}</td><td>{{value}}</td>'            
        }
    });

Edit: Further testing shows that this still breaks ng-transclude:

<div ng-app="tables">
    <table>
        <tr row key="Label 1"><input type="text"></input></tr>
        <tr row key="Label 2">Text</tr>
    </table>
</div>
angular.module('tables',[])
    .directive('row', function() {
        return {
            transclude: true,
            scope: { key: '@' },
            template: '<td>{{key}}</td><td> <span ng-transclude></span>{{value}}</td>'            
        }
    });

It appears that the browser is again moving the element content outside of the element before angular has a chance to apply the template. I'm not sure that it's even possible for angular to make this work.

Avoiding table/tr/td entirely and instead using a css-based approach may be the only feasible option here.

@phaas
Copy link
Author

phaas commented Oct 15, 2012

I'll write this one off to the browser modifying the markup (moving nodes out of the table) before it ever gets to angular.

I rewrote the page & directive using divs with display: table.. styling applied and it works fine.

@marco-m-alves
Copy link

I have the same problem trying to transclude td into a tr.

Please see http://jsbin.com/otogih/9.

@marco-m-alves
Copy link

And how about using this solution to ensure transclusion of content which otherwise would be removed by the browser --- if that's the case:

< directive-that-transcludes-content >
< script id="transclude-1" type="text/ng-transclude">
< !-- insert content here -- >
< /script >
< /directive-that-transcludes-content >

Angular would have to try to detect if it is transcluding a script element and then extract the html template from the script and insert it in each element.

Note: I think the id attribute in the script tag is not required

@marco-m-alves
Copy link

Working solution here: http://jsbin.com/otogih/18/edit

It uses a scriptTransclude directive.

@vojtajina
Copy link
Contributor

Re-opening, as I think this is still an issue.

Here's very simple example of the problem http://jsfiddle.net/A2FPR/2/

@vojtajina vojtajina reopened this Feb 14, 2013
@marco-m-alves
Copy link

Here is a simpler example comparing the scriptTransclude directive and the regular ng-transclude.

It is not directly applicable to the example Vojta has shared but has similarities.

http://jsbin.com/uhocep/2/edit

@rwlogel
Copy link

rwlogel commented Feb 27, 2013

I have also run into this problem trying to replace a element with in a directive with the following template:

    ...
    replace:true,
    transclude:true,
    template: '<td ng-transclude></td>

I have a potential fix for the problem. In the file "src/ng/compile.js" line 660 it does this:

    if (directive.replace) {
            $template = jqLite('<div>' +
                                 trim(directiveValue) +
                               '</div>').contents();
            compileNode = $template[0];
            if ($template.length != 1 || compileNode.nodeType !== 1) {
              throw new Error(MULTI_ROOT_TEMPLATE_ERROR + directiveValue);
            }

That is the exception that is thrown, the jqLite calls is failing because it is wrapping the td/tr with in a div the jqLite node creator thinks it's bad HTML. This is the fix I made to make work:

    if (directive.replace) {
            var trimDirective = trim(directiveValue);
            if (trimDirective.substring(0, 3) == '<tr') {
                trimDirective = '<tbody>' + trimDirective + '</tbody>';
            } else if (trimDirective.substring(0, 3) == '<td') {
                trimDirective = '<tr>' + trimDirective + '</tr>';
            } else {
                trimDirective = '<div>' + trimDirective + '</div>';
            }
            $template = jqLite(trimDirective).contents();
            compileNode = $template[0];

@stevedomin
Copy link

Is this planned to be fixed at some point ?
I'm happy to look into it if no one's working on it.

@arturgspb
Copy link

vote +1

@davidells
Copy link

+1

The work around I've gone with for this problem is naive, but workable for my situation: A linking function to swap out the tag name...

directive("wsReplaceTag", function() {
  return function(scope, element, attrs) {
      var newTag = attrs.wsReplaceTag;
      var nodeAttributes = {};

      $.each(element[0].attributes, function(idx, attr) {
          nodeAttributes[attr.nodeName] = attr.nodeValue;
      });

      element.replaceWith(function () {
          return $("<" + newTag + "/>", nodeAttributes).append(element.contents());
      });
  };
});

And then some monkey business in the template that looks like this (ws-lister is creating a table that ends up with a <tr ng-transclude> element)...

<div ws-lister>

  <div ws-replace-tag="td">
    Name: {{item.name}}
  </div>

  <div ws-replace-tag="td">
    Email: {{item.email}}
  </div>

</div>

@rweng
Copy link

rweng commented Aug 5, 2013

+1

@pauljoey
Copy link

I have encountered the same issue in 1.15.

As suggested, I had to replace the "replace: true" approach by using an attribute tag instead. This worked.

<tr my-row></tr>

@EverybodyKurts
Copy link

Yep, running into this problem right now. I've tried both writing the directive as an element as well as an attribute.

@pelme
Copy link
Contributor

pelme commented Aug 17, 2013

I just managed to fix this for <tr> tags, special cases will be needed for at least thead, tbody, tfoot, th, td too, since none of those can be wrapped in a div.

I will continue work on this tomorrow and hopefully have a working pull request ready that covers all those tags.

@bcronje
Copy link

bcronje commented Oct 15, 2013

Any idea if/when pelme's PR will be merged?

@marcorinck
Copy link

Stumbled about this today too, d'oh! Took me a while to understand that it wasn't my fault but instead a limitation of angular.

@Holokai
Copy link

Holokai commented Nov 8, 2013

Looking forward to an update on this...

@ooflorent
Copy link

+1

@EverybodyKurts
Copy link

I got around this by doing <div><tr>...</tr></div>. Ugly ugly ugly but it works.

@prosellen
Copy link

+1

@calebegg
Copy link
Member

Just a note that this also seems to affect templates whose root element is <body>

@ghost
Copy link

ghost commented Nov 20, 2013

So to put it plainly, at present it is impossible to write directives using HTML tables without resorting to "hacks"?

@stevedomin
Copy link

Yes, that's correct

khepin pushed a commit to khepin/angular.js that referenced this issue Feb 19, 2014
If the first element in a template is a <tr>, <th>, <td>, or <tbody> tag,
the HTML compiler will ensure that the template is wrapped in a <table>
element so that the table content is not discarded.

Closes angular#2848
Closes angular#1459
Closes angular#3647
Closes angular#3241
@anagrius
Copy link

+1

@timjacobi
Copy link

I'm still seeing this issue using 1.3. Am I doing anything wrong?
http://plnkr.co/edit/7OF9zJgaTXTTlvPw1lPr?p=preview

@caitp
Copy link
Contributor

caitp commented Oct 24, 2014

@timjacobi -- the issue is that the browser's HTML parser will take non-table-content nodes out of your table before angular ever sees them, so we aren't able to put them back for you. To work around this, you can use attribute directives.

@timjacobi
Copy link

Cheers. I'm trying to only use tag names like in Polymer. Surrounding the <table> with a <div> seems to do the trick though. Not optimal but a good workaround.
template: '<div><table><post-item ng-repeat="post in posts" post="post"></post-item></table></div>'

@caitp
Copy link
Contributor

caitp commented Oct 24, 2014

I don't think polymer will do any better in this case, the HTML parser just pukes on non-table content within tables unless is="tr" (for example) is used --- but only super-modern browsers support those at all

@pavlovt
Copy link

pavlovt commented Oct 30, 2014

Here is my solution to this problem:
http://jsfiddle.net/pavlovt/jbwgjkgx/

@caitp
Copy link
Contributor

caitp commented Oct 30, 2014

so... you're adding transcludes and never using them? :P anyways, it works in that case because the tbl isn't actually a table element when the html is originally parsed, so it doesn't get re-arranged.

@caitp
Copy link
Contributor

caitp commented Oct 30, 2014

oh hang on, I missed the ng-transclude, so I guess you are using them --- let me tell you, this is going to create zillions of scopes that you probably don't want, so be careful when doing that

@caitp
Copy link
Contributor

caitp commented Oct 30, 2014

not to mention all of the known unfixable bugs with replace directives

@pavlovt
Copy link

pavlovt commented Oct 31, 2014

Thanks, I will look for better solution.

@caitp
Copy link
Contributor

caitp commented Oct 31, 2014

I mean, it works fine, you just have to be careful with it =)

@chazzlabs
Copy link

@caitp

I've been struggling with this issue today, so I found and modified an existing plunkr (http://plnkr.co/edit/n2aUcuz2yCoPe2RUE5Gx?p=preview) to give me a simple example of using attribute directives to get around this, but I'm still seeing the table row contents being replaced by a span.

Did I misunderstand or incorrectly implement the workaround you suggested?

@caitp
Copy link
Contributor

caitp commented Dec 16, 2014

You're misunderstanding.

So, here's the deal. The HTML parser (in the browser) has this really stupid "adoption agency algorithm", which will reparent elements during parsing when it decides they don't belong. So what happens is, if you have a <table><tr><custom-td></custom-td></tr></table>, the <custom-td> element gets moved outside of the table during parsing --- this is HTML stupidity, but unfortunately it's not going anywhere.

So, to work around this, you have two options.

  1. Don't use custom-td elements, but just use <td custom-component> --- this will survive HTML parsing, and can be replaced with whatever you want.
  2. Don't make a <table> at all --- If you insert the table dynamically and transclude your child nodes into it, you don't need to worry about the adoption agency algorithm (provided that the element names you use don't result in the reparenting behaviour). This is what you're doing in the plunker you showed --- now in this case, it doesn't really make a difference if the "custom table" directive is an element or attribute directive, it will work in both cases because child nodes won't get reparented

@gitnik
Copy link

gitnik commented Feb 5, 2015

Hey, I am also running into this problem (I think?). I am using the ng-multi-transclude plugin, which basically let's me have multiple transcludes instead of just one. When using the plugin, the content inside the table is not transcluded at all, unless I change that <td> to a <div>, which is not what I want. Omitting the plugin and using just one ngTransclude (the one inside the table), also didn't work for me, because angular will always replace the TD with a SPAN.
http://plnkr.co/edit/bN3FYKrQi4YMhyt8ocft?p=preview

@konrad-garus
Copy link

It seems to still be broken with 1.4.7. Check out this plunkr: http://plnkr.co/edit/vgDJadfnwZo7OgCSEVuj?p=preview

This code:

  <div>
  <table>
      <tr><my-cell /><my-cell /></tr>
      <tr><my-cell /><my-cell /></tr>
  </table>
  </div>
  <script>
    angular
      .module('oops', [])
      .directive('myCell', function() {
        return {
          template: '<td>Hello</td>'
        }
      });
  </script>

... leads to the following DOM:

<div>
    <my-cell>Hello</my-cell><my-cell>Hello</my-cell>
    <table>
        <tbody><tr></tr>
        <tr></tr>
    </tbody></table>
</div>

Note how the cells land outside table, and there's only one of them for each table row (template had 4, DOM has 2).

@caitp
Copy link
Contributor

caitp commented Oct 23, 2015

As has been pointed out in this thread several times now, HTML does not work the way you're wanting it to here, and there is no way for Angular to make this work for you.

When the browser parses <table><tr><my-cell></tr></table>, <my-cell> is evicted via the adoption agency algorithm (my-cell gets reparented), and this happens before Angular ever gets to process the template. This happens because <table>, <tr> and friends are only allowed to have a limited number of tags as child nodes (a tag named my-cell is not in the list of allowed tags)

For technical details on this, see https://html.spec.whatwg.org/#parsing-main-intable

Sorry, but this is not feasible :( You can work around this by defining your own custom <table> directive (eg <my-table>, (and friends), or by using other ways of invoking the directive (such as attributes rather than tag names). Or basically anything you can come up with that will prevent the browser from reparenting the node during parsing

@hgoebl
Copy link

hgoebl commented Jan 19, 2016

@caitp many thanks for this information - saved me a lot of time finding a workaround.

@otterslide
Copy link

For anyone still looking, I managed to replace the TR itself with no problem.

Template:
    <script type="text/ng-template" id="comment.html" class="collapse">
            <td>{{commentData.content}}</td><td>test</td>
        </script>

HTML:
<tr comment-item  ng-repeat="comment in comments"
data-index="{{$index}}" data-comment-data="comment">
</tr> 


Directive:
app.directive('commentItem', function($timeout, $http, $document) {
    return {
        restrict: 'A',
        scope: {
            index: '@'
        }, templateUrl: 'comment.html',
        link: function postLink(scope) {

@abacaj
Copy link

abacaj commented Sep 14, 2016

Why shouldn't this work the way "we expect HTML to work"?

This is why Angular sucks. Long live React.

@caitp
Copy link
Contributor

caitp commented Sep 14, 2016

That is pretty inappropriate, @abacaj. Please refer to code-of-conduct

@abacaj
Copy link

abacaj commented Sep 14, 2016

Don't think it's inappropriate. What's inappropriate is having to build out a workaround because of Angular not because of the browser.

Clearly - this is something Angular failed to resolve.

@sebfie
Copy link

sebfie commented Sep 14, 2016 via email

@otterslide
Copy link

otterslide commented Sep 14, 2016

@abacaj I have provided the solution and investigated why it happens. There's nothing angular can do to make it work, it's the browser that expects TR within TABLE opening and closing elements that is the issue. If it finds anything like myRow, which is a custom Angular element, it will just discard it. So, this is not why Angular sucks, sorry. Read @caitp 's post, it explains exactly what is going on.

Clearly - this is something Angular failed to resolve.

There is no way to make this work, due to way the the browser behaves. It's not possible to resolve this.

@abacaj
Copy link

abacaj commented Sep 14, 2016

@otterslide I found it cumbersome that we couldn't use the 'E' restriction. But I suppose it works.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet