Recently I experienced a weird behavior of an old AngularJS directive which simply added a wrapper DOM using jQuery wrap function together with ngRepeat. Of course there are many better ways to provide the same functionality without use of jQuery, but this is not the focus of this blog. I want to show the reason behind why jQuery is not recommended to be used with AngularJS for DOM manipulation. The demo is set up on JSFiddle here. The directive just calls ‘elem.wrap(‘<div class=”wrapper”></div>’)’ to wrap the DOM, which will make the text black; otherwise it will be in red. Then an element using this directive is rendered with ngRepeat on an object to show two texts. A button “Reset allTexts” is used to clone this object and reassign itself. Sounds easy enough and there shouldn’t be any changes after resetting the object? Try it. As you can see, the DOM structure is changed and the texts are now not inside the wrapper DOMs. What happened?
To find the reason, I tried to reduce the scope by rewriting it using transclude and replace instead of jQuery. Clearly it worked well without changing the DOM tree, so it’s not an issue of my logic. Then I tried to replace the object with an array (the commented part at the top). It didn’t change the tree. It seemed like something wrong with using object for ngRepeat, so I dived into AngularJS source code to find the actual cause.
The version was 1.6.10 and the source code is here on GitHub. When ngRepeat is used, a block which consists of object information (scope, element, id) is created for each single item.
The clone stores the element itself and the end ngRepeat comment (<!– end ngRepeat: (key, textObj) in allTexts –>). After all the rendering, this block information is stored in lastBlockMap, which will be reused for future for object input if the same instance is detected again. I guess this is for optimizing the performance in not creating the block information every time. The object input is then being watched using $watchCollection and re-render every time when the input is changed. However, it is a shallow watch which only detects the changes of reference.
After clicking the “Reset allTexts” button, the reference is changed and triggers the function in the watch. It retrieves previously defined block objects with the same key and reuses it in line 618. Then it comes to the root cause of the issue. In line 629, it checks if the start of the block, which is the first element from the clone in object information, equals to nextNode, which is supposed to be the node right after the beginning comment(<!– ngRepeat: (key, textObj) in allTexts –>). However, when the element is wrapped with jQuery, nextNode becomes the DOM div element with the wrapper class. It thinks that the existing item is moved, so it tries to move it back to the original position in line 631. Under the hood, $animate.move uses jQuery after function to moves the element. It calls the after function in line 326 in animate.js. The element that is placed after is the beginning comment. According to jQuery documentation, “[i]f an element selected this way is inserted into a single location elsewhere in the DOM, it will be moved rather than cloned”. Thus, the span element is moved away from the wrapper div to after the beginning comment, which causes the change in tree structure and so the color. Why does an array work in this case then? Because for array it removes the scope of each block every time when the array is new, so it will not pass through the check of block.scope.
Using jQuery to manipulate DOM structure should be avoided as much as possible when using AngularJS, same for other popular front-end frameworks. Out of curiosity, I also make an almost identical case for VueJS to see if similar issue happens (written in the same JSFiddle demo). It also uses a directive to wrap the element, but it does not trigger the rendering when the input is changed. Why? This can be the next investigation.