From ea6fc6e69c2a2aa213c71ed4e917a0d54d064e4c Mon Sep 17 00:00:00 2001 From: Caitlin Potter Date: Fri, 22 Aug 2014 18:17:55 -0400 Subject: [PATCH] feat($http): implement mechanism for coalescing calls to $apply in $http When multiple responses are received within a short window from each other, it can be wasteful to perform full dirty-checking cycles for each individual response. In order to prevent this, it is now possible to coalesce calls to $apply for responses which occur close together. This behaviour is opt-in, and the default is disabled, in order to avoid breaking tests or applications. In order to activate coalesced apply in tests or in an application, simply perform the following steps during configuration. angular.module('myFancyApp', []). config(function($httpProvider) { $httpProvider.useApplyAsync(true); }); OR: angular.mock.module(function($httpProvider) { $httpProvider.useApplyAsync(true); }); Closes #8736 Closes #7634 Closes #5297 --- src/ng/http.js | 40 ++++++++++++++++++- src/ngMock/angular-mocks.js | 12 +++--- test/ng/httpSpec.js | 77 +++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/ng/http.js b/src/ng/http.js index 9017fe85..1fdc615f 100644 --- a/src/ng/http.js +++ b/src/ng/http.js @@ -143,6 +143,34 @@ function $HttpProvider() { xsrfHeaderName: 'X-XSRF-TOKEN' }; + var useApplyAsync = false; + /** + * @ngdoc method + * @name $httpProvider#useApplyAsync + * @description + * + * Configure $http service to combine processing of multiple http responses received at around + * the same time via {@link ng.$rootScope#applyAsync $rootScope.$applyAsync}. This can result in + * significant performance improvement for bigger applications that make many HTTP requests + * concurrently (common during application bootstrap). + * + * Defaults to false. If no value is specifed, returns the current configured value. + * + * @param {boolean=} value If true, when requests are loaded, they will schedule a deferred + * "apply" on the next tick, giving time for subsequent requests in a roughly ~10ms window + * to load and share the same digest cycle. + * + * @returns {boolean|Object} If a value is specified, returns the $httpProvider for chaining. + * otherwise, returns the current configured value. + **/ + this.useApplyAsync = function(value) { + if (isDefined(value)) { + useApplyAsync = !!value; + return this; + } + return useApplyAsync; + }; + /** * Are ordered by request, i.e. they are applied in the same order as the * array, on request, but reverse order, on response. @@ -949,8 +977,16 @@ function $HttpProvider() { } } - resolvePromise(response, status, headersString, statusText); - if (!$rootScope.$$phase) $rootScope.$apply(); + function resolveHttpPromise() { + resolvePromise(response, status, headersString, statusText); + } + + if (useApplyAsync) { + $rootScope.$applyAsync(resolveHttpPromise); + } else { + resolveHttpPromise(); + if (!$rootScope.$$phase) $rootScope.$apply(); + } } diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index ba979053..073dadc0 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -1488,11 +1488,11 @@ function createHttpBackendMock($rootScope, $delegate, $browser) { * 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). */ - $httpBackend.flush = function(count) { - $rootScope.$digest(); + $httpBackend.flush = function(count, digest) { + if (digest !== false) $rootScope.$digest(); if (!responses.length) throw new Error('No pending request to flush !'); - if (angular.isDefined(count)) { + if (angular.isDefined(count) && count !== null) { while (count--) { if (!responses.length) throw new Error('No more pending request to flush !'); responses.shift()(); @@ -1502,7 +1502,7 @@ function createHttpBackendMock($rootScope, $delegate, $browser) { responses.shift()(); } } - $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingExpectation(digest); }; @@ -1520,8 +1520,8 @@ function createHttpBackendMock($rootScope, $delegate, $browser) { * afterEach($httpBackend.verifyNoOutstandingExpectation); * ``` */ - $httpBackend.verifyNoOutstandingExpectation = function() { - $rootScope.$digest(); + $httpBackend.verifyNoOutstandingExpectation = function(digest) { + if (digest !== false) $rootScope.$digest(); if (expectations.length) { throw new Error('Unsatisfied requests: ' + expectations.join(', ')); } diff --git a/test/ng/httpSpec.js b/test/ng/httpSpec.js index c1c33ffb..c2ad25f0 100644 --- a/test/ng/httpSpec.js +++ b/test/ng/httpSpec.js @@ -1526,3 +1526,80 @@ describe('$http', function() { $httpBackend.verifyNoOutstandingExpectation = noop; }); }); + + +describe('$http with $applyAapply', function() { + var $http, $httpBackend, $rootScope, $browser, log; + beforeEach(module(function($httpProvider) { + $httpProvider.useApplyAsync(true); + }, provideLog)); + + + beforeEach(inject(['$http', '$httpBackend', '$rootScope', '$browser', 'log', function(http, backend, scope, browser, logger) { + $http = http; + $httpBackend = backend; + $rootScope = scope; + $browser = browser; + spyOn($rootScope, '$apply').andCallThrough(); + spyOn($rootScope, '$applyAsync').andCallThrough(); + spyOn($rootScope, '$digest').andCallThrough(); + spyOn($browser.defer, 'cancel').andCallThrough(); + log = logger; + }])); + + + it('should schedule coalesced apply on response', function() { + var handler = jasmine.createSpy('handler'); + $httpBackend.expect('GET', '/template1.html').respond(200, '

Header!

', {}); + $http.get('/template1.html').then(handler); + // Ensure requests are sent + $rootScope.$digest(); + + $httpBackend.flush(null, false); + expect($rootScope.$applyAsync).toHaveBeenCalledOnce(); + expect(handler).not.toHaveBeenCalled(); + + $browser.defer.flush(); + expect(handler).toHaveBeenCalledOnce(); + }); + + + it('should combine multiple responses within short time frame into a single $apply', function() { + $httpBackend.expect('GET', '/template1.html').respond(200, '

Header!

', {}); + $httpBackend.expect('GET', '/template2.html').respond(200, '

Body!

', {}); + + $http.get('/template1.html').then(log.fn('response 1')); + $http.get('/template2.html').then(log.fn('response 2')); + // Ensure requests are sent + $rootScope.$digest(); + + $httpBackend.flush(null, false); + expect(log).toEqual([]); + + $browser.defer.flush(); + expect(log).toEqual(['response 1', 'response 2']); + }); + + + it('should handle pending responses immediately if a digest occurs on $rootScope', function() { + $httpBackend.expect('GET', '/template1.html').respond(200, '

Header!

', {}); + $httpBackend.expect('GET', '/template2.html').respond(200, '

Body!

', {}); + $httpBackend.expect('GET', '/template3.html').respond(200, '

Body!

', {}); + + $http.get('/template1.html').then(log.fn('response 1')); + $http.get('/template2.html').then(log.fn('response 2')); + $http.get('/template3.html').then(log.fn('response 3')); + // Ensure requests are sent + $rootScope.$digest(); + + // Intermediate $digest occurs before 3rd response is received, assert that pending responses + /// are handled + $httpBackend.flush(2); + expect(log).toEqual(['response 1', 'response 2']); + + // Finally, third response is received, and a second coalesced $apply is started + $httpBackend.flush(null, false); + $browser.defer.flush(); + expect(log).toEqual(['response 1', 'response 2', 'response 3']); + }); +});