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

refactor($http): coalesce calls to $rootScope.$apply WIP #7634

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions src/ng/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ function $HttpProvider() {
var JSON_START = /^\s*(\[|\{[^\{])/,
JSON_END = /[\}\]]\s*$/,
PROTECTION_PREFIX = /^\)\]\}',?\n/,
CONTENT_TYPE_APPLICATION_JSON = {'Content-Type': 'application/json;charset=utf-8'};
CONTENT_TYPE_APPLICATION_JSON = {'Content-Type': 'application/json;charset=utf-8'},
APPLY_ASYNC_WAIT = 10;

var defaults = this.defaults = {
// transform incoming response data
Expand Down Expand Up @@ -126,6 +127,23 @@ function $HttpProvider() {
*/
var interceptorFactories = this.interceptors = [];

/**
* @ngdoc method
* @name $httpProvider#debounceApply
* @description
*
* Sets the amount of milliseconds to wait before calling $digest when a response is received. If
* multiple responses are received during this window, they will all share the same digest. This
* ends up adding a small wait before handling to $http responses, but will shave time off of an
* application due to processing fewer digests.
*
* @param {number} timeout value to wait for coalesced calls to $apply. If a number is not specified,
* the value used is 0. The default is 10.
*/
this.debounceApply = function(timeout) {
APPLY_ASYNC_WAIT = timeout;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't be named like a constant

};

this.$get = ['$httpBackend', '$browser', '$cacheFactory', '$rootScope', '$q', '$injector',
function($httpBackend, $browser, $cacheFactory, $rootScope, $q, $injector) {

Expand Down Expand Up @@ -911,7 +929,7 @@ function $HttpProvider() {
}

resolvePromise(response, status, headersString, statusText);
if (!$rootScope.$$phase) $rootScope.$apply();
$rootScope.$applyAsync(APPLY_ASYNC_WAIT);
}


Expand Down
29 changes: 27 additions & 2 deletions src/ng/rootScope.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function $RootScopeProvider(){

this.$get = ['$injector', '$exceptionHandler', '$parse', '$browser',
function( $injector, $exceptionHandler, $parse, $browser) {

var $timeout;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could not inject this directly, due to circular dependencies

/**
* @ngdoc type
* @name $rootScope.Scope
Expand Down Expand Up @@ -641,7 +641,7 @@ function $RootScopeProvider(){
* ```
*
*/
$digest: function() {
$digest: function(calledByFn) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this argument is just a helper which enables a bit better support for mocking --- it could be removed and tests would need to be refactored

var watch, value, last,
watchers,
asyncQueue = this.$$asyncQueue,
Expand Down Expand Up @@ -972,6 +972,21 @@ function $RootScopeProvider(){
}
},


$applyAsync: function(timeout) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be $$? And digestAsync?

if (!$timeout) {
$timeout = $injector.get('$timeout');
}
if (!isNumber(timeout)) {
timeout = 0;
}

if (!applyingAsync) {
applyingAsync = true;
$timeout(applyAsync, timeout, false);
}
},

/**
* @ngdoc method
* @name $rootScope.Scope#$on
Expand Down Expand Up @@ -1169,6 +1184,7 @@ function $RootScopeProvider(){
};

var $rootScope = new Scope();
var applyingAsync = false;

return $rootScope;

Expand Down Expand Up @@ -1201,6 +1217,15 @@ function $RootScopeProvider(){
} while ((current = current.$parent));
}

function applyAsync() {
applyingAsync = false;
try {
$rootScope.$digest("applyAsync");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: single quotes

} catch (e) {
$exceptionHandler(e);
}
}

/**
* function used as an initial value for watchers.
* because it's unique we can easily tell it apart from other values
Expand Down
44 changes: 38 additions & 6 deletions src/ngMock/angular-mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -1067,7 +1067,7 @@ angular.mock.dump = function(object) {
```
*/
angular.mock.$HttpBackendProvider = function() {
this.$get = ['$rootScope', createHttpBackendMock];
this.$get = ['$rootScope', '$timeout', createHttpBackendMock];
};

/**
Expand All @@ -1084,7 +1084,7 @@ angular.mock.$HttpBackendProvider = function() {
* @param {Object=} $browser Auto-flushing enabled if specified
* @return {Object} Instance of $httpBackend mock
*/
function createHttpBackendMock($rootScope, $delegate, $browser) {
function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) {
var definitions = [],
expectations = [],
responses = [],
Expand Down Expand Up @@ -1442,12 +1442,19 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
* @param {number=} count Number of responses to flush (in the order they arrived). If undefined,
* all pending requests will be flushed. If there are no pending requests when the flush method
* is called an exception is thrown (as this typically a sign of programming error).
* @param {number=|boolean} flushTimeout amount to pass to $timeout.flush(). If value is false,
* $timeout.flush() is never called --- Otherwise, if the value is a number, it is called with
* that value. Otherwise, it is called with Infinity. This enables applications to test their
* behaviour using simulated coalesced $applyAsync calls.
*/
$httpBackend.flush = function(count) {
$httpBackend.flush = function(count, flushTimeout) {
$rootScope.$digest();
if (!responses.length) throw new Error('No pending request to flush !');
if (!responses.length && !angular.isNumber(flushTimeout) && typeof flushTimeout !== "boolean") {
throw new Error('No pending request to flush !');
}

if (angular.isDefined(count)) {
// flush the responses
if (angular.isNumber(count)) {
while (count--) {
if (!responses.length) throw new Error('No more pending request to flush !');
responses.shift()();
Expand All @@ -1457,6 +1464,16 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
responses.shift()();
}
}

// If asking to flush timeout, do so
if (typeof $timeout.flush === 'function' && flushTimeout !== false) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know I could use angular.isFunction here, but it doesn't seem that ueful and wouldn't save that many characters

if (typeof flushTimeout === 'number') {
$timeout.flush(flushTimeout);
} else {
$timeout.flush(Infinity);
}
}

$httpBackend.verifyNoOutstandingExpectation();
};

Expand Down Expand Up @@ -1627,6 +1644,20 @@ function MockXhr() {
this.abort = angular.noop;
}

angular.mock.$RootScopeDecorator = ['$delegate', '$browser', function ($delegate, $browser) {
var Scope = $delegate.constructor;
var digest = Scope.prototype.$digest;

Scope.prototype.$digest = function(caller) {
digest.call(this);
if (caller === "applyAsync") {
if (angular.isFunction(this.$$didAsyncDigest)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the way the function parameter helps testing a little bit --- since $httpBackend.flush() needs to $digest a lot anyways, this is useful because it tells us when the "actual" digest happens --- but I'm not opposed to removing it if this can be refactored differently (I spent time thinking how to do this and it kept ending up sort of bad the other ways)

this.$$didAsyncDigest();
}
}
};
return $delegate;
}];

/**
* @ngdoc service
Expand Down Expand Up @@ -1752,6 +1783,7 @@ angular.module('ngMock', ['ng']).provider({
$httpBackend: angular.mock.$HttpBackendProvider,
$rootElement: angular.mock.$RootElementProvider
}).config(['$provide', function($provide) {
$provide.decorator('$rootScope', angular.mock.$RootScopeDecorator);
$provide.decorator('$timeout', angular.mock.$TimeoutDecorator);
$provide.decorator('$$rAF', angular.mock.$RAFDecorator);
$provide.decorator('$$asyncCallback', angular.mock.$AsyncCallbackDecorator);
Expand Down Expand Up @@ -1950,7 +1982,7 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) {
*/
angular.mock.e2e = {};
angular.mock.e2e.$httpBackendDecorator =
['$rootScope', '$delegate', '$browser', createHttpBackendMock];
['$rootScope', '$timeout', '$delegate', '$browser', createHttpBackendMock];


angular.mock.clearDataCache = function() {
Expand Down
44 changes: 35 additions & 9 deletions test/ng/httpSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,17 +272,20 @@ describe('$http', function() {


describe('the instance', function() {
var $httpBackend, $http, $rootScope;
var $httpBackend, $http, $rootScope, $timeout, $$didAsyncDigest;

beforeEach(inject(['$rootScope', function($rs) {
$rootScope = $rs;

spyOn($rootScope, '$apply').andCallThrough();
spyOn($rootScope, '$digest').andCallThrough();
spyOn($rootScope, '$applyAsync').andCallThrough();
$$didAsyncDigest = $rootScope.$$didAsyncDigest = jasmine.createSpy('$$didAsyncDigest');
}]));

beforeEach(inject(['$httpBackend', '$http', function($hb, $h) {
beforeEach(inject(['$httpBackend', '$http', '$timeout', function($hb, $h, $t) {
$httpBackend = $hb;
$http = $h;
$timeout = $t;
}]));

it('should send GET requests if no method specified', inject(function($httpBackend, $http) {
Expand Down Expand Up @@ -827,32 +830,55 @@ describe('$http', function() {

describe('scope.$apply', function() {

it('should $apply after success callback', function() {
it('should $applyAsync after success callback', function() {
$httpBackend.when('GET').respond(200);
$http({method: 'GET', url: '/some'});
$httpBackend.flush();
expect($rootScope.$apply).toHaveBeenCalledOnce();
$httpBackend.flush(null, 0);
expect($rootScope.$applyAsync).toHaveBeenCalledOnce();

expect($$didAsyncDigest).not.toHaveBeenCalled();
$httpBackend.flush(null, Infinity);

expect($$didAsyncDigest).toHaveBeenCalledOnce();
});


it('should $apply after error callback', function() {
$httpBackend.when('GET').respond(404);
$http({method: 'GET', url: '/some'});
$httpBackend.flush();
expect($rootScope.$apply).toHaveBeenCalledOnce();
expect($rootScope.$applyAsync).toHaveBeenCalledOnce();
});


it('should $apply even if exception thrown during callback', inject(function($exceptionHandler){
it('should $applyAsync even if exception thrown during callback', inject(function($exceptionHandler){
$httpBackend.when('GET').respond(200);
callback.andThrow('error in callback');

$http({method: 'GET', url: '/some'}).then(callback);
$httpBackend.flush();
expect($rootScope.$apply).toHaveBeenCalledOnce();
expect($rootScope.$applyAsync).toHaveBeenCalledOnce();

$exceptionHandler.errors = [];
}));


it('should react to multiple responses in a single $digest', function() {
var spy = $rootScope.$$didAsyncDigest = jasmine.createSpy('$$didAsyncDigest');
$httpBackend.whenGET('/url1').respond(200);
$httpBackend.whenGET('/url2').respond(200);
$http({method: 'GET', url: '/url1'}).then(callback);
$http({method: 'GET', url: '/url2'}).then(callback);
expect(spy.callCount).toBe(0);
expect(callback.callCount).toBe(0);

$httpBackend.flush(null, 1);
expect(spy.callCount).toBe(0);

$httpBackend.flush(null, Infinity);
expect(spy.callCount).toBe(1);
expect(callback.callCount).toBe(2);
});
});


Expand Down
98 changes: 98 additions & 0 deletions test/ng/rootScopeSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,104 @@ describe('Scope', function() {
});


describe('$applyAsync', function() {
beforeEach(module(function($exceptionHandlerProvider) {
$exceptionHandlerProvider.mode('log');
}));


it('should log exceptions from $digest', function() {
module(function($rootScopeProvider, $exceptionHandlerProvider) {
$rootScopeProvider.digestTtl(2);
});
inject(function($rootScope, $exceptionHandler, $timeout) {
$rootScope.$watch('a', function() {$rootScope.b++;});
$rootScope.$watch('b', function() {$rootScope.a++;});
$rootScope.a = $rootScope.b = 0;

$rootScope.$applyAsync();
$timeout.flush();

expect($exceptionHandler.errors[0]).toBeDefined();

expect($rootScope.$$phase).toBeNull();
});
});


describe('recursive $apply protection', function() {
it('should throw an exception if $apply is called while a watch is being initialized', inject(
function($rootScope, $timeout, $exceptionHandler) {
var childScope1 = $rootScope.$new();
childScope1.$watch('x', function() {
childScope1.$apply();
});
$rootScope.$applyAsync();
$timeout.flush();
expect($exceptionHandler.errors[0].message).
toMatch(/^\[\$rootScope:inprog\] \$digest already in progress/);
}));


it('should thrown an exception if $apply in called from a watch fn (after init)', inject(
function($rootScope, $exceptionHandler, $timeout) {
var childScope2 = $rootScope.$new();
childScope2.$watch('x', function(newVal, oldVal) {
if (newVal !== oldVal) {
childScope2.$apply();
}
});

$rootScope.$applyAsync();
$timeout.flush();

expect($exceptionHandler.errors.length).toBe(0);

childScope2.x = 'something';
$rootScope.$applyAsync();
$timeout.flush();

expect($exceptionHandler.errors[0].message).
toMatch(/^\[\$rootScope:inprog\] \$digest already in progress/);
}));


it('should not throw when calling $applyAsync during $digest', inject(
function($rootScope, $exceptionHandler, $timeout) {
var childScope2 = $rootScope.$new();
childScope2.$watch('x', function(newVal, oldVal, scope) {
scope.$applyAsync();
});

$rootScope.$digest();

expect($exceptionHandler.errors.length).toBe(0);
}));
});


it('should debounce calls to $digest from calls to $applyAsync', inject(
function($rootScope, $exceptionHandler, $timeout) {
var spy = spyOn($rootScope, '$digest');

$rootScope.$applyAsync(100);
$rootScope.$applyAsync(20);
$rootScope.$applyAsync(40);
$rootScope.$applyAsync(60);
$rootScope.$applyAsync(80);

$timeout.flush(25);
expect(spy).not.toHaveBeenCalled();
$timeout.flush(25);
expect(spy).not.toHaveBeenCalled();
$timeout.flush(25);
expect(spy).not.toHaveBeenCalled();
$timeout.flush(25);
expect(spy).toHaveBeenCalled();
}));
});


describe('events', function() {

describe('$on', function() {
Expand Down