Issue #2159965 by Wim Leers, jessebeach: Fix two memory leaks + subtle bugs in Edit's JS (discovered by working on the Backbone upgrade in the D7 backport).

8.0.x
webchick 2013-12-23 12:50:36 -08:00
parent ebd7e8fb42
commit ae4177b829
6 changed files with 107 additions and 73 deletions

View File

@ -183,7 +183,9 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({
fieldModel.set('htmlForOtherViewModes', response.other_view_modes);
// Finally, set the 'html' attribute on the field model. This will cause
// the field to be rerendered.
fieldModel.set('html', response.data);
_.defer(function () {
fieldModel.set('html', response.data);
});
};
// Unsuccessfully saved; validation errors.

View File

@ -81,9 +81,11 @@ Drupal.edit.EntityModel = Backbone.Model.extend({
var to = state;
switch (to) {
case 'closed':
this.set('isActive', false);
this.set('inTempStore', false);
this.set('isDirty', false);
this.set({
'isActive': false,
'inTempStore': false,
'isDirty': false
});
break;
case 'launching':
@ -103,18 +105,9 @@ Drupal.edit.EntityModel = Backbone.Model.extend({
case 'committing':
// The user indicated they want to save the entity.
// For fields already in a candidate-ish state, trigger a change
// event so that the entityModel can move to the next state in
// committing.
this.get('fields').chain()
.filter(function (fieldModel) {
return _.intersection([fieldModel.get('state')], Drupal.edit.app.readyFieldStates).length;
})
.each(function (fieldModel) {
fieldModel.trigger('change:state', fieldModel, fieldModel.get('state'), options);
});
var fields = this.get('fields');
// For fields that are in an active state, transition them to candidate.
this.get('fields').chain()
fields.chain()
.filter(function (fieldModel) {
return _.intersection([fieldModel.get('state')], ['active']).length;
})
@ -123,7 +116,7 @@ Drupal.edit.EntityModel = Backbone.Model.extend({
});
// For fields that are in a changed state, field values must first be
// stored in TempStore.
this.get('fields').chain()
fields.chain()
.filter(function (fieldModel) {
return _.intersection([fieldModel.get('state')], Drupal.edit.app.changedFieldStates).length;
})
@ -192,18 +185,56 @@ Drupal.edit.EntityModel = Backbone.Model.extend({
}
},
/**
* Updates a Field and Entity model's "inTempStore" when appropriate.
*
* Helper function.
*
* @param Drupal.edit.EntityModel entityModel
* The model of the entity for which a field's state attribute has changed.
* @param Drupal.edit.FieldModel fieldModel
* The model of the field whose state attribute has changed.
*
* @see fieldStateChange()
*/
_updateInTempStoreAttributes: function (entityModel, fieldModel) {
var current = fieldModel.get('state');
var previous = fieldModel.previous('state');
var fieldsInTempStore = entityModel.get('fieldsInTempStore');
// If the fieldModel changed to the 'saved' state: remember that this
// field was saved to TempStore.
if (current === 'saved') {
// Mark the entity as saved in TempStore, so that we can pass the
// proper "reset TempStore" boolean value when communicating with the
// server.
entityModel.set('inTempStore', true);
// Mark the field as saved in TempStore, so that visual indicators
// signifying just that may be rendered.
fieldModel.set('inTempStore', true);
// Remember that this field is in TempStore, restore when rerendered.
fieldsInTempStore.push(fieldModel.get('fieldID'));
fieldsInTempStore = _.uniq(fieldsInTempStore);
entityModel.set('fieldsInTempStore', fieldsInTempStore);
}
// If the fieldModel changed to the 'candidate' state from the
// 'inactive' state, then this is a field for this entity that got
// rerendered. Restore its previous 'inTempStore' attribute value.
else if (current === 'candidate' && previous === 'inactive') {
fieldModel.set('inTempStore', _.intersection([fieldModel.get('fieldID')], fieldsInTempStore).length > 0);
}
},
/**
* Reacts to state changes in this entity's fields.
*
* @param Drupal.edit.FieldModel fieldModel
* The model of the field whose state property changed.
* The model of the field whose state attribute changed.
* @param String state
* The state of the associated field. One of Drupal.edit.FieldModel.states.
*/
fieldStateChange: function (fieldModel, state) {
var entityModel = this;
var fieldState = state;
var fieldsInTempStore = this.get('fieldsInTempStore');
// Switch on the entityModel state.
// The EntityModel responds to FieldModel state changes as a function of its
// state. For example, a field switching back to 'candidate' state when its
@ -245,43 +276,22 @@ Drupal.edit.EntityModel = Backbone.Model.extend({
if (fieldState === 'changed') {
entityModel.set('isDirty', true);
}
// If the fieldModel changed to the 'saved' state: remember that this
// field was saved to TempStore.
else if (fieldState === 'saved') {
// Mark the entity as saved in TempStore, so that we can pass the
// proper "reset TempStore" boolean value when communicating with the
// server.
entityModel.set('inTempStore', true);
// Mark the field as saved in TempStore, so that visual indicators
// signifying just that may be rendered.
fieldModel.set('inTempStore', true);
// Remember that this field is in TempStore, restore when rerendered.
fieldsInTempStore.push(fieldModel.get('fieldID'));
fieldsInTempStore = _.uniq(fieldsInTempStore);
entityModel.set('fieldsInTempStore', fieldsInTempStore);
}
// If the fieldModel changed to the 'candidate' state from the
// 'inactive' state, then this is a field for this entity that got
// rerendered. Restore its previous 'inTempStore' attribute value.
else if (fieldState === 'candidate' && fieldModel.previous('state') === 'inactive') {
fieldModel.set('inTempStore', _.intersection([fieldModel.get('fieldID')], fieldsInTempStore).length > 0);
else {
this._updateInTempStoreAttributes(entityModel, fieldModel);
}
break;
case 'committing':
// If the field save returned a validation error, set the state of the
// entity back to opened.
// entity back to 'opened'.
if (fieldState === 'invalid') {
// A state change in reaction to another state change must be deferred.
_.defer(function() {
entityModel.set('state', 'opened', { reason: 'invalid' });
});
}
// If the fieldModel changed to the 'candidate' state from the
// 'inactive' state, then this is a field for this entity that got
// rerendered. Restore its previous 'inTempStore' attribute value.
else if (fieldState === 'candidate' && fieldModel.previous('state') === 'inactive') {
fieldModel.set('inTempStore', _.intersection([fieldModel.get('fieldID')], fieldsInTempStore).length > 0);
else {
this._updateInTempStoreAttributes(entityModel, fieldModel);
}
// Attempt to save the entity. If the entity's fields are not yet all in
@ -503,6 +513,8 @@ Drupal.edit.EntityModel = Backbone.Model.extend({
destroy: function (options) {
Backbone.Model.prototype.destroy.apply(this, options);
this.off(null, null, this);
// Destroy all fields of this entity.
this.get('fields').each(function (fieldModel) {
fieldModel.destroy();

View File

@ -82,7 +82,7 @@ Drupal.edit.FieldModel = Backbone.Model.extend({
if (this.get('state') !== 'inactive') {
throw new Error("FieldModel cannot be destroyed if it is not inactive state.");
}
Backbone.Model.prototype.destroy.apply(this, options);
Backbone.Model.prototype.destroy.call(this, options);
},
/**
@ -97,11 +97,6 @@ Drupal.edit.FieldModel = Backbone.Model.extend({
* {@inheritdoc}
*/
validate: function (attrs, options) {
// We only care about validating the 'state' attribute.
if (!_.has(attrs, 'state')) {
return;
}
var current = this.get('state');
var next = attrs.state;
if (current !== next) {

View File

@ -436,31 +436,48 @@ Drupal.edit.AppView = Backbone.View.extend({
var $fieldWrapper = $(fieldModel.get('el'));
var $context = $fieldWrapper.parent();
var renderField = function () {
// Destroy the field model; this will cause all attached views to be
// destroyed too, and removal from all collections in which it exists.
fieldModel.destroy();
// Replace the old content with the new content.
$fieldWrapper.replaceWith(html);
// Attach behaviors again to the modified piece of HTML; this will
// create a new field model and call rerenderedFieldToCandidate() with
// it.
Drupal.attachBehaviors($context);
};
// When propagating the changes of another instance of this field, this
// field is not being actively edited and hence no state changes are
// necessary. So: only update the state of this field when the rerendering
// of this field happens not because of propagation, but because it is being
// edited itself.
// of this field happens not because of propagation, but because it is
// being edited itself.
if (!options.propagation) {
// First set the state to 'candidate', to allow all attached views to
// clean up all their "active state"-related changes.
fieldModel.set('state', 'candidate');
// Deferred because renderUpdatedField is reacting to a field model change
// event, and we want to make sure that event fully propagates before
// making another change to the same model.
_.defer(function () {
// First set the state to 'candidate', to allow all attached views to
// clean up all their "active state"-related changes.
fieldModel.set('state', 'candidate');
// Set the field's state to 'inactive', to enable the updating of its DOM
// value.
fieldModel.set('state', 'inactive', { reason: 'rerender' });
// Similarly, the above .set() call's change event must fully propagate
// before calling it again.
_.defer(function () {
// Set the field's state to 'inactive', to enable the updating of its
// DOM value.
fieldModel.set('state', 'inactive', { reason: 'rerender' });
renderField();
});
});
}
else {
renderField();
}
// Destroy the field model; this will cause all attached views to be
// destroyed too, and removal from all collections in which it exists.
fieldModel.destroy();
// Replace the old content with the new content.
$fieldWrapper.replaceWith(html);
// Attach behaviors again to the modified piece of HTML; this will create
// a new field model and call rerenderedFieldToCandidate() with it.
Drupal.attachBehaviors($context);
},
/**

View File

@ -35,7 +35,7 @@ Drupal.edit.ContextualLinkView = Backbone.View.extend({
*/
initialize: function (options) {
// Insert the text of the quick edit toggle.
this.$el.find('a').text(this.options.strings.quickEdit);
this.$el.find('a').text(options.strings.quickEdit);
// Initial render.
this.render();
// Re-render whenever this entity's isActive attribute changes.

View File

@ -13,9 +13,9 @@ Drupal.edit.EntityToolbarView = Backbone.View.extend({
events: function () {
var map = {
'click.edit button.action-save': 'onClickSave',
'click.edit button.action-cancel': 'onClickCancel',
'mouseenter.edit': 'onMouseenter'
'click button.action-save': 'onClickSave',
'click button.action-cancel': 'onClickCancel',
'mouseenter': 'onMouseenter'
};
return map;
},
@ -117,7 +117,15 @@ Drupal.edit.EntityToolbarView = Backbone.View.extend({
* {@inheritdoc}
*/
remove: function () {
// Remove additional DOM elements controlled by this View.
this.$fence.remove();
// Stop listening to additional events.
this.appModel.off(null, null, this);
this.model.get('fields').off(null, null, this);
$(window).off('resize.edit scroll.edit');
$(document).off('drupalViewportOffsetChange.edit');
Backbone.View.prototype.remove.call(this);
},