From e94d454b840f6cc55a440741382b407836ad245b Mon Sep 17 00:00:00 2001 From: Caitlin Potter Date: Fri, 22 Aug 2014 16:38:21 -0400 Subject: [PATCH 1/2] feat($rootScope): implement $applyAsync to support combining calls to $apply into a single digest. It is now possible to queue up multiple expressions to be evaluated in a single digest using $applyAsync. The asynchronous expressions will be evaluated either 1) the next time $apply or $rootScope.$digest is called, or 2) after after the queue flushing scheduled for the next turn occurs (roughly ~10ms depending on browser and application). --- src/ng/rootScope.js | 56 ++++++++++++++++++++++++++ test/ng/rootScopeSpec.js | 85 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) 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/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() { From f1829ea376905aba47b02c38c593c726777377ae Mon Sep 17 00:00:00 2001 From: Caitlin Potter Date: Fri, 22 Aug 2014 18:17:55 -0400 Subject: [PATCH 2/2] 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); }); --- 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 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/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']); + }); +});