diff --git a/backbone.js b/backbone.js index 1a0ebae3..0430752b 100644 --- a/backbone.js +++ b/backbone.js @@ -176,6 +176,8 @@ this.cid = _.uniqueId('c'); this.set(attributes, {silent: true}); delete this._changed; + delete this._silent; + delete this._pending; this._previousAttributes = _.clone(this.attributes); this.initialize.apply(this, arguments); }; @@ -183,6 +185,17 @@ // Attach all inheritable methods to the Model prototype. _.extend(Backbone.Model.prototype, Backbone.Events, { + // A hash of attributes whose current and previous value differ. + _changed: void 0, + + // A hash of attributes that have silently changed since the last time + // `change` was called. Will become pending attributes on the next call. + _silent: void 0, + + // A hash of attributes that have changed since the last `'change'` event + // began. + _pending: void 0, + // The default name for the JSON `id` attribute is `"id"`. MongoDB and // CouchDB users may want to set this to `"_id"`. idAttribute: 'id', @@ -239,33 +252,36 @@ // Check for changes of `id`. if (this.idAttribute in attrs) this.id = attrs[this.idAttribute]; + var changes = {}; var now = this.attributes; var escaped = this._escapedAttributes; var prev = this._previousAttributes || {}; - var alreadySetting = this._setting; this._changed || (this._changed = {}); - this._setting = true; + this._silent || (this._silent = {}); + this._pending || (this._pending = {}); - // Update attributes. for (attr in attrs) { val = attrs[attr]; - if (!_.isEqual(now[attr], val)) delete escaped[attr]; - options.unset ? delete now[attr] : now[attr] = val; - if (this._changing && !_.isEqual(this._changed[attr], val)) { - this.trigger('change:' + attr, this, val, options); - this._moreChanges = true; + // If the new and current value differ, record the change. + if (!_.isEqual(now[attr], val) || (options.unset && _.has(now, attr))) { + delete escaped[attr]; + (options.silent ? this._silent : changes)[attr] = true; } - delete this._changed[attr]; + // Update the current value. + options.unset ? delete now[attr] : now[attr] = val; + // If the new and previous value differ, record the change. If not, + // then remove changes for this attribute. if (!_.isEqual(prev[attr], val) || (_.has(now, attr) != _.has(prev, attr))) { - this._changed[attr] = val; + this._changed[attr] = true; + if (!options.silent) this._pending[attr] = true; + } else { + delete this._changed[attr]; + delete this._pending[attr]; } } - // Fire the `"change"` events, if the model has been changed. - if (!alreadySetting) { - if (!options.silent && this.hasChanged()) this.change(options); - this._setting = false; - } + // Fire the `"change"` events. + if (!options.silent) this.change(_.extend({changes: changes}, options)); return this; }, @@ -392,18 +408,26 @@ // a `"change:attribute"` event for each changed attribute. // Calling this will cause all objects observing the model to update. change: function(options) { - if (this._changing || !this.hasChanged()) return this; + options || (options = {}); + var changing = this._changing; this._changing = true; - this._moreChanges = true; - for (var attr in this._changed) { - this.trigger('change:' + attr, this, this._changed[attr], options); + // Silent changes become pending changes. + this._pending = _.extend(this._pending || {}, this._silent); + // Silent changes are triggered. + var changes = _.extend({}, options.changes, this._silent); + delete this._silent; + for (var attr in changes) { + this.trigger('change:' + attr, this, this.attributes[attr], options); } - while (this._moreChanges) { - this._moreChanges = false; + if (changing) return this; + // Continue firing `"change"` events while there are pending changes. + while (!_.isEmpty(this._pending)) { + delete this._pending; this.trigger('change', this, options); + // Pending and silent changes still remain. + this._changed = _.extend({}, this._pending, this._silent); + this._previousAttributes = _.clone(this.attributes); } - this._previousAttributes = _.clone(this.attributes); - delete this._changed; this._changing = false; return this; }, @@ -422,7 +446,12 @@ // You can also pass an attributes object to diff against the model, // determining if there *would be* a change. changedAttributes: function(diff) { - if (!diff) return this.hasChanged() ? _.clone(this._changed) : false; + if (!diff) { + if (!this.hasChanged()) return false; + var changes = {}; + for (var attr in this._changed) changes[attr] = this.attributes[attr]; + return changes; + } var val, changed = false, old = this._previousAttributes; for (var attr in diff) { if (_.isEqual(old[attr], (val = diff[attr]))) continue; diff --git a/test/model.js b/test/model.js index 549eeadf..136960c8 100644 --- a/test/model.js +++ b/test/model.js @@ -517,8 +517,8 @@ $(document).ready(function() { } }); - a = new A(); - b = new B({a: a}); + var a = new A(); + var b = new B({a: a}); a.set({state: 'hello'}); }); @@ -632,15 +632,21 @@ $(document).ready(function() { equal(changed, 1); }); - test("nested `set` during `'change:attr'`", 1, function() { + test("nested `set` during `'change:attr'`", function() { + var events = []; var model = new Backbone.Model(); - model.on('change:x', function() { ok(true); }); - model.on('change:y', function() { - model.set({x: true}); - // only fires once - model.set({x: true}); + model.on('all', function(event) { events.push(event); }); + model.on('change', function() { + model.set({z: true}, {silent:true}); }); - model.set({y: true}); + model.on('change:x', function() { + model.set({y: true}); + }); + model.set({x: true}); + deepEqual(events, ['change:y', 'change:x', 'change']); + events = []; + model.change(); + deepEqual(events, ['change:z', 'change']); }); test("nested `change` only fires once", 1, function() { @@ -658,21 +664,24 @@ $(document).ready(function() { model.change(); }); - test("nested `set` suring `'change'`", 3, function() { + test("nested `set` during `'change'`", 6, function() { var count = 0; var model = new Backbone.Model(); model.on('change', function() { switch(count++) { case 0: deepEqual(this.changedAttributes(), {x: true}); + equal(model.previous('x'), undefined); model.set({y: true}); break; case 1: - deepEqual(this.changedAttributes(), {x: true, y: true}); + deepEqual(this.changedAttributes(), {y: true}); + equal(model.previous('x'), true); model.set({z: true}); break; case 2: - deepEqual(this.changedAttributes(), {x: true, y: true, z: true}); + deepEqual(this.changedAttributes(), {z: true}); + equal(model.previous('y'), true); break; default: ok(false); @@ -681,6 +690,76 @@ $(document).ready(function() { model.set({x: true}); }); + test("nested `'change'` with silent", 3, function() { + var count = 0; + var model = new Backbone.Model(); + model.on('change:y', function() { ok(true); }); + model.on('change', function() { + switch(count++) { + case 0: + deepEqual(this.changedAttributes(), {x: true}); + model.set({y: true}, {silent: true}); + break; + case 1: + deepEqual(this.changedAttributes(), {y: true, z: true}); + break; + default: + ok(false); + } + }); + model.set({x: true}); + model.set({z: true}); + }); + + test("nested `'change:attr'` with silent", 1, function() { + var model = new Backbone.Model(); + model.on('change:y', function(){ ok(true); }); + model.on('change', function() { + model.set({y: true}, {silent: true}); + model.set({z: true}); + }); + model.set({x: true}); + }); + + test("multiple nested changes with silent", 1, function() { + var model = new Backbone.Model(); + model.on('change:x', function() { + model.set({y: 1}, {silent: true}); + model.set({y: 2}); + }); + model.on('change:y', function(model, val) { + equal(val, 2); + }); + model.set({x: true}); + model.change(); + }); + + test("multiple nested changes with silent", function() { + var changes = []; + var model = new Backbone.Model(); + model.on('change:b', function(model, val) { changes.push(val); }); + model.on('change', function() { + model.set({b: 1}); + model.set({b: 2}, {silent: true}); + }); + model.set({b: 0}); + deepEqual(changes, [0, 1, 1]); + model.change(); + deepEqual(changes, [0, 1, 1, 2, 1]); + }); + + test("nested set multiple times", 1, function() { + var model = new Backbone.Model(); + model.on('change:b', function() { + ok(true); + }); + model.on('change:a', function() { + model.set({b: true}); + model.set({b: true}); + }); + model.set({a: true}); + }); + test("Backbone.wrapError triggers `'error'`", 12, function() { var resp = {}; var options = {};