diff --git a/src/ng/q.js b/src/ng/q.js index 22c9caa9a973..df1d6ae9f3bf 100644 --- a/src/ng/q.js +++ b/src/ng/q.js @@ -57,11 +57,17 @@ * * # The Deferred API * - * A new instance of deferred is constructed by calling `$q.defer()`. + * A new instance of deferred is constructed by calling `$q.defer(canceler)`. * * The purpose of the deferred object is to expose the associated Promise instance as well as APIs * that can be used for signaling the successful or unsuccessful completion of the task. * + * `$q.defer` can optionally take a canceler function. This function will cause resulting promises, + * and any derived promises, to have a `cancel()` method, and will be invoked if the promise is + * canceled. The canceler receives the reason the promise was canceled as its argument. The promise + * is rejected with the canceler's return value or the original cancel reason if nothing is + * returned. + * * **Methods** * * - `resolve(value)` – resolves the derived promise with the `value`. If the value is a rejection @@ -97,6 +103,11 @@ * specification](https://github.com/kriskowal/q/wiki/API-Reference#promisefinallycallback) for * more information. * + * - `cancel(reason)` - optionally available if a canceler was provided to `$q.defer`. The canceler + * is invoked and the promise rejected. A reason may be sent to the canceler explaining why it's + * being canceled. Returns true if the promise has not been resolved and was successfully + * canceled. + * * # Chaining promises * * Because calling `then` api of a promise returns a new derived promise, it is easily possible @@ -180,9 +191,10 @@ function qFactory(nextTick, exceptionHandler) { * @description * Creates a `Deferred` object which represents a task which will finish in the future. * + * @param {function(*)=} canceler Function which will be called if the task is canceled. * @returns {Deferred} Returns a new instance of deferred. */ - var defer = function() { + var defer = function(canceler) { var pending = [], value, deferred; @@ -214,7 +226,7 @@ function qFactory(nextTick, exceptionHandler) { promise: { then: function(callback, errback) { - var result = defer(); + var result = defer(wrappedCanceler); var wrappedCallback = function(value) { try { @@ -281,6 +293,27 @@ function qFactory(nextTick, exceptionHandler) { } }; + if (isFunction(canceler)) { + var wrappedCanceler = function(reason) { + try { + var value = canceler(reason); + if (isDefined(value)) reason = value; + } catch(e) { + exceptionHandler(e); + reason = e; + } + when(reason).then(deferred.reject, deferred.reject); + return reason; + }; + + deferred.promise.cancel = function(reason) { + if (pending) { + return !(wrappedCanceler(reason) instanceof Error); + } + return false; + }; + } + return deferred; }; diff --git a/test/ng/qSpec.js b/test/ng/qSpec.js index 0c59db89496b..bdcc5d4818d4 100644 --- a/test/ng/qSpec.js +++ b/test/ng/qSpec.js @@ -350,6 +350,94 @@ describe('q', function() { expect(typeof promise.always).toBe('function'); }); + it('should not have a cancel method if no canceler is provided', function() { + expect(promise.cancel).not.toBeDefined(); + }); + + it('should have a cancel method if a canceler is provided', function() { + deferred = defer(noop); + promise = deferred.promise; + expect(promise.cancel).toBeDefined(); + promise = promise.always(noop); + expect(promise.cancel).toBeDefined(); + }); + + + describe('cancel', function() { + var canceler; + + beforeEach(function() { + canceler = jasmine.createSpy(); + deferred = defer(canceler); + promise = deferred.promise; + }); + + it('should cancel a pending task and reject the promise', function() { + promise.then(success(), error()); + expect(promise.cancel('foo')).toBe(true); + expect(canceler).toHaveBeenCalledWith('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('error(foo)'); + }); + + it('should reject the promise with a reason returned from the canceler', function() { + canceler.andReturn('bar'); + promise.then(success(), error()); + expect(promise.cancel('foo')).toBe(true); + expect(canceler).toHaveBeenCalledWith('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('error(bar)'); + }); + + it('should log exceptions thrown in the canceler', function() { + canceler.andThrow(Error('oops')); + promise.then(success(), error()); + expect(promise.cancel('foo')).toBe(false); + expect(canceler).toHaveBeenCalledWith('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('error(Error: oops)'); + }); + + it('should not cancel a resolved promise', function() { + promise.then(success(), error()); + syncResolve(deferred, 'foo'); + expect(promise.cancel('bar')).toBe(false); + expect(canceler).not.toHaveBeenCalled(); + expect(logStr()).toBe('success(foo)'); + }); + + it('should propagate the cancel method and reasons', function() { + promise = promise.then(success(1), error(1)).then(success(2), error(2)); + expect(promise.cancel).toBeDefined(); + expect(promise.cancel('foo')).toBe(true); + expect(canceler).toHaveBeenCalledWith('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('error1(foo); error2(foo)'); + }); + + it('should reject all derived promises', function() { + var promiseA = promise.then(success('A'), error('A')); + var promiseB = promise.then(success('B'), error('B')); + var promiseC = promiseB.then(success('C'), error('C')); + var promiseD = promiseB.then(success('D'), error('D')); + var promiseE = promiseD.always(error('E')); + expect(promiseC.cancel('foo')).toBe(true); + expect(canceler).toHaveBeenCalledWith('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('errorA(foo); errorB(foo); errorC(foo); errorD(foo); errorE()'); + }); + + it('should resolve promises returned by the canceler', function() { + var deferred2 = defer(); + canceler.andReturn(deferred2.promise.then(success('A'))); + promise.then(success('B'), error('B')); + expect(promise.cancel('foo')).toBe(true); + expect(canceler).toHaveBeenCalledWith('foo'); + syncResolve(deferred2, 'bar'); + expect(logStr()).toBe('successA(bar); errorB(bar)'); + }) + }); + describe('then', function() { it('should allow registration of a success callback without an errback and resolve',