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

fix(jqLite): don't attach event handler to comments (memory leak) #7942

Closed
wants to merge 4 commits into from

Conversation

petebacondarwin
Copy link
Contributor

No description provided.

@petebacondarwin petebacondarwin added this to the 1.3.0-beta.14 milestone Jun 23, 2014
petebacondarwin referenced this pull request Jun 23, 2014
This is what jQuery does by default: https://github.com/jquery/jquery/blob/c18c6229c84cd2f0c9fe9f6fc3749e2c93608cc7/src/data/accepts.js#L16

We don't need to set data on text/comment nodes internally and if we don't
allow setting data on these nodes, we don't need to worry about cleaning
it up.

BREAKING CHANGE: previously it was possible to set jqLite data on Text/Comment
nodes, but now that is allowed only on Element and Document nodes just like in
jQuery. We don't expect that app code actually depends on this accidental feature.
@jbedard
Copy link
Contributor

jbedard commented Jun 23, 2014

@petebacondarwin I don't think this is a complete solution. jqLite still leaks, just not with events anymore (see my test snippet in a196c8b comments). But the other thing is that https://github.com/angular/angular.js/blob/master/src/ng/compile.js#L1005 is still adding a $destroy event to comment nodes which will now do nothing, isn't that still a problem? When running the tests there's about 15 cases where that line adds a $destroy event only to comment nodes. I think it is always nested-transclusion cases and it always adds the $destory event to 2 comment nodes. Yet the tests still pass and I haven't been able to write a failing one (that's what I was trying to do with the ng-if="1<2" but I clearly did that wrong). Is there a reason the $destroy event isn't needed in those nested-transclusion cases?

@petebacondarwin
Copy link
Contributor Author

@jbedard - I agree that there is still potential for a memory leak (as you show in your snippet) but that that particular memory leak does not occur in this case. With this PR in place the line that you are talking about no longer attaches anything to the comment node - jqLite and jQuery will do nothing if you try to attach a handler to a comment.

@petebacondarwin
Copy link
Contributor Author

Just to be clear - with this fix in place we are no longer adding an event handler (even one that does nothing) the on() method just drops out without adding anything if the subject is a comment or text node.

@petebacondarwin
Copy link
Contributor Author

The reason that you are not able to make a failing test is that we attach a valid handler to the non-comment nodes that constitute the clone, and that when these elements are removed, the scope is correctly destroyed.

@jbedard
Copy link
Contributor

jbedard commented Jun 23, 2014

Where does the handler get attached to the non-comment nodes in the case where the boundTranscluseFn clone is only comment nodes? Or is it not necessary in that case?

@petebacondarwin
Copy link
Contributor Author

As far as I can see at this point they are never only comment nodes. Can you find a situation where they are?

@jbedard
Copy link
Contributor

jbedard commented Jun 23, 2014

If you just add a snippet in boundTranscludeFn you can see about ~15 cases when you run the tests where there are only comment nodes.

Something such as... (I haven't run this exact code, but I did something similar this morning, and I'm typing this straight into this comment so no guarantee it runs :)

if (clone.length && [].every.call(clone, function(n) { return n.nodeType !== 1 && n.nodeType !== 9; })) {
  console.log( [].map.call(clone, function(n) { return n.textContent; }).join("\n") );
}

@petebacondarwin
Copy link
Contributor Author

Right. I see. Let's take a look at that...

@petebacondarwin
Copy link
Contributor Author

It appears that the only time when you get this situation of the clone only consisting of comments is when you have more than one "element" transclusion directive on the same element.

So I have these two tests that cover this:

    it('should not leak if two transclusions are on the same element', function() {
      var calcCacheSize = function() {
        var size = 0;
        forEach(jqLite.cache, function(item, key) { size++; });
        return size;
      };

      inject(function($compile, $rootScope) {
        expect(calcCacheSize()).toEqual(0);
        $compile('<div><div ng-repeat="x in xs" ng-if="x==1">{{x}}</div></div>')($rootScope);
        expect(calcCacheSize()).toEqual(1);
        $rootScope.$apply('xs = [0,1]');
        expect(calcCacheSize()).toEqual(2);
        $rootScope.$apply('xs = [0]');
        expect(calcCacheSize()).toEqual(1);
        $rootScope.$apply('xs = []');
        expect(calcCacheSize()).toEqual(1);
      });
    });


    it('should not leak if two transclusions are on the same element', function() {
      var calcCacheSize = function() {
        var size = 0;
        forEach(jqLite.cache, function(item, key) { size++; });
        return size;
      };
      inject(function($compile, $rootScope) {
        expect(calcCacheSize()).toEqual(0);
        element = $compile('<div><div ng-repeat="x in xs" ng-if="val">{{x}}</div></div>')($rootScope);

        $rootScope.$apply('xs = [0,1]');
        expect(calcCacheSize()).toEqual(1);

        $rootScope.$apply('val = true');
        expect(calcCacheSize()).toEqual(3);

        $rootScope.$apply('val = false');
        expect(calcCacheSize()).toEqual(1);
      });
    });

They don't seem to leak onto the cache once we stop attaching event handlers to comments.

@jbedard
Copy link
Contributor

jbedard commented Jun 23, 2014

I see, so those are all cases with more then one element transclusion on the same element, where I was assuming it was nested transclusions. We know it won't leak listeners onto comment nodes now, but I was wondering if there is an issue now that there are no element $destroy listeners on those transcluded elements. So if someone manually removes the elements the scope won't be destroyed? That's what I was trying to reproduce in the 'should remove transclusion scope, when the DOM is destroyed' test...

@petebacondarwin
Copy link
Contributor Author

As far as I can see, all top level elements in a clone will have a $destroy handler attached. Any top level comments in the clone are now ignored.

But comments don't have scope attached, while elements do. So you can remove comments with no issues and if you remove elements then the scope will be destroyed.

One should be aware though that if there are multiple elements in a clone then removing any one of them will destroy the scope, so they should all be removed at once. I'll put together another test that has something along the lines of:

<div ng-if="val0">
  <div ng-if="val1">...</div>
  <div ng-if="val2">...</div>
<div ng-if-end>

@@ -750,6 +750,12 @@ forEach({
on: function onFn(element, type, fn, unsupported){
if (isDefined(unsupported)) throw jqLiteMinErr('onargs', 'jqLite#on() does not support the `selector` or `eventData` parameters');


// Do not add event handlers to non-elements because they will not be cleaned up.
if ( element.nodeType && element.nodeType !== 1 && element.nodeType !== 9 ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we now have this check in at least two places. I think we should have a helper test fn instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we allow adding data to arbitrary objects so we probably don't need the jquery version. I'll refactor into a simple helper

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I take that back, the window object can handle events but has no nodeType property.

If an element contains two "element" transcludes then the initial clone
consists of only comment nodes.  The concern was that this meant that
the transclude scopes would not be cleaned up.

But it turns out that in the case that there are only comments then the
scope is never attached to anything so we don't need to worry about cleaning
it up.

Later if a concrete element is created as part of the transclude then these
elements will have destroy handlers.
We were attaching handlers to comment nodes when setting up bound transclusion
functions. But we don't clean up comments and text nodes when deallocating so
there was a memory leak.

Closes angular#7913
expect(calcCacheSize()).toEqual(1);

$rootScope.$apply('xs = [0,1]');
expect(calcCacheSize()).toEqual(2);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw wouldn't the global after each that checks the state of the cache catch the leaks?

These tests are much more readable, so keep things as they are. I'm just curious.

@IgorMinar
Copy link
Contributor

otherwise this looks good. thanks for all the tests

ckknight pushed a commit to ckknight/angular.js that referenced this pull request Jul 16, 2014
We were attaching handlers to comment nodes when setting up bound transclusion
functions. But we don't clean up comments and text nodes when deallocating so
there was a memory leak.

Closes angular#7913
Closes angular#7942
@petebacondarwin petebacondarwin deleted the pr7913 branch December 17, 2015 12:53
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants