Skip to content

Commit ec1c5df

Browse files
mheveryIgorMinar
authored andcommitted
fix(jqLite): .data()/.bind() memory leak
Since angular attaches scope/injector/controller into DOM it should clean up after itself. No need to complain about memory leaks, since they can only happened on detached DOM. Detached DOM would only be in tests, since in production the DOM would be attached to render tree and removal would automatically clear memory.
1 parent 24e7da4 commit ec1c5df

File tree

5 files changed

+135
-51
lines changed

5 files changed

+135
-51
lines changed

src/jqLite.js

+52-48
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
* @returns {Object} jQuery object.
7575
*/
7676

77-
var jqCache = {},
77+
var jqCache = JQLite.cache = {},
7878
jqName = JQLite.expando = 'ng-' + new Date().getTime(),
7979
jqId = 1,
8080
addEventListenerFn = (window.document.addEventListener
@@ -122,15 +122,15 @@ function JQLitePatchJQueryRemove(name, dispatchThis) {
122122
fireEvent = dispatchThis,
123123
set, setIndex, setLength,
124124
element, childIndex, childLength, children,
125-
fns, data;
125+
fns, events;
126126

127127
while(list.length) {
128128
set = list.shift();
129129
for(setIndex = 0, setLength = set.length; setIndex < setLength; setIndex++) {
130130
element = jqLite(set[setIndex]);
131131
if (fireEvent) {
132-
data = element.data('events');
133-
if ( (fns = data && data.$destroy) ) {
132+
events = element.data('events');
133+
if ( (fns = events && events.$destroy) ) {
134134
forEach(fns, function(fn){
135135
fn.handler();
136136
});
@@ -185,19 +185,35 @@ function JQLiteDealoc(element){
185185
}
186186
}
187187

188+
function JQLiteUnbind(element, type, fn) {
189+
var events = JQLiteData(element, 'events'),
190+
handle = JQLiteData(element, 'handle');
191+
192+
if (!handle) return; //no listeners registered
193+
194+
if (isUndefined(type)) {
195+
forEach(events, function(eventHandler, type) {
196+
removeEventListenerFn(element, type, eventHandler);
197+
delete events[type];
198+
});
199+
} else {
200+
if (isUndefined(fn)) {
201+
removeEventListenerFn(element, type, events[type]);
202+
delete events[type];
203+
} else {
204+
arrayRemove(events[type], fn);
205+
}
206+
}
207+
}
208+
188209
function JQLiteRemoveData(element) {
189210
var cacheId = element[jqName],
190211
cache = jqCache[cacheId];
191212

192213
if (cache) {
193-
if (cache.bind) {
194-
forEach(cache.bind, function(fn, type){
195-
if (type == '$destroy') {
196-
fn({});
197-
} else {
198-
removeEventListenerFn(element, type, fn);
199-
}
200-
});
214+
if (cache.handle) {
215+
cache.events.$destroy && cache.handle({}, '$destroy');
216+
JQLiteUnbind(element);
201217
}
202218
delete jqCache[cacheId];
203219
element[jqName] = undefined; // ie does not allow deletion of attributes on elements.
@@ -499,8 +515,8 @@ forEach({
499515
};
500516
});
501517

502-
function createEventHandler(element) {
503-
var eventHandler = function (event) {
518+
function createEventHandler(element, events) {
519+
var eventHandler = function (event, type) {
504520
if (!event.preventDefault) {
505521
event.preventDefault = function() {
506522
event.returnValue = false; //ie
@@ -530,8 +546,12 @@ function createEventHandler(element) {
530546
return event.defaultPrevented;
531547
};
532548

533-
forEach(eventHandler.fns, function(fn){
534-
fn.call(element, event);
549+
forEach(events[type || event.type], function(fn) {
550+
try {
551+
fn.call(element, event);
552+
} catch (e) {
553+
// Not much to do here since jQuery ignores these anyway
554+
}
535555
});
536556

537557
// Remove monkey-patched methods (IE),
@@ -548,7 +568,7 @@ function createEventHandler(element) {
548568
delete event.isDefaultPrevented;
549569
}
550570
};
551-
eventHandler.fns = [];
571+
eventHandler.elem = element;
552572
return eventHandler;
553573
}
554574

@@ -563,61 +583,45 @@ forEach({
563583
dealoc: JQLiteDealoc,
564584

565585
bind: function bindFn(element, type, fn){
566-
var bind = JQLiteData(element, 'bind');
586+
var events = JQLiteData(element, 'events'),
587+
handle = JQLiteData(element, 'handle');
567588

589+
if (!events) JQLiteData(element, 'events', events = {});
590+
if (!handle) JQLiteData(element, 'handle', handle = createEventHandler(element, events));
568591

569-
if (!bind) JQLiteData(element, 'bind', bind = {});
570592
forEach(type.split(' '), function(type){
571-
var eventHandler = bind[type];
572-
593+
var eventFns = events[type];
573594

574-
if (!eventHandler) {
595+
if (!eventFns) {
575596
if (type == 'mouseenter' || type == 'mouseleave') {
576-
var mouseenter = bind.mouseenter = createEventHandler(element);
577-
var mouseleave = bind.mouseleave = createEventHandler(element);
578597
var counter = 0;
579598

599+
events.mouseenter = [];
600+
events.mouseleave = [];
580601

581602
bindFn(element, 'mouseover', function(event) {
582603
counter++;
583604
if (counter == 1) {
584-
mouseenter(event);
605+
handle(event, 'mouseenter');
585606
}
586607
});
587608
bindFn(element, 'mouseout', function(event) {
588609
counter --;
589610
if (counter == 0) {
590-
mouseleave(event);
611+
handle(event, 'mouseleave');
591612
}
592613
});
593-
eventHandler = bind[type];
594614
} else {
595-
eventHandler = bind[type] = createEventHandler(element);
596-
addEventListenerFn(element, type, eventHandler);
615+
addEventListenerFn(element, type, handle);
616+
events[type] = [];
597617
}
618+
eventFns = events[type]
598619
}
599-
eventHandler.fns.push(fn);
620+
eventFns.push(fn);
600621
});
601622
},
602623

603-
unbind: function(element, type, fn) {
604-
var bind = JQLiteData(element, 'bind');
605-
if (!bind) return; //no listeners registered
606-
607-
if (isUndefined(type)) {
608-
forEach(bind, function(eventHandler, type) {
609-
removeEventListenerFn(element, type, eventHandler);
610-
delete bind[type];
611-
});
612-
} else {
613-
if (isUndefined(fn)) {
614-
removeEventListenerFn(element, type, bind[type]);
615-
delete bind[type];
616-
} else {
617-
arrayRemove(bind[type].fns, fn);
618-
}
619-
}
620-
},
624+
unbind: JQLiteUnbind,
621625

622626
replaceWith: function(element, replaceNode) {
623627
var index, parent = element.parentNode;

src/ngMock/angular-mocks.js

+15
Original file line numberDiff line numberDiff line change
@@ -1526,6 +1526,20 @@ angular.mock.e2e = {};
15261526
angular.mock.e2e.$httpBackendDecorator = ['$delegate', '$browser', createHttpBackendMock];
15271527

15281528

1529+
angular.mock.clearDataCache = function() {
1530+
var key,
1531+
cache = angular.element.cache;
1532+
1533+
for(key in cache) {
1534+
if (cache.hasOwnProperty(key)) {
1535+
var handle = cache[key].handle;
1536+
1537+
handle && angular.element(handle.elem).unbind();
1538+
delete cache[key];
1539+
}
1540+
}
1541+
};
1542+
15291543

15301544
window.jstestdriver && (function(window) {
15311545
/**
@@ -1550,6 +1564,7 @@ window.jasmine && (function(window) {
15501564
var spec = getCurrentSpec();
15511565
spec.$injector = null;
15521566
spec.$modules = null;
1567+
angular.mock.clearDataCache();
15531568
});
15541569

15551570
function getCurrentSpec() {

test/jqLiteSpec.js

+28-2
Original file line numberDiff line numberDiff line change
@@ -291,11 +291,37 @@ describe('jqLite', function() {
291291
expect(element.data()).toEqual({meLike: 'turtles', youLike: 'carrots', existing: 'val'});
292292
expect(element.data()).toBe(oldData); // merge into the old object
293293
});
294+
295+
describe('data cleanup', function() {
296+
it('should remove data on element removal', function() {
297+
var div = jqLite('<div><span>text</span></div>'),
298+
span = div.find('span');
299+
300+
span.data('name', 'angular');
301+
span.remove();
302+
expect(span.data('name')).toBeUndefined();
303+
});
304+
305+
it('should remove event listeners on element removal', function() {
306+
var div = jqLite('<div><span>text</span></div>'),
307+
span = div.find('span'),
308+
log = '';
309+
310+
span.bind('click', function() { log+= 'click;'});
311+
browserTrigger(span);
312+
expect(log).toEqual('click;');
313+
314+
span.remove();
315+
316+
browserTrigger(span);
317+
expect(log).toEqual('click;');
318+
});
319+
});
294320
});
295321

296322

297323
describe('attr', function() {
298-
it('shoul read write and remove attr', function() {
324+
it('should read write and remove attr', function() {
299325
var selector = jqLite([a, b]);
300326

301327
expect(selector.attr('prop', 'value')).toEqual(selector);
@@ -667,7 +693,7 @@ describe('jqLite', function() {
667693
var jWindow = jqLite(window).bind('hashchange', function() {
668694
log = 'works!';
669695
});
670-
eventFn({});
696+
eventFn({type: 'hashchange'});
671697
expect(log).toEqual('works!');
672698
dealoc(jWindow);
673699
});

test/ngMock/angular-mocksSpec.js

+40
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ describe('ngMock', function() {
148148
});
149149
});
150150

151+
151152
describe('$log', function() {
152153
var $log;
153154
beforeEach(inject(['$log', function(log) {
@@ -229,6 +230,7 @@ describe('ngMock', function() {
229230
});
230231
});
231232

233+
232234
describe('defer', function() {
233235
var browser, log;
234236
beforeEach(inject(function($browser) {
@@ -341,6 +343,44 @@ describe('ngMock', function() {
341343
});
342344
});
343345

346+
347+
describe('angular.mock.clearDataCache', function() {
348+
function keys(obj) {
349+
var keys = [];
350+
for(var key in obj) {
351+
if (obj.hasOwnProperty(key)) keys.push(key);
352+
}
353+
return keys.sort();
354+
}
355+
356+
it('should remove data', function() {
357+
expect(angular.element.cache).toEqual({});
358+
var div = angular.element('<div></div>');
359+
div.data('name', 'angular');
360+
expect(keys(angular.element.cache)).not.toEqual([]);
361+
angular.mock.clearDataCache();
362+
expect(keys(angular.element.cache)).toEqual([]);
363+
});
364+
365+
it('should deregister event handlers', function() {
366+
expect(keys(angular.element.cache)).toEqual([]);
367+
368+
var div = angular.element('<div></div>');
369+
370+
div.bind('click', angular.noop);
371+
div.bind('mousemove', angular.noop);
372+
div.data('some', 'data');
373+
expect(keys(angular.element.cache).length).toBe(1);
374+
375+
angular.mock.clearDataCache();
376+
expect(keys(angular.element.cache)).toEqual([]);
377+
expect(div.data('some')).toBeUndefined();
378+
379+
div.remove();
380+
});
381+
});
382+
383+
344384
describe('jasmine module and inject', function(){
345385
var log;
346386

test/testabilityPatch.js

-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ afterEach(function() {
4242
var count = 0;
4343
forEachSorted(jqCache, function(value, key){
4444
count ++;
45-
delete jqCache[key];
4645
forEach(value, function(value, key){
4746
if (value.$element) {
4847
dump('LEAK', key, value.$id, sortedHtml(value.$element));

0 commit comments

Comments
 (0)