How can I unbind subviews after navigating to a different view in Backbone.js? - backbone.js

I am new to backbone, and I'm trying to fix a memory leak issue. The project I'm working on has a collection of models that is represented by a list. Each model has a submit button. The button navigates the user to a view for the corresponding model that either lets them assign the model's data to a user in the database, or return to the list view. Everything I described so far works as intended, but when the user returns to the list after navigating from a model view and chooses a different model to submit instead, then the old one will be submitted in addition to the one in the current view.
From what I've read, I know this is caused by the old view listening to events that are still bound to it, and I've looked into the various backbone functions (remove, unbind, listenTo, etc) that can be used to deal with these views. However, I've tried the functions in the view file where I believe the leak is coming from and it still doesn't seem to kill the old views. I'm also using a function in the router that removes views before loading another, but it doesn't seem to remove the subviews for the models that were navigated to in the list.
The initialize function, return function, and events for the view generating leaks
initialize: function (options) {
if (!(options && options.model)) {
throw new Error("No item specified for model");
}
if (!options.bus) {
throw new Error("No bus specified");
}
if (!options.router) {
throw new Error("No router specified");
}
this.bus = options.bus;
this.router = options.router;
this.listenTo(this.model, "returnToList", this.unbind()); //using unbind here after the return button is clicked
},
events: {
"click #submit": "submit",
"click #back": "returnToList"
},
//this function will call a function in the router that creates a view for the collection and then calls the loadView function to remove the old view.
returnToList: function () {
this.router.navigate("exceptionIndex", { trigger: true });
}
This is the function used in the router to remove old views
loadView: function (view) {
if (this._currentView) {
this._currentView.remove();
}
$("#MainContainer").html(view.render().$el);
this._currentView = view;
}
The model view should only submit the model that is being referenced for the view, even if the user previously navigated from other model views.
The console produces no errors when this issue occurs.

Related

Backbone.Collection.reset() => child view is out of sync with parent

I have a list of items. They are stored in backbone pageable collection.
They are displayed like this
|---item1---------------------------|
|---item2---------------------------|
|---item3---------------------------|
|---item4---------------------------|
|---item5---------------------------|
|---item6---------------------------|
|---item7---------------------------|
<< 1,2,3...end >>
User can click on individual item to open detail view in a separate page. Detail view has listeners initialized
when it's created. Those listeners are bound to the item model.
Since the detail view is huge, I cache it in the DOM by toggling the visibility.
The subsequent click on the item will toggle the cached view.
------ here is the problem -----
When item list is switched to another page, the collection is reset (by paginator). And all the models previously stored in the collection is dereferenced and
a new set of models is created. So after the page is switched back and forth, the previously opened item has a different copy of itself stored
in the collection. So when I change the name of the item in the detail view (in the view cache), the name in the item list is not changed.
The views are out of sync! because they are referencing to different models.
Not sure if anyone else encounter this before. If you do, please share with me how you solve it.
Thanks very much.
The most straight-forward way to maintain a fresh reference between your list view items and the corresponding detail view, on page change, is to re-render the detail view. But I'm assuming this options is not acceptable within the scope of your project.
What I often do, when I have the task of forming relationships within logically separate views is use listeners. As long as the views share a unique identifier (for example, they both share a model, or at least identical model ids), I can always send a message that will reach the view I'm interested in.
For this you'll need a centralized event hub, which with Backbone is trivially easy to generate. In some appropiately global variable (like, for example, MyApp) we simply do:
MyApp.EventBus = _.extend({}, Backbone.Events);
Set up the detail view
On the detail view initialize function I would drop this listener,
initialize: function () {
// Listen to a toggle visibility on this view
this.listenTo(MyApp.EventBus, 'detail-view:toggle-view', toggleView);
},
toggleView: function (id) {
if (this.model.id == id) {
// Show this view if I have the passed id
this.$el.show()
// Notify the parent list item view that its detail view exists
MyApp.EventBus.trigger('detail:view:exists', true);
} else {
// Hide all other views
this.$el.hide();
}
},
changeName: function () {
// logic that parses DOM user input to
// local variable name
// We now trigger an event 'detail-view:change:name', and we send as
// parameters our model's id and the new name
MyApp.EventBus.trigger('detail-view:change:name', this.model.id, name);
}
Setting up the list item view
The list item view will want to listen to a name change (or any other model property in the detail view that you want the list item to be aware of). So we'll set up a handler for the 'detail-view:change:name' event.
We'll also want to wire our click handler to toggle the visibility of the list item's detail view. The tricky part is to handle the event that a view has not been rendered yet (I'm assuming you're lazy loading the detail view). So we set up a second listener for the detail:view:exists event the detail view triggers when it catches a detail-view:toggle-view event. If we don't hear the detail:view:exists event from the targeted detail view in a timely manner (I'm using 100 ms, but you can play around with that to suit your needs), then we render the view.
initialize: function () {
// Listen to when the detail associated with this list item changes
// the the list item name
this.listenTo(MyApp.EventBus, 'detail-view:change:name', onNameChange);
// Set a property in this view if its detail view exists
this.listenTo(MyApp.EventBus, 'detail:view:exists',
_.bind(function () { this.detailViewExists = true; }, this));
// Create a debounced function that tests whether this view's
// detail view exists
_.debounce(_.bind(this.handleViewState, this), 100);
},
events {
click: 'toggleDetailView'
},
toggleDetailView: function (id) {
MyApp.EventBus.trigger('detail-view:toggle-view', this.model.id);
this.handleViewState();
},
// Debounced function that will wait 100ms asynchronously for the
// detail view to respond. If the detailViewExists bit is not set to true
// then we assume the view does not exist and we render it
handleViewState: function () {
if (!this.detailViewExists)
// The view does not exist, render and attach the view
// Set the bit to false to allow testing in the event that the detail view
// is destroyed in the future
this.detailViewExists = false;
},
changeName: function (id, newname) {
if (this.model.id == id) {
// Change the name of this list item view
this.$('.item-name').text(newname);
}
The take-away
Now, the reference between these two disparate views is the shared unique identifier. Since, by design, these two identifiers are unique in their scope, and should not change, and assuming the detail view has been rendered and attached to the DOM, then regardless of the rendering its state the list item view will always be able to communicate with its detail view.

remove orphaned child views in backbone

I have a backbone view that contains project data for various research projects.
In this view, I have a button, that when clicked, it executes a method called 'toggleChildView'. This method inserts a child view into the main page.
In the child view, I'm listening for an event where the user clicks anywhere on the page except for the element that contains the research child review.
The problem is, if I close the child view, the child view is actually still hanging around somewhere because the event is still firing, and will fire multiple times if I had opened and closed the child view.
So for example, if I opened and closed the childview 5 times, after the final close, the event will still fire 5 times.
But it shouldn't fire at all if closed, and only once when opened.
I think my question would best be phrased like this:
Is there anyway to get rid of "orphaned" child views and ensure that only one child view is open at a time?
thanks!
Parent View:
toggleChildView: function (e) {
this.$('.displayresearchdata').toggle();
this.$('.editdata').toggle();
//initialize the research view into our main view
var researchView = new researchView({ parent: this });
self.$('.research').append(researchView.render().$el);
},
saveresearchdata: function (e) {
this.model.save('researchData', this.$(".researchData").html());
},
Child render method:
initialize: function (options) {
this.parent = options.parent;
},
render: function () {
var self = this;
this.$el.append(this.template());
$("body").on("click", function (event) {
if (!$(event.target).closest('.editdata').length) {
if (self.parent.$('.editdata').is(":visible")) {
self.parent.saveresearchdata();
}
}
});
return this;
},
As #mu is too short points out, you need to explictly remove() any view you add.
If that view adds some custom event listeners, you should remove them too. If you use the view.listenTo(target, "eventname", this.functionName) flavor of event listening, those event handlers will be removed automatically when you call view.remove() because of the stopListening() method being called.
In your code, the problem is that you're not keeping a reference to the child view(s) you're adding, so you can't call remove on it. Keep a internal reference from the parent to the child like:
//initialize the research view into our main view
if(this._researchView) {
this._researchView.remove();
}
this._researchView = new researchView(...)
this.$(".research").empty().append(this._researchView.render().$el);
Take note of the use of empty before appending, if you don't want to have many researchViews added, only one at a time. If you indeed want many views, then you can remove that, and keep the internal reference as an array.
Handling so-called 'zombie' views is one of the trickiest parts of using Backbone, and if you have a lot of sub-views, it can become a real problem if you do not manage the views correctly. The seminal post on the subject is this one by Derrik Bailey although note that some of the methods he references, such as bind are now deprecated in favor of listenTo
#CharlieBrown's answer will do trick. However, if you plan on creating other views and/or subviews, here's one way you can set things up on a larger scale:
1) Create a Baseview, from which all other views will be extended.
var BaseView = Backbone.View.extend({
//typical initialize and render functions
//...
//Create the close method
close: function () {
if (this.onClose) this.onClose(); //looks for an onClose method in your subviews
this.undelegateEvents();
this.$el.off();
this.$el.children().remove();
this.$el.empty();
this.stopListening();
}
});
2) Now in your Backbone router you can create a trackView function which will call the the close() method from the base view
//within router
trackView: function (next) {
console.log('now closing ' + next.cid);
if (this.current) this.current.close();
this.current = next;
},
3) All other views in the router should now be called from within trackview like this:
//within router
someView: function () {
console.log('loading create user page');
this.trackView(new someView()); //use trackView from step 2
},
4) Finally, within any sub-views, be sure to add an 'onClose()' method where you can close down any potential zombies using the close method inherited from the Baseview:
//inside a view
onClose: function() {
if (this.couldBeAZombieView) this.couldBeAZombieView.close();
}
Now you are setup for a more complex site. There are other ways to set this up, but this is the one I'm familiar with.

How do I get a view to refresh after I delete a model from a collection?

I have a view that is a list of phones. In the list, you can add or delete a phone. Here, for example, is the function called when a delete phone form is clicked on. The problem is that I want to navigate to the updated list once the phone is deleted. The way that I am using Backbone, that will mean refetching the collection of phones from the server, but I guess that's okay, as long as the list is updated. With the function below, the list is not refreshed.
deletePhone: function (ev) {
var that = this;
ev.preventDefault();
var phoneDetails = $(ev.currentTarget).serializeObject();
var phone = new P.phone();
phone.set({id: phoneDetails['phone-to-delete']});
phone.destroy({
success: function(model, response, options) {
Backbone.history.navigate('phones', {trigger: true});
},
error: function(model, response, options) {
console.log("In error: ");
}
});
},
So how do I navigate to the updated list?
Once you call delete on a model within a collection and that model is deleted successfully on the server, that model will trigger a 'destroy' event that will bubble up to the collection. The view just meeds to be told to listen to a destroy event on the collection, and then re-render once this happens. To do this, put this line of code in the initialization function of the view:
this.listenTo(this.collection, 'destroy', this.render);
This code will call the render function once a model in the collection has been deleted. Notice that it is not necessary to get the updated list from the server, as backbone has already removed it from the collection on the front end. Of course, if there is a possibility that the list has been changed by other clients, then grabbing it from the server again may be needed.
This may be better than placing a render call in the remove function itself the cover that case that a model is removed from the collection by other means.

Collection create function firing add event too quickly

(Using Backbone 0.9.10)
I have a list view of sorts where the user can click a button to show a modal view. The list view has a counter that shows the amount of items contained in the list. Clicking a button in the modal view executes create on a collection that is passed into both views. This fires the add event in the list view, which in turn runs this function:
renderCount: function () {
// Container for model count
var $num = this.$el.find('.num');
if ($num.length > 0) {
// 'count' is a value returned from a service that
// always contains the total amount of models in the
// collection. This is necessary as I use a form of
// pagination. It's essentially the same as
// this.collection.length had it returned all models
// at once.
$num.html(this.collection.count);
}
}
However, add seems to be fired immediately (as it should be, according to the docs), before the collection has a chance to update the model count. I looked into sync but it didn't seem to do much of anything.
How can I make sure the collection is updated before calling renderCount?
Here's the list view initialize function, for reference:
initialize: function (options) {
this.collection = options.collection;
this.listenTo(this.collection, 'add remove reset', this.renderCount);
this.render();
}
EDIT:
Turns out I was forgetting to refetch the collection on success in the modal view.
$num.html(this.collection.count);
shoule be:
$num.html(this.collection.size());
Backbone.Collection uses methods imported from underscore, here is list: http://backbonejs.org/#Collection-Underscore-Methods
Turns out I was forgetting to refetch the collection on success in the modal view.

How do I design MarionetteJS ItemView to properly show loading message?

First off - I am a MarionetteJS noob.
I am having trouble making an ItemView display a loading message or throbber while it is being fetched. This is especially problematic when this ItemView is being displayed from a deep link in Backbone's history (i.e. the ItemView is the first page of the app being displayed since the user linked directly to it). I want to indicate that the page is loading (fetching), preferably with a simple view, and then show the real templated view with the fetched model.
I have seen other answers on SO like Marionette.async, (which has been deprecated) and changing the template during ItemView.initalize().
Anybody (Derrick?) got any suggestions or best practices here?
UPDATE:
I am getting the model from the collection using collection.get(id), not using model.fetch() directly.
After thinking about this, the real question is where should this be implemented:
I could change my controller to see if the model exists in the collection (and if the collection is loaded) and decide which view to show accordingly. this seems like a lot of boilerplate everywhere since this could happen with any ItemView and any controller.
I could change my ItemView initialize to test for existence of the model (and a loaded collection), but same comment here: every ItemView could have this problem.
UPDATE 2:
This is what I ended up with, in case anybody else want this solution:
app.ModelLayout = Backbone.Marionette.Layout.extend({
constructor: function(){
var args = Array.prototype.slice.apply(arguments);
Backbone.Marionette.Layout.prototype.constructor.apply(this, args);
// we want to know when the collection is loaded or changed significantly
this.listenTo(this.collection, "reset sync", this.resetHandler);
},
resetHandler: function () {
// whenever the collection is reset/sync'ed, we try to render the selected model
if (this.collection.length) {
// when there is no model, check to see if the collection is loaded and then try to get the
// specified id to render into this view
this.model = this.collection.get(this.id);
}
this.render();
},
getTemplate: function(){
// getTemplate will be called during render() processing.
// return a template based on state of collection, and state of model
if (this.model){
// normal case: we have a valid model, return the normal template
return this.template;
} else if (this.collection && this.collection.isSyncing) {
// collection is still syncing, tell the user that it is Loading
return this.loadingView;
} else {
// we're not syncing and we don't have a model, therefore, not found
return this.emptyView;
}
}
});
And here is how to use it:
// display a single model on a page
app.Access.Layout.CardLayout = app.ModelLayout.extend({
regions: {
detailsRegion:"#detailsRegion",
eventsRegion:"#eventsRegion"
},
template:"CardLayout", // this is the normal template with a loaded model
loadingView:"LoadingView", // this is a template to show while loading the collection
emptyView:"PageNotFoundView", // this is a template to show when the model is not found
onRender : function() {
this.detailsRegion.show( blah );
this.eventsRegion.show( blah );
}
});
thanks!
For the ItemView
I think you can add a spinner in your initialize function, I really like spin.js http://fgnass.github.io/spin.js/ because its pretty easy and simple to use, and you can hide the spinner in the onRender function of the Itemview
For The CollectionView
in the CollectionView you could handle it like this....
Take a look at the solution that Derick posted..
https://github.com/marionettejs/backbone.marionette/wiki/Displaying-A-%22loading-...%22-Message-For-A-Collection-Or-Composite-View
I'd suggest using jQuery deferreds:
Start fetching your data, and store the return value (which is a jQuery promise)
Instanciate your content view
Show your loading view
When the promise is done, show the view containing the content
I've talked about implementing this technique on my blog:
http://davidsulc.com/blog/2013/04/01/using-jquery-promises-to-render-backbone-views-after-fetching-data/
http://davidsulc.com/blog/2013/04/02/rendering-a-view-after-multiple-async-functions-return-using-promises/
The issue with the solution linked by Rayweb_on, is that your loading view will be displayed any time your collection is empty (i.e. not just when it's being fetched). Besides, the ItemView doesn't have an emptyView attribute, so it won't be applicable to your case anyway.
Update:
Based on your updated question, you should still be able to apply the concept by dynamically specifying which template to use: https://github.com/marionettejs/backbone.marionette/blob/master/docs/marionette.view.md#change-which-template-is-rendered-for-a-view
Then, when/if the data has been fetched successfully, trigger a rerender in the view.

Resources