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

TRANSCLUDE MEMORY LEAK: fix($compile): connect transclude scopes to their containing scope to prevent memory leaks #9281

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 17 additions & 16 deletions src/ng/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,13 +294,20 @@
* compile the content of the element and make it available to the directive.
* Typically used with {@link ng.directive:ngTransclude
* ngTransclude}. The advantage of transclusion is that the linking function receives a
* transclusion function which is pre-bound to the correct scope. In a typical setup the widget
* creates an `isolate` scope, but the transclusion is not a child, but a sibling of the `isolate`
* scope. This makes it possible for the widget to have private state, and the transclusion to
* be bound to the parent (pre-`isolate`) scope.
* transclusion function which is pre-bound to the scope of the position in the DOM from where
* it was taken.
*
* * `true` - transclude the content of the directive.
* * `'element'` - transclude the whole element including any directives defined at lower priority.
* In a typical setup the widget creates an `isolate` scope, but the transcluded
* content has its own **transclusion scope**. While the **transclusion scope** is owned as a child,
* by the **isolate scope**, it prototypically inherits from the original scope from where the
* transcluded content was taken.
*
* This makes it possible for the widget to have private state, and the transclusion to
* be bound to the original (pre-`isolate`) scope.
*
* * `true` - transclude the content (i.e. the child nodes) of the directive's element.
* * `'element'` - transclude the whole of the directive's element including any directives on this
* element that defined at a lower priority than this directive.
*
* <div class="alert alert-warning">
* **Note:** When testing an element transclude directive you must not place the directive at the root of the
Expand Down Expand Up @@ -1166,20 +1173,14 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {

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

var boundTranscludeFn = function(transcludedScope, cloneFn, controllers, futureParentElement) {
var scopeCreated = false;
var boundTranscludeFn = function(transcludedScope, cloneFn, controllers, futureParentElement, containingScope) {

if (!transcludedScope) {
transcludedScope = scope.$new();
transcludedScope = scope.$new(false, containingScope);
transcludedScope.$$transcluded = true;
Copy link
Contributor

Choose a reason for hiding this comment

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

I see that the breaking change is documented in the second commit --- people are going to be mad about this landing in a release candidate, but I think you're right, the solution here is better

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 believe that for the vast majority of users they won't even notice.

Those that it does affect were writing dubious code anyway, since they should not have been manipulating the DOM without worrying about the scope.

And even in those cases, above, the apps will not "break" they will just leak memory, and as soon as the containing scope is destroyed the memory will be recovered. If they get a memory leak, they will file an issue and we can provide them with the directions to fix it.

Also, while this is a breaking change, it is a memory leak bug fix and not a new feature. So I think that is reasonable even so close to release.

Copy link
Contributor

Choose a reason for hiding this comment

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

I am in agreement with everything you say, just noting that it's going to upset people =) I definitely think it's worth it to improve well-behaved apps

scopeCreated = true;
}

var clone = transcludeFn(transcludedScope, cloneFn, controllers, previousBoundTranscludeFn, futureParentElement);
if (scopeCreated && !elementTransclusion) {
clone.on('$destroy', function() { transcludedScope.$destroy(); });
}
return clone;
return transcludeFn(transcludedScope, cloneFn, controllers, previousBoundTranscludeFn, futureParentElement);
};

return boundTranscludeFn;
Expand Down Expand Up @@ -1810,7 +1811,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
if (!futureParentElement) {
futureParentElement = hasElementTranscludeDirective ? $element.parent() : $element;
}
return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers, futureParentElement);
return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers, futureParentElement, scopeToChild);
}
}
}
Expand Down
22 changes: 15 additions & 7 deletions src/ng/rootScope.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,20 @@ function $RootScopeProvider(){
* When creating widgets, it is useful for the widget to not accidentally read parent
* state.
*
* @param {Scope} [parent=this] The {@link ng.$rootScope.Scope `Scope`} that will be the `$parent`
* of the newly created scope. Defaults to `this` scope if not provided.
* This is used when creating a transclude scope to correctly place it
* in the scope hierarchy while maintaining the correct prototypical
* inheritance.
*
* @returns {Object} The newly created child scope.
*
*/
$new: function(isolate) {
$new: function(isolate, parent) {
var child;

parent = parent || this;

if (isolate) {
child = new Scope();
child.$root = this.$root;
Expand All @@ -213,13 +221,13 @@ function $RootScopeProvider(){
child = new this.$$ChildScope();
}
child['this'] = child;
child.$parent = this;
child.$$prevSibling = this.$$childTail;
if (this.$$childHead) {
this.$$childTail.$$nextSibling = child;
this.$$childTail = child;
child.$parent = parent;
child.$$prevSibling = parent.$$childTail;
if (parent.$$childHead) {
parent.$$childTail.$$nextSibling = child;
parent.$$childTail = child;
} else {
this.$$childHead = this.$$childTail = child;
parent.$$childHead = parent.$$childTail = child;
}
return child;
},
Expand Down
211 changes: 165 additions & 46 deletions test/ng/compileSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4331,17 +4331,21 @@ describe('$compile', function() {
return {
transclude: 'content',
replace: true,
scope: true,
template: '<ul><li>W:{{$parent.$id}}-{{$id}};</li><li ng-transclude></li></ul>'
scope: {},
link: function(scope) {
scope.x='iso';
},
template: '<ul><li>W:{{x}}-{{$parent.$id}}-{{$id}};</li><li ng-transclude></li></ul>'
};
});
});
inject(function(log, $rootScope, $compile) {
element = $compile('<div><div trans>T:{{$parent.$id}}-{{$id}}<span>;</span></div></div>')
element = $compile('<div><div trans>T:{{x}}-{{$parent.$id}}-{{$id}}<span>;</span></div></div>')
($rootScope);
$rootScope.x = 'root';
$rootScope.$apply();
expect(element.text()).toEqual('W:1-2;T:1-3;');
expect(jqLite(element.find('span')[0]).text()).toEqual('T:1-3');
expect(element.text()).toEqual('W:iso-1-2;T:root-2-3;');
expect(jqLite(element.find('span')[0]).text()).toEqual('T:root-2-3');
expect(jqLite(element.find('span')[1]).text()).toEqual(';');
});
});
Expand Down Expand Up @@ -4551,47 +4555,6 @@ describe('$compile', function() {
}


it('should remove transclusion scope, when the DOM is destroyed', function() {
module(function() {
directive('box', valueFn({
transclude: true,
scope: { name: '=', show: '=' },
template: '<div><h1>Hello: {{name}}!</h1><div ng-transclude></div></div>',
link: function(scope, element) {
scope.$watch(
'show',
function(show) {
if (!show) {
element.find('div').find('div').remove();
}
}
);
}
}));
});
inject(function($compile, $rootScope) {
$rootScope.username = 'Misko';
$rootScope.select = true;
element = $compile(
'<div><div box name="username" show="select">user: {{username}}</div></div>')
($rootScope);
$rootScope.$apply();
expect(element.text()).toEqual('Hello: Misko!user: Misko');

var widgetScope = $rootScope.$$childHead;
var transcludeScope = widgetScope.$$nextSibling;
expect(widgetScope.name).toEqual('Misko');
expect(widgetScope.$parent).toEqual($rootScope);
expect(transcludeScope.$parent).toEqual($rootScope);

$rootScope.select = false;
$rootScope.$apply();
expect(element.text()).toEqual('Hello: Misko!');
expect(widgetScope.$$nextSibling).toEqual(null);
});
});


it('should add a $$transcluded property onto the transcluded scope', function() {
module(function() {
directive('trans', function() {
Expand Down Expand Up @@ -4964,6 +4927,162 @@ describe('$compile', function() {
});


// see issue https://github.com/angular/angular.js/issues/9095
describe('removing a transcluded element', function() {

function countScopes($rootScope) {
return [$rootScope].concat(
getChildScopes($rootScope)
).length;

function getChildScopes(scope) {
var children = [];
if (!scope.$$childHead) { return children; }
var childScope = scope.$$childHead;
do {
children.push(childScope);
children = children.concat(getChildScopes(childScope));
} while ((childScope = childScope.$$nextSibling));
return children;
}
}

beforeEach(module(function() {
directive('toggle', function() {
return {
transclude: true,
template: '<div ng:if="t"><div ng:transclude></div></div>'
};
});
}));


it('should not leak the transclude scope when the transcluded content is an element transclusion directive',
inject(function($compile, $rootScope) {

element = $compile(
'<div toggle>' +
'<div ng:repeat="msg in [\'msg-1\']">{{ msg }}</div>' +
'</div>'
)($rootScope);

$rootScope.$apply('t = true');
expect(element.text()).toContain('msg-1');
// Expected scopes: $rootScope, ngIf, transclusion, ngRepeat
expect(countScopes($rootScope)).toEqual(4);
Copy link
Contributor

Choose a reason for hiding this comment

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

it might be good to clarify that the 4 is for $rootScope, ngIf-scope, ngTransclude-scope, ngRepeat-scope, otherwise it's sort of hard to tell what's being tested (or what went wrong if this ever starts failing)

Copy link
Contributor

Choose a reason for hiding this comment

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

(same for other similar assertions)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea!


$rootScope.$apply('t = false');
expect(element.text()).not.toContain('msg-1');
// Expected scopes: $rootScope
expect(countScopes($rootScope)).toEqual(1);

$rootScope.$apply('t = true');
expect(element.text()).toContain('msg-1');
// Expected scopes: $rootScope, ngIf, transclusion, ngRepeat
expect(countScopes($rootScope)).toEqual(4);

$rootScope.$apply('t = false');
expect(element.text()).not.toContain('msg-1');
// Expected scopes: $rootScope
expect(countScopes($rootScope)).toEqual(1);
}));


it('should not leak the transclude scope when the transcluded content is an multi-element transclusion directive',
inject(function($compile, $rootScope) {

element = $compile(
'<div toggle>' +
'<div ng:repeat-start="msg in [\'msg-1\']">{{ msg }}</div>' +
'<div ng:repeat-end>{{ msg }}</div>' +
'</div>'
)($rootScope);

$rootScope.$apply('t = true');
expect(element.text()).toContain('msg-1msg-1');
// Expected scopes: $rootScope, ngIf, transclusion, ngRepeat
expect(countScopes($rootScope)).toEqual(4);

$rootScope.$apply('t = false');
expect(element.text()).not.toContain('msg-1msg-1');
// Expected scopes: $rootScope
expect(countScopes($rootScope)).toEqual(1);

$rootScope.$apply('t = true');
expect(element.text()).toContain('msg-1msg-1');
// Expected scopes: $rootScope, ngIf, transclusion, ngRepeat
expect(countScopes($rootScope)).toEqual(4);

$rootScope.$apply('t = false');
expect(element.text()).not.toContain('msg-1msg-1');
// Expected scopes: $rootScope
expect(countScopes($rootScope)).toEqual(1);
}));


it('should not leak the transclude scope if the transcluded contains only comments',
inject(function($compile, $rootScope) {

element = $compile(
'<div toggle>' +
'<!-- some comment -->' +
'</div>'
)($rootScope);

$rootScope.$apply('t = true');
expect(element.html()).toContain('some comment');
// Expected scopes: $rootScope, ngIf, transclusion
expect(countScopes($rootScope)).toEqual(3);

$rootScope.$apply('t = false');
expect(element.html()).not.toContain('some comment');
// Expected scopes: $rootScope
expect(countScopes($rootScope)).toEqual(1);

$rootScope.$apply('t = true');
expect(element.html()).toContain('some comment');
// Expected scopes: $rootScope, ngIf, transclusion
expect(countScopes($rootScope)).toEqual(3);

$rootScope.$apply('t = false');
expect(element.html()).not.toContain('some comment');
// Expected scopes: $rootScope
expect(countScopes($rootScope)).toEqual(1);
}));

it('should not leak the transclude scope if the transcluded contains only text nodes',
inject(function($compile, $rootScope) {

element = $compile(
'<div toggle>' +
'some text' +
'</div>'
)($rootScope);

$rootScope.$apply('t = true');
expect(element.html()).toContain('some text');
// Expected scopes: $rootScope, ngIf, transclusion
expect(countScopes($rootScope)).toEqual(3);

$rootScope.$apply('t = false');
expect(element.html()).not.toContain('some text');
// Expected scopes: $rootScope
expect(countScopes($rootScope)).toEqual(1);

$rootScope.$apply('t = true');
expect(element.html()).toContain('some text');
// Expected scopes: $rootScope, ngIf, transclusion
expect(countScopes($rootScope)).toEqual(3);

$rootScope.$apply('t = false');
expect(element.html()).not.toContain('some text');
// Expected scopes: $rootScope
expect(countScopes($rootScope)).toEqual(1);
}));

});


describe('nested transcludes', function() {

beforeEach(module(function($compileProvider) {
Expand Down
9 changes: 9 additions & 0 deletions test/ng/rootScopeSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ describe('Scope', function() {
expect(child.$new).toBe($rootScope.$new);
expect(child.$root).toBe($rootScope);
}));

it("should attach the child scope to a specified parent", inject(function($rootScope) {
var isolated = $rootScope.$new(true);
var trans = $rootScope.$new(false, isolated);
$rootScope.a = 123;
expect(isolated.a).toBeUndefined();
expect(trans.a).toEqual(123);
expect(trans.$parent).toBe(isolated);
}));
});


Expand Down