diff --git a/backbone.js b/backbone.js index 58c8403c..b3284468 100644 --- a/backbone.js +++ b/backbone.js @@ -184,22 +184,20 @@ var Model = Backbone.Model = function(attributes, options) { var defaults; var attrs = attributes || {}; - if (options && options.collection) this.collection = options.collection; - this.attributes = {}; - this._escapedAttributes = {}; this.cid = _.uniqueId('c'); this.changed = {}; - this._changes = {}; - this._pending = {}; + this.attributes = {}; + this._escapedAttributes = {}; + this._modelState = []; + if (options && options.collection) this.collection = options.collection; if (options && options.parse) attrs = this.parse(attrs); if (defaults = _.result(this, 'defaults')) { attrs = _.extend({}, defaults, attrs); } this.set(attrs, {silent: true}); - // Reset change tracking. - this.changed = {}; - this._changes = {}; - this._pending = {}; + this._cleanChange = true; + this._modelState = []; + this._currentState = _.clone(this.attributes); this._previousAttributes = _.clone(this.attributes); this.initialize.apply(this, arguments); }; @@ -210,17 +208,26 @@ // A hash of attributes whose current and previous value differ. changed: null, - // A hash of attributes that have changed since the last time `change` - // was called. - _changes: null, + // Whether there is a pending request to fire in the final `change` loop. + _pending: false, - // A hash of attributes that have changed since the last `change` event - // began. - _pending: null, + // Whether the model is in the midst of a change cycle. + _changing: false, - // A hash of attributes with the current model state to determine if - // a `change` should be recorded within a nested `change` block. - _changing : null, + // Whether there has been a `set` call since the last + // calculation of the changed hash, for efficiency. + _cleanChange: true, + + // The model state used for comparison in determining if a + // change should be fired. + _currentState: null, + + // An array queue of all changes attributed to a model + // on the next non-silent change event. + _modelState: null, + + // A hash of the model's attributes when the last `change` occured. + _previousAttributes: null, // The default name for the JSON `id` attribute is `"id"`. MongoDB and // CouchDB users may want to set this to `"_id"`. @@ -276,6 +283,7 @@ // Extract attributes and options. var silent = options && options.silent; var unset = options && options.unset; + if (attrs instanceof Model) attrs = attrs.attributes; if (unset) for (attr in attrs) attrs[attr] = void 0; @@ -285,38 +293,26 @@ // Check for changes of `id`. if (this.idAttribute in attrs) this.id = attrs[this.idAttribute]; - var changing = this._changing; var now = this.attributes; - var escaped = this._escapedAttributes; - var prev = this._previousAttributes || {}; + var esc = this._escapedAttributes; // For each `set` attribute... for (attr in attrs) { val = attrs[attr]; - // If the new and current value differ, record the change. - if (!_.isEqual(now[attr], val) || (unset && _.has(now, attr))) { - delete escaped[attr]; - this._changes[attr] = true; - } + // If an escaped attr exists, and the new and current value differ, remove the escaped attr. + if (esc[attr] && !_.isEqual(now[attr], val) || (unset && _.has(now, attr))) delete esc[attr]; // Update or delete the current value. 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; - if (!silent) this._pending[attr] = true; - } else { - delete this.changed[attr]; - delete this._pending[attr]; - if (!changing) delete this._changes[attr]; - } - - if (changing && _.isEqual(now[attr], changing[attr])) delete this._changes[attr]; + // Track any action on the attribute. + this._modelState.push(attr, val, unset); } + // Signal that the model's state has potentially changed. + this._cleanChange = false; + // Fire the `"change"` events. if (!silent) this.change(options); return this; @@ -463,36 +459,22 @@ // Calling this will cause all objects observing the model to update. change: function(options) { var changing = this._changing; - var current = this._changing = {}; + this._changing = true; - // Silent changes become pending changes. - for (var attr in this._changes) this._pending[attr] = true; + // Generate the changes to be triggered on the model. + var triggers = this._changeCenter(true); + this._pending = triggers.length; - // Trigger 'change:attr' for any new or silent changes. - var changes = this._changes; - this._changes = {}; - - // Set the correct state for this._changing values - var triggers = []; - for (var attr in changes) { - current[attr] = this.get(attr); - triggers.push(attr); + for (i = triggers.length - 2; i >= 0; i -= 2) { + this.trigger('change:' + triggers[i], this, triggers[i + 1], options); } - for (var i=0, l=triggers.length; i < l; i++) { - this.trigger('change:' + triggers[i], this, current[triggers[i]], options); - } if (changing) return this; - // Continue firing `"change"` events while there are pending changes. - while (!_.isEmpty(this._pending)) { - this._pending = {}; + // Trigger a `change` while there have been changes. + while (this._pending) { + this._pending = false; this.trigger('change', this, options); - // Pending and silent changes still remain. - for (var attr in this.changed) { - if (this._pending[attr] || this._changes[attr]) continue; - delete this.changed[attr]; - } this._previousAttributes = _.clone(this.attributes); } @@ -503,6 +485,7 @@ // Determine if the model has changed since the last `"change"` event. // If you specify an attribute name, determine if that attribute has changed. hasChanged: function(attr) { + if (!this._cleanChange) this._changeCenter(); if (attr == null) return !_.isEmpty(this.changed); return _.has(this.changed, attr); }, @@ -523,6 +506,42 @@ return changed; }, + // Calculates and handles any changes in `this._modelState`, + // checking against `this._currentState` to determine current changes. + _changeCenter: function (change) { + this.changed = {}; + var local = {}; + var triggers = []; + var modelState = this._modelState; + var currentState = this._currentState; + + // Loop through the current queue of potential model changes. + for (var i = modelState.length - 3; i >= 0; i -= 3) { + var key = modelState[i], val = modelState[i + 1], unset = modelState[i + 2]; + + // If the item hasn't been set locally this round, proceed. + if (!local[key]) { + local[key] = val; + + // Check if the attribute has been modified since the last change, + // and update `this.changed` accordingly. + if (currentState[key] !== val || (_.has(currentState, key) && unset)) { + this.changed[key] = val; + + // Triggers & modifications are only created inside a `change` call. + if (!change) continue; + triggers.push(key, val); + (!unset) ? currentState[key] = val : delete currentState[key]; + } + } + modelState.splice(i,3); + } + + // Signals `this.changed` is current to prevent duplicate calls from `this.hasChanged`. + this._cleanChange = true; + return triggers; + }, + // Get the previous value of an attribute, recorded at the time the last // `"change"` event was fired. previous: function(attr) { diff --git a/test/model.js b/test/model.js index 38197531..fd0be2d5 100644 --- a/test/model.js +++ b/test/model.js @@ -903,4 +903,18 @@ $(document).ready(function() { expect(0); }); + test("silent changes in last `change` event back to original does not trigger change", 2, function() { + var changes = []; + var model = new Backbone.Model(); + model.on('change:a change:b change:c', function(model, val) { changes.push(val); }); + model.on('change', function() { + model.set({a:'c'}, {silent:true}); + }); + model.set({a:'a'}); + deepEqual(changes, ['a']); + model.set({a:'a'}, {silent:true}); + model.change(); + deepEqual(changes, ['a']); + }); + }); diff --git a/test/speed.js b/test/speed.js index db0a2c8d..3581b5d2 100644 --- a/test/speed.js +++ b/test/speed.js @@ -42,4 +42,14 @@ keyModel.set({number: Math.random()}); }); + var silentModel = new Backbone.Model; + silentModel.on('change', fn); + + JSLitmus.test('Model: silent sets with rand(), with an attribute observer', function () { + for (var i=0;i<10;i++) { + silentModel.set({number:Math.random()}, {silent:true}); + } + silentModel.set({number:'one'}); + }); + })();