-
-
Notifications
You must be signed in to change notification settings - Fork 5.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
v2: Can of Promises #3729
base: master
Are you sure you want to change the base?
v2: Can of Promises #3729
Changes from all commits
bfa268c
e9f019e
ecb606f
91227ee
9adf677
5b39cf9
bdaa11b
ca27d88
53e9555
0d4fe5b
0bee4c0
403bc44
fb45026
820dd25
509adab
747ffaa
7eddccd
2e8c79c
0833ee7
44bec7b
667f81d
21ab2ee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -433,7 +433,7 @@ | |
// Proxy `Backbone.sync` by default -- but override this if you need | ||
// custom syncing semantics for *this* particular model. | ||
sync: function() { | ||
return Backbone.sync.apply(this, arguments); | ||
return Promise.resolve(Backbone.sync.apply(this, arguments)); | ||
}, | ||
|
||
// Get the value of an attribute. | ||
|
@@ -587,15 +587,21 @@ | |
fetch: function(options) { | ||
options = _.extend({parse: true}, options); | ||
var model = this; | ||
var success = options.success; | ||
options.success = function(resp) { | ||
var serverAttrs = options.parse ? model.parse(resp, options) : resp; | ||
if (!model.set(serverAttrs, options)) return false; | ||
if (success) success.call(options.context, model, resp, options); | ||
model.trigger('sync', model, resp, options); | ||
}; | ||
wrapError(this, options); | ||
return this.sync('read', this, options); | ||
var success = options.success || _.identity; | ||
var error = options.error; | ||
delete options.success; | ||
delete options.error; | ||
return wrapError(this.sync('read', this, options), this, options, error) | ||
.then(function(resp) { | ||
var serverAttrs = options.parse ? model.parse(resp, options) : resp; | ||
if (model.set(serverAttrs, options)) | ||
return Promise.resolve(success.call(options.context, model, resp, options)) | ||
.then(function(delivered) { | ||
model.trigger('sync', model, resp, options); | ||
return delivered; | ||
}); | ||
return model; | ||
}); | ||
}, | ||
|
||
// Set a hash of model attributes, and sync the model to the server. | ||
|
@@ -613,43 +619,48 @@ | |
|
||
options = _.extend({validate: true, parse: true}, options); | ||
var wait = options.wait; | ||
var success = options.success || _.identity; | ||
var error = options.error; | ||
delete options.success; | ||
delete options.error; | ||
|
||
// If we're not waiting and attributes exist, save acts as | ||
// `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 Promise.reject(this.validationError); | ||
} else { | ||
if (!this._validate(attrs, options)) return false; | ||
if (!this._validate(attrs, options)) return Promise.reject(this.validationError); | ||
} | ||
|
||
// After a successful server-side save, the client is (optionally) | ||
// updated with the server-side state. | ||
var model = this; | ||
var success = options.success; | ||
var attributes = this.attributes; | ||
options.success = function(resp) { | ||
// Ensure attributes are restored during synchronous saves. | ||
model.attributes = attributes; | ||
var serverAttrs = options.parse ? model.parse(resp, options) : resp; | ||
if (wait) serverAttrs = _.extend({}, attrs, serverAttrs); | ||
if (serverAttrs && !model.set(serverAttrs, options)) return false; | ||
if (success) success.call(options.context, model, resp, options); | ||
model.trigger('sync', model, resp, options); | ||
}; | ||
wrapError(this, options); | ||
|
||
// Set temporary attributes if `{wait: true}` to properly find new ids. | ||
if (attrs && wait) this.attributes = _.extend({}, attributes, attrs); | ||
|
||
var method = this.isNew() ? 'create' : (options.patch ? 'patch' : 'update'); | ||
if (method === 'patch' && !options.attrs) options.attrs = attrs; | ||
var xhr = this.sync(method, this, options); | ||
var promise = wrapError(this.sync(method, this, options), this, options, error); | ||
|
||
// Restore attributes. | ||
this.attributes = attributes; | ||
|
||
return xhr; | ||
return promise.then(function(resp) { | ||
// Ensure attributes are restored during synchronous saves. | ||
model.attributes = attributes; | ||
var serverAttrs = options.parse ? model.parse(resp, options) : resp; | ||
if (wait) serverAttrs = _.extend({}, attrs, serverAttrs); | ||
if (!serverAttrs || model.set(serverAttrs, options)) | ||
return Promise.resolve(success.call(options.context, model, resp, options)) | ||
.then(function(delivered) { | ||
model.trigger('sync', model, resp, options); | ||
return delivered; | ||
}); | ||
return model; | ||
}); | ||
}, | ||
|
||
// Destroy this model on the server if it was already persisted. | ||
|
@@ -658,29 +669,33 @@ | |
destroy: function(options) { | ||
options = options ? _.clone(options) : {}; | ||
var model = this; | ||
var success = options.success; | ||
var wait = options.wait; | ||
var success = options.success; | ||
var error = options.error; | ||
delete options.success; | ||
delete options.error; | ||
|
||
var destroy = function() { | ||
model.stopListening(); | ||
model.trigger('destroy', model, model.collection, options); | ||
}; | ||
|
||
options.success = function(resp) { | ||
if (wait) destroy(); | ||
if (success) success.call(options.context, model, resp, options); | ||
if (!model.isNew()) model.trigger('sync', model, resp, options); | ||
}; | ||
|
||
var xhr = false; | ||
if (this.isNew()) { | ||
_.defer(options.success); | ||
} else { | ||
wrapError(this, options); | ||
xhr = this.sync('delete', this, options); | ||
} | ||
var isNew = model.isNew(); | ||
var promise = isNew ? Promise.resolve(model.attributes) : | ||
wrapError(this.sync('delete', this, options), this, options, error); | ||
if (!wait) destroy(); | ||
return xhr; | ||
return promise.then(function(resp) { | ||
var promise = Promise.resolve(model); | ||
if (wait) destroy(); | ||
if (success) | ||
promise = Promise.resolve(success.call(options.context, model, resp, options)); | ||
if (!isNew) | ||
promise = promise.then(function(delivered) { | ||
model.trigger('sync', model, resp, options); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This could be written differently, but the point is that when an existing model is destroyed then the sync event will be triggered after the success callback. Providing a hook to the developer to resolve any rejection by the sync method before the event is triggered. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I got confused, the error callback provides the hook to resolve a rejected promise from Thinking about it some more, you could argue that the sync event doesn't care how or what the promise is fulfilled (i.e. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, that's entirely your point isn't it? Sorry, I'm a bit slow... So, to that point: the triggering of sync must be present in the returned chained of promises so that any errors can be caught by the caller. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is super important to me, the other day I got bit by an error that was being thrown by a jquery plugin inside a stickit method that is called by a sync trigger. The error was swallowed deep inside a promise (of my own making) and was very hard to trace. If I had this code the error object would have been propagated up to the user and the user would have probably/hopefully been presented with an error instead of half a page of broken content. Of course the outcome is that I had to work around the buggy jquery plugin (there's no escape from that!) but there would have been a better developer experience at the very least. So, I wrote a test: 403bc44 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My point was we can avoid returning a new promise and instead just return if (!isNew) {
promise.then(function(delivered) {
model.trigger('sync', model, resp, options);
});
} Note not redefining There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The resulting promise of that expression needs to be returned. |
||
return delivered; | ||
}); | ||
return promise; | ||
}); | ||
}, | ||
|
||
// Default URL for the model's representation on the server -- if you're | ||
|
@@ -693,7 +708,12 @@ | |
urlError(); | ||
if (this.isNew()) return base; | ||
var id = this.get(this.idAttribute); | ||
return base.replace(/[^\/]$/, '$&/') + encodeURIComponent(id); | ||
var consume = function(base) { | ||
return base.replace(/[^\/]$/, '$&/') + encodeURIComponent(id); | ||
}; | ||
if (isPromise(base)) | ||
return base.then(consume); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might want to make a helper for this. Also There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point. I used There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is 0d4fe5b what you had in mind? Also, shall go ahead and drop the polyfill under |
||
return consume(base); | ||
}, | ||
|
||
// **parse** converts a response into the hash of attributes to be `set` on | ||
|
@@ -783,7 +803,7 @@ | |
|
||
// Proxy `Backbone.sync` by default. | ||
sync: function() { | ||
return Backbone.sync.apply(this, arguments); | ||
return Promise.resolve(Backbone.sync.apply(this, arguments)); | ||
}, | ||
|
||
// Add a model, or list of models to the set. `models` may be Backbone | ||
|
@@ -1004,16 +1024,21 @@ | |
// data will be passed through the `reset` method instead of `set`. | ||
fetch: function(options) { | ||
options = _.extend({parse: true}, options); | ||
var success = options.success; | ||
var collection = this; | ||
options.success = function(resp) { | ||
var method = options.reset ? 'reset' : 'set'; | ||
collection[method](resp, options); | ||
if (success) success.call(options.context, collection, resp, options); | ||
collection.trigger('sync', collection, resp, options); | ||
}; | ||
wrapError(this, options); | ||
return this.sync('read', this, options); | ||
var success = options.success || _.identity; | ||
var error = options.error; | ||
delete options.success; | ||
delete options.error; | ||
return wrapError(this.sync('read', this, options), this, options, error) | ||
.then(function(resp) { | ||
var method = options.reset ? 'reset' : 'set'; | ||
collection[method](resp, options); | ||
return Promise.resolve(success.call(options.context, collection, resp, options)) | ||
.then(function(delivered) { | ||
collection.trigger('sync', collection, resp, options); | ||
return delivered; | ||
}); | ||
}); | ||
}, | ||
|
||
// Create a new instance of a model in this collection. Add the model to the | ||
|
@@ -1026,13 +1051,18 @@ | |
if (!model) return false; | ||
if (!wait) this.add(model, options); | ||
var collection = this; | ||
var success = options.success; | ||
options.success = function(model, resp, callbackOpts) { | ||
if (wait) collection.add(model, callbackOpts); | ||
if (success) success.call(callbackOpts.context, model, resp, callbackOpts); | ||
var success = options.success || _.identity; | ||
var error = options.error; | ||
delete options.error; | ||
options.success = function(_, resp, opts) { | ||
options = opts; | ||
return resp; | ||
}; | ||
model.save(null, options); | ||
return model; | ||
return model.save(null, options) | ||
.then(function(resp) { | ||
if (wait) collection.add(model, options); | ||
return Promise.resolve(success.call(options.context, model, resp, options)); | ||
}); | ||
}, | ||
|
||
// **parse** converts a response into a list of models to be added to the | ||
|
@@ -1351,9 +1381,7 @@ | |
var params = {type: type, dataType: 'json'}; | ||
|
||
// Ensure that we have a URL. | ||
if (!options.url) { | ||
params.url = _.result(model, 'url') || urlError(); | ||
} | ||
params.url = options.url || _.result(model, 'url') || urlError(); | ||
|
||
// Ensure that we have the appropriate request data. | ||
if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) { | ||
|
@@ -1385,17 +1413,21 @@ | |
} | ||
|
||
// Pass along `textStatus` and `errorThrown` from jQuery. | ||
var error = options.error; | ||
options.error = function(xhr, textStatus, errorThrown) { | ||
options.textStatus = textStatus; | ||
options.errorThrown = errorThrown; | ||
if (error) error.call(options.context, xhr, textStatus, errorThrown); | ||
}; | ||
|
||
// Make the request, allowing the user to override any Ajax options. | ||
var xhr = options.xhr = Backbone.ajax(_.extend(params, options)); | ||
model.trigger('request', model, xhr, options); | ||
return xhr; | ||
var consume = function(url) { | ||
params.url = url || urlError(); | ||
var promise = Promise.resolve(options.xhr = Backbone.ajax(_.extend(params, _.omit(options, 'url')))); | ||
model.trigger('request', model, promise, options); | ||
return promise; | ||
}; | ||
if (isPromise(params.url)) | ||
return params.url.then(consume); | ||
return consume(params.url); | ||
}; | ||
|
||
// Map from CRUD to HTTP for our default `Backbone.sync` implementation. | ||
|
@@ -1455,19 +1487,42 @@ | |
var router = this; | ||
Backbone.history.route(route, function(fragment) { | ||
var args = router._extractParameters(route, fragment); | ||
if (router.execute(callback, args, name) !== false) { | ||
router.trigger.apply(router, ['route:' + name].concat(args)); | ||
router.trigger('route', name, args); | ||
Backbone.history.trigger('route', router, name, args); | ||
} | ||
var result = router.execute(callback, args, name); | ||
var consume = function(result) { | ||
if (result !== false) { | ||
router.trigger.apply(router, ['route:' + name].concat(args)); | ||
router.trigger('route', name, args); | ||
Backbone.history.trigger('route', router, name, args); | ||
} | ||
}; | ||
if (isPromise(result)) | ||
result.then(consume); | ||
else | ||
consume(result); | ||
}); | ||
return this; | ||
}, | ||
|
||
// Execute a route handler with the provided parameters. This is an | ||
// excellent place to do pre-route setup or post-route cleanup. | ||
execute: function(callback, args, name) { | ||
if (callback) callback.apply(this, args); | ||
var router = this; | ||
var error = function(err) { | ||
router.trigger('error', err, name, args, callback); | ||
}; | ||
if (callback) { | ||
try { | ||
var route = callback.apply(this, args); | ||
if (isPromise(route)) { | ||
return route['catch'](error); | ||
} | ||
return route; | ||
} catch(e) { | ||
error(e); | ||
} | ||
} else { | ||
error(); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You may want to add additional tests for this functionality. Fwiw (although it may be irrelevant to this PR), if all of my desired changes to the router land then this method will no longer exist. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you talking about jmeas/backbone.base-router? I should like to check it out :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, sorry, should have linked the PR. I was referring to #3660. That PR is heavily inspired by the ideas in BaseRouter, though :) |
||
}, | ||
|
||
// Simple proxy to `Backbone.history` to save a fragment into the history. | ||
|
@@ -1709,8 +1764,8 @@ | |
|
||
// Add a route to be tested when the fragment changes. Routes added later | ||
// may override previous routes. | ||
route: function(route, callback) { | ||
this.handlers.unshift({route: route, callback: callback}); | ||
route: function(route, callback, meta) { | ||
this.handlers.unshift(_.defaults({route: route, callback: callback}, meta)); | ||
}, | ||
|
||
// Checks the current URL to see if it has changed, and if it has, | ||
|
@@ -1865,15 +1920,26 @@ | |
throw new Error('A "url" property or function must be specified'); | ||
}; | ||
|
||
// Wrap an optional error callback with a fallback error event. | ||
var wrapError = function(model, options) { | ||
var error = options.error; | ||
options.error = function(resp) { | ||
if (error) error.call(options.context, model, resp, options); | ||
model.trigger('error', model, resp, options); | ||
}; | ||
// Allow a callback to resolve a rejected promise, otherwise promise | ||
// will remain rejected and an `error` event will be triggered on model. | ||
var wrapError = function(promise, model, options, callback) { | ||
return Promise.resolve(promise) | ||
['catch'](function(resp) { | ||
if (callback) | ||
return callback.call(options.context, model, resp, options); | ||
throw resp; | ||
}) | ||
['catch'](function(resp) { | ||
model.trigger('error', model, resp, options); | ||
throw resp; | ||
}); | ||
}; | ||
|
||
// Allow any `then`able (and `catch`able) to be considered a `Promise` | ||
var isPromise = function(o) { | ||
return o && (typeof o === 'object' || typeof o === 'function') && typeof o.then === 'function' && typeof o['catch'] === 'function'; | ||
} | ||
|
||
return Backbone; | ||
|
||
})); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't modify the
options
objectThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
err, but the master branch does modify the
options
object already... I guess you object to thedelete
lines?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh, but I'm not modifying the object that is provided in the arguments.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do you care to delete these properties? It shouldn't matter if they stick around, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't want them called by ajax (i.e. jQuery or whatever it might be), because I want them to be apart of the promise chain.