diff --git a/src/ng/http.js b/src/ng/http.js index e9c283c90796..0901cc8a13dd 100644 --- a/src/ng/http.js +++ b/src/ng/http.js @@ -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 @@ -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; + }; + this.$get = ['$httpBackend', '$browser', '$cacheFactory', '$rootScope', '$q', '$injector', function($httpBackend, $browser, $cacheFactory, $rootScope, $q, $injector) { @@ -911,7 +929,7 @@ function $HttpProvider() { } resolvePromise(response, status, headersString, statusText); - if (!$rootScope.$$phase) $rootScope.$apply(); + $rootScope.$applyAsync(APPLY_ASYNC_WAIT); } diff --git a/src/ng/rootScope.js b/src/ng/rootScope.js index 030e0b05c248..0b488193211b 100644 --- a/src/ng/rootScope.js +++ b/src/ng/rootScope.js @@ -81,7 +81,7 @@ function $RootScopeProvider(){ this.$get = ['$injector', '$exceptionHandler', '$parse', '$browser', function( $injector, $exceptionHandler, $parse, $browser) { - + var $timeout; /** * @ngdoc type * @name $rootScope.Scope @@ -641,7 +641,7 @@ function $RootScopeProvider(){ * ``` * */ - $digest: function() { + $digest: function(calledByFn) { var watch, value, last, watchers, asyncQueue = this.$$asyncQueue, @@ -972,6 +972,21 @@ function $RootScopeProvider(){ } }, + + $applyAsync: function(timeout) { + 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 @@ -1169,6 +1184,7 @@ function $RootScopeProvider(){ }; var $rootScope = new Scope(); + var applyingAsync = false; return $rootScope; @@ -1201,6 +1217,15 @@ function $RootScopeProvider(){ } while ((current = current.$parent)); } + function applyAsync() { + applyingAsync = false; + try { + $rootScope.$digest("applyAsync"); + } 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 diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index a44b90b5aa3e..96c3e87ec29e 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -1067,7 +1067,7 @@ angular.mock.dump = function(object) { ``` */ angular.mock.$HttpBackendProvider = function() { - this.$get = ['$rootScope', createHttpBackendMock]; + this.$get = ['$rootScope', '$timeout', createHttpBackendMock]; }; /** @@ -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 = [], @@ -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()(); @@ -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) { + if (typeof flushTimeout === 'number') { + $timeout.flush(flushTimeout); + } else { + $timeout.flush(Infinity); + } + } + $httpBackend.verifyNoOutstandingExpectation(); }; @@ -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)) { + this.$$didAsyncDigest(); + } + } + }; + return $delegate; +}]; /** * @ngdoc service @@ -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); @@ -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() { diff --git a/test/ng/httpSpec.js b/test/ng/httpSpec.js index 27017061635c..8fe6a10b787f 100644 --- a/test/ng/httpSpec.js +++ b/test/ng/httpSpec.js @@ -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) { @@ -827,11 +830,16 @@ 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(); }); @@ -839,20 +847,38 @@ describe('$http', 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); + }); }); diff --git a/test/ng/rootScopeSpec.js b/test/ng/rootScopeSpec.js index 1007e9760f28..84b7c2915320 100644 --- a/test/ng/rootScopeSpec.js +++ b/test/ng/rootScopeSpec.js @@ -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() {