Monday, November 28, 2011

Redirection in Backbone.js

‹prev | My Chain | next›

I made a bit of a mess of yesterday's Backbone.js router post. Several things conspired against me last night, not the least of which was my own ignorance. OK, so it was entirely my ignorance, you happy? Ahem.

Anyhow, I would ultimately like to come up with a reasonable strategy for redirection that Nick Gauthier and I can include in Recipes with Backbone. I can safely say that dedicated redirect routes do not work in Backbone. For instance, if I want to redirect my user to the next result in a set, a poor practice is to establish links along the lines of:
    <div id='navigation'>
      <a href='#previous'>Previous</a>
      <a href='#next'>Next</a>
    <div>
I can make that kinda/sorta work with the following routing code:
  var Routes = Backbone.Router.extend({
    initialize: function(options) {
      this.paginator = options.paginator;
      _.bindAll(this, 'next', 'previous', 'page');
    },

    routes: {
      "next": "next",
      "previous": "previous",
      "page/:page": "page"
    },

    next: function() {
      console.log("[next]");
      this.paginator.move(1).render();
      Backbone.history.navigate("#page/" + this.paginator.page());
    },

    previous: function() { /* ... /* },

    page: function(page) {
      this.paginator.page(page);
      this.paginator.render();
    }
  });
The next() method tells the paginator view class to increment its internal page count and re-render (presumably with the correct page number). I then explicitly perform a "redirect", via Backbone.history.navigate() to #page/2.

It would not make any sense to leave the user on the #next resource—it is not a real resource, just a stopping point between real destinations. Besides, if the user attempted to bookmark the #next location, hilarity would ensure. And by "hilarity", I mean pain. For the user.

Anyhow, I say that this kinda/sorta works because the back button is broken with this redirection mechanism. If I am on page 2 and click "Back", I will hit the #next resource, which will send me onto page 3—exactly the opposite of where I want go.

So really, explicitly linking to redirection routes is a non-starter. I remove this solution. Instead, I focus on trying to generate custom events in my view to which the router can respond:
    var Paginator = Backbone.View.extend({
      // ...
      previous: function() {
        this.trigger('previous');
      },
      next: function() {
        this.trigger('next');
      },
      // ...
    });
Now, I can pass my paginator view to my router:
  var paginator = new Views.Paginator({el: root_el}).render();
  new Routes({paginator: paginator});
And my routes can then subscribe to the view's redirection events:
  var Routes = Backbone.Router.extend({
    initialize: function(options) {
      this.paginator = options.paginator;
      _.bindAll(this, 'next', 'previous', 'page');
      paginator.bind('next', this.next);
      paginator.bind('previous', this.previous);
    },

    next: function() {
      console.log("[next]");
      this.paginator.move(1).render();
      Backbone.history.navigate("#page/" + this.paginator.page());
    },

    previous: function() {
      console.log("[previous]");
      this.paginator.move(-1).render();
      Backbone.history.navigate("");
    },
    // ...
  });
Now, when I navigate with the "Next" and "Previous" controls from my view (that only generate events, but do not link to anything in the traditional sense), the controls stay in sync with the browser URL. Even click the Back button sends me to the correct URL (#page/1) with the correct page number rendering in the view:


The Back button is working here thanks to the definition of the #page/1 route:
  var Routes = Backbone.Router.extend({
    initialize: function(options) { /* ... */ },

    routes: {
      "page/:page": "page"
    },

    next: function() { /* ... */ },

    previous: function() { /* ... */ },

    page: function(page) {
      this.paginator.page(page);
      this.paginator.render();
    }
  });
The only trouble that I run into with this solution is clicking all the way back to the first page. When I first enter, I am shown page zero:


But when I click the Back button, there is no matching default route, so the controls remain stuck on page one while the URL is sent back to the original, root URL:


One solution to this is that I can set a default route for my application. But again, if I do something like that, I end up in a redirection loop when trying to hit the Back button past the default route.

To get that working, I make a Backbone.history.navigate() call in my default route:
  var Routes = Backbone.Router.extend({
    //...
    routes: {
      "": "setDefault",
      "page/:page": "page"
    },

    setDefault: function() {
      Backbone.history.navigate("#page/" + this.paginator.page());
    },
    //...
  });
It turns out that does exactly what I was looking for. Rather than adding a new history entry on top of the default "" route, it replaces the original route with #page/0. Success!


Except it does not work in Firefox. In Firefox, the default route sticks around, even on page zero:


Bah! There may be a way to overcome this in Firefox, but of course, then there is IE. Double Bah!

I call it a day at this point. I think I have enough research information for Recipes with Backbone. I cannot quite offer a complete solution, but at least I know the extent to which the recipe applies. Good enough for a 1.0 edition.


Day #220

1 comment:

  1. One thing you can try is have the empty route also go to the page function, and in the page function default the page to 1 (or 0, whatever the first is).

    Another thought I had, and this would need to be developed further, is to break up routers into specific domains of the app and have them accept the views they interact with in their constructor.

    So in our case, this could be the AppointmentPaginationRouter which listens to the Paginator's events.

    Then you could have a RootRouter which delegates the empty route to AppointmentPaginationRouter#page(0).

    By splitting up routers to go hand-in-hand with views, the views could avoid concerning themselves with routes and just fire methods, and the router could do both the navigation and reception of routes.

    /nick

    ReplyDelete