Skip to content

Commit f555340

Browse files
committed
fix($rootScope): fix potential memory leak when removing scope listeners
Previously the array entry for listeners was set to null but the array size was not trimmed until the event was broadcasted again (see e6966e0). By keeping track of the listener iteration index globally it can be adjusted if a listener removal effects the index. Fixes angular#16135 BREAKING CHANGE: `$emit`/`$broadcast` listeners of a specific event name on a scope can no longer be recursivly invoked.
1 parent c15c8a1 commit f555340

File tree

3 files changed

+146
-48
lines changed

3 files changed

+146
-48
lines changed
+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
@ngdoc error
2+
@name $rootScope:inevt
3+
@fullName Recursive $emit/$broadcast event
4+
@description
5+
6+
This error occurs when the an event is `$emit`ed or `$broadcast`ed recursively on a scope.
7+
8+
For example, when an event listener fires the same event being listened to.
9+
10+
```
11+
$scope.$on('foo', function() {
12+
$scope.$emit('foo');
13+
});
14+
```
15+
16+
Or when a parent element causes indirect recursion.
17+
18+
```
19+
$scope.$on('foo', function() {
20+
$rootScope.$broadcast('foo');
21+
});
22+
```

src/ng/rootScope.js

+35-46
Original file line numberDiff line numberDiff line change
@@ -1167,7 +1167,7 @@ function $RootScopeProvider() {
11671167
$on: function(name, listener) {
11681168
var namedListeners = this.$$listeners[name];
11691169
if (!namedListeners) {
1170-
this.$$listeners[name] = namedListeners = [];
1170+
this.$$listeners[name] = namedListeners = extend([], {$$index: -1});
11711171
}
11721172
namedListeners.push(listener);
11731173

@@ -1181,10 +1181,14 @@ function $RootScopeProvider() {
11811181

11821182
var self = this;
11831183
return function() {
1184-
var indexOfListener = namedListeners.indexOf(listener);
1185-
if (indexOfListener !== -1) {
1186-
namedListeners[indexOfListener] = null;
1184+
var index = arrayRemove(namedListeners, listener);
1185+
if (index >= 0) {
11871186
decrementListenerCount(self, 1, name);
1187+
// We are removing a listener while iterating over the list of listeners.
1188+
// Update the current $$index if necessary to ensure no listener is skipped.
1189+
if (index <= namedListeners.$$index) {
1190+
namedListeners.$$index--;
1191+
}
11881192
}
11891193
};
11901194
},
@@ -1213,9 +1217,7 @@ function $RootScopeProvider() {
12131217
* @return {Object} Event object (see {@link ng.$rootScope.Scope#$on}).
12141218
*/
12151219
$emit: function(name, args) {
1216-
var empty = [],
1217-
namedListeners,
1218-
scope = this,
1220+
var scope = this,
12191221
stopPropagation = false,
12201222
event = {
12211223
name: name,
@@ -1226,28 +1228,11 @@ function $RootScopeProvider() {
12261228
},
12271229
defaultPrevented: false
12281230
},
1229-
listenerArgs = concat([event], arguments, 1),
1230-
i, length;
1231+
listenerArgs = concat([event], arguments, 1);
12311232

12321233
do {
1233-
namedListeners = scope.$$listeners[name] || empty;
1234-
event.currentScope = scope;
1235-
for (i = 0, length = namedListeners.length; i < length; i++) {
1236-
1237-
// if listeners were deregistered, defragment the array
1238-
if (!namedListeners[i]) {
1239-
namedListeners.splice(i, 1);
1240-
i--;
1241-
length--;
1242-
continue;
1243-
}
1244-
try {
1245-
//allow all listeners attached to the current scope to run
1246-
namedListeners[i].apply(null, listenerArgs);
1247-
} catch (e) {
1248-
$exceptionHandler(e);
1249-
}
1250-
}
1234+
invokeListeners(scope, event, listenerArgs, name);
1235+
12511236
//if any listener on the current scope stops propagation, prevent bubbling
12521237
if (stopPropagation) {
12531238
event.currentScope = null;
@@ -1299,28 +1284,11 @@ function $RootScopeProvider() {
12991284

13001285
if (!target.$$listenerCount[name]) return event;
13011286

1302-
var listenerArgs = concat([event], arguments, 1),
1303-
listeners, i, length;
1287+
var listenerArgs = concat([event], arguments, 1);
13041288

13051289
//down while you can, then up and next sibling or up and next sibling until back at root
13061290
while ((current = next)) {
1307-
event.currentScope = current;
1308-
listeners = current.$$listeners[name] || [];
1309-
for (i = 0, length = listeners.length; i < length; i++) {
1310-
// if listeners were deregistered, defragment the array
1311-
if (!listeners[i]) {
1312-
listeners.splice(i, 1);
1313-
i--;
1314-
length--;
1315-
continue;
1316-
}
1317-
1318-
try {
1319-
listeners[i].apply(null, listenerArgs);
1320-
} catch (e) {
1321-
$exceptionHandler(e);
1322-
}
1323-
}
1291+
invokeListeners(current, event, listenerArgs, name);
13241292

13251293
// Insanity Warning: scope depth-first traversal
13261294
// yes, this code is a bit crazy, but it works and we have tests to prove it!
@@ -1350,6 +1318,27 @@ function $RootScopeProvider() {
13501318

13511319
return $rootScope;
13521320

1321+
function invokeListeners(scope, event, listenerArgs, name) {
1322+
var listeners = scope.$$listeners[name];
1323+
if (listeners) {
1324+
if (listeners.$$index !== -1) {
1325+
throw $rootScopeMinErr('inevt', '{0} already $emit/$broadcast-ing on scope ({1})', name, scope.$id);
1326+
}
1327+
event.currentScope = scope;
1328+
try {
1329+
for (listeners.$$index = 0; listeners.$$index < listeners.length; listeners.$$index++) {
1330+
try {
1331+
//allow all listeners attached to the current scope to run
1332+
listeners[listeners.$$index].apply(null, listenerArgs);
1333+
} catch (e) {
1334+
$exceptionHandler(e);
1335+
}
1336+
}
1337+
} finally {
1338+
listeners.$$index = -1;
1339+
}
1340+
}
1341+
}
13531342

13541343
function beginPhase(phase) {
13551344
if ($rootScope.$$phase) {

test/ng/rootScopeSpec.js

+89-2
Original file line numberDiff line numberDiff line change
@@ -2316,6 +2316,19 @@ describe('Scope', function() {
23162316
}));
23172317

23182318

2319+
// See issue https://github.com/angular/angular.js/issues/16135
2320+
it('should deallocate the listener array entry', inject(function($rootScope) {
2321+
var remove1 = $rootScope.$on('abc', noop);
2322+
$rootScope.$on('abc', noop);
2323+
2324+
expect($rootScope.$$listeners['abc'].length).toBe(2);
2325+
2326+
remove1();
2327+
2328+
expect($rootScope.$$listeners['abc'].length).toBe(1);
2329+
}));
2330+
2331+
23192332
it('should call next listener after removing the current listener via its own handler', inject(function($rootScope) {
23202333
var listener1 = jasmine.createSpy('listener1').and.callFake(function() { remove1(); });
23212334
var remove1 = $rootScope.$on('abc', listener1);
@@ -2448,6 +2461,80 @@ describe('Scope', function() {
24482461
expect($rootScope.$$listenerCount).toEqual({abc: 1});
24492462
expect(child.$$listenerCount).toEqual({abc: 1});
24502463
}));
2464+
2465+
2466+
it('should throw on recursive $broadcast', inject(function($rootScope) {
2467+
$rootScope.$on('e', function() { $rootScope.$broadcast('e'); });
2468+
2469+
expect(function() { $rootScope.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2470+
expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2471+
}));
2472+
2473+
2474+
it('should throw on nested recursive $broadcast', inject(function($rootScope) {
2475+
$rootScope.$on('e2', function() { $rootScope.$broadcast('e'); });
2476+
$rootScope.$on('e', function() { $rootScope.$broadcast('e2'); });
2477+
2478+
expect(function() { $rootScope.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2479+
expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2480+
}));
2481+
2482+
2483+
it('should throw on recursive $emit', inject(function($rootScope) {
2484+
$rootScope.$on('e', function() { $rootScope.$emit('e'); });
2485+
2486+
expect(function() { $rootScope.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2487+
expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2488+
}));
2489+
2490+
2491+
it('should throw on nested recursive $emit', inject(function($rootScope) {
2492+
$rootScope.$on('e2', function() { $rootScope.$emit('e'); });
2493+
$rootScope.$on('e', function() { $rootScope.$emit('e2'); });
2494+
2495+
expect(function() { $rootScope.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2496+
expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2497+
}));
2498+
2499+
2500+
it('should throw on recursive $broadcast on child listener', inject(function($rootScope) {
2501+
var child = $rootScope.$new();
2502+
child.$on('e', function() { $rootScope.$broadcast('e'); });
2503+
2504+
expect(function() { child.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (2)');
2505+
expect(function() { child.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (2)');
2506+
}));
2507+
2508+
2509+
it('should throw on nested recursive $broadcast on child listener', inject(function($rootScope) {
2510+
var child = $rootScope.$new();
2511+
child.$on('e2', function() { $rootScope.$broadcast('e'); });
2512+
child.$on('e', function() { $rootScope.$broadcast('e2'); });
2513+
2514+
expect(function() { child.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (2)');
2515+
expect(function() { child.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (2)');
2516+
}));
2517+
2518+
2519+
it('should throw on recursive $emit parent listener', inject(function($rootScope) {
2520+
var child = $rootScope.$new();
2521+
$rootScope.$on('e', function() { child.$emit('e'); });
2522+
2523+
expect(function() { child.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2524+
expect(function() { $rootScope.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2525+
expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2526+
}));
2527+
2528+
2529+
it('should throw on nested recursive $emit parent listener', inject(function($rootScope) {
2530+
var child = $rootScope.$new();
2531+
$rootScope.$on('e2', function() { child.$emit('e'); });
2532+
$rootScope.$on('e', function() { child.$emit('e2'); });
2533+
2534+
expect(function() { child.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2535+
expect(function() { $rootScope.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2536+
expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2537+
}));
24512538
});
24522539
});
24532540

@@ -2537,7 +2624,7 @@ describe('Scope', function() {
25372624
expect(spy1).toHaveBeenCalledOnce();
25382625
expect(spy2).toHaveBeenCalledOnce();
25392626
expect(spy3).toHaveBeenCalledOnce();
2540-
expect(child.$$listeners['evt'].length).toBe(3); // cleanup will happen on next $emit
2627+
expect(child.$$listeners['evt'].length).toBe(2);
25412628

25422629
spy1.calls.reset();
25432630
spy2.calls.reset();
@@ -2571,7 +2658,7 @@ describe('Scope', function() {
25712658
expect(spy1).toHaveBeenCalledOnce();
25722659
expect(spy2).toHaveBeenCalledOnce();
25732660
expect(spy3).toHaveBeenCalledOnce();
2574-
expect(child.$$listeners['evt'].length).toBe(3); //cleanup will happen on next $broadcast
2661+
expect(child.$$listeners['evt'].length).toBe(2);
25752662

25762663
spy1.calls.reset();
25772664
spy2.calls.reset();

0 commit comments

Comments
 (0)