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

refactor($compile): remove skipDestroyOnNextJQueryCleanData, fix data leak on some replaced nodes #12094

Closed
wants to merge 2 commits into from
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
2 changes: 0 additions & 2 deletions src/.jshintrc
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,6 @@
"VALIDITY_STATE_PROPERTY": false,
"reloadWithDebugInfo": false,

"skipDestroyOnNextJQueryCleanData": true,

"NODE_TYPE_ELEMENT": false,
"NODE_TYPE_ATTRIBUTE": false,
"NODE_TYPE_TEXT": false,
Expand Down
13 changes: 4 additions & 9 deletions src/Angular.js
Original file line number Diff line number Diff line change
Expand Up @@ -1678,7 +1678,6 @@ function snake_case(name, separator) {
}

var bindJQueryFired = false;
var skipDestroyOnNextJQueryCleanData;
function bindJQuery() {
var originalCleanData;

Expand Down Expand Up @@ -1712,15 +1711,11 @@ function bindJQuery() {
originalCleanData = jQuery.cleanData;
jQuery.cleanData = function(elems) {
var events;
if (!skipDestroyOnNextJQueryCleanData) {
for (var i = 0, elem; (elem = elems[i]) != null; i++) {
events = jQuery._data(elem, "events");
if (events && events.$destroy) {
jQuery(elem).triggerHandler('$destroy');
}
for (var i = 0, elem; (elem = elems[i]) != null; i++) {
events = jQuery._data(elem, "events");
if (events && events.$destroy) {
jQuery(elem).triggerHandler('$destroy');
}
} else {
skipDestroyOnNextJQueryCleanData = false;
}
originalCleanData(elems);
};
Expand Down
9 changes: 8 additions & 1 deletion src/jqLite.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ function jqLiteHasData(node) {
return false;
}

function jqLiteCleanData(nodes) {
for (var i = 0, ii = nodes.length; i < ii; i++) {
jqLiteRemoveData(nodes[i]);
}
}

function jqLiteBuildFragment(html, context) {
var tmp, tag, wrap,
fragment = context.createDocumentFragment(),
Expand Down Expand Up @@ -571,7 +577,8 @@ function getAliasedAttrName(name) {
forEach({
data: jqLiteData,
removeData: jqLiteRemoveData,
hasData: jqLiteHasData
hasData: jqLiteHasData,
cleanData: jqLiteCleanData
}, function(fn, name) {
JQLite[name] = fn;
});
Expand Down
40 changes: 16 additions & 24 deletions src/ng/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -2634,41 +2634,33 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
parent.replaceChild(newNode, firstElementToRemove);
}

// TODO(perf): what's this document fragment for? is it needed? can we at least reuse it?
// Append all the `elementsToRemove` to a fragment. This will...
// - remove them from the DOM
// - allow them to still be traversed with .nextSibling
// - allow a single fragment.qSA to fetch all elements being removed
var fragment = document.createDocumentFragment();
fragment.appendChild(firstElementToRemove);
for (i = 0; i < removeCount; i++) {
fragment.appendChild(elementsToRemove[i]);
}

if (jqLite.hasData(firstElementToRemove)) {
// Copy over user data (that includes Angular's $scope etc.). Don't copy private
// data here because there's no public interface in jQuery to do that and copying over
// event listeners (which is the main use of private data) wouldn't work anyway.
jqLite.data(newNode, jqLite.data(firstElementToRemove));

// Remove data of the replaced element. We cannot just call .remove()
// on the element it since that would deallocate scope that is needed
// for the new node. Instead, remove the data "manually".
if (!jQuery) {
delete jqLite.cache[firstElementToRemove[jqLite.expando]];
} else {
// jQuery 2.x doesn't expose the data storage. Use jQuery.cleanData to clean up after
// the replaced element. The cleanData version monkey-patched by Angular would cause
// the scope to be trashed and we do need the very same scope to work with the new
// element. However, we cannot just cache the non-patched version and use it here as
// that would break if another library patches the method after Angular does (one
// example is jQuery UI). Instead, set a flag indicating scope destroying should be
// skipped this one time.
skipDestroyOnNextJQueryCleanData = true;
jQuery.cleanData([firstElementToRemove]);
}
// Remove $destroy event listeners from `firstElementToRemove`
jqLite(firstElementToRemove).off('$destroy');
Copy link
Member

Choose a reason for hiding this comment

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

I wouldn't delete the whole above comment. The following:

        // Remove data of the replaced element. We cannot just call .remove()
        // on the element it since that would deallocate scope that is needed
        // for the new node. Instead, remove the data "manually".

seems still relevant to the .off('$destroy') code.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not sure if that comment is 100% correct. .remove() doesn't "deallocate [the] scope" it just fires a DOM $destroy event (not the scope $destroy event). It doesn't actually touch the scope...

}

for (var k = 1, kk = elementsToRemove.length; k < kk; k++) {
var element = elementsToRemove[k];
jqLite(element).remove(); // must do this way to clean up expando
fragment.appendChild(element);
delete elementsToRemove[k];
}
// Cleanup any data/listeners on the elements and children.
// This includes invoking the $destroy event on any elements with listeners.
jqLite.cleanData(fragment.querySelectorAll('*'));

// Update the jqLite collection to only contain the `newNode`
for (i = 1; i < removeCount; i++) {
delete elementsToRemove[i];
}
elementsToRemove[0] = newNode;
elementsToRemove.length = 1;
}
Expand Down
126 changes: 125 additions & 1 deletion test/ng/compileSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5844,7 +5844,9 @@ describe('$compile', function() {

testCleanup();

expect(cleanedCount).toBe(xs.length);
// The ng-repeat template is removed/cleaned (the +1)
// and each clone of the ng-repeat template is also removed (xs.length)
expect(cleanedCount).toBe(xs.length + 1);

// Restore the previous jQuery.cleanData.
jQuery.cleanData = currentCleanData;
Expand Down Expand Up @@ -8587,4 +8589,126 @@ describe('$compile', function() {
expect(element.hasClass('fire')).toBe(true);
}));
});

describe('element replacement', function() {
it('should broadcast $destroy only on removed elements, not replaced', function() {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Does anyone know if this is actually intentional? Why is this done? It seems like the replaced node was never .remove()ed so this was always the case. But when multi-element directives were added (e46100f) they used .remove() which does fire $destroy.

Should $destroy actually be fired on the replaced node as well?

Copy link
Member

Choose a reason for hiding this comment

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

Did you try to remove the jqLite(firstElementToRemove).off('$destroy'); line and run unit tests? From what I remember the whole skipDestroyOnNextJQueryCleanData was added because the scope is reused on the replaced elements so removing this logic made existing unit tests fail.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Removing jqLite(firstElementToRemove).off('$destroy'); doesn't cause anything to fail. Even this test designed to fail in that case isn't failing! 😟

I thought of another case though which I think might show that not invoking the DOM $destory is (sometimes) wrong...

<div ng-form="myForm" lower-priority-replace-directive>

In this case the ng-form directive runs first which adds itself to the parent form then does formElement.on('$destroy', function() { parentFormCtrl.$removeControl(controller); ... }). Then the other lower priority directive replaces that element with something brand new, that replaced element is not removed from the ng-form because no $destroy was fired. The replaced ng-form controller left behind a) is leaked and b) might cause form validation issues?

However if that low priority directive was doing transclude instead of replace then maybe not invoking the DOM $destory is valid? Meaning replaceWith would need an invokeDestroy argument...

Copy link
Member

Choose a reason for hiding this comment

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

Interesting; just removing the skipDestroyOnNextJQueryCleanData logic from my commit definitely did fail unit tests.

This might be related, I totally forgot about this WIP PR: #8695

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Was that PR ready or was there more to look into still?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm tempted to just drop this test for now. The cases when $destroy should/should-not fire are too unclear and I think the current implementation is wrong (or at least super weird) so I'm not sure if I want to enforce that with a test. I think all the other tests are good though (ensuring all elements get passed to cleanData).

var linkCalls = [];
var destroyCalls = [];

module(function($compileProvider) {
$compileProvider.directive('replace', function() {
return {
multiElement: true,
replace: true,
templateUrl: 'template123'
};
});

$compileProvider.directive('foo', function() {
return {
priority: 1, // before the replace directive
link: function($scope, $element, $attrs) {
linkCalls.push($attrs.foo);
$element.on('$destroy', function() {
destroyCalls.push($attrs.foo);
});
}
};
});
});

inject(function($compile, $templateCache, $rootScope) {
$templateCache.put('template123', '<p></p>');

$compile(
'<div replace-start foo="1"><span foo="1.1"></span></div>' +
'<div foo="2"><span foo="2.1"></span></div>' +
'<div replace-end foo="3"><span foo="3.1"></span></div>'
)($rootScope);

expect(linkCalls).toEqual(['2', '3']);
expect(destroyCalls).toEqual([]);
$rootScope.$apply();
expect(linkCalls).toEqual(['2', '3', '1']);
expect(destroyCalls).toEqual(['2', '3']);
});
});

function getAll($root) {
// check for .querySelectorAll to support comment nodes
return [$root[0]].concat($root[0].querySelectorAll ? sliceArgs($root[0].querySelectorAll('*')) : []);
Copy link
Member

Choose a reason for hiding this comment

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

Where can $root[0].querySelectorAll be falsy?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

For comment nodes in the transclude-element case. I've added a comment.

}

function testCompileLinkDataCleanup(template) {
inject(function($compile, $rootScope) {
var toCompile = jqLite(template);

var preCompiledChildren = getAll(toCompile);
forEach(preCompiledChildren, function(element, i) {
jqLite.data(element, 'foo', 'template#' + i);
});

var linkedElements = $compile(toCompile)($rootScope);
$rootScope.$apply();
linkedElements.remove();

forEach(preCompiledChildren, function(element, i) {
expect(jqLite.hasData(element)).toBe(false, "template#" + i);
Copy link
Member

Choose a reason for hiding this comment

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

What does toBe with two parameters do? I haven't seen this signature.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The second param becomes part of the error message if the toBe fails.

});
forEach(getAll(linkedElements), function(element, i) {
expect(jqLite.hasData(element)).toBe(false, "linked#" + i);
});
});
}
it('should clean data of element-transcluded link-cloned elements', function() {
testCompileLinkDataCleanup('<div><div ng-repeat-start="i in [1,2]"><span></span></div><div ng-repeat-end></div></div>');
});
it('should clean data of element-transcluded elements', function() {
testCompileLinkDataCleanup('<div ng-if-start="false"><span><span/></div><span></span><div ng-if-end><span></span></div>');
});

function testReplaceElementCleanup(dirOptions) {
var template = '<div></div>';
module(function($compileProvider) {
$compileProvider.directive('theDir', function() {
return {
multiElement: true,
replace: dirOptions.replace,
transclude: dirOptions.transclude,
template: dirOptions.asyncTemplate ? undefined : template,
templateUrl: dirOptions.asyncTemplate ? 'the-dir-template-url' : undefined
};
});
});
inject(function($templateCache, $compile, $rootScope) {
$templateCache.put('the-dir-template-url', template);

testCompileLinkDataCleanup(
'<div>' +
'<div the-dir-start><span></span></div>' +
'<div><span></span><span></span></div>' +
'<div the-dir-end><span></span></div>' +
'</div>'
);
});
}
it('should clean data of elements removed for directive template', function() {
testReplaceElementCleanup({});
});
it('should clean data of elements removed for directive templateUrl', function() {
testReplaceElementCleanup({asyncTmeplate: true});
});
it('should clean data of elements transcluded into directive template', function() {
testReplaceElementCleanup({transclude: true});
});
it('should clean data of elements transcluded into directive templateUrl', function() {
testReplaceElementCleanup({transclude: true, asyncTmeplate: true});
});
it('should clean data of elements replaced with directive template', function() {
testReplaceElementCleanup({replace: true});
});
it('should clean data of elements replaced with directive templateUrl', function() {
testReplaceElementCleanup({replace: true, asyncTemplate: true});
});
});
});