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

Commit b379414

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 82a4545 commit b379414

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
}
@@ -669,8 +680,8 @@ function ngMessageDirectiveFactory() {
669680
// when we are destroying the node later.
670681
var $$attachId = currentElement.$$attachId = ngMessagesCtrl.getAttachId();
671682

672-
// in the event that the parent element is destroyed
673-
// by any other structural directive then it's time
683+
// in the event that the element or a parent element is destroyed
684+
// by another structural directive then it's time
674685
// to deregister the message from the controller
675686
currentElement.on('$destroy', function() {
676687
if (currentElement && currentElement.$$attachId === $$attachId) {

test/ngMessages/messagesSpec.js

+151
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,126 @@ describe('ngMessages', function() {
485485
});
486486
});
487487

488+
describe('ngMessage nested nested inside elements', function() {
489+
490+
it('should not crash or leak memory when the messages are transcluded, the first message is ' +
491+
'visible, and ngMessages is removed by ngIf', function() {
492+
493+
module(function($compileProvider) {
494+
$compileProvider.directive('messageWrap', function() {
495+
return {
496+
transclude: true,
497+
scope: {
498+
col: '=col'
499+
},
500+
template: '<div ng-messages="col"><ng-transclude></ng-transclude></div>'
501+
};
502+
});
503+
});
504+
505+
inject(function($rootScope, $compile) {
506+
507+
element = $compile('<div><div ng-if="show"><div message-wrap col="col">' +
508+
' <div ng-message="a">A</div>' +
509+
' <div ng-message="b">B</div>' +
510+
'</div></div></div>')($rootScope);
511+
512+
$rootScope.$apply(function() {
513+
$rootScope.show = true;
514+
$rootScope.col = {
515+
a: true,
516+
b: true
517+
};
518+
});
519+
520+
expect(messageChildren(element).length).toBe(1);
521+
expect(trim(element.text())).toEqual('A');
522+
523+
$rootScope.$apply('show = false');
524+
525+
expect(messageChildren(element).length).toBe(0);
526+
});
527+
});
528+
529+
530+
it('should not crash when the first of two nested messages is removed', function() {
531+
inject(function($rootScope, $compile) {
532+
533+
element = $compile(
534+
'<div ng-messages="col">' +
535+
'<div class="wrapper">' +
536+
'<div remove-me ng-message="a">A</div>' +
537+
'<div ng-message="b">B</div>' +
538+
'</div>' +
539+
'</div>'
540+
)($rootScope);
541+
542+
$rootScope.$apply(function() {
543+
$rootScope.col = {
544+
a: true,
545+
b: false
546+
};
547+
});
548+
549+
expect(messageChildren(element).length).toBe(1);
550+
expect(trim(element.text())).toEqual('A');
551+
552+
var ctrl = element.controller('ngMessages');
553+
var deregisterSpy = spyOn(ctrl, 'deregister').and.callThrough();
554+
555+
var nodeA = element[0].querySelector('[ng-message="a"]');
556+
jqLite(nodeA).remove();
557+
$rootScope.$digest(); // The next digest triggers the error
558+
559+
// Make sure removing the element triggers the deregistration in ngMessages
560+
expect(trim(deregisterSpy.calls.mostRecent().args[0].nodeValue)).toBe('ngMessage: a');
561+
expect(messageChildren(element).length).toBe(0);
562+
});
563+
});
564+
565+
566+
it('should not crash, but show deeply nested messages correctly after a message ' +
567+
'has been removed', function() {
568+
inject(function($rootScope, $compile) {
569+
570+
element = $compile(
571+
'<div ng-messages="col" ng-messages-multiple>' +
572+
'<div class="another-wrapper">' +
573+
'<div ng-message="a">A</div>' +
574+
'<div class="wrapper">' +
575+
'<div ng-message="b">B</div>' +
576+
'<div ng-message="c">C</div>' +
577+
'</div>' +
578+
'<div ng-message="d">D</div>' +
579+
'</div>' +
580+
'</div>'
581+
)($rootScope);
582+
583+
$rootScope.$apply(function() {
584+
$rootScope.col = {
585+
a: true,
586+
b: true
587+
};
588+
});
589+
590+
expect(messageChildren(element).length).toBe(2);
591+
expect(trim(element.text())).toEqual('AB');
592+
593+
var ctrl = element.controller('ngMessages');
594+
var deregisterSpy = spyOn(ctrl, 'deregister').and.callThrough();
595+
596+
var nodeB = element[0].querySelector('[ng-message="b"]');
597+
jqLite(nodeB).remove();
598+
$rootScope.$digest(); // The next digest triggers the error
599+
600+
// Make sure removing the element triggers the deregistration in ngMessages
601+
expect(trim(deregisterSpy.calls.mostRecent().args[0].nodeValue)).toBe('ngMessage: b');
602+
expect(messageChildren(element).length).toBe(1);
603+
expect(trim(element.text())).toEqual('A');
604+
});
605+
});
606+
});
607+
488608
describe('when including templates', function() {
489609
they('should work with a dynamic collection model which is managed by ngRepeat',
490610
{'<div ng-messages-include="...">': '<div ng-messages="item">' +
@@ -691,6 +811,37 @@ describe('ngMessages', function() {
691811
expect(trim(element.text())).toEqual("C");
692812
}));
693813

814+
815+
it('should properly detect a previous message, even if it was registered later',
816+
inject(function($compile, $rootScope, $templateCache) {
817+
$templateCache.put('include.html', '<div ng-message="a">A</div>');
818+
var html =
819+
'<div ng-messages="items">' +
820+
'<div ng-include="\'include.html\'"></div>' +
821+
'<div ng-message="b">B</div>' +
822+
'<div ng-message="c">C</div>' +
823+
'</div>';
824+
825+
element = $compile(html)($rootScope);
826+
$rootScope.$apply('items = {b: true, c: true}');
827+
828+
expect(element.text()).toBe('B');
829+
830+
var ctrl = element.controller('ngMessages');
831+
var deregisterSpy = spyOn(ctrl, 'deregister').and.callThrough();
832+
833+
var nodeB = element[0].querySelector('[ng-message="b"]');
834+
jqLite(nodeB).remove();
835+
836+
// Make sure removing the element triggers the deregistration in ngMessages
837+
expect(trim(deregisterSpy.calls.mostRecent().args[0].nodeValue)).toBe('ngMessage: b');
838+
839+
$rootScope.$apply('items.a = true');
840+
841+
expect(element.text()).toBe('A');
842+
})
843+
);
844+
694845
});
695846

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

0 commit comments

Comments
 (0)