Permalink
Browse files

Fixes #959 - Silent changes fire `'change:attr'`.

* Silent changes are tracked so `'change:attr'` can be
  fired next time `change` is called.
* Pending changes are tracked to prevent infinite loops
  and accurately reflect nested changes.
  • Loading branch information...
1 parent e5db1c9 commit 69b80f5e3a86452c4150caa249332f9ea4efd901 @braddunbar braddunbar committed Feb 4, 2012
Showing with 144 additions and 36 deletions.
  1. +53 −24 backbone.js
  2. +91 −12 test/model.js
View
@@ -176,13 +176,26 @@
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);
};
// 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;
View
@@ -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.on('change:x', function() {
+ model.set({y: true});
});
- 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 = {};

0 comments on commit 69b80f5

Please sign in to comment.