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

Commit 701aeeb

Browse files
feat($rootScope): allow suspending and resuming watchers on scope
This can be very helpful for external modules that help making the digest loop faster by ignoring some of the watchers under some circumstance. Example: https://github.com/shahata/angular-viewport-watch Thanks to @shahata for the original implementation. Closes #5301
1 parent aa3f951 commit 701aeeb

File tree

2 files changed

+215
-2
lines changed

2 files changed

+215
-2
lines changed

src/ng/rootScope.js

+93-2
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ function $RootScopeProvider() {
9191
this.$$watchersCount = 0;
9292
this.$id = nextUid();
9393
this.$$ChildScope = null;
94+
this.$$suspended = false;
9495
}
9596
ChildScope.prototype = parent;
9697
return ChildScope;
@@ -178,6 +179,7 @@ function $RootScopeProvider() {
178179
this.$$childHead = this.$$childTail = null;
179180
this.$root = this;
180181
this.$$destroyed = false;
182+
this.$$suspended = false;
181183
this.$$listeners = {};
182184
this.$$listenerCount = {};
183185
this.$$watchersCount = 0;
@@ -811,7 +813,7 @@ function $RootScopeProvider() {
811813

812814
traverseScopesLoop:
813815
do { // "traverse the scopes" loop
814-
if ((watchers = current.$$watchers)) {
816+
if ((watchers = !current.$$suspended && current.$$watchers)) {
815817
// process our watches
816818
watchers.$$digestWatchIndex = watchers.length;
817819
while (watchers.$$digestWatchIndex--) {
@@ -855,7 +857,7 @@ function $RootScopeProvider() {
855857
// Insanity Warning: scope depth-first traversal
856858
// yes, this code is a bit crazy, but it works and we have tests to prove it!
857859
// this piece should be kept in sync with the traversal in $broadcast
858-
if (!(next = ((current.$$watchersCount && current.$$childHead) ||
860+
if (!(next = ((!current.$$suspended && current.$$watchersCount && current.$$childHead) ||
859861
(current !== target && current.$$nextSibling)))) {
860862
while (current !== target && !(next = current.$$nextSibling)) {
861863
current = current.$parent;
@@ -892,6 +894,95 @@ function $RootScopeProvider() {
892894
$browser.$$checkUrlChange();
893895
},
894896

897+
/**
898+
* @ngdoc method
899+
* @name $rootScope.Scope#$suspend
900+
* @kind function
901+
*
902+
* @description
903+
* Suspend watchers of this scope subtree so that they will not be invoked during digest.
904+
*
905+
* This can be used to optimize your application when you know that running those watchers
906+
* is redundant.
907+
*
908+
* **Warning**
909+
*
910+
* Suspending scopes from the digest cycle can have unwanted and difficult to debug results.
911+
* Only use this approach if you are confident that you know what you are doing and have
912+
* ample tests to ensure that bindings get updated as you expect.
913+
*
914+
* Some of the things to consider are:
915+
*
916+
* * Any external event on a directive/component will not trigger a digest while the hosting
917+
* scope is suspended - even if the event handler calls `$apply()` or `$digest()`.
918+
* * Transcluded content exists on a scope that inherits from outside a directive but exists
919+
* as a child of the directive's containing scope. If the containing scope is suspended the
920+
* transcluded scope will also be suspended, even if the scope from which the transcluded
921+
* scope inherits is not suspended.
922+
* * Multiple directives trying to manage the suspended status of a scope can confuse each other:
923+
* * A call to `$suspend` an already suspended scope is a no-op.
924+
* * A call to `$resume` a non-suspended scope is a no-op.
925+
* * If two directives suspend a scope, then one of them resumes the scope, the scope will no
926+
* longer be suspended. This could result in the other directive believing a scope to be
927+
* suspended when it is not.
928+
* * If a parent scope is suspended then all its descendants will be also excluded from future
929+
* digests whether or not they have been suspended themselves. Note that this also applies to
930+
* isolate child scopes.
931+
* * Calling `$digest()` directly on a descendant of a suspended scope will still run the watchers
932+
* for that scope and its descendants. When digesting we only check whether the current scope is
933+
* locally suspended, rather than checking whether it has a suspended ancestor.
934+
* * Calling `$resume()` on a scope that has a suspended ancestor will not cause the scope to be
935+
* included in future digests until all its ancestors have been resumed.
936+
* * Resolved promises, e.g. from explicit `$q` deferreds and `$http` calls, trigger `$apply()`
937+
* against the `$rootScope` and so will still trigger a global digest even if the promise was
938+
* initialiated by a component that lives on a suspended scope.
939+
*/
940+
$suspend: function() {
941+
this.$$suspended = true;
942+
},
943+
944+
/**
945+
* @ngdoc method
946+
* @name $rootScope.Scope#$isSuspended
947+
* @kind function
948+
*
949+
* @description
950+
* Call this method to determine if this scope has been explicitly suspended. It will not
951+
* tell you whether an ancestor has been suspended.
952+
* To determine if this scope will be excluded from a digest triggered at the $rootScope,
953+
* for example, you must check all its ancestors:
954+
*
955+
* ```
956+
* function isExcludedFromDigest(scope) {
957+
* while(scope) {
958+
* if (scope.$isSuspended()) return true;
959+
* scope = scope.$parent;
960+
* }
961+
* return false;
962+
* ```
963+
*
964+
* Be aware that a scope may not be included in digests if it has a suspended ancestor,
965+
* even if `$isSuspended()` returns false.
966+
*
967+
* @returns true if the current scope has been suspended.
968+
*/
969+
$isSuspended: function() {
970+
return this.$$suspended;
971+
},
972+
973+
/**
974+
* @ngdoc method
975+
* @name $rootScope.Scope#$resume
976+
* @kind function
977+
*
978+
* @description
979+
* Resume watchers of this scope subtree in case it was suspended.
980+
*
981+
* See {@link $rootScope.Scope#$suspend} for information about the dangers of using this approach.
982+
*/
983+
$resume: function() {
984+
this.$$suspended = false;
985+
},
895986

896987
/**
897988
* @ngdoc event

test/ng/rootScopeSpec.js

+122
Original file line numberDiff line numberDiff line change
@@ -1255,6 +1255,128 @@ describe('Scope', function() {
12551255
});
12561256
});
12571257

1258+
1259+
describe('$suspend/$resume/$isSuspended', function() {
1260+
it('should suspend watchers on scope', inject(function($rootScope) {
1261+
var watchSpy = jasmine.createSpy('watchSpy');
1262+
$rootScope.$watch(watchSpy);
1263+
$rootScope.$suspend();
1264+
$rootScope.$digest();
1265+
expect(watchSpy).not.toHaveBeenCalled();
1266+
}));
1267+
1268+
it('should resume watchers on scope', inject(function($rootScope) {
1269+
var watchSpy = jasmine.createSpy('watchSpy');
1270+
$rootScope.$watch(watchSpy);
1271+
$rootScope.$suspend();
1272+
$rootScope.$resume();
1273+
$rootScope.$digest();
1274+
expect(watchSpy).toHaveBeenCalled();
1275+
}));
1276+
1277+
it('should suspend watchers on child scope', inject(function($rootScope) {
1278+
var watchSpy = jasmine.createSpy('watchSpy');
1279+
var scope = $rootScope.$new(true);
1280+
scope.$watch(watchSpy);
1281+
$rootScope.$suspend();
1282+
$rootScope.$digest();
1283+
expect(watchSpy).not.toHaveBeenCalled();
1284+
}));
1285+
1286+
it('should resume watchers on child scope', inject(function($rootScope) {
1287+
var watchSpy = jasmine.createSpy('watchSpy');
1288+
var scope = $rootScope.$new(true);
1289+
scope.$watch(watchSpy);
1290+
$rootScope.$suspend();
1291+
$rootScope.$resume();
1292+
$rootScope.$digest();
1293+
expect(watchSpy).toHaveBeenCalled();
1294+
}));
1295+
1296+
it('should resume digesting immediately if `$resume` is called from an ancestor scope watch handler', inject(function($rootScope) {
1297+
var watchSpy = jasmine.createSpy('watchSpy');
1298+
var scope = $rootScope.$new();
1299+
1300+
// Setup a handler that will toggle the scope suspension
1301+
$rootScope.$watch('a', function(a) { if (a) scope.$resume(); else scope.$suspend(); });
1302+
1303+
// Spy on the scope watches being called
1304+
scope.$watch(watchSpy);
1305+
1306+
// Trigger a digest that should suspend the scope from within the watch handler
1307+
$rootScope.$apply('a = false');
1308+
// The scope is suspended before it gets to do a digest
1309+
expect(watchSpy).not.toHaveBeenCalled();
1310+
1311+
// Trigger a digest that should resume the scope from within the watch handler
1312+
$rootScope.$apply('a = true');
1313+
// The watch handler that resumes the scope is in the parent, so the resumed scope will digest immediately
1314+
expect(watchSpy).toHaveBeenCalled();
1315+
}));
1316+
1317+
it('should resume digesting immediately if `$resume` is called from a non-ancestor scope watch handler', inject(function($rootScope) {
1318+
var watchSpy = jasmine.createSpy('watchSpy');
1319+
var scope = $rootScope.$new();
1320+
var sibling = $rootScope.$new();
1321+
1322+
// Setup a handler that will toggle the scope suspension
1323+
sibling.$watch('a', function(a) { if (a) scope.$resume(); else scope.$suspend(); });
1324+
1325+
// Spy on the scope watches being called
1326+
scope.$watch(watchSpy);
1327+
1328+
// Trigger a digest that should suspend the scope from within the watch handler
1329+
$rootScope.$apply('a = false');
1330+
// The scope is suspended by the sibling handler after the scope has already digested
1331+
expect(watchSpy).toHaveBeenCalled();
1332+
watchSpy.calls.reset();
1333+
1334+
// Trigger a digest that should resume the scope from within the watch handler
1335+
$rootScope.$apply('a = true');
1336+
// The watch handler that resumes the scope marks the digest as dirty, so it will run an extra digest
1337+
expect(watchSpy).toHaveBeenCalled();
1338+
}));
1339+
1340+
it('should not suspend watchers on parent or sibling scopes', inject(function($rootScope) {
1341+
var watchSpyParent = jasmine.createSpy('watchSpyParent');
1342+
var watchSpyChild = jasmine.createSpy('watchSpyChild');
1343+
var watchSpySibling = jasmine.createSpy('watchSpySibling');
1344+
1345+
var parent = $rootScope.$new();
1346+
parent.$watch(watchSpyParent);
1347+
var child = parent.$new();
1348+
child.$watch(watchSpyChild);
1349+
var sibling = parent.$new();
1350+
sibling.$watch(watchSpySibling);
1351+
1352+
child.$suspend();
1353+
$rootScope.$digest();
1354+
expect(watchSpyParent).toHaveBeenCalled();
1355+
expect(watchSpyChild).not.toHaveBeenCalled();
1356+
expect(watchSpySibling).toHaveBeenCalled();
1357+
}));
1358+
1359+
it('should return true from `$isSuspended()` when a scope is suspended', inject(function($rootScope) {
1360+
$rootScope.$suspend();
1361+
expect($rootScope.$isSuspended()).toBe(true);
1362+
$rootScope.$resume();
1363+
expect($rootScope.$isSuspended()).toBe(false);
1364+
}));
1365+
1366+
it('should return false from `$isSuspended()` for a non-suspended scope that has a suspended ancestor', inject(function($rootScope) {
1367+
var childScope = $rootScope.$new();
1368+
$rootScope.$suspend();
1369+
expect(childScope.$isSuspended()).toBe(false);
1370+
childScope.$suspend();
1371+
expect(childScope.$isSuspended()).toBe(true);
1372+
childScope.$resume();
1373+
expect(childScope.$isSuspended()).toBe(false);
1374+
$rootScope.$resume();
1375+
expect(childScope.$isSuspended()).toBe(false);
1376+
}));
1377+
});
1378+
1379+
12581380
describe('optimizations', function() {
12591381

12601382
function setupWatches(scope, log) {

0 commit comments

Comments
 (0)