Skip to content

Commit

Permalink
Fixes jashkenas#959 - Silent changes fire 'change:attr'.
Browse files Browse the repository at this point in the history
* 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
braddunbar committed Feb 17, 2012
1 parent e5db1c9 commit 69b80f5
Show file tree
Hide file tree
Showing 2 changed files with 144 additions and 36 deletions.
77 changes: 53 additions & 24 deletions backbone.js
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
},

Expand Down Expand Up @@ -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;
},
Expand All @@ -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;
Expand Down
103 changes: 91 additions & 12 deletions test/model.js
Expand Up @@ -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'});
});

Expand Down Expand Up @@ -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() {
Expand All @@ -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);
Expand All @@ -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 = {};
Expand Down

0 comments on commit 69b80f5

Please sign in to comment.