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

Commit fb0c77f

Browse files
fix($compile): connect transclude scopes to their containing scope to prevent memory leaks
Transcluded scopes are now connected to the scope in which they are created via their `$parent` property. This means that they will be automatically destroyed when their "containing" scope is destroyed, without having to resort to listening for a `$destroy` event on various DOM elements or other scopes. Previously, transclude scope not only inherited prototypically from the scope from which they were transcluded but they were also still owned by that "outer" scope. This meant that there were scenarios where the "real" container scope/element was destroyed but the transclude scope was not, leading to memory leaks. The original strategy for dealing with this was to attach a `$destroy` event handler to the DOM elements in the transcluded content, so that if the elements were removed from the DOM then their associated transcluded scope would be destroyed. This didn't work for transclude contents that didn't contain any elements - most importantly in the case of the transclude content containing an element transclude directive at its root, since the compiler swaps out this element for a comment before a destroy handler could be attached. BREAKING CHANGE: `$transclude` functions no longer attach `$destroy` event handlers to the transcluded content, and so the associated transclude scope will not automatically be destroyed if you remove a transcluded element from the DOM using direct DOM manipulation such as the jquery `remove()` method. If you want to explicitly remove DOM elements inside your directive that have been compiled, and so potentially contain child (and transcluded) scopes, then it is your responsibility to get hold of the scope and destroy it at the same time. The suggested approach is to create a new child scope of your own around any DOM elements that you wish to manipulate in this way and destroy those scopes if you remove their contents - any child scopes will then be destroyed and cleaned up automatically. Note that all the built-in directives that manipulate the DOM (ngIf, ngRepeat, ngSwitch, etc) already follow this best practice, so if you only use these for manipulating the DOM then you do not have to worry about this change. Closes #9095 Closes #9281
1 parent 6417a3e commit fb0c77f

File tree

2 files changed

+182
-62
lines changed

2 files changed

+182
-62
lines changed

src/ng/compile.js

+17-16
Original file line numberDiff line numberDiff line change
@@ -297,13 +297,20 @@
297297
* compile the content of the element and make it available to the directive.
298298
* Typically used with {@link ng.directive:ngTransclude
299299
* ngTransclude}. The advantage of transclusion is that the linking function receives a
300-
* transclusion function which is pre-bound to the correct scope. In a typical setup the widget
301-
* creates an `isolate` scope, but the transclusion is not a child, but a sibling of the `isolate`
302-
* scope. This makes it possible for the widget to have private state, and the transclusion to
303-
* be bound to the parent (pre-`isolate`) scope.
300+
* transclusion function which is pre-bound to the scope of the position in the DOM from where
301+
* it was taken.
304302
*
305-
* * `true` - transclude the content of the directive.
306-
* * `'element'` - transclude the whole element including any directives defined at lower priority.
303+
* In a typical setup the widget creates an `isolate` scope, but the transcluded
304+
* content has its own **transclusion scope**. While the **transclusion scope** is owned as a child,
305+
* by the **isolate scope**, it prototypically inherits from the original scope from where the
306+
* transcluded content was taken.
307+
*
308+
* This makes it possible for the widget to have private state, and the transclusion to
309+
* be bound to the original (pre-`isolate`) scope.
310+
*
311+
* * `true` - transclude the content (i.e. the child nodes) of the directive's element.
312+
* * `'element'` - transclude the whole of the directive's element including any directives on this
313+
* element that defined at a lower priority than this directive.
307314
*
308315
* <div class="alert alert-warning">
309316
* **Note:** When testing an element transclude directive you must not place the directive at the root of the
@@ -1170,20 +1177,14 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
11701177

11711178
function createBoundTranscludeFn(scope, transcludeFn, previousBoundTranscludeFn, elementTransclusion) {
11721179

1173-
var boundTranscludeFn = function(transcludedScope, cloneFn, controllers, futureParentElement) {
1174-
var scopeCreated = false;
1180+
var boundTranscludeFn = function(transcludedScope, cloneFn, controllers, futureParentElement, containingScope) {
11751181

11761182
if (!transcludedScope) {
1177-
transcludedScope = scope.$new();
1183+
transcludedScope = scope.$new(false, containingScope);
11781184
transcludedScope.$$transcluded = true;
1179-
scopeCreated = true;
11801185
}
11811186

1182-
var clone = transcludeFn(transcludedScope, cloneFn, controllers, previousBoundTranscludeFn, futureParentElement);
1183-
if (scopeCreated && !elementTransclusion) {
1184-
clone.on('$destroy', function() { transcludedScope.$destroy(); });
1185-
}
1186-
return clone;
1187+
return transcludeFn(transcludedScope, cloneFn, controllers, previousBoundTranscludeFn, futureParentElement);
11871188
};
11881189

11891190
return boundTranscludeFn;
@@ -1826,7 +1827,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
18261827
if (!futureParentElement) {
18271828
futureParentElement = hasElementTranscludeDirective ? $element.parent() : $element;
18281829
}
1829-
return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers, futureParentElement);
1830+
return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers, futureParentElement, scopeToChild);
18301831
}
18311832
}
18321833
}

test/ng/compileSpec.js

+165-46
Original file line numberDiff line numberDiff line change
@@ -4368,17 +4368,21 @@ describe('$compile', function() {
43684368
return {
43694369
transclude: 'content',
43704370
replace: true,
4371-
scope: true,
4372-
template: '<ul><li>W:{{$parent.$id}}-{{$id}};</li><li ng-transclude></li></ul>'
4371+
scope: {},
4372+
link: function(scope) {
4373+
scope.x='iso';
4374+
},
4375+
template: '<ul><li>W:{{x}}-{{$parent.$id}}-{{$id}};</li><li ng-transclude></li></ul>'
43734376
};
43744377
});
43754378
});
43764379
inject(function(log, $rootScope, $compile) {
4377-
element = $compile('<div><div trans>T:{{$parent.$id}}-{{$id}}<span>;</span></div></div>')
4380+
element = $compile('<div><div trans>T:{{x}}-{{$parent.$id}}-{{$id}}<span>;</span></div></div>')
43784381
($rootScope);
4382+
$rootScope.x = 'root';
43794383
$rootScope.$apply();
4380-
expect(element.text()).toEqual('W:1-2;T:1-3;');
4381-
expect(jqLite(element.find('span')[0]).text()).toEqual('T:1-3');
4384+
expect(element.text()).toEqual('W:iso-1-2;T:root-2-3;');
4385+
expect(jqLite(element.find('span')[0]).text()).toEqual('T:root-2-3');
43824386
expect(jqLite(element.find('span')[1]).text()).toEqual(';');
43834387
});
43844388
});
@@ -4588,47 +4592,6 @@ describe('$compile', function() {
45884592
}
45894593

45904594

4591-
it('should remove transclusion scope, when the DOM is destroyed', function() {
4592-
module(function() {
4593-
directive('box', valueFn({
4594-
transclude: true,
4595-
scope: { name: '=', show: '=' },
4596-
template: '<div><h1>Hello: {{name}}!</h1><div ng-transclude></div></div>',
4597-
link: function(scope, element) {
4598-
scope.$watch(
4599-
'show',
4600-
function(show) {
4601-
if (!show) {
4602-
element.find('div').find('div').remove();
4603-
}
4604-
}
4605-
);
4606-
}
4607-
}));
4608-
});
4609-
inject(function($compile, $rootScope) {
4610-
$rootScope.username = 'Misko';
4611-
$rootScope.select = true;
4612-
element = $compile(
4613-
'<div><div box name="username" show="select">user: {{username}}</div></div>')
4614-
($rootScope);
4615-
$rootScope.$apply();
4616-
expect(element.text()).toEqual('Hello: Misko!user: Misko');
4617-
4618-
var widgetScope = $rootScope.$$childHead;
4619-
var transcludeScope = widgetScope.$$nextSibling;
4620-
expect(widgetScope.name).toEqual('Misko');
4621-
expect(widgetScope.$parent).toEqual($rootScope);
4622-
expect(transcludeScope.$parent).toEqual($rootScope);
4623-
4624-
$rootScope.select = false;
4625-
$rootScope.$apply();
4626-
expect(element.text()).toEqual('Hello: Misko!');
4627-
expect(widgetScope.$$nextSibling).toEqual(null);
4628-
});
4629-
});
4630-
4631-
46324595
it('should add a $$transcluded property onto the transcluded scope', function() {
46334596
module(function() {
46344597
directive('trans', function() {
@@ -5001,6 +4964,162 @@ describe('$compile', function() {
50014964
});
50024965

50034966

4967+
// see issue https://github.com/angular/angular.js/issues/9095
4968+
describe('removing a transcluded element', function() {
4969+
4970+
function countScopes($rootScope) {
4971+
return [$rootScope].concat(
4972+
getChildScopes($rootScope)
4973+
).length;
4974+
4975+
function getChildScopes(scope) {
4976+
var children = [];
4977+
if (!scope.$$childHead) { return children; }
4978+
var childScope = scope.$$childHead;
4979+
do {
4980+
children.push(childScope);
4981+
children = children.concat(getChildScopes(childScope));
4982+
} while ((childScope = childScope.$$nextSibling));
4983+
return children;
4984+
}
4985+
}
4986+
4987+
beforeEach(module(function() {
4988+
directive('toggle', function() {
4989+
return {
4990+
transclude: true,
4991+
template: '<div ng:if="t"><div ng:transclude></div></div>'
4992+
};
4993+
});
4994+
}));
4995+
4996+
4997+
it('should not leak the transclude scope when the transcluded content is an element transclusion directive',
4998+
inject(function($compile, $rootScope) {
4999+
5000+
element = $compile(
5001+
'<div toggle>' +
5002+
'<div ng:repeat="msg in [\'msg-1\']">{{ msg }}</div>' +
5003+
'</div>'
5004+
)($rootScope);
5005+
5006+
$rootScope.$apply('t = true');
5007+
expect(element.text()).toContain('msg-1');
5008+
// Expected scopes: $rootScope, ngIf, transclusion, ngRepeat
5009+
expect(countScopes($rootScope)).toEqual(4);
5010+
5011+
$rootScope.$apply('t = false');
5012+
expect(element.text()).not.toContain('msg-1');
5013+
// Expected scopes: $rootScope
5014+
expect(countScopes($rootScope)).toEqual(1);
5015+
5016+
$rootScope.$apply('t = true');
5017+
expect(element.text()).toContain('msg-1');
5018+
// Expected scopes: $rootScope, ngIf, transclusion, ngRepeat
5019+
expect(countScopes($rootScope)).toEqual(4);
5020+
5021+
$rootScope.$apply('t = false');
5022+
expect(element.text()).not.toContain('msg-1');
5023+
// Expected scopes: $rootScope
5024+
expect(countScopes($rootScope)).toEqual(1);
5025+
}));
5026+
5027+
5028+
it('should not leak the transclude scope when the transcluded content is an multi-element transclusion directive',
5029+
inject(function($compile, $rootScope) {
5030+
5031+
element = $compile(
5032+
'<div toggle>' +
5033+
'<div ng:repeat-start="msg in [\'msg-1\']">{{ msg }}</div>' +
5034+
'<div ng:repeat-end>{{ msg }}</div>' +
5035+
'</div>'
5036+
)($rootScope);
5037+
5038+
$rootScope.$apply('t = true');
5039+
expect(element.text()).toContain('msg-1msg-1');
5040+
// Expected scopes: $rootScope, ngIf, transclusion, ngRepeat
5041+
expect(countScopes($rootScope)).toEqual(4);
5042+
5043+
$rootScope.$apply('t = false');
5044+
expect(element.text()).not.toContain('msg-1msg-1');
5045+
// Expected scopes: $rootScope
5046+
expect(countScopes($rootScope)).toEqual(1);
5047+
5048+
$rootScope.$apply('t = true');
5049+
expect(element.text()).toContain('msg-1msg-1');
5050+
// Expected scopes: $rootScope, ngIf, transclusion, ngRepeat
5051+
expect(countScopes($rootScope)).toEqual(4);
5052+
5053+
$rootScope.$apply('t = false');
5054+
expect(element.text()).not.toContain('msg-1msg-1');
5055+
// Expected scopes: $rootScope
5056+
expect(countScopes($rootScope)).toEqual(1);
5057+
}));
5058+
5059+
5060+
it('should not leak the transclude scope if the transcluded contains only comments',
5061+
inject(function($compile, $rootScope) {
5062+
5063+
element = $compile(
5064+
'<div toggle>' +
5065+
'<!-- some comment -->' +
5066+
'</div>'
5067+
)($rootScope);
5068+
5069+
$rootScope.$apply('t = true');
5070+
expect(element.html()).toContain('some comment');
5071+
// Expected scopes: $rootScope, ngIf, transclusion
5072+
expect(countScopes($rootScope)).toEqual(3);
5073+
5074+
$rootScope.$apply('t = false');
5075+
expect(element.html()).not.toContain('some comment');
5076+
// Expected scopes: $rootScope
5077+
expect(countScopes($rootScope)).toEqual(1);
5078+
5079+
$rootScope.$apply('t = true');
5080+
expect(element.html()).toContain('some comment');
5081+
// Expected scopes: $rootScope, ngIf, transclusion
5082+
expect(countScopes($rootScope)).toEqual(3);
5083+
5084+
$rootScope.$apply('t = false');
5085+
expect(element.html()).not.toContain('some comment');
5086+
// Expected scopes: $rootScope
5087+
expect(countScopes($rootScope)).toEqual(1);
5088+
}));
5089+
5090+
it('should not leak the transclude scope if the transcluded contains only text nodes',
5091+
inject(function($compile, $rootScope) {
5092+
5093+
element = $compile(
5094+
'<div toggle>' +
5095+
'some text' +
5096+
'</div>'
5097+
)($rootScope);
5098+
5099+
$rootScope.$apply('t = true');
5100+
expect(element.html()).toContain('some text');
5101+
// Expected scopes: $rootScope, ngIf, transclusion
5102+
expect(countScopes($rootScope)).toEqual(3);
5103+
5104+
$rootScope.$apply('t = false');
5105+
expect(element.html()).not.toContain('some text');
5106+
// Expected scopes: $rootScope
5107+
expect(countScopes($rootScope)).toEqual(1);
5108+
5109+
$rootScope.$apply('t = true');
5110+
expect(element.html()).toContain('some text');
5111+
// Expected scopes: $rootScope, ngIf, transclusion
5112+
expect(countScopes($rootScope)).toEqual(3);
5113+
5114+
$rootScope.$apply('t = false');
5115+
expect(element.html()).not.toContain('some text');
5116+
// Expected scopes: $rootScope
5117+
expect(countScopes($rootScope)).toEqual(1);
5118+
}));
5119+
5120+
});
5121+
5122+
50045123
describe('nested transcludes', function() {
50055124

50065125
beforeEach(module(function($compileProvider) {

0 commit comments

Comments
 (0)