diff --git a/src/jqLite.js b/src/jqLite.js index 3eee01f507d1..e81ecb755677 100644 --- a/src/jqLite.js +++ b/src/jqLite.js @@ -161,6 +161,12 @@ function jqLiteIsTextNode(html) { return !HTML_REGEXP.test(html); } +function jqLiteAcceptsData(node) { + // The window object can accept data but has no nodeType + // Otherwise we are only interested in elements (1) and documents (9) + return !node.nodeType || node.nodeType === 1 || node.nodeType === 9; +} + function jqLiteBuildFragment(html, context) { var elem, tmp, tag, wrap, fragment = context.createDocumentFragment(), @@ -319,7 +325,7 @@ function jqLiteData(element, key, value) { if (isSetter) { // set data only on Elements and Documents - if (element.nodeType === 1 || element.nodeType === 9) { + if (jqLiteAcceptsData(element)) { data[key] = value; } } else { @@ -750,6 +756,11 @@ 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 (!jqLiteAcceptsData(element)) { + return; + } + var events = jqLiteExpandoStore(element, 'events'), handle = jqLiteExpandoStore(element, 'handle'); diff --git a/test/jqLiteSpec.js b/test/jqLiteSpec.js index ffe02570f59d..da7200fce934 100644 --- a/test/jqLiteSpec.js +++ b/test/jqLiteSpec.js @@ -993,6 +993,16 @@ describe('jqLite', function() { expect(log).toEqual('click on: A;click on: B;'); }); + it('should not bind to comment or text nodes', function() { + var nodes = jqLite('Some text'); + var someEventHandler = jasmine.createSpy('someEventHandler'); + + nodes.on('someEvent', someEventHandler); + nodes.triggerHandler('someEvent'); + + expect(someEventHandler).not.toHaveBeenCalled(); + }); + it('should bind to all events separated by space', function() { var elm = jqLite(a), callback = jasmine.createSpy('callback'); diff --git a/test/ng/compileSpec.js b/test/ng/compileSpec.js index 5de1728a262b..b0de0f63a831 100755 --- a/test/ng/compileSpec.js +++ b/test/ng/compileSpec.js @@ -1,5 +1,13 @@ 'use strict'; + +function calcCacheSize() { + var size = 0; + for(var key in jqLite.cache) { size++; } + return size; +} + + describe('$compile', function() { var element, directive, $compile, $rootScope; @@ -158,12 +166,6 @@ describe('$compile', function() { }); it('should not leak memory when there are top level empty text nodes', function() { - var calcCacheSize = function() { - var size = 0; - forEach(jqLite.cache, function(item, key) { size++; }); - return size; - }; - // We compile the contents of element (i.e. not element itself) // Then delete these contents and check the cache has been reset to zero @@ -3928,6 +3930,66 @@ describe('$compile', function() { }); + + it('should not leak if two "element" 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('
{{x}}
')($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); + + element.remove(); + expect(calcCacheSize()).toEqual(0); + }); + }); + + + it('should not leak if two "element" 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('
{{x}}
')($rootScope); + + $rootScope.$apply('xs = [0,1]'); + // At this point we have a bunch of comment placeholders but no real transcluded elements + // So the cache only contains the root element's data + expect(calcCacheSize()).toEqual(1); + + $rootScope.$apply('val = true'); + // Now we have two concrete transcluded elements plus some comments so two more cache items + expect(calcCacheSize()).toEqual(3); + + $rootScope.$apply('val = false'); + // Once again we only have comments so no transcluded elements and the cache is back to just + // the root element + expect(calcCacheSize()).toEqual(1); + + element.remove(); + // Now we've even removed the root element along with its cache + expect(calcCacheSize()).toEqual(0); + }); + }); + + it('should remove transclusion scope, when the DOM is destroyed', function() { module(function() { directive('box', valueFn({ @@ -4409,6 +4471,35 @@ describe('$compile', function() { $rootScope.$digest(); expect(element.text()).toEqual('transcluded content'); })); + + + it('should not leak memory with nested transclusion', function() { + var calcCacheSize = function() { + var count = 0; + for (var k in jqLite.cache) { ++count; } + return count; + }; + + inject(function($compile, $rootScope) { + var size; + + expect(calcCacheSize()).toEqual(0); + + element = jqLite('
'); + $compile(element)($rootScope.$new()); + + $rootScope.nums = [0,1,2]; + $rootScope.$apply(); + size = calcCacheSize(); + + $rootScope.nums = [3,4,5]; + $rootScope.$apply(); + expect(calcCacheSize()).toEqual(size); + + element.remove(); + expect(calcCacheSize()).toEqual(0); + }); + }); }); @@ -5380,7 +5471,7 @@ describe('$compile', function() { })); - it('should group on nested groups of same directive', inject(function($compile, $rootScope) { + it('should group on nested groups', inject(function($compile, $rootScope) { $rootScope.show = false; element = $compile( '
' + @@ -5395,7 +5486,7 @@ describe('$compile', function() { })); - it('should group on nested groups', inject(function($compile, $rootScope) { + it('should group on nested groups of same directive', inject(function($compile, $rootScope) { $rootScope.show = false; element = $compile( '
' + @@ -5410,6 +5501,176 @@ describe('$compile', function() { })); + it('should set up and destroy the transclusion scopes correctly', + inject(function($compile, $rootScope) { + element = $compile( + '
' + + '
' + + '
' + + '
' + )($rootScope); + $rootScope.$apply('val0 = true; val1 = true; val2 = true'); + + // At this point we should have something like: + // + //
+ // + // + // + //
+ // + // + // + //
+ // + //
+ // + // + // + //
+ // + // + //
+ var ngIfStartScope = element.find('div').eq(0).scope(); + var ngIfEndScope = element.find('div').eq(1).scope(); + + expect(ngIfStartScope.$id).toEqual(ngIfEndScope.$id); + + var ngIf1Scope = element.find('span').eq(0).scope(); + var ngIf2Scope = element.find('span').eq(1).scope(); + + expect(ngIf1Scope.$id).not.toEqual(ngIf2Scope.$id); + expect(ngIf1Scope.$parent.$id).toEqual(ngIf2Scope.$parent.$id); + + $rootScope.$apply('val1 = false'); + + // Now we should have something like: + // + //
+ // + //
+ // + //
+ //
+ // + // + // + //
+ // + //
+ + expect(ngIfStartScope.$$destroyed).not.toEqual(true); + expect(ngIf1Scope.$$destroyed).toEqual(true); + expect(ngIf2Scope.$$destroyed).not.toEqual(true); + + $rootScope.$apply('val0 = false'); + + // Now we should have something like: + // + //
+ // + //
+ + expect(ngIfStartScope.$$destroyed).toEqual(true); + expect(ngIf1Scope.$$destroyed).toEqual(true); + expect(ngIf2Scope.$$destroyed).toEqual(true); + })); + + + it('should set up and destroy the transclusion scopes correctly', + inject(function($compile, $rootScope) { + element = $compile( + '
' + + '
' + + '
' + + '
' + )($rootScope); + + // To begin with there is (almost) nothing: + //
+ // + //
+ + expect(element.scope().$id).toEqual($rootScope.$id); + + // Now we create all the elements + $rootScope.$apply('val0 = [1]; val1 = true; val2 = true'); + + // At this point we have: + // + //
+ // + // + // + //
+ //
+ // + // + // + //
+ //
+ // + // + //
+ var ngIf1Scope = element.find('div').eq(0).scope(); + var ngIf2Scope = element.find('div').eq(1).scope(); + var ngRepeatScope = ngIf1Scope.$parent; + + expect(ngIf1Scope.$id).not.toEqual(ngIf2Scope.$id); + expect(ngIf1Scope.$parent.$id).toEqual(ngRepeatScope.$id); + expect(ngIf2Scope.$parent.$id).toEqual(ngRepeatScope.$id); + + // What is happening here?? + // We seem to have a repeater scope which doesn't actually match to any element + expect(ngRepeatScope.$parent.$id).toEqual($rootScope.$id); + + + // Now remove the first ngIf element from the first item in the repeater + $rootScope.$apply('val1 = false'); + + // At this point we should have: + // + //
+ // + // + // + // + // + //
+ // + // + // + //
+ // + expect(ngRepeatScope.$$destroyed).toEqual(false); + expect(ngIf1Scope.$$destroyed).toEqual(true); + expect(ngIf2Scope.$$destroyed).toEqual(false); + + // Now remove the second ngIf element from the first item in the repeater + $rootScope.$apply('val2 = false'); + + // We are mostly back to where we started + // + //
+ // + // + // + // + //
+ + expect(ngRepeatScope.$$destroyed).toEqual(false); + expect(ngIf1Scope.$$destroyed).toEqual(true); + expect(ngIf2Scope.$$destroyed).toEqual(true); + + // Finally remove the repeat items + $rootScope.$apply('val0 = []'); + + // Somehow this ngRepeat scope knows how to destroy itself... + expect(ngRepeatScope.$$destroyed).toEqual(true); + expect(ngIf1Scope.$$destroyed).toEqual(true); + expect(ngIf2Scope.$$destroyed).toEqual(true); + })); + it('should throw error if unterminated', function () { module(function($compileProvider) { $compileProvider.directive('foo', function() {