Merge pull request #982 from braddunbar/change

Fixes #959 - Silent changes fire `'change:attr'`.
This commit is contained in:
Jeremy Ashkenas
2012-02-17 05:06:52 -08:00
3 changed files with 170 additions and 51 deletions

View File

@@ -174,8 +174,14 @@
this.attributes = {};
this._escapedAttributes = {};
this.cid = _.uniqueId('c');
this.changed = {};
this._silent = {};
this._pending = {};
this.set(attributes, {silent: true});
delete this._changed;
// Reset change tracking.
this.changed = {};
this._silent = {};
this._pending = {};
this._previousAttributes = _.clone(this.attributes);
this.initialize.apply(this, arguments);
};
@@ -183,6 +189,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: null,
// A hash of attributes that have silently changed since the last time
// `change` was called. Will become pending attributes on the next call.
_silent: null,
// A hash of attributes that have changed since the last `'change'` event
// began.
_pending: null,
// 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 +256,33 @@
// Check for changes of `id`.
if (this.idAttribute in attrs) this.id = attrs[this.idAttribute];
var changes = options.changes = {};
var now = this.attributes;
var escaped = this._escapedAttributes;
var prev = this._previousAttributes || {};
var alreadySetting = this._setting;
this._changed || (this._changed = {});
this._setting = true;
// 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] = val;
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(options);
return this;
},
@@ -392,18 +409,29 @@
// 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.
for (var attr in this._silent) this._pending[attr] = true;
// Silent changes are triggered.
var changes = _.extend({}, options.changes, this._silent);
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)) {
this._pending = {};
this.trigger('change', this, options);
// Pending and silent changes still remain.
for (var attr in this.changed) {
if (this._pending[attr] || this._silent[attr]) continue;
delete this.changed[attr];
}
this._previousAttributes = _.clone(this.attributes);
}
this._previousAttributes = _.clone(this.attributes);
delete this._changed;
this._changing = false;
return this;
},
@@ -411,8 +439,8 @@
// 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 (!arguments.length) return !_.isEmpty(this._changed);
return this._changed && _.has(this._changed, attr);
if (!arguments.length) return !_.isEmpty(this.changed);
return _.has(this.changed, attr);
},
// Return an object containing all the attributes that have changed, or
@@ -422,7 +450,7 @@
// 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) return this.hasChanged() ? _.clone(this.changed) : false;
var val, changed = false, old = this._previousAttributes;
for (var attr in diff) {
if (_.isEqual(old[attr], (val = diff[attr]))) continue;

View File

@@ -238,6 +238,7 @@
<li> <a href="#Model-idAttribute">idAttribute</a></li>
<li> <a href="#Model-cid">cid</a></li>
<li> <a href="#Model-attributes">attributes</a></li>
<li> <a href="#Model-changed">changed</a></li>
<li> <a href="#Model-defaults">defaults</a></li>
<li> <a href="#Model-toJSON">toJSON</a></li>
<li> <a href="#Model-fetch">fetch</a></li>
@@ -857,6 +858,17 @@ alert("Cake id: " + cake.id);
attributes, use <a href="#Model-toJSON">toJSON</a> instead.
</p>
<p id="Model-changed">
<b class="header">changed</b><code>model.changed</code>
<br />
The <b>changed</b> property is the internal hash containing all the attributes
that have changed since the last <tt>"change"</tt> event was triggered.
Please do not update <b>changed</b> directly. It's state is maintained internally
by <a href="#Model-set">set</a> and <a href="#Model-change">change</a>.
A copy of <b>changed</b> can be acquired from
<a href="#Model-changedAttributes">changedAttributes</a>.
</p>
<p id="Model-defaults">
<b class="header">defaults</b><code>model.defaults or model.defaults()</code>
<br />
@@ -2392,18 +2404,18 @@ var model = localBackbone.Model.extend(...);
<img src="docs/images/wunderkit.png" alt="Wunderkit" class="example_image" />
</a>
</div>
<h2 id="examples-khan-academy">Khan Academy</h2>
<p>
<a href="http://www.khanacademy.org">Khan Academy</a> is on a mission to
provide a free world-class education to anyone anywhere. With thousands of
videos, hundreds of JavaScript-driven exercises, and big plans for the
future, Khan Academy uses Backbone to keep frontend code modular and organized.
<a href="https://khanacademy.kilnhg.com/Code/Website/Group/stable/Files/javascript/profile-package?rev=tip">User profiles</a>
and <a href="https://khanacademy.kilnhg.com/Code/Website/Group/stable/File/javascript/shared-package/goals.js?rev=tip">goal setting</a>
are implemented with Backbone, jQuery and Handlebars, and most new feature
work is being pushed to the client side, greatly increasing the quality of
<a href="http://www.khanacademy.org">Khan Academy</a> is on a mission to
provide a free world-class education to anyone anywhere. With thousands of
videos, hundreds of JavaScript-driven exercises, and big plans for the
future, Khan Academy uses Backbone to keep frontend code modular and organized.
<a href="https://khanacademy.kilnhg.com/Code/Website/Group/stable/Files/javascript/profile-package?rev=tip">User profiles</a>
and <a href="https://khanacademy.kilnhg.com/Code/Website/Group/stable/File/javascript/shared-package/goals.js?rev=tip">goal setting</a>
are implemented with Backbone, jQuery and Handlebars, and most new feature
work is being pushed to the client side, greatly increasing the quality of
<a href="https://github.com/Khan/khan-api/">the API</a>.
</p>
@@ -2835,14 +2847,14 @@ var model = localBackbone.Model.extend(...);
<img src="docs/images/ducksboard.png" alt="Ducksboard" class="example_image" />
</a>
</div>
<h2 id="examples-picklive">Picklive</h2>
<p>
<a href="http://twitter.com/timruffles">Tim Ruffles</a> and <a href="http://twitter.com/timparker">Tim Parker</a>
created the game client for <a href="https://free.picklive.com">Picklive</a>, a real-time fantasy-soccer game.
The client is written in <a href="http://coffeescript.org">CoffeeScript</a>, organised into
modules via <a href="http://requirejs.org">require.js</a>, tested with
modules via <a href="http://requirejs.org">require.js</a>, tested with
<a href="http://code.google.com/p/js-test-driver">jsTestDriver</a> and uses
<a href="http://mustache.github.com">Mustache.js</a> for templating. Backbone's model and sync layer separation
manages the complexity of mixed polling and web-sockets based synchronisation.

View File

@@ -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 = {};