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

Commit 71dca7c

Browse files
committed
fix(ngMessages): don't crash when nested messages are removed
Under specific circumstances, ngMessages would go into an infinite loop and crash the browser / page: - At least two ngMessage elements are wrapped inside another element (e.g. ngTransclude) - The first message is currently visible - The first message is removed (e.g. when the whole ngMessages element is removed by an ngIf) When a message is removed, it looks for a previous message - in this specific case it would misidentify the second message for a previous message, which would then cause the first message to be marked as the second message's next message, resulting in an infinite loop, and crash. This fix ensures that when searching for previous messages, ngMessage walks the DOM in a way so that messages that come after the current message are never identified as previous messages. This commit also detaches and destroys all child ngMessage elements when the ngMessages element is destroyed, which should improve performance slightly. Fixes #14183 Closes #14242
1 parent ce77c25 commit 71dca7c

File tree

2 files changed

+165
-3
lines changed

2 files changed

+165
-3
lines changed

src/ngMessages/messages.js

+14-3
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,13 @@ angular.module('ngMessages', [])
410410

411411
$scope.$watchCollection($attrs.ngMessages || $attrs['for'], ctrl.render);
412412

413+
// If the element is destroyed, proactively destroy all the currently visible messages
414+
$element.on('$destroy', function() {
415+
forEach(messages, function(item) {
416+
item.message.detach();
417+
});
418+
});
419+
413420
this.reRender = function() {
414421
if (!renderLater) {
415422
renderLater = true;
@@ -444,6 +451,7 @@ angular.module('ngMessages', [])
444451
function findPreviousMessage(parent, comment) {
445452
var prevNode = comment;
446453
var parentLookup = [];
454+
447455
while (prevNode && prevNode !== parent) {
448456
var prevKey = prevNode.$$ngMessageNode;
449457
if (prevKey && prevKey.length) {
@@ -455,8 +463,11 @@ angular.module('ngMessages', [])
455463
if (prevNode.childNodes.length && parentLookup.indexOf(prevNode) == -1) {
456464
parentLookup.push(prevNode);
457465
prevNode = prevNode.childNodes[prevNode.childNodes.length - 1];
466+
} else if (prevNode.previousSibling) {
467+
prevNode = prevNode.previousSibling;
458468
} else {
459-
prevNode = prevNode.previousSibling || prevNode.parentNode;
469+
prevNode = prevNode.parentNode;
470+
parentLookup.push(prevNode);
460471
}
461472
}
462473
}
@@ -664,8 +675,8 @@ function ngMessageDirectiveFactory(restrict) {
664675
// when we are destroying the node later.
665676
var $$attachId = currentElement.$$attachId = ngMessagesCtrl.getAttachId();
666677

667-
// in the event that the parent element is destroyed
668-
// by any other structural directive then it's time
678+
// in the event that the element or a parent element is destroyed
679+
// by another structural directive then it's time
669680
// to deregister the message from the controller
670681
currentElement.on('$destroy', function() {
671682
if (currentElement && currentElement.$$attachId === $$attachId) {

test/ngMessages/messagesSpec.js

+151
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,126 @@ describe('ngMessages', function() {
445445
});
446446
});
447447

448+
describe('ngMessage nested nested inside elements', function() {
449+
450+
it('should not crash or leak memory when the messages are transcluded, the first message is ' +
451+
'visible, and ngMessages is removed by ngIf', function() {
452+
453+
module(function($compileProvider) {
454+
$compileProvider.directive('messageWrap', function() {
455+
return {
456+
transclude: true,
457+
scope: {
458+
col: '=col'
459+
},
460+
template: '<div ng-messages="col"><ng-transclude></ng-transclude></div>'
461+
};
462+
});
463+
});
464+
465+
inject(function($rootScope, $compile) {
466+
467+
element = $compile('<div><div ng-if="show"><div message-wrap col="col">' +
468+
' <div ng-message="a">A</div>' +
469+
' <div ng-message="b">B</div>' +
470+
'</div></div></div>')($rootScope);
471+
472+
$rootScope.$apply(function() {
473+
$rootScope.show = true;
474+
$rootScope.col = {
475+
a: true,
476+
b: true
477+
};
478+
});
479+
480+
expect(messageChildren(element).length).toBe(1);
481+
expect(trim(element.text())).toEqual('A');
482+
483+
$rootScope.$apply('show = false');
484+
485+
expect(messageChildren(element).length).toBe(0);
486+
});
487+
});
488+
489+
490+
it('should not crash when the first of two nested messages is removed', function() {
491+
inject(function($rootScope, $compile) {
492+
493+
element = $compile(
494+
'<div ng-messages="col">' +
495+
'<div class="wrapper">' +
496+
'<div remove-me ng-message="a">A</div>' +
497+
'<div ng-message="b">B</div>' +
498+
'</div>' +
499+
'</div>'
500+
)($rootScope);
501+
502+
$rootScope.$apply(function() {
503+
$rootScope.col = {
504+
a: true,
505+
b: false
506+
};
507+
});
508+
509+
expect(messageChildren(element).length).toBe(1);
510+
expect(trim(element.text())).toEqual('A');
511+
512+
var ctrl = element.controller('ngMessages');
513+
var deregisterSpy = spyOn(ctrl, 'deregister').and.callThrough();
514+
515+
var nodeA = element[0].querySelector('[ng-message="a"]');
516+
jqLite(nodeA).remove();
517+
$rootScope.$digest(); // The next digest triggers the error
518+
519+
// Make sure removing the element triggers the deregistration in ngMessages
520+
expect(trim(deregisterSpy.calls.mostRecent().args[0].nodeValue)).toBe('ngMessage: a');
521+
expect(messageChildren(element).length).toBe(0);
522+
});
523+
});
524+
525+
526+
it('should not crash, but show deeply nested messages correctly after a message ' +
527+
'has been removed', function() {
528+
inject(function($rootScope, $compile) {
529+
530+
element = $compile(
531+
'<div ng-messages="col" ng-messages-multiple>' +
532+
'<div class="another-wrapper">' +
533+
'<div ng-message="a">A</div>' +
534+
'<div class="wrapper">' +
535+
'<div ng-message="b">B</div>' +
536+
'<div ng-message="c">C</div>' +
537+
'</div>' +
538+
'<div ng-message="d">D</div>' +
539+
'</div>' +
540+
'</div>'
541+
)($rootScope);
542+
543+
$rootScope.$apply(function() {
544+
$rootScope.col = {
545+
a: true,
546+
b: true
547+
};
548+
});
549+
550+
expect(messageChildren(element).length).toBe(2);
551+
expect(trim(element.text())).toEqual('AB');
552+
553+
var ctrl = element.controller('ngMessages');
554+
var deregisterSpy = spyOn(ctrl, 'deregister').and.callThrough();
555+
556+
var nodeB = element[0].querySelector('[ng-message="b"]');
557+
jqLite(nodeB).remove();
558+
$rootScope.$digest(); // The next digest triggers the error
559+
560+
// Make sure removing the element triggers the deregistration in ngMessages
561+
expect(trim(deregisterSpy.calls.mostRecent().args[0].nodeValue)).toBe('ngMessage: b');
562+
expect(messageChildren(element).length).toBe(1);
563+
expect(trim(element.text())).toEqual('A');
564+
});
565+
});
566+
});
567+
448568
describe('when including templates', function() {
449569
they('should work with a dynamic collection model which is managed by ngRepeat',
450570
{'<div ng-messages-include="...">': '<div ng-messages="item">' +
@@ -651,6 +771,37 @@ describe('ngMessages', function() {
651771
expect(trim(element.text())).toEqual("C");
652772
}));
653773

774+
775+
it('should properly detect a previous message, even if it was registered later',
776+
inject(function($compile, $rootScope, $templateCache) {
777+
$templateCache.put('include.html', '<div ng-message="a">A</div>');
778+
var html =
779+
'<div ng-messages="items">' +
780+
'<div ng-include="\'include.html\'"></div>' +
781+
'<div ng-message="b">B</div>' +
782+
'<div ng-message="c">C</div>' +
783+
'</div>';
784+
785+
element = $compile(html)($rootScope);
786+
$rootScope.$apply('items = {b: true, c: true}');
787+
788+
expect(element.text()).toBe('B');
789+
790+
var ctrl = element.controller('ngMessages');
791+
var deregisterSpy = spyOn(ctrl, 'deregister').and.callThrough();
792+
793+
var nodeB = element[0].querySelector('[ng-message="b"]');
794+
jqLite(nodeB).remove();
795+
796+
// Make sure removing the element triggers the deregistration in ngMessages
797+
expect(trim(deregisterSpy.calls.mostRecent().args[0].nodeValue)).toBe('ngMessage: b');
798+
799+
$rootScope.$apply('items.a = true');
800+
801+
expect(element.text()).toBe('A');
802+
})
803+
);
804+
654805
});
655806

656807
describe('when multiple', function() {

0 commit comments

Comments
 (0)