Saturday, September 3, 2011

Event Propagation in Backbone.js

‹prev | My Chain | next›

update: Turns out most of this discussion is wrong. Can you spot why? It took me two months, so you're smarter than me if you can. If not, the answers are in a follow-up post.

I am making good progress on my little Backbone.js calendar application. I can now add appointments and delete them (to and from a CouchDB store). There is a bit of mess in the code, but there is also a defect in the UI. When I click the delete icon, a Backbone event deletes the record and removes the appointment from the calendar.
The problem is that immediately after the appointment is removed, a jQuery UI Dialog for a new Appointment pops up. The little "X" icon handle its event, but then allows events to continue to bubble back on up to the containing TD. The containing TD has a click event (through a separate Backbone View) which opens the add appointment dialog.

The current Appointment View looks like:

    window.AppointmentView = Backbone.View.extend({
      // ...
      events: {
        'click .delete': 'deleteClick'
      },
      deleteClick: function(e) {
        this.model.destroy();
      },
      // ...
    });
So the solution ought to be as simple as adding a jQuery stopPropagation() to the deleteClick() handler:
    window.AppointmentView = Backbone.View.extend({
      // ...
      events: {
        'click .delete': 'deleteClick'
      },
      deleteClick: function(e) {
        e.stopPropagation();
        this.model.destroy();
      },
      // ...
    });
It ought to be as simple as that, except it does not work. In fact, stopImmediatePropagation() and even the old stand-by return false do not work either:
      // ...
      deleteClick: function(e) {
        e.stopPropagation();
        e.stopImmediatePropagation();

        this.model.destroy();

        return false;
      }
      // ...
Hrm... next step: print STDERR. Since I am in Javascript land, that means console.log():


    window.AppointmentView = Backbone.View.extend({
      // ...
      deleteClick: function(e) {
        console.log("deleteClick");
        // ...
      },
      // ...
    });

    window.DayView = Backbone.View.extend({
      // ...
      addClick: function() {
        console.log("addClick");
        // ...
      }
    });
So now I have logging click handlers on the outer TD (which adds new appointments) and the inner "X" (which removes appointments). Clicking on the inner "X" ought to trigger an event first on the inner "X", which should then bubble up to the outer TD. Right?

Well no:

The add-click is registered first. Since I am not stopping propagation from the add-click handler, the event then moves on to the "X". Weird. That seems suspiciously like the capturing event model. But I am using jQuery -- events bubble in jQuery. Right?

As a quick sanity check, I have a look at the DOM structure and, indeed, the "X" is a child of the outer TD.
So does Backbone somehow muck with event capture vs. bubbling? An easy way to check that out is to drop into the Chrome Javascript debugger:

    window.DayView = Backbone.View.extend({
      addClick: function(e) {
        console.log("addClick");

        debugger;
        // ...
      }
    });
When I try to delete an appointment, the first thing triggered is that addClick() handler. Looking around again, I am in an event bubble after all:
Uh... what?

After a bit of digging, I finally notice that the two events are declared differently:
    window.AppointmentView = Backbone.View.extend({
     // ...
      events: {
        'click .delete': 'deleteClick'
      },
      // ...
    });

    window.DayView = Backbone.View.extend({
      events : {
        'click': 'addClick'
      },
      // ...
    });
It turns out that the difference between "click .delete" and "click" in Backbone is the difference between delegating events and directly binding events in jQuery. Delegated events will not fire until the event had propagated all the way up the DOM tree. This means that any "real" events — events bound directly to an HTML element like my TD — will fire before any delegated event, regardless of where they are in the DOM tree.

Phew! I thought I was going mad. At this point, I understand the problem, but I still lack a solution. Since the TD and "X" both occupy the same space, it seems that both will have to follow the same event binding model. Either both need to be bound directly to the appropriate HTML element or both need to be delegated.

I think the Appointment view is the one that needs to change. I had been setting the element explicitly when creating a new View for individual views:
          var el = $('#' + appointment.get("startDate")),
              view = new AppointmentView({model: appointment, el: el});
          view.render();
I think the el was a rookie mistake on my part. That element does not refer to the actual appointment, but rather the TD that will contain my appointment (TDs in my calendar are ID'd by the ISO 8601 date that they represent). If there is a Backbone-way it is that View should encapsulate what they represent. Here I am trying to encapsulate the TD and then do fancy delegating to overcome my mistake.

The AppointmentView object still needs to know the containing element (so that it can append the appointment to it). I could pass this in via constructor argument, but I think I will just allow the AppointmentView to have the knowledge that its parent is a TD with an ID of the ISO 8601 date of the appointment. So the constructor becomes:

          var view = new AppointmentView({model: appointment});
          view.render();
After mucking with the rest of the class to adjust to the new container, I can now bind directly to the proper event:

      events: {
        'click': 'deleteClick'
      },
And finally, I can delete appointments.

There is still work to be done, but I feel like I made progress tonight. At least I can remove appointments without immediately being asked to add new ones. That was like some kind of middle management nightmare.

Day #132

3 comments:

  1. This comment has been removed by the author.

    ReplyDelete
  2. Thanks for the post.

    This problem puzzled me for awhile in my mobile app as well. I had a iScroll setup in the first view with a button. This button (not an anchor link) had a click event to the target view. Also, in the target view, I had a form field which is positionws exactly under the button of first view.

    Here is the problem: When I tap on the button, it goes to the next view and then focuses into the form field. And then, soft keyboard comes out. It was very annoying issue.

    The following functions didn't help at all, because, there was no bubbling to stop or no need to prevent default action because it was not an anchor which might have a default link.
    e.preventDefault();
    e.stopPropagation();
    e.stopImmediatePropagation();

    I found the issue in iScroll. I had to turn off the click handling by iScroll.
    I just needed to pass the following option to the iScroll settings.

    handleClick : false


    Now, for your issue, you may just need to bind a click event to the cells (days of the calendar), then using the following code, detect whether you click on the delete button or the cell itself.

    var elm = $(e.currentTarget);

    It could be even better if you bind only one click event to the calendar itself, then delegate the click events to detect which element you click on.

    ReplyDelete