From ce76e26debb90d77c7b575a09df98ccfa0e87c88 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Dopazo Date: Thu, 16 May 2013 14:41:47 -0300 Subject: [PATCH 01/29] Promises extras update --- src/promise/HISTORY.md | 6 + src/promise/build.json | 15 +- src/promise/docs/basic-example.mustache | 4 +- src/promise/docs/component.json | 7 - src/promise/docs/index.mustache | 34 +- .../docs/partials/github-cache.mustache | 4 +- .../docs/partials/node-plugin-plugin.mustache | 4 +- .../partials/node-plugin-transition.mustache | 18 +- .../subclass-array-operations.mustache | 24 - .../docs/partials/subclass-css.mustache | 5 - .../docs/partials/subclass-js.mustache | 27 - .../partials/subclass-toarray-fn.mustache | 6 - src/promise/docs/subclass-example.mustache | 66 --- src/promise/js/{ => extras}/batch.js | 15 +- src/promise/js/extras/combinators.js | 108 ++++ src/promise/js/extras/factories.js | 32 ++ src/promise/js/extras/methods.js | 92 ++++ src/promise/js/{ => extras}/when.js | 9 + src/promise/js/promise.js | 37 +- src/promise/js/resolver.js | 117 ++--- src/promise/meta/promise.json | 20 +- src/promise/tests/cli/run.js | 6 +- src/promise/tests/unit/assets/batch-tests.js | 6 +- src/promise/tests/unit/assets/extras-tests.js | 489 ++++++++++++++++++ .../tests/unit/assets/promise-tests.js | 88 ++-- src/promise/tests/unit/promise-extras.html | 39 ++ src/promise/tests/unit/promise.html | 16 +- 27 files changed, 967 insertions(+), 327 deletions(-) delete mode 100644 src/promise/docs/partials/subclass-array-operations.mustache delete mode 100644 src/promise/docs/partials/subclass-css.mustache delete mode 100644 src/promise/docs/partials/subclass-js.mustache delete mode 100644 src/promise/docs/partials/subclass-toarray-fn.mustache delete mode 100644 src/promise/docs/subclass-example.mustache rename src/promise/js/{ => extras}/batch.js (71%) create mode 100644 src/promise/js/extras/combinators.js create mode 100644 src/promise/js/extras/factories.js create mode 100644 src/promise/js/extras/methods.js rename src/promise/js/{ => extras}/when.js (90%) create mode 100644 src/promise/tests/unit/assets/extras-tests.js create mode 100644 src/promise/tests/unit/promise-extras.html diff --git a/src/promise/HISTORY.md b/src/promise/HISTORY.md index cdcaf0a050e..f1da55ab63d 100644 --- a/src/promise/HISTORY.md +++ b/src/promise/HISTORY.md @@ -4,6 +4,12 @@ Promise Change History @VERSION@ ------ +* Split the module into promise-core and promise-extras +* then() no longer returns an instance of this.constructor +* Added combinators, factories and extra methods +* [!] Deprecated Y.batch() in favor of Y.Promise.every() +* The first function received as parameter of the Promise init function is now + a reference to resolve() instead of fulfill() * Changed the value of |this| inside callbacks to undefined to match the Promises A+ spec diff --git a/src/promise/build.json b/src/promise/build.json index 8556d96d7c0..0b7b5172dc1 100644 --- a/src/promise/build.json +++ b/src/promise/build.json @@ -2,12 +2,19 @@ "name": "promise", "builds": { - "promise": { + "promise-core": { "jsfiles": [ "promise.js", - "resolver.js", - "when.js", - "batch.js" + "resolver.js" + ] + }, + "promise-extras": { + "jsfiles": [ + "extras/when.js", + "extras/methods.js", + "extras/factories.js", + "extras/combinators.js", + "extras/batch.js" ] } } diff --git a/src/promise/docs/basic-example.mustache b/src/promise/docs/basic-example.mustache index 9024277d12d..f0cf06d3230 100644 --- a/src/promise/docs/basic-example.mustache +++ b/src/promise/docs/basic-example.mustache @@ -56,7 +56,7 @@ getUser: function (name) { ``` // Fetches a URL, stores a promise in the cache and returns it function fetch(url) { - var promise = new Y.Promise(function (fulfill, reject) { + var promise = new Y.Promise(function (resolve, reject) { Y.jsonp(url, function (res) { var meta = res.meta, data = res.data; @@ -64,7 +64,7 @@ function fetch(url) { // Check for a successful response, otherwise reject the // promise with the message returned by the GitHub API. if (meta.status >= 200 && meta.status < 300) { - fulfill(data); + resolve(data); } else { reject(new Error(data.message)); } diff --git a/src/promise/docs/component.json b/src/promise/docs/component.json index 4684f287c68..4b104ea7bbe 100644 --- a/src/promise/docs/component.json +++ b/src/promise/docs/component.json @@ -18,13 +18,6 @@ "modules": ["promise", "jsonp"], "useModules": ["promise", "jsonp"] }, - { - "name": "subclass-example", - "displayName": "Subclassing Y.Promise", - "description": "Extend Y.Promise to create classes that encapsulate standard transaction logic in descriptive method names", - "modules": ["promise", "jsonp"], - "useModules": ["promise", "jsonp"] - }, { "name" : "plugin-example", "displayName": "Creating a Node Plugin that chains transitions", diff --git a/src/promise/docs/index.mustache b/src/promise/docs/index.mustache index c8bb5b8fcd7..b34ce2008ca 100644 --- a/src/promise/docs/index.mustache +++ b/src/promise/docs/index.mustache @@ -476,33 +476,33 @@ MyDatabase.prototype.save = function (key, data) { }; ``` -

Non-serial Operation Batching

+

Handling multiple non-sequential operations

Promise chaining works great to serialize synchronous and asynchronous operations, but often several asynchronous operations can be performed - simultaneously. This is where `Y.batch()` comes in. + simultaneously. This is where `Y.Promise.every()` comes in.

- `Y.batch()` takes any number of promises as arguments, and returns a new - promise that will resolve when all the batched promises have resolved. The + `Y.Promise.every()` takes an array of promises, and returns a new promise + that will resolve when all the promises in the array have resolved. The resolved value will be an array of values from the individual promises, in - the order they were passed to `Y.batch()`. + the order they were passed to `Y.Promise.every()`.

- If any one of the batched promises should be rejected, the batch promise - is immediately rejected with that reason, so failures can be dealt with - sooner rather than later. + If any one of the promises in the list should be rejected, the returned + promise is immediately rejected with that reason, so failures can be dealt + with sooner rather than later.

``` -Y.batch( +Y.Promise.every([ getUserAccountInfo(userId), getUserPosts(userId, { page: 1, postsPerPage: 5 }), getUserRank(userId) - ) + ]) .then(function (data) { var account = data[0], posts = data[1], @@ -564,13 +564,13 @@ Y.batch(

`Y.Parallel`

- `Y.Parallel` is similar to `Y.batch` in that it provides a mechanism to - execute a callback when several independent asynchronous operations have - completed. However, it doesn't handle errors or guarantee asynchronous - callback execution. It is also transactional, but the batch of operations - is bound to a specific callback, where `Y.batch()` returns a promise that - represents the aggregated values of those operations. The promise can be - used by multiple consumers if necessary. + `Y.Parallel` is similar to `Y.Promise.every()` in that it provides a + mechanism to execute a callback when several independent asynchronous + operations have completed. However, it doesn't handle errors or guarantee + asynchronous callback execution. It is also transactional, but the list of + operations is bound to a specific callback, where `Y.Promise.every()` + returns a promise that represents the aggregated values of those operations. + The promise can be used by multiple consumers if necessary.

What are the plans for Promises in the library?

diff --git a/src/promise/docs/partials/github-cache.mustache b/src/promise/docs/partials/github-cache.mustache index ac79cfa2396..66525c65d7f 100644 --- a/src/promise/docs/partials/github-cache.mustache +++ b/src/promise/docs/partials/github-cache.mustache @@ -12,7 +12,7 @@ var GitHub = (function () { // Fetches a URL, stores a promise in the cache and returns it function fetch(url) { - var promise = new Y.Promise(function (fulfill, reject) { + var promise = new Y.Promise(function (resolve, reject) { Y.jsonp(url, function (res) { var meta = res.meta, data = res.data; @@ -20,7 +20,7 @@ var GitHub = (function () { // Check for a successful response, otherwise reject the // promise with the message returned by the GitHub API. if (meta.status >= 200 && meta.status < 300) { - fulfill(data); + resolve(data); } else { reject(new Error(data.message)); } diff --git a/src/promise/docs/partials/node-plugin-plugin.mustache b/src/promise/docs/partials/node-plugin-plugin.mustache index bdcec673fab..a233f77af5c 100644 --- a/src/promise/docs/partials/node-plugin-plugin.mustache +++ b/src/promise/docs/partials/node-plugin-plugin.mustache @@ -1,8 +1,8 @@ function PromisePlugin(config) { // Create a private NodePromise instance that points to the plugin host - this._promise = new NodePromise(function (fulfill) { + this._promise = new NodePromise(function (resolve) { // Since this is a Node plugin, config.host will be an instance of Node - fulfill(config.host); + resolve(config.host); }); } diff --git a/src/promise/docs/partials/node-plugin-transition.mustache b/src/promise/docs/partials/node-plugin-transition.mustache index 1578019e7bd..afd321a4833 100644 --- a/src/promise/docs/partials/node-plugin-transition.mustache +++ b/src/promise/docs/partials/node-plugin-transition.mustache @@ -1,19 +1,17 @@ // This method takes the same "config" parameter as Node's transition method // but returns a NodePromise instead NodePromise.prototype.transition = function (config) { - // We call this.then to ensure the promise is fulfilled. - // Since we will be creating a chain of transitions this means we will be - // waiting for the previous transition to end - return this.then(function (node) { - // As noted in the user guide, returning a promise inside the then() - // callback causes the promise returned by then() to be synced with this - // new promise. This is a way to control when the returned promise is - // fulfilled - return new Y.Promise(function (fulfill, reject) { + var promise = this; + + // Return a new NodePromise so that chaining calls to transition() + // results in transition executed sequentially + return new NodePromise(function (resolve, reject) { + // Call promise.then to the previous transition ended + promise.then(function (node) { node.transition(config, function () { // The transition is done, signal the promise that all is ready // by fulfilling it with the same node - fulfill(node); + resolve(node); }); }); }); diff --git a/src/promise/docs/partials/subclass-array-operations.mustache b/src/promise/docs/partials/subclass-array-operations.mustache deleted file mode 100644 index 02e96dd449e..00000000000 --- a/src/promise/docs/partials/subclass-array-operations.mustache +++ /dev/null @@ -1,24 +0,0 @@ -log('Fetching GitHub data for users: "yui", "yahoo" and "davglass"...') - -// requests is a regular promise -var requests = Y.batch(GitHub.getUser('yui'), GitHub.getUser('yahoo'), GitHub.getUser('davglass')); -// users is now an ArrayPromise -var users = toArrayPromise(requests); - -// Transform the data into a list of names -users.map(function (data) { - log('Getting name for user "' + data.login + '"...') - return data.name; -}).filter(function (name) { - log('Checking if the name "' + name + '" starts with "Y"...') - return name.charAt(0) === 'Y'; -}).then(function (names) { - log('Done!'); - return names; -}).each(function (name, i) { - log(i + '. ' + name); -}).then(null, function (error) { - // if there was an error in any step or request, it is automatically - // passed around the promise chain so we can react to it at the end - showError(error.message); -}); diff --git a/src/promise/docs/partials/subclass-css.mustache b/src/promise/docs/partials/subclass-css.mustache deleted file mode 100644 index db3c6762715..00000000000 --- a/src/promise/docs/partials/subclass-css.mustache +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/src/promise/docs/partials/subclass-js.mustache b/src/promise/docs/partials/subclass-js.mustache deleted file mode 100644 index 5b80df7bec2..00000000000 --- a/src/promise/docs/partials/subclass-js.mustache +++ /dev/null @@ -1,27 +0,0 @@ - diff --git a/src/promise/docs/partials/subclass-toarray-fn.mustache b/src/promise/docs/partials/subclass-toarray-fn.mustache deleted file mode 100644 index f8198d4389d..00000000000 --- a/src/promise/docs/partials/subclass-toarray-fn.mustache +++ /dev/null @@ -1,6 +0,0 @@ -// Takes any promise and returns an ArrayPromise -function toArrayPromise(promise) { - return new ArrayPromise(function (fulfill, reject) { - promise.then(fulfill, reject); - }); -} diff --git a/src/promise/docs/subclass-example.mustache b/src/promise/docs/subclass-example.mustache deleted file mode 100644 index abdd67ae145..00000000000 --- a/src/promise/docs/subclass-example.mustache +++ /dev/null @@ -1,66 +0,0 @@ -{{>subclass-css}} - -
-

- This example expands on the Wrapping async transactions with promises example to illustrate how to create your own Promise subclass for performing operations on arrays. -

-
- -
-
- {{>subclass-js}} -
- -

Subclassing Y.Promise

- -

- You can subclass a YUI promise with Y.extend the same way you would any other class. Keep in mind that Promise constructors take a function as a parameter so you need to call the superclass constructor in order for it to work. -

- -``` -function ArrayPromise() { - ArrayPromise.superclass.constructor.apply(this, arguments); -} -Y.extend(ArrayPromise, Y.Promise); -``` - -

Method Chaining

- -

- Chaining promise methods is done by returning the result of calling the promise's `then()` method. `then()` always returns a promise of its same kind, so this will allow us to chain array operations as if they were real arrays. -

-

- For the purpose of this example we will only add the `each`, `filter` and `map` methods from the `array-extras` module. -

- -``` -{{>subclass-array-methods}} -``` - -

- Finally we need a simple way to take a promise that we know contains an array and create an ArrayPromise with its value. -

- -``` -{{>subclass-toarray-fn}} -``` - -

Putting our Class to Action

- -

- There are many cases in which you would want to work on asynchronous array values. Performing more than one async operation at a time and dealing with the result is one common use case. `Y.batch` waits for many operations and returns a promise representing an array with the result of all the operations, so you could wrap it in an ArrayPromise to modify all those results. -

- -

- We will use the JSONP Cache from the previous example and make several simultaneous requests. -

- -``` -{{>subclass-array-operations}} -``` - -

Full Example Code

- -``` -{{>subclass-js}} -``` diff --git a/src/promise/js/batch.js b/src/promise/js/extras/batch.js similarity index 71% rename from src/promise/js/batch.js rename to src/promise/js/extras/batch.js index be06860e4b0..4e3b0778550 100644 --- a/src/promise/js/batch.js +++ b/src/promise/js/extras/batch.js @@ -1,17 +1,18 @@ -var slice = [].slice; - /** Returns a new promise that will be resolved when all operations have completed. -Takes both any numer of values as arguments. If an argument is a not a promise, -it will be wrapped in a new promise, same as in `Y.when()`. +Takes both callbacks and promises as arguments. If an argument is a callback, +it will be wrapped in a new promise. @for YUI @method batch -@param {Any} operation* Any number of Y.Promise objects or regular JS values -@return {Promise} Promise to be fulfilled when all provided promises are - resolved +@param {Function|Promise} operation* Any number of functions or Y.Promise + objects +@return {Promise} +@deprecated @SINCE@ **/ Y.batch = function () { + Y.log('batch() was deprecated in YUI @SINCE@. Use Promise.every()', 'warn'); + var funcs = slice.call(arguments), remaining = funcs.length, i = 0, diff --git a/src/promise/js/extras/combinators.js b/src/promise/js/extras/combinators.js new file mode 100644 index 00000000000..a71af00ede9 --- /dev/null +++ b/src/promise/js/extras/combinators.js @@ -0,0 +1,108 @@ +/** +Returns a promise that is resolved or rejected when any of values is either +resolved or rejected. + +@for Promise +@method any +@param {Any[]} values A list of either promises or any JavaScript value +@return {Promise} A promise with the value or reason of the first resolved or + rejected promise +@static +@since @SINCE@ +**/ +Promise.any = function (values) { + return new Promise(function (resolve, reject) { + if (values.length < 1) { + return resolve(); + } + // just go through the list and resolve and reject at the first change + for (var i = 0, count = values.length; i < count; i++) { + Y.when(values[i], resolve, reject); + } + }); +}; + +/** +Returns a promise that is resolved or rejected when all values are resolved or +any is rejected. If the array passed is empty, the returned promise will be +resolved witn `undefined`. + +@for Promise +@method every +@param {Any[]} values An Array of either promises or any JavaScript value +@return {Promise} A promise with the list of all resolved values or the + rejection reason of the first rejected promise +@static +@since @SINCE@ +**/ +Promise.every = function (values) { + var remaining = values.length, + i = 0, + length = values.length, + results = []; + + return new Promise(function (resolve, reject) { + function oneDone(index) { + return function (value) { + results[index] = value; + + remaining--; + + if (!remaining) { + resolve(results); + } + }; + } + + if (length < 1) { + return resolve(); + } + + for (; i < length; i++) { + Y.when(values[i], oneDone(i), reject); + } + }); +}; + +/** +Returns a promise that is resolved or rejected when one of values is resolved +or all are rejected. If the array passed is empty, the returned promise will be +resolved witn `undefined`. + +@for Promise +@method some +@param {Any[]} values A list of either promises or any JavaScript value +@return {Promise} A promise with the value of the first resolved promise or a + list of all the rejection reasons +@static +@since @SINCE@ +**/ +Promise.some = function (values) { + var remaining = values.length, + i = 0, + length = values.length, + results = []; + + // Basically a mirror implementation of Promise.every + return new Promise(function (resolve, reject) { + function oneRejected(index) { + return function (value) { + results[index] = value; + + remaining--; + + if (!remaining) { + reject(results); + } + }; + } + + if (length < 1) { + return resolve(); + } + + for (; i < length; i++) { + Y.when(values[i], resolve, oneRejected(i)); + } + }); +}; diff --git a/src/promise/js/extras/factories.js b/src/promise/js/extras/factories.js new file mode 100644 index 00000000000..06bfd8ab131 --- /dev/null +++ b/src/promise/js/extras/factories.js @@ -0,0 +1,32 @@ +/** +Creates a fulfilled promise for a certain value. If the value is a promise, the +new promise will be fulfilled or rejected based on the provided promise + +@for Promise +@method resolve +@param {Any} valueOrPromise Any value to wrap in a promise +@return {Promise} A new promise for the provided value +@static +@since @SINCE@ +**/ +Promise.resolve = function (valueOrPromise) { + return new Promise(function (resolve) { + resolve(valueOrPromise); + }); +}; + +/** +Creates a rejected promise based on a certain reason + +@for Promise +@method reject +@param {Any} reason Any reason to reject a promise with +@return {Promise} A new reject promise for the provided reason +@static +@since @SINCE@ +**/ +Promise.reject = function (reason) { + return new Promise(function (resolve, reject) { + reject(reason); + }); +}; diff --git a/src/promise/js/extras/methods.js b/src/promise/js/extras/methods.js new file mode 100644 index 00000000000..90d77628237 --- /dev/null +++ b/src/promise/js/extras/methods.js @@ -0,0 +1,92 @@ +Y.mix(Promise.Resolver.prototype, { + /** + Returns the current status of the Resolver as a string "pending", + "fulfilled", or "rejected". + + @for Promise.Resolver + @method getStatus + @return {String} The status of the resolver + **/ + getStatus: function () { + return this._status; + }, + + /** + Takes callbacks for the success and failure resolutions like `then()` but + does not return a new promise and so thrown errors are not turned into + rejections. + + @for Promise.Resolver + @method _done + @param {Function} [callback] Callback for the success case + @param {Function} [errback] Callback for the failure case + @private + **/ + _done: function (callback, errback) { + var callbackList = this._callbacks || [], + errbackList = this._errbacks || []; + + if (typeof callback === 'function') { + callbackList.push(callback); + } + errbackList.push( + // if no errback is provided, we want done() to just throw + typeof errback === 'function' ? errback : function (e) { + throw e; + } + ); + + if (this._status === 'fulfilled') { + this.fulfill(this._result); + } + if (this._status === 'rejected') { + this.reject(this._result); + } + } +}); + +Y.mix(Promise.prototype, { + /** + Takes callbacks for the success and failure resolutions like `then()` but + does not return a new promise and so thrown errors are not turned into + rejections. + + By default `done()` throws the rejection reason for the promise, so adding + `.done()` without arguments at the end of your promise chain will turn + rejections into a thrown exception. + + @for Promise + @method done + @param {Function} [callback] Callback for the success case + @param {Function} [errback] Callback for the failure case + @since @SINCE@ + **/ + done: function (callback, errback) { + this._resolver._done(callback, errback); + }, + /** + Shorthand for subscribing to the rejection branch of a promise. Sugar for + `promise.then(null, errback)` + + @for Promise + @method fail + @param {Function} errback Callback for rejections + @return {Promise} A new promise that will be resolved or rejected based + on the result of the errback + @since @SINCE@ + **/ + fail: function (errback) { + return this.then(null, errback); + }, + /** + Returns the current status of the operation. Possible results are + "pending", "fulfilled", and "rejected". + + @for Promise + @method getStatus + @return {String} + **/ + getStatus: function () { + return this._resolver.getStatus(); + } +}); diff --git a/src/promise/js/when.js b/src/promise/js/extras/when.js similarity index 90% rename from src/promise/js/when.js rename to src/promise/js/extras/when.js index 2080ee8a583..2b86190e2b3 100644 --- a/src/promise/js/when.js +++ b/src/promise/js/extras/when.js @@ -1,3 +1,12 @@ +/** +Extra utilities for YUI3 promises + +@module promise-extras +**/ + +var Promise = Y.Promise, + slice = [].slice; + /** Abstraction API allowing you to interact with promises or raw values as if they were promises. If a non-promise object is passed in, a new Resolver is created diff --git a/src/promise/js/promise.js b/src/promise/js/promise.js index f8b02fb6601..bebb25c0bae 100644 --- a/src/promise/js/promise.js +++ b/src/promise/js/promise.js @@ -2,26 +2,8 @@ Wraps the execution of asynchronous operations, providing a promise object that can be used to subscribe to the various ways the operation may terminate. -When the operation completes successfully, call the Resolver's `fulfill()` -method, passing any relevant response data for subscribers. If the operation -encounters an error or is unsuccessful in some way, call `reject()`, again -passing any relevant data for subscribers. - -The Resolver object should be shared only with the code resposible for -resolving or rejecting it. Public access for the Resolver is through its -_promise_, which is returned from the Resolver's `promise` property. While both -Resolver and promise allow subscriptions to the Resolver's state changes, the -promise may be exposed to non-controlling code. It is the preferable interface -for adding subscriptions. - -Subscribe to state changes in the Resolver with the promise's -`then(callback, errback)` method. `then()` wraps the passed callbacks in a -new Resolver and returns the corresponding promise, allowing chaining of -asynchronous or synchronous operations. E.g. -`promise.then(someAsyncFunc).then(anotherAsyncFunc)` - -@module promise -@since 3.9.0 +@module promise-core +@since 3.11.0 **/ /** @@ -67,7 +49,7 @@ function Promise(fn) { this._resolver = resolver; fn.call(this, function (value) { - resolver.fulfill(value); + resolver.resolve(value); }, function (reason) { resolver.reject(reason); }); @@ -88,22 +70,11 @@ Y.mix(Promise.prototype, { resolves successfully @param {Function} [errback] function to execute if the promise resolves unsuccessfully - @return {Promise} A promise wrapping the resolution of either "resolve" or + @return {Promise} A promise wrapping the resolution of either "fulfill" or "reject" callback **/ then: function (callback, errback) { return this._resolver.then(callback, errback); - }, - - /** - Returns the current status of the operation. Possible results are - "pending", "fulfilled", and "rejected". - - @method getStatus - @return {String} - **/ - getStatus: function () { - return this._resolver.getStatus(); } }); diff --git a/src/promise/js/resolver.js b/src/promise/js/resolver.js index b7c9d1eb899..d71a6aff70c 100644 --- a/src/promise/js/resolver.js +++ b/src/promise/js/resolver.js @@ -50,8 +50,8 @@ Y.mix(Resolver.prototype, { /** Resolves the promise, signaling successful completion of the represented operation. All "onFulfilled" subscriptions are executed and passed - the value provided to this method. After calling `fulfill()`, `reject()` and - `notify()` are disabled. + the value provided to this method. After calling `fulfill()`, `reject()` is + disabled. @method fulfill @param {Any} value Value to pass along to the "onFulfilled" subscribers @@ -80,6 +80,31 @@ Y.mix(Resolver.prototype, { } }, + /** + Resolves the promise with the provided value. If the value is a promise + `resolve()` will call its `then()` method to adopt its state. + + @method resolve + @param {Any} value Either a promise or a value. If value is a promise, + `resolve()` will adopt its state + **/ + resolve: function (value) { + var self = this; + + if (Promise.isPromise(value)) { + value.then(function (x) { + // This essentially makes the process recursive, flattening + // promises for promises. This is still a topic of discussion + // in the community + self.resolve(x); + }, function (e) { + self.reject(e); + }); + } else { + this.fulfill(value); + } + }, + /** Resolves the promise, signaling *un*successful completion of the represented operation. All "onRejected" subscriptions are executed with @@ -122,42 +147,33 @@ Y.mix(Resolver.prototype, { of either "resolve" or "reject" callback **/ then: function (callback, errback) { + var self = this, + callbackList = this._callbacks || [], + errbackList = this._errbacks || [], + status = this._status, + result = this._result; + // When the current promise is fulfilled or rejected, either the // callback or errback will be executed via the function pushed onto // this._callbacks or this._errbacks. However, to allow then() - // chaining, the execution of either function needs to be represented - // by a Resolver (the same Resolver can represent both flow paths), and - // its promise returned. - var promise = this.promise, - thenFulfill, thenReject, - - // using promise constructor allows for customized promises to be - // returned instead of plain ones - then = new promise.constructor(function (fulfill, reject) { - thenFulfill = fulfill; - thenReject = reject; - }), - - callbackList = this._callbacks || [], - errbackList = this._errbacks || []; - - // Because the callback and errback are represented by a Resolver, it - // must be fulfilled or rejected to propagate through the then() chain. - // The same logic applies to resolve() and reject() for fulfillment. - callbackList.push(typeof callback === 'function' ? - this._wrap(thenFulfill, thenReject, callback) : thenFulfill); - errbackList.push(typeof errback === 'function' ? - this._wrap(thenFulfill, thenReject, errback) : thenReject); - - // If a promise is already fulfilled or rejected, notify the newly added - // callbacks by calling fulfill() or reject() - if (this._status === 'fulfilled') { - this.fulfill(this._result); - } else if (this._status === 'rejected') { - this.reject(this._result); - } - - return then; + // chaining, it must return a new promise + return new Promise(function (resolve, reject) { + // Because the callback and errback are represented by a Resolver, it + // must be fulfilled or rejected to propagate through the then() chain. + // The same logic applies to resolve() and reject() for fulfillment. + callbackList.push(typeof callback === 'function' ? + self._wrap(resolve, reject, callback) : resolve); + errbackList.push(typeof errback === 'function' ? + self._wrap(resolve, reject, errback) : reject); + + // If a promise is already fulfilled or rejected, notify the newly added + // callbacks by calling fulfill() or reject() + if (status === 'fulfilled') { + self.fulfill(result); + } else if (status === 'rejected') { + self.reject(result); + } + }); }, /** @@ -166,7 +182,7 @@ Y.mix(Resolver.prototype, { returned from the `then` callback. @method _wrap - @param {Function} thenFulfill Fulfillment function of the resolver that + @param {Function} thenResolve `resolve()` function of the resolver that handles this promise @param {Function} thenReject Rejection function of the resolver that handles this promise @@ -174,7 +190,7 @@ Y.mix(Resolver.prototype, { @return {Function} @private **/ - _wrap: function (thenFulfill, thenReject, fn) { + _wrap: function (thenResolve, thenReject, fn) { // callbacks and errbacks only get one argument return function (valueOrReason) { var result; @@ -193,31 +209,12 @@ Y.mix(Resolver.prototype, { return thenReject(e); } - if (Promise.isPromise(result)) { - // Returning a promise from a callback makes the current - // promise sync up with the returned promise - result.then(thenFulfill, thenReject); - } else { - // Non-promise return values always trigger resolve() - // because callback is affirmative, and errback is - // recovery. To continue on the rejection path, errbacks - // must return rejected promises or throw. - thenFulfill(result); - } + // resolve() checks if the result is a promise or a thenable and + // adopts it or assimilates it + thenResolve(result); }; }, - /** - Returns the current status of the Resolver as a string "pending", - "fulfilled", or "rejected". - - @method getStatus - @return {String} - **/ - getStatus: function () { - return this._status; - }, - /** Executes an array of callbacks from a specified context, passing a set of arguments. @@ -246,6 +243,6 @@ Y.mix(Resolver.prototype, { } } -}, true); +}); Y.Promise.Resolver = Resolver; diff --git a/src/promise/meta/promise.json b/src/promise/meta/promise.json index 4a76fa3d4e5..aedf191626e 100644 --- a/src/promise/meta/promise.json +++ b/src/promise/meta/promise.json @@ -1,7 +1,21 @@ { "promise": { - "requires": [ - "timers" - ] + "use": [ + "promise-core", + "promise-extras" + ], + + "submodules": { + "promise-core": { + "requires": [ + "timers" + ] + }, + "promise-extras": { + "requires": [ + "promise-core" + ] + } + } } } diff --git a/src/promise/tests/cli/run.js b/src/promise/tests/cli/run.js index acef7fff405..3fba1ae9374 100755 --- a/src/promise/tests/cli/run.js +++ b/src/promise/tests/cli/run.js @@ -30,6 +30,10 @@ YUI({useSync: true }).use('test', function(Y) { }, 'promise-tests': { fullpath: path.join(__dirname, '../unit/assets/promise-tests.js'), + requires: ['promise-core', 'test'] + }, + 'extras-tests': { + fullpath: path.join(__dirname, '../unit/assets/extras-tests.js'), requires: ['promise', 'test'] }, 'aplus-tests': { @@ -39,7 +43,7 @@ YUI({useSync: true }).use('test', function(Y) { } }); - Y.use('batch-tests', 'when-tests', 'promise-tests', 'aplus-tests'); + Y.use('promise-tests', 'batch-tests', 'when-tests', 'extras-tests', 'aplus-tests'); Y.Test.Runner.setName('yql cli tests'); diff --git a/src/promise/tests/unit/assets/batch-tests.js b/src/promise/tests/unit/assets/batch-tests.js index 8f432189bc0..dd0c013083f 100644 --- a/src/promise/tests/unit/assets/batch-tests.js +++ b/src/promise/tests/unit/assets/batch-tests.js @@ -47,7 +47,7 @@ YUI.add('batch-tests', function (Y) { }); }); - test.wait(100); + test.wait(); }, 'order of promises should be preserved': function () { @@ -101,11 +101,11 @@ YUI.add('batch-tests', function (Y) { Y.batch(rejectedAfter(20), rejectedAfter(10), rejectedAfter(15)).then(null, function (reason) { test.resume(function () { - Assert.areEqual(10, reason, 'reason should be the one from the first promise to be rejected'); + Assert.areEqual('10', reason, 'reason should be the one from the first promise to be rejected'); }); }); - test.wait(500); + test.wait(100); } })); diff --git a/src/promise/tests/unit/assets/extras-tests.js b/src/promise/tests/unit/assets/extras-tests.js new file mode 100644 index 00000000000..9c116f6f266 --- /dev/null +++ b/src/promise/tests/unit/assets/extras-tests.js @@ -0,0 +1,489 @@ +YUI.add('extras-tests', function (Y) { + + var Assert = Y.Assert, + ArrayAssert = Y.Test.ArrayAssert, + Promise = Y.Promise, + isPromise = Promise.isPromise; + + /** + Takes a promise and a callback. Calls the callback with a boolean paramter + indicating if the promise is fulfilled and the value as the next parameter + **/ + function isFulfilled(promise, next) { + promise.then(function (x) { + next(true, x); + }, function (e) { + next(false, e); + }); + } + + /** + Takes a promise and a callback. Calls the callback with a boolean paramter + indicating if the promise is rejected and the reason as the next parameter + **/ + function isRejected(promise, next) { + promise.then(function (x) { + next(false, x); + }, function (e) { + next(true, e); + }); + } + + function wait(ms) { + return new Promise(function (fulfill) { + setTimeout(function () { + fulfill(ms); + }, ms); + }); + } + + function rejectedAfter(ms) { + return new Promise(function (fulfill, reject) { + setTimeout(function () { + reject(ms); + }, ms); + }); + } + + // -- Suite -------------------------------------------------------------------- + var suite = new Y.Test.Suite({ + name: 'Promise extras tests' + }); + + suite.add(new Y.Test.Case({ + name: 'Promise extra methods tests', + + _should: { + ignore: { + 'errors thrown inside done() are not caught': !Y.config.win + } + }, + + 'fail() adds an errback to a rejected promise': function () { + var test = this, + expected = new Error('foo'), + promise = Promise.reject(expected), + returnValue; + + returnValue = promise.fail(function (err) { + test.resume(function () { + Assert.areSame(expected, err, 'Promise rejected with the wrong reason'); + Assert.isTrue(isPromise(returnValue), 'fail() should return a promise'); + }); + }); + + test.wait(250); + }, + + 'done() does not return a promise': function () { + Assert.isUndefined(Promise.resolve('foo').done(), 'done() should return undefined'); + }, + + 'done() treats resolution the same way as then()': function () { + var test = this, + value = {}, + promise = Promise.resolve(value); + + function next(fulfilled, result) { + test.resume(function () { + Assert.isTrue(fulfilled, 'done() should respect the success path'); + Assert.areSame(value, result, 'done() should respect the promise value'); + }); + } + + promise.done(function (x) { + next(true, x); + }, function (e) { + next(false, e); + }); + + test.wait(); + }, + + 'done() treats rejection the same way as then()': function () { + var test = this, + value = {}, + promise = Promise.reject(value); + + function next(fulfilled, result) { + test.resume(function () { + Assert.isFalse(fulfilled, 'done() should respect the failure path'); + Assert.areSame(value, result, 'done() should respect the promise value'); + }); + } + + promise.done(function (x) { + next(true, x); + }, function (e) { + next(false, e); + }); + + test.wait(); + }, + + 'errors thrown inside done() are not caught': function () { + var test = this, + message = 'foo', + value = new Error(message), + promise = Promise.reject(value); + + promise.done(); + + Y.one('win').once('error', function (e) { + e.halt(); + test.resume(function () { + Assert.isTrue(e._event.message.indexOf(message) > -1, 'empty done() should send an uncaught error'); + }); + }); + + test.wait(250); + }, + + 'promise state should change synchronously': function () { + var pending = new Promise(function () {}), + fulfilled = new Promise(function (resolve) { + resolve(5); + }), + rejected = new Promise(function (resolve, reject) { + reject('foo'); + }); + + Assert.isString(pending.getStatus(), 'status should be a string'); + Assert.isString(fulfilled.getStatus(), 'status should be a string'); + Assert.isString(rejected.getStatus(), 'status should be a string'); + + Assert.areEqual('pending', pending.getStatus(), 'pending promise status should be "pending"'); + Assert.areEqual('fulfilled', fulfilled.getStatus(), 'fulfilled promise status should be "fulfilled"'); + Assert.areEqual('rejected', rejected.getStatus(), 'rejected promise status should be "rejected"'); + }, + + 'promise state should change only once': function () { + var fulfilled = new Promise(function (fulfill, reject) { + Assert.areEqual('pending', this.getStatus(), 'before fulfillment the resolver status should be "pending"'); + + fulfill(5); + + Assert.areEqual('fulfilled', this.getStatus(), 'once fulfilled the resolver status should be "fulfilled"'); + + reject(new Error('reject')); + + Assert.areEqual('fulfilled', this.getStatus(), 'rejecting a fulfilled promise should not change its status'); + }), + + rejected = new Promise(function (fulfill, reject) { + Assert.areEqual('pending', this.getStatus(), 'before rejection the resolver status should be "pending"'); + + reject(new Error('reject')); + + Assert.areEqual('rejected', this.getStatus(), 'once rejected the resolver status should be "rejected"'); + + fulfill(5); + + Assert.areEqual('rejected', this.getStatus(), 'fulfilling a rejected promise should not change its status'); + }); + + Assert.areEqual('fulfilled', fulfilled.getStatus(), 'status of a fulfilled promise should be "fulfilled"'); + Assert.areEqual('rejected', rejected.getStatus(), 'status of a rejected promise should be "rejected"'); + } + + })); + + suite.add(new Y.Test.Case({ + name: 'Promise factories tests', + + 'Promise.reject() returns an rejected promise': function () { + var test = this, + value = new Error('foo'), + promise = Promise.reject(value); + + Assert.isTrue(isPromise(promise), 'Promise.reject() should return a promise'); + + isRejected(promise, function next(rejected, result) { + test.resume(function () { + Assert.isTrue(rejected, 'promise should be rejected, not fulfilled'); + Assert.areSame(value, result, 'Promise.reject() should respect the passed value'); + }); + }); + + test.wait(); + }, + + 'Promise.reject() should wrap fulfilled promises': function () { + var test = this, + value = new Promise(function (resolve) { + resolve('foo'); + }), + promise = Promise.reject(value); + + isRejected(promise, function (rejected, result) { + test.resume(function () { + Assert.isTrue(rejected, 'promise should be rejected, not fulfilled'); + Assert.areSame(value, result, 'Promise.reject() should wrap fulfilled promises'); + }); + }); + + test.wait(); + }, + + 'Promise.reject() should wrap rejected promises': function () { + var test = this, + value = new Promise(function (resolve, reject) { + reject('foo'); + }), + promise = Promise.reject(value); + + isRejected(promise, function (rejected, result) { + test.resume(function () { + Assert.isTrue(rejected, 'promise should be rejected, not fulfilled'); + Assert.areSame(value, result, 'Promise.reject() should wrap rejected promises'); + }); + }); + + test.wait(); + }, + + 'Promise.resolve() is fulfilled when passed a regular value': function () { + var test = this, + value = {}, + promise = Promise.resolve(value); + + isFulfilled(promise, function (fulfilled, result) { + test.resume(function () { + Assert.isTrue(fulfilled, 'resolved promise should be fulfilled'); + Assert.areSame(value, result, 'resolved promise should respect the value passed to it'); + }); + }); + + test.wait(); + }, + + 'Promise.resolve() adopts the state of an fulfilled promise': function () { + var test = this, + value = {}, + fulfilled = Promise.resolve(value), + promise = Promise.resolve(fulfilled); + + isFulfilled(promise, function (fulfilled, result) { + test.resume(function () { + Assert.isTrue(fulfilled, 'resolved promise should be fulfilled'); + Assert.areSame(value, result, 'resolved promise should take the value of the provided promise'); + }); + }); + + test.wait(); + }, + + 'Promise.resolve() adopts the state of a rejected promise': function () { + var test = this, + value = {}, + fulfilled = Promise.reject(value), + promise = Promise.resolve(fulfilled); + + isRejected(promise, function (rejected, result) { + test.resume(function () { + Assert.isTrue(rejected, 'resolved promise should be rejected'); + Assert.areSame(value, result, 'resolved promise should take the value of the provided promise'); + }); + }); + + test.wait(); + } + })); + + suite.add(new Y.Test.Case({ + name: 'Promise.every() tests', + + 'Promise.empty() should return a promise': function () { + var somePromise = new Promise(function () {}); + + Assert.isInstanceOf(Promise, Promise.every([5]), 'when passed a value, Promise.every() should return a promise'); + Assert.isInstanceOf(Promise, Promise.every([new Promise(function () {})]), 'when passed a promise, Promise.every() should return a promise'); + Assert.isInstanceOf(Promise, Promise.every([]), 'with an empty list Promise.every() should still return a promise'); + Assert.areNotSame(somePromise, Promise.every([somePromise]), 'when passed a promise, Promise.every() should return a new promise'); + }, + + 'empty list should resolve to undefined': function () { + var test = this; + + Promise.every([]).then(function (result) { + test.resume(function () { + Assert.isUndefined(result, 'with an empty list Promise.every() should resolve to undefined'); + }); + }); + + test.wait(); + }, + + 'order of promises should be preserved': function () { + var test = this; + + Promise.every([wait(20), wait(10), wait(15)]).then(function (result) { + test.resume(function () { + ArrayAssert.itemsAreSame([20, 10, 15], result, 'order of returned values should be the same as the parameter list'); + }); + }); + + test.wait(); + }, + + 'values should be wrapped in a promise': function () { + var test = this, + obj = { + hello: 'world' + }; + + Promise.every(['foo', 5, obj]).then(function (result) { + test.resume(function () { + ArrayAssert.itemsAreSame(['foo', 5, obj], result, 'values passed to Promise.every() should be wrapped in promises, not ignored'); + }); + }); + + test.wait(); + }, + + 'correct handling of function parameters': function () { + var test = this; + + function testFn() {} + + Promise.every([testFn]).then(function (values) { + test.resume(function () { + Assert.isFunction(values[0], 'promise value should be a function'); + Assert.areSame(testFn, values[0], 'promise value should be the passed function'); + }); + }); + + test.wait(); + }, + + 'Promise.every() should fail as fast as possible': function () { + var test = this; + + Promise.every([rejectedAfter(20), rejectedAfter(10), rejectedAfter(15)]).then(null, function (reason) { + test.resume(function () { + Assert.areEqual(10, reason, 'reason should be the one from the first promise to be rejected'); + }); + }); + + test.wait(); + } + + })); + + suite.add(new Y.Test.Case({ + name: 'Promise.any() tests', + + 'empty list shoudl resolve to undefined': function () { + var test = this; + + Promise.any([]).then(function (result) { + test.resume(function () { + Assert.isUndefined(result, 'Promise.any() with an empty list should resolve to undefined'); + }); + }); + + test.wait(); + }, + + 'Promise.any() should fulfill when passed a fulfilled promise': function () { + var test = this; + + Promise.any([wait(10)]).then(function (result) { + test.resume(function () { + Assert.areEqual(10, result, 'Promise.any() should fulfill when passed a fulfilled promise'); + }); + }); + + test.wait(); + }, + + 'Promise.any() should reject when passed a rejected promise': function () { + var test = this; + + Promise.any([rejectedAfter(10)]).then(null, function (result) { + test.resume(function () { + Assert.areEqual(10, result, 'Promise.any() should reject when passed a rejected promise'); + }); + }); + + test.wait(); + }, + + 'Promise.any() should fulfill to the value of the first promise to be fulfilled': function () { + var test = this; + + Promise.any([wait(10), wait(100)]).then(function (result) { + test.resume(function () { + Assert.areEqual(10, result, 'Promise.any() should fulfill to the value of the first promise to be fulfilled'); + }); + }); + + test.wait(); + }, + + 'Promise.any() should reject with the reason of the first promise to be rejected': function () { + var test = this; + + Promise.any([rejectedAfter(10), rejectedAfter(100)]).then(null, function (result) { + test.resume(function () { + Assert.areEqual(10, result, 'Promise.any() should reject with the reason of the first promise to be rejected'); + }); + }); + + test.wait(); + } + })); + + suite.add(new Y.Test.Case({ + name: 'Promise.some() tests', + + 'empty list should resolve to undefined': function () { + var test = this; + + Promise.some([]).then(function (result) { + test.resume(function () { + Assert.isUndefined(result, 'Promise.some() with an empty list should resolve to undefined'); + }); + }); + + test.wait(); + }, + + 'one fulfilled promise should fulfill the returned promise': function () { + var test = this; + + Promise.some([rejectedAfter(10), wait(20), rejectedAfter(100)]).then(function (result) { + test.resume(function () { + Assert.areEqual(20, result, 'promise should be resolved to the first fulfilled value'); + }); + }, function (err) { + test.resume(function () { + throw err; + }); + }); + + test.wait(); + }, + + 'all rejected promises should reject the returned promise': function () { + var test = this; + + Promise.some([rejectedAfter(20), rejectedAfter(10)]).then(null, function (results) { + test.resume(function () { + Assert.isArray(results, 'rejection reason should be an array'); + ArrayAssert.itemsAreSame([20, 10], results, 'array of reasons should match the order passed to Promise.some()'); + }); + }); + + test.wait(); + } + })); + + Y.Test.Runner.add(suite); + +}, '@VERSION@', { + requires: [ + 'promise', + 'test' + ] +}); diff --git a/src/promise/tests/unit/assets/promise-tests.js b/src/promise/tests/unit/assets/promise-tests.js index 1081c05836b..9abbf4b8ebe 100644 --- a/src/promise/tests/unit/assets/promise-tests.js +++ b/src/promise/tests/unit/assets/promise-tests.js @@ -7,7 +7,7 @@ YUI.add('promise-tests', function (Y) { // -- Suite -------------------------------------------------------------------- var suite = new Y.Test.Suite({ - name: 'Promise tests' + name: 'Promise core tests' }); // -- Lifecycle ---------------------------------------------------------------- @@ -26,35 +26,6 @@ YUI.add('promise-tests', function (Y) { Assert.isInstanceOf(Y.Promise, promise.then(), 'promise.then returns a promise'); }, - 'promise state should change only once': function () { - var fulfilled = new Promise(function (fulfill, reject) { - Assert.areEqual('pending', this.getStatus(), 'before fulfillment the resolver status should be "pending"'); - - fulfill(5); - - Assert.areEqual('fulfilled', this.getStatus(), 'once fulfilled the resolver status should be "fulfilled"'); - - reject(new Error('reject')); - - Assert.areEqual('fulfilled', this.getStatus(), 'rejecting a fulfilled promise should not change its status'); - }), - - rejected = new Promise(function (fulfill, reject) { - Assert.areEqual('pending', this.getStatus(), 'before rejection the resolver status should be "pending"'); - - reject(new Error('reject')); - - Assert.areEqual('rejected', this.getStatus(), 'once rejected the resolver status should be "rejected"'); - - fulfill(5); - - Assert.areEqual('rejected', this.getStatus(), 'fulfilling a rejected promise should not change its status'); - }); - - Assert.areEqual('fulfilled', fulfilled.getStatus(), 'status of a fulfilled promise should be "fulfilled"'); - Assert.areEqual('rejected', rejected.getStatus(), 'status of a rejected promise should be "rejected"'); - }, - 'fulfilling more than once should not change the promise value': function () { var test = this; @@ -96,7 +67,7 @@ YUI.add('promise-tests', function (Y) { Assert.areSame(promiseA, promiseB, 'the return value of Y.Promise and "this" inside the init function should be the same'); }, - 'callbacks passed to then should be called asynchronously': function () { + 'callbacks passed to then() should be called asynchronously': function () { var test = this; var foo = false; @@ -110,9 +81,54 @@ YUI.add('promise-tests', function (Y) { Assert.areEqual(false, foo, 'callback should not modify local variable in this turn of the event loop'); + test.wait(); + }, + + 'resolving with a fulfilled promise creates a fulfilled promise': function () { + var test = this, + expected = {}; + fulfilled = new Y.Promise(function (resolve) { + resolve(expected); + }); + + Y.Promise(function (resolve) { + resolve(fulfilled); + }).then(function (value) { + test.resume(function () { + Assert.areSame(expected, value, 'value of the resolved promise should be the same as the previous promise'); + }); + }, function (err) { + test.resume(function () { + throw err; + }); + }); + + test.wait(); + }, + + 'resolving with a rejected promise creates a rejected promise': function () { + var test = this, + expected = new Error('foo'); + rejected = new Y.Promise(function (resolve, reject) { + reject(expected); + }); + + Y.Promise(function (resolve) { + resolve(rejected); + }).then(function (value) { + test.resume(function () { + throw new Error('Y.Promise failed to resolve a rejected promise'); + }); + }, function (err) { + test.resume(function () { + Assert.areSame(expected, err, 'value of the resolved promise should be the same as the previous promise'); + }); + }); + test.wait(); } + })); suite.add(new Y.Test.Case({ @@ -144,7 +160,7 @@ YUI.add('promise-tests', function (Y) { }); }); - test.wait(50); + test.wait(); }, 'returning a promise from a callback should link both promises': function () { @@ -162,7 +178,7 @@ YUI.add('promise-tests', function (Y) { }); }); - test.wait(100); + test.wait(); }, // This test is run only when not in strict mode @@ -189,7 +205,7 @@ YUI.add('promise-tests', function (Y) { }); }); - test.wait(300); + test.wait(); }, // This test is run only in strict mode @@ -218,7 +234,7 @@ YUI.add('promise-tests', function (Y) { }); }); - test.wait(300); + test.wait(); } })); @@ -262,7 +278,7 @@ YUI.add('promise-tests', function (Y) { }, '@VERSION@', { requires: [ - 'promise', + 'promise-core', 'test' ] }); diff --git a/src/promise/tests/unit/promise-extras.html b/src/promise/tests/unit/promise-extras.html new file mode 100644 index 00000000000..388eb6071f1 --- /dev/null +++ b/src/promise/tests/unit/promise-extras.html @@ -0,0 +1,39 @@ + + + + Promises Tests + + + +
+ + + + + + diff --git a/src/promise/tests/unit/promise.html b/src/promise/tests/unit/promise.html index 3cd60e2f18a..977fb5cc730 100644 --- a/src/promise/tests/unit/promise.html +++ b/src/promise/tests/unit/promise.html @@ -11,26 +11,18 @@