diff --git a/README.md b/README.md index bfcdd93b..3bfe8faf 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Backbone-relational depends on [backbone](https://github.com/documentcloud/backb ``` -Backbone-relational has been tested with Backbone 0.9.0 (or newer) and Underscore 1.3.1 (or newer). +Backbone-relational has been tested with Backbone 0.9.2 (or newer) and Underscore 1.3.1 (or newer). ## Backbone.Relation options @@ -225,7 +225,7 @@ It's only mandatory to supply a `key`; `relatedModel` is automatically set. The ## Backbone.RelationalModel -`Backbone.RelationalModel` introduces a couple of new methods and events. +`Backbone.RelationalModel` introduces a couple of new methods, events and properties. ### Methods @@ -254,6 +254,56 @@ See the example at the top of [Backbone.Relation options](#backbone-relation) or * `update`: triggered on changes to the key itself on `HasMany` and `HasOne` relations. Bind to `update:`; arguments: `(model, related)`. +### Properties + +Properties can be defined along with the subclass prototype when extending `Backbone.RelationalModel` or a subclass thereof. + +###### **partOfSupermodel** + +Value: a boolean. Default: `false`. + +Determines whether this model should be considered a proper submodel of its +superclass (the model type you're extending), with a shared id pool. + +This means that when looking for an object of the supermodel's type, objects +of this submodel's type could be returned as well, as long as the id matches. +In effect, any relations pointing to the supermodel will look for objects +of the supermodel's submodel's types as well. + +Suppose that we have an `Animal` model and a `Dog` model extending `Animal` +with `partOfSupermodel` set to `true`. If we have a `Dog` object with id `3`, +this object will be returned when we have a relation pointing to an `Animal` +with id `3`, as `Dog` is regarded a specific kind of `Animal`: it's just an +`Animal` with possibly some dog-specific properties or methods. + +Note that this means that there cannot be any overlap in ids between instances +of classes `Animal` and `Dog`, as the `Dog` with id `3` will *be* the `Animal` +with id `3`. + +###### **submodelType** + +Value: a string. + +When building a model instance for a relation with a `relatedModel` that has +one or more submodels (i.e. models that have +[`partOfSupermodel`](#property-part-of-supermodel) set to true), we need to +determine what kind of object we're dealing with and an instance of what +submodel should be built. This is done by finding the `relatedModel`'s +submodel for which the `submodelType` is equal to the value of the +[`submodelTypeAttribute`](#property-submodel-type-attribute) attribute on the +newly passed in data object. + +###### **submodelTypeAttribute** + +Value: a string. References an attribute on the data used to instantiate +`relatedModel`. Default: `"type"`. + +The attribute that will be checked to determine the type of model that +should be built when a raw object of attributes is set as the related value, +and if the `relatedModel` has one or more submodels. + +See [`submodelType`](#property-submodel-type) for more information. + ## Example ```javascript @@ -386,14 +436,14 @@ User = Backbone.RelationalModel.extend(); ## Known problems and solutions -> **Q:** (Reverse) relations don't seem to be initialized properly (and I'm using Coffeescript!) +> **Q:** (Reverse) relations or submodels don't seem to be initialized properly (and I'm using CoffeeScript!) **A:** You're probably using the syntax `class MyModel extends Backbone.RelationalModel` instead of `MyModel = Backbone.RelationalModel.extend`. This has advantages in CoffeeScript, but it also means that `Backbone.Model.extend` will not get called. Instead, CoffeeScript generates piece of code that would normally achieve roughly the same. -However, `extend` is also the method that Backbone-relational overrides to set up relations as soon as your code gets parsed by the JavaScript engine. +However, `extend` is also the method that Backbone-relational overrides to set up relations and other things as you're defining your `Backbone.RelationalModel` subclass. -A possible solution is to initialize a blank placeholder model right after defining a model that contains reverseRelations; this will also bootstrap the relations. For example: +For exactly this scenario where you're not using `.extend`, `Backbone.RelationalModel` has the `.setup` method, that you can call manually after defining your subclass CoffeeScript-style. For example: ```javascript class MyModel extends Backbone.RelationalModel @@ -401,10 +451,10 @@ class MyModel extends Backbone.RelationalModel // etc ] -new MyModel +MyModel.setup() ``` -See [issue #91](https://github.com/PaulUithol/Backbone-relational/issues/91) for more information and workarounds. +See [issue #91](https://github.com/PaulUithol/Backbone-relational/issues/91) for more information. > **Q:** After a fetch, I don't get `add:` events for nested relations. diff --git a/backbone-relational.js b/backbone-relational.js old mode 100644 new mode 100755 index 2e7d5b86..30af79f9 --- a/backbone-relational.js +++ b/backbone-relational.js @@ -116,7 +116,7 @@ * Backbone.Store keeps track of all created (and destruction of) Backbone.RelationalModel. * Handles lookup for relations. */ - Backbone.Store = function() { + Backbone.Store = function() { this._collections = []; this._reverseRelations = []; }; @@ -135,18 +135,26 @@ */ addReverseRelation: function( relation ) { var exists = _.any( this._reverseRelations, function( rel ) { - return _.all( relation, function( val, key ) { - return val === rel[ key ]; + return _.all( relation, function( val, key ) { + return val === rel[ key ]; + }); }); - }); if ( !exists && relation.model && relation.type ) { this._reverseRelations.push( relation ); - if ( !relation.model.prototype.relations ) { - relation.model.prototype.relations = []; + var addRelation = function( model, relation ) { + if ( !model.prototype.relations ) { + model.prototype.relations = []; + } + model.prototype.relations.push( relation ); + + _.each( model._submodels, function( submodel ) { + addRelation( submodel, relation ); + }, this ); } - relation.model.prototype.relations.push( relation ); + + addRelation( relation.model, relation ); this.retroFitRelation( relation ); } @@ -159,6 +167,9 @@ retroFitRelation: function( relation ) { var coll = this.getCollection( relation.model ); coll.each( function( model ) { + if ( !( model instanceof relation.model ) ) { + return; + } new relation.type( model, relation ); }, this); }, @@ -168,10 +179,18 @@ * @param {Backbone.RelationalModel} model * @return {Backbone.Collection} A collection if found (or applicable for 'model'), or null */ - getCollection: function( model ) { - var coll = _.detect( this._collections, function( c ) { - // Check if model is the type itself (a ref to the constructor), or is of type c.model - return model === c.model || model.constructor === c.model; + getCollection: function( model ) { + if ( model instanceof Backbone.RelationalModel ) { + model = model.constructor; + } + + var rootModel = model; + while ( rootModel._supermodel ) { + rootModel = rootModel._supermodel; + } + + var coll = _.detect( this._collections, function( c ) { + return c.model === rootModel; }); if ( !coll ) { @@ -196,13 +215,13 @@ _createCollection: function( type ) { var coll; - // If 'type' is an instance, take it's constructor + // If 'type' is an instance, take its constructor if ( type instanceof Backbone.RelationalModel ) { type = type.constructor; } // Type should inherit from Backbone.RelationalModel. - if ( type.prototype instanceof Backbone.RelationalModel.prototype.constructor ) { + if ( type.prototype instanceof Backbone.RelationalModel ) { coll = new Backbone.Collection(); coll.model = type; @@ -240,7 +259,14 @@ find: function( type, item ) { var id = this.resolveIdForItem( type, item ); var coll = this.getCollection( type ); - return coll && coll.get( id ); + + // Because the found object could be of any of the type's supermodel + // types, only return it if it's actually of the type asked for. + var obj; + if ( coll && ( obj = coll.get( id ) ) && ( obj instanceof type ) ) { + return obj; + } + return null; }, /** @@ -284,7 +310,7 @@ * @param {object} options * @param {string} options.key * @param {Backbone.RelationalModel.constructor} options.relatedModel - * @param {Boolean|String} [options.includeInJSON=true] Serialize the given attribute for related model(s)' in toJSON, or just their ids. + * @param {Boolean|String} [options.includeInJSON=true] Serialize the given attribute for related model(s)' in toJSON, or just their ids. * @param {Boolean} [options.createModels=true] Create objects from the contents of keys if the object is not found in Backbone.store. * @param {object} [options.reverseRelation] Specify a bi-directional relation. If provided, Relation will reciprocate * the relation to the 'relatedModel'. Required and optional properties match 'options', except that it also needs @@ -340,7 +366,7 @@ _.bindAll( this, '_modelRemovedFromCollection', '_relatedModelAdded', '_relatedModelRemoved' ); - if( instance ) { + if ( instance ) { this.initialize(); // When a model in the store is destroyed, check if it is 'this.instance'. @@ -405,28 +431,28 @@ return false; } // Check if the type in 'relatedModel' inherits from Backbone.RelationalModel - if ( !( m.prototype instanceof Backbone.RelationalModel.prototype.constructor ) ) { + if ( !( m.prototype instanceof Backbone.RelationalModel ) ) { warn && console.warn( 'Relation=%o; model does not inherit from Backbone.RelationalModel (%o)', this, i ); return false; } // Check if the type in 'relatedModel' inherits from Backbone.RelationalModel - if ( !( rm.prototype instanceof Backbone.RelationalModel.prototype.constructor ) ) { + if ( !( rm.prototype instanceof Backbone.RelationalModel ) ) { warn && console.warn( 'Relation=%o; relatedModel does not inherit from Backbone.RelationalModel (%o)', this, rm ); return false; } // Check if this is not a HasMany, and the reverse relation is HasMany as well - if ( this instanceof Backbone.HasMany && this.reverseRelation.type === Backbone.HasMany.prototype.constructor ) { + if ( this instanceof Backbone.HasMany && this.reverseRelation.type === Backbone.HasMany ) { warn && console.warn( 'Relation=%o; relation is a HasMany, and the reverseRelation is HasMany as well.', this ); return false; } // Check if we're not attempting to create a duplicate relationship - if( i && i._relations.length ) { + if ( i && i._relations.length ) { var exists = _.any( i._relations, function( rel ) { - var hasReverseRelation = this.reverseRelation.key && rel.reverseRelation.key; - return rel.relatedModel === rm && rel.key === k && - ( !hasReverseRelation || this.reverseRelation.key === rel.reverseRelation.key ); - }, this ); + var hasReverseRelation = this.reverseRelation.key && rel.reverseRelation.key; + return rel.relatedModel === rm && rel.key === k && + ( !hasReverseRelation || this.reverseRelation.key === rel.reverseRelation.key ); + }, this ); if ( exists ) { warn && console.warn( 'Relation=%o between instance=%o.%s and relatedModel=%o.%s already exists', @@ -453,7 +479,7 @@ createModel: function( item ) { if ( this.options.createModels && typeof( item ) === 'object' ) { - return new this.relatedModel( item ); + return this.relatedModel.build( item ); } }, @@ -482,12 +508,12 @@ // Iterate over 'model', 'this.related.models' (if this.related is a Backbone.Collection), or wrap 'this.related' in an array. var models = !_.isUndefined( model ) ? [ model ] : this.related && ( this.related.models || [ this.related ] ); _.each( models , function( related ) { - _.each( related.getRelations(), function( relation ) { - if ( this._isReverseRelation( relation ) ) { - reverseRelations.push( relation ); - } + _.each( related.getRelations(), function( relation ) { + if ( this._isReverseRelation( relation ) ) { + reverseRelations.push( relation ); + } + }, this ); }, this ); - }, this ); return reverseRelations; }, @@ -551,10 +577,9 @@ this.setRelated( model ); // Notify new 'related' object of the new relation. - var dit = this; - _.each( dit.getReverseRelations(), function( relation ) { - relation.addRelated( dit.instance ); - } ); + _.each( this.getReverseRelations(), function( relation ) { + relation.addRelated( this.instance ); + }, this ); }, findRelated: function( options ) { @@ -691,10 +716,10 @@ // Handle a custom 'collectionType' this.collectionType = this.options.collectionType; - if ( _( this.collectionType ).isString() ) { + if ( _.isString( this.collectionType ) ) { this.collectionType = Backbone.Relational.store.getObjectByName( this.collectionType ); } - if ( !this.collectionType.prototype instanceof Backbone.Collection.prototype.constructor ){ + if ( !this.collectionType.prototype instanceof Backbone.Collection ){ throw new Error( 'collectionType must inherit from Backbone.Collection' ); } @@ -710,7 +735,7 @@ }, _getCollectionOptions: function() { - return _.isFunction( this.options.collectionOptions ) ? + return _.isFunction( this.options.collectionOptions ) ? this.options.collectionOptions( this.instance ) : this.options.collectionOptions; }, @@ -737,12 +762,12 @@ if ( this.options.collectionKey ) { var key = this.options.collectionKey === true ? this.options.reverseRelation.key : this.options.collectionKey; - if (collection[ key ] && collection[ key ] !== this.instance ) { + if ( collection[ key ] && collection[ key ] !== this.instance ) { if ( Backbone.Relational.showWarnings && typeof console !== 'undefined' ) { console.warn( 'Relation=%o; collectionKey=%s already exists on collection=%o', this, key, this.options.collectionKey ); } } - else if (key) { + else if ( key ) { collection[ key ] = this.instance; } } @@ -768,19 +793,25 @@ // Try to find instances of the appropriate 'relatedModel' in the store _.each( this.keyContents, function( item ) { - var model = Backbone.Relational.store.find( this.relatedModel, item ); - - if ( model && _.isObject( item ) ) { - model.set( item, options ); - } - else if ( !model ) { - model = this.createModel( item ); - } + var model = null; + if ( item instanceof this.relatedModel ) { + model = item; + } + else { + model = Backbone.Relational.store.find( this.relatedModel, item ); + + if ( model && _.isObject( item ) ) { + model.set( item, options ); + } + else if ( !model ) { + model = this.createModel( item ); + } + } - if ( model && !this.related.getByCid( model ) && !this.related.get( model ) ) { - models.push( model ); - } - }, this ); + if ( model && !this.related.getByCid( model ) && !this.related.get( model ) ) { + models.push( model ); + } + }, this ); } // Add all found 'models' in on go, so 'add' will only be called once (and thus 'sort', etc.) @@ -842,9 +873,9 @@ if ( !this.related.getByCid( model ) && !this.related.get( model ) ) { // Check if this new model was specified in 'this.keyContents' var item = _.any( this.keyContents, function( item ) { - var id = Backbone.Relational.store.resolveIdForItem( this.relatedModel, item ); - return id && id === model.id; - }, this ); + var id = Backbone.Relational.store.resolveIdForItem( this.relatedModel, item ); + return id && id === model.id; + }, this ); if ( item ) { this.related.add( model, options ); @@ -860,7 +891,7 @@ //console.debug('handleAddition called; args=%o', arguments); // Make sure the model is in fact a valid model before continuing. // (it can be invalid as a result of failing validation in Backbone.Collection._prepareModel) - if( !( model instanceof Backbone.Model ) ) { + if ( !( model instanceof Backbone.Model ) ) { return; } @@ -883,7 +914,7 @@ */ handleRemoval: function( model, coll, options ) { //console.debug('handleRemoval called; args=%o', arguments); - if( !( model instanceof Backbone.Model ) ) { + if ( !( model instanceof Backbone.Model ) ) { return; } @@ -940,6 +971,9 @@ _deferProcessing: false, _queue: null, + submodelTypeAttribute: 'type', + submodelType: null, + constructor: function( attributes, options ) { // Nasty hack, for cases like 'model.get( ).add( item )'. // Defer 'processQueue', so that when 'Relation.createModels' is used we: @@ -971,7 +1005,7 @@ this._queue.block(); Backbone.Relational.eventQueue.block(); - Backbone.Model.prototype.constructor.apply( this, arguments ); + Backbone.Model.apply( this, arguments ); // Try to run the global queue holding external events Backbone.Relational.eventQueue.unblock(); @@ -1004,7 +1038,7 @@ _.each( this.relations, function( rel ) { var type = !_.isString( rel.type ) ? rel.type : Backbone[ rel.type ] || Backbone.Relational.store.getObjectByName( rel.type ); - if ( type && type.prototype instanceof Backbone.Relation.prototype.constructor ) { + if ( type && type.prototype instanceof Backbone.Relation ) { new type( this, rel ); // Also pushes the new Relation into _relations } else { @@ -1022,13 +1056,13 @@ * (Relation.setRelated locks this model before calling 'set' on it to prevent loops) */ updateRelations: function( options ) { - if( this._isInitialized && !this.isLocked() ) { + if ( this._isInitialized && !this.isLocked() ) { _.each( this._relations, function( rel ) { - var val = this.attributes[ rel.key ]; - if ( rel.related !== val ) { - this.trigger('relational:change:' + rel.key, this, val, options || {} ); - } - }, this ); + var val = this.attributes[ rel.key ]; + if ( rel.related !== val ) { + this.trigger( 'relational:change:' + rel.key, this, val, options || {} ); + } + }, this ); } }, @@ -1092,12 +1126,12 @@ var model; if ( typeof( item ) === 'object' ) { - model = new rel.relatedModel( item ); + model = rel.relatedModel.build( item ); } else { var attrs = {}; attrs[ rel.relatedModel.prototype.idAttribute ] = item; - model = new rel.relatedModel( attrs ); + model = rel.relatedModel.build( attrs ); } return model; @@ -1117,9 +1151,9 @@ error: function() { var args = arguments; _.each( models, function( model ) { - model.trigger( 'destroy', model, model.collection, options ); - options.error && options.error.apply( model, args ); - }); + model.trigger( 'destroy', model, model.collection, options ); + options.error && options.error.apply( model, args ); + }); }, url: setUrl }, @@ -1153,7 +1187,7 @@ // Duplicate backbone's behavior to allow separate key/value parameters, instead of a single 'attributes' object var attributes; - if (_.isObject( key ) || key == null) { + if ( _.isObject( key ) || key == null ) { attributes = key; options = value; } @@ -1164,7 +1198,6 @@ var result = Backbone.Model.prototype.set.apply( this, arguments ); - // 'set' is called quite late in 'Backbone.Model.prototype.constructor', but before 'initialize'. // Ideal place to set up relations :) if ( !this._isInitialized && !this.isLocked() ) { Backbone.Relational.store.register( this ); @@ -1227,8 +1260,8 @@ } _.each( this.getRelations(), function( rel ) { - delete attributes[ rel.key ]; - }); + delete attributes[ rel.key ]; + }); return new this.constructor( attributes ); }, @@ -1245,35 +1278,142 @@ this.acquire(); var json = Backbone.Model.prototype.toJSON.call( this ); + if ( this.submodelType && !( this.submodelTypeAttribute in json ) ) { + json[ this.submodelTypeAttribute ] = this.submodelType; + } + _.each( this._relations, function( rel ) { - var value = json[ rel.key ]; + var value = json[ rel.key ]; - if ( rel.options.includeInJSON === true && value && _.isFunction( value.toJSON ) ) { - json[ rel.keyDestination ] = value.toJSON(); - } - else if ( _.isString( rel.options.includeInJSON ) ) { - if ( value instanceof Backbone.Collection ) { - json[ rel.keyDestination ] = value.pluck( rel.options.includeInJSON ); + if ( rel.options.includeInJSON === true && value && _.isFunction( value.toJSON ) ) { + json[ rel.keyDestination ] = value.toJSON(); } - else if ( value instanceof Backbone.Model ) { - json[ rel.keyDestination ] = value.get( rel.options.includeInJSON ); + else if ( _.isString( rel.options.includeInJSON ) ) { + if ( value instanceof Backbone.Collection ) { + json[ rel.keyDestination ] = value.pluck( rel.options.includeInJSON ); + } + else if ( value instanceof Backbone.Model ) { + json[ rel.keyDestination ] = value.get( rel.options.includeInJSON ); + } + } + else { + delete json[ rel.key ]; } + + if ( rel.keyDestination !== rel.key ) { + delete json[ rel.key ]; + } + }); + + this.release(); + return json; + } + }, { + setup: function( supermodel ) { + // We don't want to share a relations array with a parent, as this will cause problems with + // reverse relations. + this.prototype.relations = (this.prototype.relations || []).slice( 0 ); + + this._submodels = []; + + // If this new model is part of a supermodel, make the connection and copy + // over all of the supermodel's relations if this hasn't been done already. + if ( this.prototype.partOfSupermodel ) { + if ( !supermodel ) { + throw new Error( "Backbone.RelationalModel.setup() supermodel attribute is required when `partOfSupermodel` property is true." ); } - else { - delete json[ rel.key ]; + + var rootSupermodel = supermodel; + while ( rootSupermodel._supermodel ) { + rootSupermodel = rootSupermodel._supermodel } - if ( rel.keyDestination !== rel.key ) { - delete json[ rel.key ]; + this._supermodel = rootSupermodel; + rootSupermodel._submodels.push( this ); + + if ( supermodel.prototype.relations ) { + var supermodelRelationsExist = _.any( this.prototype.relations, function( rel ) { + return rel.model && rel.model !== this; + }, this ); + if ( !supermodelRelationsExist ) { + this.prototype.relations = supermodel.prototype.relations.concat( this.prototype.relations ); + } } - }, this ); + } + + // Initialize all reverseRelations that belong to this new model. + _.each( this.prototype.relations, function( rel ) { + if ( !rel.model ) { + rel.model = this; + } + + if ( rel.reverseRelation && rel.model === this ) { + var preInitialize = true; + if ( _.isString( rel.relatedModel ) ) { + /** + * The related model might not be defined for two reasons + * 1. it never gets defined, e.g. a typo + * 2. it is related to itself + * In neither of these cases do we need to pre-initialize reverse relations. + */ + var relatedModel = Backbone.Relational.store.getObjectByName( rel.relatedModel ); + preInitialize = relatedModel && ( relatedModel.prototype instanceof Backbone.RelationalModel ); + } + + var type = !_.isString( rel.type ) ? rel.type : Backbone[ rel.type ] || Backbone.Relational.store.getObjectByName( rel.type ); + if ( preInitialize && type && type.prototype instanceof Backbone.Relation ) { + new type( null, rel ); + } + } + }, this ); + }, + build: function( attributes, options ) { + var model = this; - this.release(); - return json; + if ( this._submodels && this.prototype.submodelTypeAttribute in attributes ) { + var submodelType = attributes[ this.prototype.submodelTypeAttribute ]; + var submodel = _.detect( this._submodels, function( model ) { + return model.prototype.submodelType && model.prototype.submodelType == submodelType; + }, this); + if ( submodel ) { + model = submodel; + } + } + + return new model( attributes, options ); } }); _.extend( Backbone.RelationalModel.prototype, Backbone.Semaphore ); + /** + * Override Backbone.Collection._prepareModel, so objects will be built using the correct type + * if the collection.model has submodels. + */ + var _prepareModel = Backbone.Collection.prototype.___prepareModel = Backbone.Collection.prototype._prepareModel; + Backbone.Collection.prototype._prepareModel = function ( model, options ) { + options || (options = {}); + if ( !( model instanceof Backbone.Model ) ) { + var attrs = model; + options.collection = this; + + if ( typeof this.model.build !== 'undefined' ) { + model = this.model.build( attrs, options ); + } + else { + model = new this.model( attrs, options ); + } + + if ( !model._validate( model.attributes, options ) ) { + model = false; + } + } + else if ( !model.collection ) { + model.collection = this; + } + + return model; + } + /** * Override Backbone.Collection.add, so objects fetched from the server multiple times will * update the existing Model. Also, trigger 'relational:add'. @@ -1289,22 +1429,22 @@ //console.debug( 'calling add on coll=%o; model=%o, options=%o', this, models, options ); _.each( models, function( model ) { - if ( !( model instanceof Backbone.Model ) ) { - // Try to find 'model' in Backbone.store. If it already exists, set the new properties on it. - var existingModel = Backbone.Relational.store.find( this.model, model[ this.model.prototype.idAttribute ] ); - if ( existingModel ) { - existingModel.set( existingModel.parse ? existingModel.parse( model ) : model, options ); - model = existingModel; - } - else { - model = Backbone.Collection.prototype._prepareModel.call( this, model, options ); + if ( !( model instanceof Backbone.Model ) ) { + // Try to find 'model' in Backbone.store. If it already exists, set the new properties on it. + var existingModel = Backbone.Relational.store.find( this.model, model[ this.model.prototype.idAttribute ] ); + if ( existingModel ) { + existingModel.set( existingModel.parse ? existingModel.parse( model ) : model, options ); + model = existingModel; + } + else { + model = Backbone.Collection.prototype._prepareModel.call( this, model, options ); + } } - } - if ( model instanceof Backbone.Model && !this.get( model ) && !this.getByCid( model ) ) { - modelsToAdd.push( model ); - } - }, this ); + if ( model instanceof Backbone.Model && !this.get( model ) && !this.getByCid( model ) ) { + modelsToAdd.push( model ); + } + }, this ); // Add 'models' in a single batch, so the original add will only be called once (and thus 'sort', etc). @@ -1312,8 +1452,8 @@ add.call( this, modelsToAdd, options ); _.each( modelsToAdd, function( model ) { - this.trigger('relational:add', model, this, options); - }, this ); + this.trigger( 'relational:add', model, this, options ); + }, this ); } return this; @@ -1325,19 +1465,19 @@ var remove = Backbone.Collection.prototype.__remove = Backbone.Collection.prototype.remove; Backbone.Collection.prototype.remove = function( models, options ) { options || (options = {}); - if (!_.isArray( models ) ) { + if ( !_.isArray( models ) ) { models = [ models ]; } //console.debug('calling remove on coll=%o; models=%o, options=%o', this, models, options ); _.each( models, function( model ) { - model = this.getByCid( model ) || this.get( model ); + model = this.getByCid( model ) || this.get( model ); - if ( model instanceof Backbone.Model ) { - remove.call( this, model, options ); - this.trigger('relational:remove', model, this, options); - } - }, this ); + if ( model instanceof Backbone.Model ) { + remove.call( this, model, options ); + this.trigger('relational:remove', model, this, options); + } + }, this ); return this; }; @@ -1372,33 +1512,11 @@ return this; }; - // Override .extend() to check for reverseRelations to initialize. + // Override .extend() to automatically call .setup() Backbone.RelationalModel.extend = function( protoProps, classProps ) { var child = Backbone.Model.extend.apply( this, arguments ); - - var relations = ( protoProps && protoProps.relations ) || []; - _.each( relations, function( rel ) { - if( rel.reverseRelation ) { - rel.model = child; - - var preInitialize = true; - if ( _.isString( rel.relatedModel ) ) { - /** - * The related model might not be defined for two reasons - * 1. it never gets defined, e.g. a typo - * 2. it is related to itself - * In neither of these cases do we need to pre-initialize reverse relations. - */ - var relatedModel = Backbone.Relational.store.getObjectByName( rel.relatedModel ); - preInitialize = relatedModel && ( relatedModel.prototype instanceof Backbone.RelationalModel.prototype.constructor ); - } - - var type = !_.isString( rel.type ) ? rel.type : Backbone[ rel.type ] || Backbone.Relational.store.getObjectByName( rel.type ); - if ( preInitialize && type && type.prototype instanceof Backbone.Relation.prototype.constructor ) { - new type( null, rel ); - } - } - }); + + child.setup( this ); return child; }; diff --git a/package.json b/package.json index af77633a..beef789b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "contributors" : "Listed at ", "dependencies" : { "underscore": ">=1.3.1", - "backbone": ">=0.9.0" + "backbone": ">=0.9.2" }, "lib" : ".", "main" : "backbone-relational.js", diff --git a/test/lib/backbone.js b/test/lib/backbone.js index d6a0aa9e..d0410b5c 100644 --- a/test/lib/backbone.js +++ b/test/lib/backbone.js @@ -1,4 +1,5 @@ -// Backbone.js 0.9.0 +// Backbone.js 0.9.2 + // (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc. // Backbone may be freely distributed under the MIT license. // For all details and documentation: @@ -31,7 +32,7 @@ } // Current version of the library. Keep in sync with `package.json`. - Backbone.VERSION = '0.9.0'; + Backbone.VERSION = '0.9.2'; // Require Underscore, if we're on the server, and it's not already present. var _ = root._; @@ -40,6 +41,15 @@ // For Backbone's purposes, jQuery, Zepto, or Ender owns the `$` variable. var $ = root.jQuery || root.Zepto || root.ender; + // Set the JavaScript library that will be used for DOM manipulation and + // Ajax calls (a.k.a. the `$` variable). By default Backbone will use: jQuery, + // Zepto, or Ender; but the `setDomLibrary()` method lets you inject an + // alternate JavaScript library (or a mock library for testing your views + // outside of a browser). + Backbone.setDomLibrary = function(lib) { + $ = lib; + }; + // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable // to its previous owner. Returns a reference to this Backbone object. Backbone.noConflict = function() { @@ -61,6 +71,9 @@ // Backbone.Events // ----------------- + // Regular expression used to split event strings + var eventSplitter = /\s+/; + // A module that can be mixed in to *any object* in order to provide it with // custom events. You may bind with `on` or remove with `off` callback functions // to an event; trigger`-ing an event fires all callbacks in succession. @@ -70,89 +83,110 @@ // object.on('expand', function(){ alert('expanded'); }); // object.trigger('expand'); // - Backbone.Events = { + var Events = Backbone.Events = { - // Bind an event, specified by a string name, `ev`, to a `callback` + // Bind one or more space separated events, `events`, to a `callback` // function. Passing `"all"` will bind the callback to all events fired. on: function(events, callback, context) { - var ev; - events = events.split(/\s+/); - var calls = this._callbacks || (this._callbacks = {}); - while (ev = events.shift()) { - // Create an immutable callback list, allowing traversal during - // modification. The tail is an empty object that will always be used - // as the next node. - var list = calls[ev] || (calls[ev] = {}); - var tail = list.tail || (list.tail = list.next = {}); - tail.callback = callback; - tail.context = context; - list.tail = tail.next = {}; + + var calls, event, node, tail, list; + if (!callback) return this; + events = events.split(eventSplitter); + calls = this._callbacks || (this._callbacks = {}); + + // Create an immutable callback list, allowing traversal during + // modification. The tail is an empty object that will always be used + // as the next node. + while (event = events.shift()) { + list = calls[event]; + node = list ? list.tail : {}; + node.next = tail = {}; + node.context = context; + node.callback = callback; + calls[event] = {tail: tail, next: list ? list.next : node}; } + return this; }, // Remove one or many callbacks. If `context` is null, removes all callbacks // with that function. If `callback` is null, removes all callbacks for the - // event. If `ev` is null, removes all bound callbacks for all events. + // event. If `events` is null, removes all bound callbacks for all events. off: function(events, callback, context) { - var ev, calls, node; - if (!events) { + var event, calls, node, tail, cb, ctx; + + // No events, or removing *all* events. + if (!(calls = this._callbacks)) return; + if (!(events || callback || context)) { delete this._callbacks; - } else if (calls = this._callbacks) { - events = events.split(/\s+/); - while (ev = events.shift()) { - node = calls[ev]; - delete calls[ev]; - if (!callback || !node) continue; - // Create a new list, omitting the indicated event/context pairs. - while ((node = node.next) && node.next) { - if (node.callback === callback && - (!context || node.context === context)) continue; - this.on(ev, node.callback, node.context); + return this; + } + + // Loop through the listed events and contexts, splicing them out of the + // linked list of callbacks if appropriate. + events = events ? events.split(eventSplitter) : _.keys(calls); + while (event = events.shift()) { + node = calls[event]; + delete calls[event]; + if (!node || !(callback || context)) continue; + // Create a new list, omitting the indicated callbacks. + tail = node.tail; + while ((node = node.next) !== tail) { + cb = node.callback; + ctx = node.context; + if ((callback && cb !== callback) || (context && ctx !== context)) { + this.on(event, cb, ctx); } } } + return this; }, - // Trigger an event, firing all bound callbacks. Callbacks are passed the - // same arguments as `trigger` is, apart from the event name. - // Listening for `"all"` passes the true event name as the first argument. + // Trigger one or many events, firing all bound callbacks. Callbacks are + // passed the same arguments as `trigger` is, apart from the event name + // (unless you're listening on `"all"`, which will cause your callback to + // receive the true name of the event as the first argument). trigger: function(events) { var event, node, calls, tail, args, all, rest; if (!(calls = this._callbacks)) return this; - all = calls['all']; - (events = events.split(/\s+/)).push(null); - // Save references to the current heads & tails. - while (event = events.shift()) { - if (all) events.push({next: all.next, tail: all.tail, event: event}); - if (!(node = calls[event])) continue; - events.push({next: node.next, tail: node.tail}); - } - // Traverse each list, stopping when the saved tail is reached. + all = calls.all; + events = events.split(eventSplitter); rest = slice.call(arguments, 1); - while (node = events.pop()) { - tail = node.tail; - args = node.event ? [node.event].concat(rest) : rest; - while ((node = node.next) !== tail) { - node.callback.apply(node.context || this, args); + + // For each event, walk through the linked list of callbacks twice, + // first to trigger the event, then to trigger any `"all"` callbacks. + while (event = events.shift()) { + if (node = calls[event]) { + tail = node.tail; + while ((node = node.next) !== tail) { + node.callback.apply(node.context || this, rest); + } + } + if (node = all) { + tail = node.tail; + args = [event].concat(rest); + while ((node = node.next) !== tail) { + node.callback.apply(node.context || this, args); + } } } + return this; } }; // Aliases for backwards compatibility. - Backbone.Events.bind = Backbone.Events.on; - Backbone.Events.unbind = Backbone.Events.off; + Events.bind = Events.on; + Events.unbind = Events.off; // Backbone.Model // -------------- // Create a new model, with defined attributes. A client id (`cid`) // is automatically generated and assigned for you. - Backbone.Model = function(attributes, options) { + var Model = Backbone.Model = function(attributes, options) { var defaults; attributes || (attributes = {}); if (options && options.parse) attributes = this.parse(attributes); @@ -163,17 +197,31 @@ this.attributes = {}; this._escapedAttributes = {}; this.cid = _.uniqueId('c'); - this._changed = {}; - if (!this.set(attributes, {silent: true})) { - throw new Error("Can't create an invalid model"); - } - this._changed = {}; + this.changed = {}; + this._silent = {}; + this._pending = {}; + this.set(attributes, {silent: true}); + // Reset change tracking. + this.changed = {}; + this._silent = {}; + 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, { + _.extend(Model.prototype, 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"`. @@ -184,7 +232,7 @@ initialize: function(){}, // Return a copy of the model's `attributes` object. - toJSON: function() { + toJSON: function(options) { return _.clone(this.attributes); }, @@ -197,20 +245,22 @@ escape: function(attr) { var html; if (html = this._escapedAttributes[attr]) return html; - var val = this.attributes[attr]; + var val = this.get(attr); return this._escapedAttributes[attr] = _.escape(val == null ? '' : '' + val); }, // Returns `true` if the attribute contains a value that is not null // or undefined. has: function(attr) { - return this.attributes[attr] != null; + return this.get(attr) != null; }, // Set a hash of model attributes on the object, firing `"change"` unless // you choose to silence it. set: function(key, value, options) { var attrs, attr, val; + + // Handle both `"key", value` and `{key: value}` -style arguments. if (_.isObject(key) || key == null) { attrs = key; options = value; @@ -222,37 +272,46 @@ // Extract attributes and options. options || (options = {}); if (!attrs) return this; - if (attrs instanceof Backbone.Model) attrs = attrs.attributes; + if (attrs instanceof Model) attrs = attrs.attributes; if (options.unset) for (attr in attrs) attrs[attr] = void 0; // Run validation. - if (this.validate && !this._performValidation(attrs, options)) return false; + if (!this._validate(attrs, options)) return false; // 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 alreadyChanging = this._changing; - this._changing = true; - // Update attributes. + // For each `set` attribute... for (attr in attrs) { val = attrs[attr]; - if (!_.isEqual(now[attr], val)) delete escaped[attr]; + + // 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; + } + + // Update or delete the current value. options.unset ? delete now[attr] : now[attr] = val; - delete this._changed[attr]; + + // 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 (!alreadyChanging) { - if (!options.silent && this.hasChanged()) this.change(options); - this._changing = false; - } + // Fire the `"change"` events. + if (!options.silent) this.change(options); return this; }, @@ -289,7 +348,9 @@ // If the server returns an attributes hash that differs, the model's // state will be `set` again. save: function(key, value, options) { - var attrs; + var attrs, current; + + // Handle both `("key", value)` and `({key: value})` -style calls. if (_.isObject(key) || key == null) { attrs = key; options = value; @@ -297,14 +358,30 @@ attrs = {}; attrs[key] = value; } - options = options ? _.clone(options) : {}; - if (attrs && !this[options.wait ? '_performValidation' : 'set'](attrs, options)) return false; + + // If we're "wait"-ing to set changed attributes, validate early. + if (options.wait) { + if (!this._validate(attrs, options)) return false; + current = _.clone(this.attributes); + } + + // Regular saves `set` attributes before persisting to the server. + var silentOptions = _.extend({}, options, {silent: true}); + if (attrs && !this.set(attrs, options.wait ? silentOptions : options)) { + return false; + } + + // After a successful server-side save, the client is (optionally) + // updated with the server-side state. var model = this; var success = options.success; options.success = function(resp, status, xhr) { var serverAttrs = model.parse(resp, xhr); - if (options.wait) serverAttrs = _.extend(attrs || {}, serverAttrs); + if (options.wait) { + delete options.wait; + serverAttrs = _.extend(attrs || {}, serverAttrs); + } if (!model.set(serverAttrs, options)) return false; if (success) { success(model, resp); @@ -312,9 +389,13 @@ model.trigger('sync', model, resp, options); } }; + + // Finish configuring and sending the Ajax request. options.error = Backbone.wrapError(options.error, model, options); var method = this.isNew() ? 'create' : 'update'; - return (this.sync || Backbone.sync).call(this, method, this, options); + var xhr = (this.sync || Backbone.sync).call(this, method, this, options); + if (options.wait) this.set(current, silentOptions); + return xhr; }, // Destroy this model on the server if it was already persisted. @@ -329,7 +410,11 @@ model.trigger('destroy', model, model.collection, options); }; - if (this.isNew()) return triggerDestroy(); + if (this.isNew()) { + triggerDestroy(); + return false; + } + options.success = function(resp) { if (options.wait) triggerDestroy(); if (success) { @@ -338,6 +423,7 @@ model.trigger('sync', model, resp, options); } }; + options.error = Backbone.wrapError(options.error, model, options); var xhr = (this.sync || Backbone.sync).call(this, 'delete', this, options); if (!options.wait) triggerDestroy(); @@ -348,7 +434,7 @@ // using Backbone's restful methods, override this to change the endpoint // that will be called. url: function() { - var base = getValue(this.collection, 'url') || getValue(this, 'urlRoot') || urlError(); + var base = getValue(this, 'urlRoot') || getValue(this.collection, 'url') || urlError(); if (this.isNew()) return base; return base + (base.charAt(base.length - 1) == '/' ? '' : '/') + encodeURIComponent(this.id); }, @@ -373,19 +459,42 @@ // a `"change:attribute"` event for each changed attribute. // Calling this will cause all objects observing the model to update. change: function(options) { - for (var attr in this._changed) { - this.trigger('change:' + attr, this, this._changed[attr], options); + options || (options = {}); + var changing = this._changing; + this._changing = true; + + // 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.get(attr), options); + } + 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.trigger('change', this, options); - this._previousAttributes = _.clone(this.attributes); - this._changed = {}; + + this._changing = false; + return this; }, // 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 (attr) return _.has(this._changed, attr); - return !_.isEmpty(this._changed); + if (!arguments.length) return !_.isEmpty(this.changed); + return _.has(this.changed, attr); }, // Return an object containing all the attributes that have changed, or @@ -395,7 +504,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; @@ -407,7 +516,7 @@ // Get the previous value of an attribute, recorded at the time the last // `"change"` event was fired. previous: function(attr) { - if (!attr || !this._previousAttributes) return null; + if (!arguments.length || !this._previousAttributes) return null; return this._previousAttributes[attr]; }, @@ -417,21 +526,26 @@ return _.clone(this._previousAttributes); }, - // Run validation against a set of incoming attributes, returning `true` - // if all is well. If a specific `error` callback has been passed, - // call that instead of firing the general `"error"` event. - _performValidation: function(attrs, options) { - var newAttrs = _.extend({}, this.attributes, attrs); - var error = this.validate(newAttrs, options); - if (error) { - if (options.error) { - options.error(this, error, options); - } else { - this.trigger('error', this, error, options); - } - return false; + // Check if the model is currently in a valid state. It's only possible to + // get into an *invalid* state if you're using silent changes. + isValid: function() { + return !this.validate(this.attributes); + }, + + // Run validation against the next complete set of model attributes, + // returning `true` if all is well. If a specific `error` callback has + // been passed, call that instead of firing the general `"error"` event. + _validate: function(attrs, options) { + if (options.silent || !this.validate) return true; + attrs = _.extend({}, this.attributes, attrs); + var error = this.validate(attrs, options); + if (!error) return true; + if (options && options.error) { + options.error(this, error, options); + } else { + this.trigger('error', this, error, options); } - return true; + return false; } }); @@ -442,8 +556,9 @@ // Provides a standard collection class for our sets of models, ordered // or unordered. If a `comparator` is specified, the Collection will maintain // its models in sort order, as they're added and removed. - Backbone.Collection = function(models, options) { + var Collection = Backbone.Collection = function(models, options) { options || (options = {}); + if (options.model) this.model = options.model; if (options.comparator) this.comparator = options.comparator; this._reset(); this.initialize.apply(this, arguments); @@ -451,11 +566,11 @@ }; // Define the Collection's inheritable methods. - _.extend(Backbone.Collection.prototype, Backbone.Events, { + _.extend(Collection.prototype, Events, { // The default model for a collection is just a **Backbone.Model**. // This should be overridden in most cases. - model: Backbone.Model, + model: Model, // Initialize is an empty function by default. Override it with your own // initialization logic. @@ -463,14 +578,14 @@ // The JSON representation of a Collection is an array of the // models' attributes. - toJSON: function() { - return this.map(function(model){ return model.toJSON(); }); + toJSON: function(options) { + return this.map(function(model){ return model.toJSON(options); }); }, // Add a model, or list of models to the set. Pass **silent** to avoid // firing the `add` event for every new model. add: function(models, options) { - var i, index, length, model, cid, id, cids = {}, ids = {}; + var i, index, length, model, cid, id, cids = {}, ids = {}, dups = []; options || (options = {}); models = _.isArray(models) ? models.slice() : [models]; @@ -480,16 +595,24 @@ if (!(model = models[i] = this._prepareModel(models[i], options))) { throw new Error("Can't add an invalid model to a collection"); } - if (cids[cid = model.cid] || this._byCid[cid] || - (((id = model.id) != null) && (ids[id] || this._byId[id]))) { - throw new Error("Can't add the same model to a collection twice"); + cid = model.cid; + id = model.id; + if (cids[cid] || this._byCid[cid] || ((id != null) && (ids[id] || this._byId[id]))) { + dups.push(i); + continue; } cids[cid] = ids[id] = model; } + // Remove duplicates. + i = dups.length; + while (i--) { + models.splice(dups[i], 1); + } + // Listen to added models' events, and index models for lookup by // `id` and by `cid`. - for (i = 0; i < length; i++) { + for (i = 0, length = models.length; i < length; i++) { (model = models[i]).on('all', this._onModelEvent, this); this._byCid[model.cid] = model; if (model.id != null) this._byId[model.id] = model; @@ -533,9 +656,37 @@ return this; }, + // Add a model to the end of the collection. + push: function(model, options) { + model = this._prepareModel(model, options); + this.add(model, options); + return model; + }, + + // Remove a model from the end of the collection. + pop: function(options) { + var model = this.at(this.length - 1); + this.remove(model, options); + return model; + }, + + // Add a model to the beginning of the collection. + unshift: function(model, options) { + model = this._prepareModel(model, options); + this.add(model, _.extend({at: 0}, options)); + return model; + }, + + // Remove a model from the beginning of the collection. + shift: function(options) { + var model = this.at(0); + this.remove(model, options); + return model; + }, + // Get a model from the set by id. get: function(id) { - if (id == null) return null; + if (id == null) return void 0; return this._byId[id.id != null ? id.id : id]; }, @@ -549,6 +700,17 @@ return this.models[index]; }, + // Return models with matching attributes. Useful for simple cases of `filter`. + where: function(attrs) { + if (_.isEmpty(attrs)) return []; + return this.filter(function(model) { + for (var key in attrs) { + if (attrs[key] !== model.get(key)) return false; + } + return true; + }); + }, + // Force the collection to re-sort itself. You don't need to call this under // normal circumstances, as the set will maintain sort order as each item // is added. @@ -580,7 +742,7 @@ this._removeReference(this.models[i]); } this._reset(); - this.add(models, {silent: true, parse: options.parse}); + this.add(models, _.extend({silent: true}, options)); if (!options.silent) this.trigger('reset', this, options); return this; }, @@ -646,11 +808,12 @@ // Prepare a model or hash of attributes to be added to this collection. _prepareModel: function(model, options) { - if (!(model instanceof Backbone.Model)) { + options || (options = {}); + if (!(model instanceof Model)) { var attrs = model; options.collection = this; model = new this.model(attrs, options); - if (model.validate && !model._performValidation(model.attributes, options)) model = false; + if (!model._validate(model.attributes, options)) model = false; } else if (!model.collection) { model.collection = this; } @@ -669,12 +832,12 @@ // Sets need to update their indexes when models change ids. All other // events simply proxy through. "add" and "remove" events that originate // in other collections are ignored. - _onModelEvent: function(ev, model, collection, options) { - if ((ev == 'add' || ev == 'remove') && collection != this) return; - if (ev == 'destroy') { + _onModelEvent: function(event, model, collection, options) { + if ((event == 'add' || event == 'remove') && collection != this) return; + if (event == 'destroy') { this.remove(model, options); } - if (model && ev === 'change:' + model.idAttribute) { + if (model && event === 'change:' + model.idAttribute) { delete this._byId[model.previous(model.idAttribute)]; this._byId[model.id] = model; } @@ -692,7 +855,7 @@ // Mix in each Underscore method as a proxy to `Collection#models`. _.each(methods, function(method) { - Backbone.Collection.prototype[method] = function() { + Collection.prototype[method] = function() { return _[method].apply(_, [this.models].concat(_.toArray(arguments))); }; }); @@ -702,7 +865,7 @@ // Routers map faux-URLs to actions, and fire events when routes are // matched. Creating a new one sets its `routes` hash, if not set statically. - Backbone.Router = function(options) { + var Router = Backbone.Router = function(options) { options || (options = {}); if (options.routes) this.routes = options.routes; this._bindRoutes(); @@ -716,7 +879,7 @@ var escapeRegExp = /[-[\]{}()+?.,\\^$|#\s]/g; // Set up all inheritable **Backbone.Router** properties and methods. - _.extend(Backbone.Router.prototype, Backbone.Events, { + _.extend(Router.prototype, Events, { // Initialize is an empty function by default. Override it with your own // initialization logic. @@ -729,7 +892,7 @@ // }); // route: function(route, name, callback) { - Backbone.history || (Backbone.history = new Backbone.History); + Backbone.history || (Backbone.history = new History); if (!_.isRegExp(route)) route = this._routeToRegExp(route); if (!callback) callback = this[name]; Backbone.history.route(route, _.bind(function(fragment) { @@ -782,7 +945,7 @@ // Handles cross-browser history management, based on URL fragments. If the // browser does not support `onhashchange`, falls back to polling. - Backbone.History = function() { + var History = Backbone.History = function() { this.handlers = []; _.bindAll(this, 'checkUrl'); }; @@ -794,15 +957,23 @@ var isExplorer = /msie [\w.]+/; // Has the history handling already been started? - var historyStarted = false; + History.started = false; // Set up all inheritable **Backbone.History** properties and methods. - _.extend(Backbone.History.prototype, Backbone.Events, { + _.extend(History.prototype, Events, { // The default interval to poll for hash changes, if necessary, is // twenty times a second. interval: 50, + // Gets the true hash value. Cannot use location.hash directly due to bug + // in Firefox where location.hash will always be decoded. + getHash: function(windowOverride) { + var loc = windowOverride ? windowOverride.location : window.location; + var match = loc.href.match(/#(.*)$/); + return match ? match[1] : ''; + }, + // Get the cross-browser normalized URL fragment, either from the URL, // the hash, or the override. getFragment: function(fragment, forcePushState) { @@ -812,21 +983,21 @@ var search = window.location.search; if (search) fragment += search; } else { - fragment = window.location.hash; + fragment = this.getHash(); } } - fragment = decodeURIComponent(fragment.replace(routeStripper, '')); if (!fragment.indexOf(this.options.root)) fragment = fragment.substr(this.options.root.length); - return fragment; + return fragment.replace(routeStripper, ''); }, // Start the hash change handling, returning `true` if the current URL matches // an existing route, and `false` otherwise. start: function(options) { + if (History.started) throw new Error("Backbone.history has already been started"); + History.started = true; // Figure out the initial configuration. Do we need an iframe? // Is pushState desired ... is it available? - if (historyStarted) throw new Error("Backbone.history has already been started"); this.options = _.extend({}, {root: '/'}, this.options, options); this._wantsHashChange = this.options.hashChange !== false; this._wantsPushState = !!this.options.pushState; @@ -834,6 +1005,7 @@ var fragment = this.getFragment(); var docMode = document.documentMode; var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7)); + if (oldIE) { this.iframe = $('