From 4511d39cc748288df70bdc258f98a8f36652e683 Mon Sep 17 00:00:00 2001 From: Igor Minar Date: Tue, 22 May 2012 23:05:26 -0700 Subject: [PATCH] feat($timeout): add $timeout service that supersedes $defer $timeout has a better name ($defer got often confused with something related to $q) and is actually promise based with cancelation support. With this commit the $defer service is deprecated and will be removed before 1.0. Closes #704, #532 --- angularFiles.js | 1 + src/AngularPublic.js | 1 + src/ng/defer.js | 6 +- src/ng/timeout.js | 87 ++++++++++++++++++ src/ngMock/angular-mocks.js | 26 ++++++ test/ng/deferSpec.js | 1 + test/ng/timeoutSpec.js | 146 +++++++++++++++++++++++++++++++ test/ngMock/angular-mocksSpec.js | 17 ++++ 8 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 src/ng/timeout.js create mode 100644 test/ng/timeoutSpec.js diff --git a/angularFiles.js b/angularFiles.js index 537b5bdea27f..e82362cf58e0 100644 --- a/angularFiles.js +++ b/angularFiles.js @@ -29,6 +29,7 @@ angularFiles = { 'src/ng/http.js', 'src/ng/httpBackend.js', 'src/ng/locale.js', + 'src/ng/timeout.js', 'src/ng/filter.js', 'src/ng/filter/filter.js', diff --git a/src/AngularPublic.js b/src/AngularPublic.js index a912448257ea..54536b431649 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -125,6 +125,7 @@ function publishExternalAPI(angular){ $q: $QProvider, $sniffer: $SnifferProvider, $templateCache: $TemplateCacheProvider, + $timeout: $TimeoutProvider, $window: $WindowProvider }); } diff --git a/src/ng/defer.js b/src/ng/defer.js index f2a893bcbda8..b5dc88440750 100644 --- a/src/ng/defer.js +++ b/src/ng/defer.js @@ -3,6 +3,8 @@ /** * @ngdoc function * @name angular.module.ng.$defer + * @deprecated Made obsolete by $timeout service. Please migrate your code. This service will be + * removed with 1.0 final. * @requires $browser * * @description @@ -29,7 +31,9 @@ * @returns {boolean} Returns `true` if the task hasn't executed yet and was successfuly canceled. */ function $DeferProvider(){ - this.$get = ['$rootScope', '$browser', function($rootScope, $browser) { + this.$get = ['$rootScope', '$browser', '$log', function($rootScope, $browser, $log) { + $log.warn('$defer service has been deprecated, migrate to $timeout'); + function defer(fn, delay) { return $browser.defer(function() { $rootScope.$apply(fn); diff --git a/src/ng/timeout.js b/src/ng/timeout.js new file mode 100644 index 000000000000..ac92bf8c98a2 --- /dev/null +++ b/src/ng/timeout.js @@ -0,0 +1,87 @@ +'use strict'; + + +function $TimeoutProvider() { + this.$get = ['$rootScope', '$browser', '$q', '$exceptionHandler', + function($rootScope, $browser, $q, $exceptionHandler) { + var deferreds = {}; + + + /** + * @ngdoc function + * @name angular.module.ng.$timeout + * @requires $browser + * + * @description + * Angular's wrapper for `window.setTimeout`. The `fn` function is wrapped into a try/catch + * block and delegates any exceptions to + * {@link angular.module.ng.$exceptionHandler $exceptionHandler} service. + * + * The return value of registering a timeout function is a promise which will be resolved when + * the timeout is reached and the timeout function is executed. + * + * To cancel a the timeout request, call `$timeout.cancel(promise)`. + * + * In tests you can use {@link angular.module.ngMock.$timeout `$timeout.flush()`} to + * synchronously flush the queue of deferred functions. + * + * @param {function()} fn A function, who's execution should be delayed. + * @param {number=} [delay=0] Delay in milliseconds. + * @param {boolean=} [invokeApply=true] If set to false skips model dirty checking, otherwise + * will invoke `fn` within the {@link angular.module.ng.$rootScope.Scope#$apply $apply} block. + * @returns {*} Promise that will be resolved when the timeout is reached. The value this + * promise will be resolved with is the return value of the `fn` function. + */ + function timeout(fn, delay, invokeApply) { + var deferred = $q.defer(), + promise = deferred.promise, + skipApply = (isDefined(invokeApply) && !invokeApply), + timeoutId, cleanup; + + timeoutId = $browser.defer(function() { + try { + deferred.resolve(fn()); + } catch(e) { + deferred.reject(e); + $exceptionHandler(e); + } + + if (!skipApply) $rootScope.$apply(); + }, delay); + + cleanup = function() { + delete deferreds[promise.$$timeoutId]; + }; + + promise.$$timeoutId = timeoutId; + deferreds[timeoutId] = deferred; + promise.then(cleanup, cleanup); + + return promise; + } + + + /** + * @ngdoc function + * @name angular.module.ng.$timeout#cancel + * @methodOf angular.module.ng.$timeout + * + * @description + * Cancels a task associated with the `promise`. As a result of this the promise will be + * resolved with a rejection. + * + * @param {Promise} promise Promise returned by the `$timeout` function. + * @returns {boolean} Returns `true` if the task hasn't executed yet and was successfully + * canceled. + */ + timeout.cancel = function(promise) { + if (promise.$$timeoutId in deferreds) { + deferreds[promise.$$timeoutId].reject('canceled'); + return $browser.defer.cancel(promise.$$timeoutId); + } + return false; + }; + + return timeout; + }]; +} diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 8b5d100afa5b..ef1833e21106 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -1328,6 +1328,25 @@ function MockXhr() { this.abort = angular.noop; } + +/** + * @ngdoc function + * @name angular.module.ngMock.$timeout + * @description + * + * This service is just a simple decorator for {@link angular.module.ng.$timeout $timeout} service + * that adds a "flush" method. + */ + +/** + * @ngdoc method + * @name angular.module.ngMock.$timeout#flush + * @methodOf angular.module.ngMock.$timeout + * @description + * + * Flushes the queue of pending tasks. + */ + /** * @ngdoc overview * @name angular.module.ngMock @@ -1341,6 +1360,13 @@ angular.module('ngMock', ['ng']).provider({ $exceptionHandler: angular.mock.$ExceptionHandlerProvider, $log: angular.mock.$LogProvider, $httpBackend: angular.mock.$HttpBackendProvider +}).config(function($provide) { + $provide.decorator('$timeout', function($delegate, $browser) { + $delegate.flush = function() { + $browser.defer.flush(); + }; + return $delegate; + }); }); diff --git a/test/ng/deferSpec.js b/test/ng/deferSpec.js index 48c9e9120187..2e31aadb3849 100644 --- a/test/ng/deferSpec.js +++ b/test/ng/deferSpec.js @@ -5,6 +5,7 @@ describe('$defer', function() { $provide.factory('$exceptionHandler', function(){ return jasmine.createSpy('$exceptionHandler'); }); + $provide.value('$log', {warn: noop}); })); diff --git a/test/ng/timeoutSpec.js b/test/ng/timeoutSpec.js new file mode 100644 index 000000000000..19db1227d30d --- /dev/null +++ b/test/ng/timeoutSpec.js @@ -0,0 +1,146 @@ +'use strict'; + +describe('$timeout', function() { + + beforeEach(module(provideLog)); + + + it('should delegate functions to $browser.defer', inject(function($timeout, $browser) { + var counter = 0; + $timeout(function() { counter++; }); + + expect(counter).toBe(0); + + $browser.defer.flush(); + expect(counter).toBe(1); + + expect(function() {$browser.defer.flush();}).toThrow('No deferred tasks to be flushed'); + expect(counter).toBe(1); + })); + + + it('should call $apply after each callback is executed', inject(function($timeout, $rootScope) { + var applySpy = spyOn($rootScope, '$apply').andCallThrough(); + + $timeout(function() {}); + expect(applySpy).not.toHaveBeenCalled(); + + $timeout.flush(); + expect(applySpy).toHaveBeenCalledOnce(); + + applySpy.reset(); + + $timeout(function() {}); + $timeout(function() {}); + $timeout.flush(); + expect(applySpy.callCount).toBe(2); + })); + + + it('should NOT call $apply if skipApply is set to true', inject(function($timeout, $rootScope) { + var applySpy = spyOn($rootScope, '$apply').andCallThrough(); + + $timeout(function() {}, 12, false); + expect(applySpy).not.toHaveBeenCalled(); + + $timeout.flush(); + expect(applySpy).not.toHaveBeenCalled(); + })); + + + it('should allow you to specify the delay time', inject(function($timeout, $browser) { + var defer = spyOn($browser, 'defer'); + $timeout(noop, 123); + expect(defer.callCount).toEqual(1); + expect(defer.mostRecentCall.args[1]).toEqual(123); + })); + + + it('should return a promise which will be resolved with return value of the timeout callback', + inject(function($timeout, log) { + var promise = $timeout(function() { log('timeout'); return 'buba'; }); + + promise.then(function(value) { log('promise success: ' + value); }, log.fn('promise error')); + expect(log).toEqual([]); + + $timeout.flush(); + expect(log).toEqual(['timeout', 'promise success: buba']); + })); + + + describe('exception handling', function() { + + beforeEach(module(function($exceptionHandlerProvider) { + $exceptionHandlerProvider.mode('log'); + })); + + + it('should delegate exception to the $exceptionHandler service', inject( + function($timeout, $exceptionHandler) { + $timeout(function() {throw "Test Error";}); + expect($exceptionHandler.errors).toEqual([]); + + $timeout.flush(); + expect($exceptionHandler.errors).toEqual(["Test Error"]); + })); + + + it('should call $apply even if an exception is thrown in callback', inject( + function($timeout, $rootScope) { + var applySpy = spyOn($rootScope, '$apply').andCallThrough(); + + $timeout(function() {throw "Test Error";}); + expect(applySpy).not.toHaveBeenCalled(); + + $timeout.flush(); + expect(applySpy).toHaveBeenCalled(); + })); + + + it('should reject the timeout promise when an exception is thrown in the timeout callback', + inject(function($timeout, log) { + var promise = $timeout(function() { throw "Some Error"; }); + + promise.then(log.fn('success'), function(reason) { log('error: ' + reason); }); + $timeout.flush(); + + expect(log).toEqual('error: Some Error'); + })); + }); + + + describe('cancel', function() { + it('should cancel tasks', inject(function($timeout) { + var task1 = jasmine.createSpy('task1'), + task2 = jasmine.createSpy('task2'), + task3 = jasmine.createSpy('task3'), + promise1, promise3; + + promise1 = $timeout(task1); + $timeout(task2); + promise3 = $timeout(task3, 333); + + $timeout.cancel(promise3); + $timeout.cancel(promise1); + $timeout.flush(); + + expect(task1).not.toHaveBeenCalled(); + expect(task2).toHaveBeenCalledOnce(); + expect(task3).not.toHaveBeenCalled(); + })); + + + it('should return true if a task was successfully canceled', inject(function($timeout) { + var task1 = jasmine.createSpy('task1'), + task2 = jasmine.createSpy('task2'), + promise1, promise2; + + promise1 = $timeout(task1); + $timeout.flush(); + promise2 = $timeout(task2); + + expect($timeout.cancel(promise1)).toBe(false); + expect($timeout.cancel(promise2)).toBe(true); + })); + }); +}); diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js index 469df91e8e45..621900723e91 100644 --- a/test/ngMock/angular-mocksSpec.js +++ b/test/ngMock/angular-mocksSpec.js @@ -313,6 +313,23 @@ describe('ngMock', function() { }); + describe('$timeout', function() { + it('should expose flush method that will flush the pending queue of tasks', inject( + function($timeout) { + var logger = [], + logFn = function(msg) { return function() { logger.push(msg) }}; + + $timeout(logFn('t1')); + $timeout(logFn('t2'), 200); + $timeout(logFn('t3')); + expect(logger).toEqual([]); + + $timeout.flush(); + expect(logger).toEqual(['t1', 't3', 't2']); + })); + }); + + describe('angular.mock.dump', function(){ var d = angular.mock.dump;