Skip to content

Commit c133ef8

Browse files
committed
fix($animate): avoid memory leak with $animate.enabled(element, enabled)
When disabling/enabling animations on a specific element (via `$animate.enabled(element, enabled)`), the element is added in a map to track its state. Previously, the element was never removed from the map, causing AngularJS to hold on to the element even after it is removed from the DOM, thus preventing it from being garbage collected. This commit fixes it by removing the element from the map on `$destroy`. Fixes angular#16637. Closes angular#16649
1 parent aa7d45e commit c133ef8

File tree

4 files changed

+80
-4
lines changed

4 files changed

+80
-4
lines changed

src/apis.js

+8-4
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,10 @@ function NgMapShim() {
4545
}
4646
NgMapShim.prototype = {
4747
_idx: function(key) {
48-
if (key === this._lastKey) {
49-
return this._lastIndex;
48+
if (key !== this._lastKey) {
49+
this._lastKey = key;
50+
this._lastIndex = this._keys.indexOf(key);
5051
}
51-
this._lastKey = key;
52-
this._lastIndex = this._keys.indexOf(key);
5352
return this._lastIndex;
5453
},
5554
_transformKey: function(key) {
@@ -62,6 +61,11 @@ NgMapShim.prototype = {
6261
return this._values[idx];
6362
}
6463
},
64+
has: function(key) {
65+
key = this._transformKey(key);
66+
var idx = this._idx(key);
67+
return idx !== -1;
68+
},
6569
set: function(key, value) {
6670
key = this._transformKey(key);
6771
var idx = this._idx(key);

src/ngAnimate/animateQueue.js

+9
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ var $$AnimateQueueProvider = ['$animateProvider', /** @this */ function($animate
120120
var disabledElementsLookup = new $$Map();
121121
var animationsEnabled = null;
122122

123+
function removeFromDisabledElementsLookup(evt) {
124+
disabledElementsLookup.delete(evt.target);
125+
}
126+
123127
function postDigestTaskFactory() {
124128
var postDigestCalled = false;
125129
return function(fn) {
@@ -303,6 +307,11 @@ var $$AnimateQueueProvider = ['$animateProvider', /** @this */ function($animate
303307
bool = !disabledElementsLookup.get(node);
304308
} else {
305309
// (element, bool) - Element setter
310+
if (!disabledElementsLookup.has(node)) {
311+
// The element is added to the map for the first time.
312+
// Create a listener to remove it on `$destroy` (to avoid memory leak).
313+
jqLite(element).on('$destroy', removeFromDisabledElementsLookup);
314+
}
306315
disabledElementsLookup.set(node, !bool);
307316
}
308317
}

test/ApiSpecs.js

+28
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,34 @@ describe('api', function() {
8989
expect(map.get(keys[2])).toBe(values[2]);
9090
});
9191

92+
it('should return if a key exists or not', function() {
93+
var map = new NgMapShim();
94+
var keys = ['foo', {}];
95+
96+
expect(map.has(keys[0])).toBe(false);
97+
expect(map.has(keys[1])).toBe(false);
98+
99+
map.set(keys[0], 'bar');
100+
expect(map.has(keys[0])).toBe(true);
101+
expect(map.has(keys[1])).toBe(false);
102+
103+
map.set(keys[1], 'baz');
104+
expect(map.has(keys[0])).toBe(true);
105+
expect(map.has(keys[1])).toBe(true);
106+
107+
map.delete(keys[0]);
108+
expect(map.has(keys[0])).toBe(false);
109+
expect(map.has(keys[1])).toBe(true);
110+
111+
map.delete(keys[1]);
112+
expect(map.has(keys[0])).toBe(false);
113+
expect(map.has(keys[1])).toBe(false);
114+
115+
map.set(keys[1], 'qux');
116+
expect(map.has(keys[0])).toBe(false);
117+
expect(map.has(keys[1])).toBe(true);
118+
});
119+
92120
it('should be able to deal with `NaN` keys', function() {
93121
var map = new NgMapShim();
94122

test/ngAnimate/animateSpec.js

+35
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,41 @@ describe('animations', function() {
577577
$rootScope.$digest();
578578
expect(capturedAnimation).toBeTruthy();
579579
}));
580+
581+
it('should remove the element from the `disabledElementsLookup` map on `$destroy`',
582+
inject(function($$Map, $animate, $rootScope) {
583+
584+
var setSpy = spyOn($$Map.prototype, 'set').and.callThrough();
585+
var deleteSpy = spyOn($$Map.prototype, 'delete').and.callThrough();
586+
587+
parent.append(element);
588+
589+
$animate.enabled(element, false);
590+
$animate.enabled(element, true);
591+
$animate.enabled(element, false);
592+
expect(setSpy).toHaveBeenCalledWith(element[0], jasmine.any(Boolean));
593+
expect(deleteSpy).not.toHaveBeenCalledWith(element[0]);
594+
expect($animate.enabled(element)).toBe(false);
595+
596+
// No clean-up on `detach` (no `$destroy` event).
597+
element.detach();
598+
expect(deleteSpy).not.toHaveBeenCalledWith(element[0]);
599+
expect($animate.enabled(element)).toBe(false);
600+
601+
// Clean-up on `remove` (causes `$destroy` event).
602+
element.remove();
603+
expect(deleteSpy).toHaveBeenCalledOnceWith(element[0]);
604+
expect($animate.enabled(element)).toBe(true);
605+
606+
deleteSpy.calls.reset();
607+
608+
element.triggerHandler('$destroy');
609+
expect(deleteSpy).not.toHaveBeenCalledWith(element[0]);
610+
611+
$animate.enabled(element, true);
612+
element.triggerHandler('$destroy');
613+
expect(deleteSpy).toHaveBeenCalledOnceWith(element[0]);
614+
}));
580615
});
581616

582617
it('should strip all comment nodes from the animation and not issue an animation if not real elements are found',

0 commit comments

Comments
 (0)