diff --git a/src/ng/http.js b/src/ng/http.js index 9017fe85292d..1fdc615f2c21 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/ng/rootScope.js b/src/ng/rootScope.js index 9938240a60e6..0ea968f16cbc 100644 --- a/src/ng/rootScope.js +++ b/src/ng/rootScope.js @@ -71,6 +71,7 @@ function $RootScopeProvider(){ var TTL = 10; var $rootScopeMinErr = minErr('$rootScope'); var lastDirtyWatch = null; + var applyAsyncId = null; this.digestTtl = function(value) { if (arguments.length) { @@ -134,6 +135,7 @@ function $RootScopeProvider(){ this.$$listeners = {}; this.$$listenerCount = {}; this.$$isolateBindings = {}; + this.$$applyAsyncQueue = []; } /** @@ -688,6 +690,13 @@ function $RootScopeProvider(){ beginPhase('$digest'); + if (this === $rootScope && applyAsyncId !== null) { + // If this is the root scope, and $applyAsync has scheduled a deferred $apply(), then + // cancel the scheduled $apply and flush the queue of expressions to be evaluated. + $browser.defer.cancel(applyAsyncId); + flushApplyAsync(); + } + lastDirtyWatch = null; do { // "while dirty" loop @@ -997,6 +1006,33 @@ function $RootScopeProvider(){ } }, + /** + * @ngdoc method + * @name $rootScope.Scope#$applyAsync + * @kind function + * + * @description + * Schedule the invokation of $apply to occur at a later time. The actual time difference + * varies across browsers, but is typically around ~10 milliseconds. + * + * This can be used to queue up multiple expressions which need to be evaluated in the same + * digest. + * + * @param {(string|function())=} exp An angular expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/expression expression}. + * - `function(scope)`: execute the function with current `scope` parameter. + */ + $applyAsync: function(expr) { + var scope = this; + expr && $rootScope.$$applyAsyncQueue.push($applyAsyncExpression); + scheduleApplyAsync(); + + function $applyAsyncExpression() { + scope.$eval(expr); + } + }, + /** * @ngdoc method * @name $rootScope.Scope#$on @@ -1229,5 +1265,25 @@ function $RootScopeProvider(){ * because it's unique we can easily tell it apart from other values */ function initWatchVal() {} + + function flushApplyAsync() { + var queue = $rootScope.$$applyAsyncQueue; + while (queue.length) { + try { + queue.shift()(); + } catch(e) { + $exceptionHandler(e); + } + } + applyAsyncId = null; + } + + function scheduleApplyAsync() { + if (applyAsyncId === null) { + applyAsyncId = $browser.defer(function() { + $rootScope.$apply(flushApplyAsync); + }); + } + } }]; } diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index ba9790539ff8..073dadc0295b 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 c1c33ffb8788..c2ad25f0ce82 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']); + }); +}); diff --git a/test/ng/rootScopeSpec.js b/test/ng/rootScopeSpec.js index 74eec39fc528..e3168ec10e86 100644 --- a/test/ng/rootScopeSpec.js +++ b/test/ng/rootScopeSpec.js @@ -1399,6 +1399,91 @@ describe('Scope', function() { }); + describe('$applyAsync', function() { + beforeEach(module(function($exceptionHandlerProvider) { + $exceptionHandlerProvider.mode('log'); + })); + + + it('should evaluate in the context of specific $scope', inject(function($rootScope, $browser) { + var scope = $rootScope.$new(); + scope.$applyAsync('x = "CODE ORANGE"'); + + $browser.defer.flush(); + expect(scope.x).toBe('CODE ORANGE'); + expect($rootScope.x).toBeUndefined(); + })); + + + it('should evaluate queued expressions in order', inject(function($rootScope, $browser) { + $rootScope.x = []; + $rootScope.$applyAsync('x.push("expr1")'); + $rootScope.$applyAsync('x.push("expr2")'); + + $browser.defer.flush(); + expect($rootScope.x).toEqual(['expr1', 'expr2']); + })); + + + it('should evaluate subsequently queued items in same turn', inject(function($rootScope, $browser) { + $rootScope.x = []; + $rootScope.$applyAsync(function() { + $rootScope.x.push('expr1'); + $rootScope.$applyAsync('x.push("expr2")'); + expect($browser.deferredFns.length).toBe(0); + }); + + $browser.defer.flush(); + expect($rootScope.x).toEqual(['expr1', 'expr2']); + })); + + + it('should pass thrown exceptions to $exceptionHandler', inject(function($rootScope, $browser, $exceptionHandler) { + $rootScope.$applyAsync(function() { + throw 'OOPS'; + }); + + $browser.defer.flush(); + expect($exceptionHandler.errors).toEqual([ + 'OOPS' + ]); + })); + + + it('should evaluate subsequent expressions after an exception is thrown', inject(function($rootScope, $browser) { + $rootScope.$applyAsync(function() { + throw 'OOPS'; + }); + $rootScope.$applyAsync('x = "All good!"'); + + $browser.defer.flush(); + expect($rootScope.x).toBe('All good!'); + })); + + + it('should be cancelled if a $rootScope digest occurs before the next tick', inject(function($rootScope, $browser) { + var apply = spyOn($rootScope, '$apply').andCallThrough(); + var cancel = spyOn($browser.defer, 'cancel').andCallThrough(); + var expression = jasmine.createSpy('expr'); + + $rootScope.$applyAsync(expression); + $rootScope.$digest(); + expect(expression).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); + expression.reset(); + cancel.reset(); + + // assert that we no longer are waiting to execute + expect($browser.deferredFns.length).toBe(0); + + // assert that another digest won't call the function again + $rootScope.$digest(); + expect(expression).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + })); + }); + + describe('events', function() { describe('$on', function() {