diff --git a/backbone.js b/backbone.js index afd37537c..0d4c1b74e 100644 --- a/backbone.js +++ b/backbone.js @@ -601,9 +601,9 @@ // `set(attr).save(null, opts)` with validation. Otherwise, check if // the model will be valid when the attributes, if any, are set. if (attrs && !wait) { - if (!this.set(attrs, options)) return false; + if (!this.set(attrs, options)) return Backbone.Promise.reject(this.validationError); } else { - if (!this._validate(attrs, options)) return false; + if (!this._validate(attrs, options)) return Backbone.Promise.reject(this.validationError); } // After a successful server-side save, the client is (optionally) @@ -639,7 +639,7 @@ // Optimistically removes the model from its collection, if it has one. // If `wait: true` is passed, waits for the server to respond before removal. destroy: function(options) { - options = options ? _.clone(options) : {}; + options = _.extend({}, options); var model = this; var success = options.success; var wait = options.wait; @@ -655,9 +655,9 @@ if (!model.isNew()) model.trigger('sync', model, resp, options); }; - var xhr = false; + var xhr; if (this.isNew()) { - _.defer(options.success); + xhr = Backbone.Promise.resolve().then(options.success); } else { wrapError(this, options); xhr = this.sync('delete', this, options); @@ -1408,6 +1408,31 @@ return Backbone.$.ajax.apply(Backbone.$, arguments); }; + // A psuedo Promise implementation used to ensure asynchronous methods + // return thenables. + // Override this if you'd like to use a different ES6 library. + Backbone.Promise = function() { + throw new Error('Backbone does not provide a spec compliant Promise by default.'); + }; + + _.extend(Backbone.Promise, { + // A wrapper around jQuery's normal resolve to force it to adopt a + // thenable's state, and execute then callbacks asynchronously. + resolve: function(value) { + var deferred = Backbone.$.Deferred(); + _.defer(deferred.resolve); + return deferred.promise().then(_.constant(value)); + }, + + // A wrapper around jQuery's normal reject to force it to execute + // then callbacks asynchronously. + reject: function(reason) { + var deferred = Backbone.$.Deferred(); + _.defer(deferred.reject, reason); + return deferred.promise(); + } + }); + // Backbone.Router // --------------- diff --git a/test/index.html b/test/index.html index 3dad8c679..1deab47a3 100644 --- a/test/index.html +++ b/test/index.html @@ -20,5 +20,6 @@ + diff --git a/test/model.js b/test/model.js index faaf61dda..de7197835 100644 --- a/test/model.js +++ b/test/model.js @@ -662,13 +662,19 @@ this.ajaxSettings.success(); }); - test("destroy", 3, function() { + asyncTest("destroy", 3, function() { doc.destroy(); equal(this.syncArgs.method, 'delete'); ok(_.isEqual(this.syncArgs.model, doc)); var newModel = new Backbone.Model; - equal(newModel.destroy(), false); + var promise = newModel.destroy(); + var async = false; + promise.then(function() { + ok(async, 'then chains asynchronously'); + start(); + }); + async = true; }); test("destroy will pass extra options to success callback", 1, function () { @@ -1156,11 +1162,18 @@ }}); }); - test("#1433 - Save: An invalid model cannot be persisted.", 1, function() { + asyncTest("#1433 - Save: An invalid model cannot be persisted.", 2, function() { var model = new Backbone.Model; - model.validate = function(){ return 'invalid'; }; + model.validate = function(){ return { error: 'invalid' }; }; model.sync = function(){ ok(false); }; - strictEqual(model.save(), false); + var promise = model.save(); + var async = false; + promise.then(null, function(reason) { + strictEqual(reason, model.validationError, 'passes error to onRejected'); + ok(async, 'then chains asynchronously'); + start(); + }) + async = true; }); test("#1377 - Save without attrs triggers 'error'.", 1, function() { diff --git a/test/promise.js b/test/promise.js new file mode 100644 index 000000000..e28e0bf29 --- /dev/null +++ b/test/promise.js @@ -0,0 +1,56 @@ +(function() { + + module("Backbone.Promise"); + + test("throws an error if invoked", 1, function() { + try { + Backbone.Promise(); + } catch (e) { + ok(e); + } + }); + + asyncTest(".resolve to passed in value", 1, function() { + var value = {}; + Backbone.Promise.resolve({}).then(function(val) { + strictEqual(val, value); + start(); + }); + }); + + asyncTest(".resolve adopts promise state", 1, function() { + var value = {}; + var promise = Backbone.Promise.resolve(val); + Backbone.Promise.resolve(promise).then(function(val) { + strictEqual(val, value); + start(); + }); + }); + + asyncTest(".resolve executes then callback asynchronously", 1, function() { + var async = false; + Backbone.Promise.resolve().then(function() { + ok(async); + start(); + }); + async = true; + }); + + asyncTest(".reject to passed in value", 1, function() { + var value = {}; + Backbone.Promise.reject({}).then(null, function(val) { + strictEqual(val, value); + start(); + }); + }); + + asyncTest(".reject executes then callback asynchronously", 1, function() { + var async = false; + Backbone.Promise.reject().then(null, function() { + ok(async); + start(); + }); + async = true; + }); + +});