backbone-js-on-rails icon indicating copy to clipboard operation
backbone-js-on-rails copied to clipboard

Any chapter/paragraph on pushState and how to handle direct traffic?

Open JeanMertz opened this issue 13 years ago • 10 comments

I've read the backbone-js-on-rails book several months ago, so I'm not sure if things have changed since then regarding this topic, but I can't wrap my head around a good way to handle the rails side of the equation when having a backbone app that uses pushState and the possibility of visitors using direct url's to navigate to a specific part of the application.

WIth that I mean, if I visit /projects/123 by clicking on the project 123 link in my Backbone app, Backbone will handle the link and generate the requested content. However, if I directly navigate to that url from the browser navigation bar, the request will be handled by Rails.

This is what the official Backbone documentation states:

Note that using real URLs requires your web server to be able to correctly render those pages, so back-end changes are required as well. For example, if you have a route of /documents/100, your web server must be able to serve that page, if the browser visits that URL directly. For full search-engine crawlability, it's best to have the server generate the complete HTML for the page ... but if it's a web application, just rendering the same content you would have for the root URL, and filling in the rest with Backbone Views and JavaScript works fine.

So I'm guessing I need to make sure all html requests on all controllers simply render the dashboard view and fire up the Backbone application the same way it would do when a user visited the root / url. After that, Backbone should take over. But will I need to do anything in Backbone to handle the new url in the browser, other than what's already described in the book?

Also, if this where to work properly, would Backbone rewrite the url to use #/projects/123 in these cases if the browser doesn' support pushState?

Thank you for any help.

JeanMertz avatar Feb 22 '12 09:02 JeanMertz

#resolve

We currently only have the section on pushState which tells about why its different and why its not the default, but we don't currently cover how to use it.

What you guess about how to handle this is exactly right. Route all html requests on those controllers to render the dashboard view, instantiate the backbone application the same way you normally do. Backbone does take over and there is nothing you need to do after that. Backbone looks at the current url when loaded and routes to it automatically.

If you do pushState like this the next issue you're going to run into is how to handle links. Your links are all going to hit the server first, which defeats the purpose. So one way to handle this is to bind all links with a click handler that passes the click to Backbone navigation. Here is a sample from our own app.

$('a').live('click', function(event) {
  if(!event.shiftKey && !event.ctrlKey && !event.metaKey) {
    var uri = $(this).attr('href');
    Trajectory.navigate(uri, event);
  }
});

The Trajectory.navigate method is as follows:

navigate: function(uri, event) {
  if(uri.indexOf(Trajectory.urlRoot) == 0) {
    uri = uri.substr(Trajectory.urlRoot.length);
    var matched = _.any(Backbone.history.handlers, function(handler) {
      if (handler.route.test(uri)) {
        return true;
      }
    });
    if(matched) {
      if(event) event.preventDefault();
      Backbone.history.navigate(uri, true);
      return false;
    }
  }
}

On Feb 22, 2012, at 4:07 AM, Jean Mertz wrote:

I've read the backbone-js-on-rails several months ago, so I'm not sure if things have changed since then regarding this topic, but I can't wrap my head around a good way to handle the rails side of the equation when having a backbone app that uses pushState and the possibility of visitors using direct url's to navigate to a specific part of the application.

WIth that I mean, if I visit /projects/123 by clicking on the project 123 link in my Backbone app, Backbone will handle the link and generate the requested content. However, if I directly navigate to that url from the browser navigation bar, the request will be handled by Rails.

This is what the official Backbone documentation states:

Note that using real URLs requires your web server to be able to correctly render those pages, so back-end changes are required as well. For example, if you have a route of /documents/100, your web server must be able to serve that page, if the browser visits that URL directly. For full search-engine crawlability, it's best to have the server generate the complete HTML for the page ... but if it's a web application, just rendering the same content you would have for the root URL, and filling in the rest with Backbone Views and JavaScript works fine.

So I'm guessing I need to make sure all html requests on all controllers simply render the dashboard view and fire up the Backbone application the same way it would do when a user visited the root / url. After that, Backbone should take over. But will I need to do anything in Backbone to handle the new url in the browser, other than what's already described in the book?

Thank you for any help.


Reply to this email directly or view it on GitHub: https://github.com/thoughtbot/backbone-js-on-rails/issues/55

cpytel avatar Feb 22 '12 14:02 cpytel

Thanks, that is perfectly clear.

Right now my controller is like this:

  respond_to :html, :json

  def index
    respond_with(@projects = current_account.projects)
  end

But I guess in order to be able to use pushState, I'd have to use something like this, correct?

  respond_to :html, :json

  def index
    respond_with(@projects = current_account.projects) do |format|
      format.html { render text: '', layout: true }
    end
  end

And then do this for every controller action. Or is there a more efficient way to do this (before_action?)

edit or... I could use match '*path', to: 'main#index' as shown by Ryan Bates in his latest railscast. Any downsides to this method? (man, it's really hard to find anything about this through google!)

JeanMertz avatar Feb 22 '12 21:02 JeanMertz

Hi,

I'm also trying to get pushState working with direct traffic.

To "learn by doing", I'm trying to simply convert a scaffolded Rails app to use backbone, but while keeping the original views functional (i.e. progressive enhancement, etc.).

I got everything mostly working here: https://github.com/davidsulc/protocolonel/commit/5001ca269254d2d84e2cbd2eed40c7096ac99b0e

However, since I was initializing backbone in the index view, navigating directly to localhost:3000/protocols/1 wouldn't initialize the backbone app, so I moved the init call to the rails application layout: https://github.com/davidsulc/protocolonel/commit/daefc4bab33a88863940507238d8c867baaefc79

If I navigate to localhost:3000/protocols/1, the backbone app is initialized properly and the proper route gets fired, but the events don't seem to be bound properly. I wrote a function that navigates to the links (Protocolonel.Support.navigateLink, in https://github.com/davidsulc/protocolonel/blob/ubiquitous_init_attempt/app/assets/javascripts/protocolonel.js), but the events in backbone's show view (https://github.com/davidsulc/protocolonel/blob/ubiquitous_init_attempt/app/assets/javascripts/views/protocol_show.js) aren't getting bound properly, and clicking on a link triggers a page request from the server.

What am I doing wrong ?

As @JeanMertz said, it's nearly impossible to find anything on the subject on the web. In addition, I'm still in the early stages of wrapping my head around "the backbone way", so I apologize in advance if there are any glaring errors in the code.

davidsulc avatar Mar 05 '12 19:03 davidsulc

Ok, so I got everything working properly with pushState : https://github.com/davidsulc/protocolonel/commit/5c15173e2b5e0a1c6da0ce7eb15f92ef9861f9ba

I needed to attach the router to an existing DOM element (#app in my code). Although this works, I don't really understanding how or why. Could you explain this in the book ? I was unable to find any example of attaching the router to a DOM element (I essentially copied it from the example app). In addition, there is no mention on the web of why this would be necessary, or what happens in backbone when you do it.

During my various attempts at solving my problem, I also noticed that direct links would work (prior to my fixing the code by attaching the router to a proper DOM element) if I specified the view's el to be an existing DOM element (although clicking on any link would break the app, since that DOM element would be removed). Any idea why this would be the case ?

davidsulc avatar Mar 06 '12 22:03 davidsulc

@davidsulc I'm not sure why you would need to attach the router to an existing DOM element. What was the problem you were having (what exactly wasn't working) when you did not do this?

While I'm not sure why you would need to do this, I can see that in all of our apps we're using SwappingRouter which does attached the router to an existing DOM element. So if there is a gotcha or bug here, its possible we just didn't hit it.

cpytel avatar Mar 08 '12 20:03 cpytel

FWIW, I agree that there is a dearth of information on pushState and I would like to expand the coverage of this in the book once we have it worked out. Trajectory is now completely pushState, but thats just one app and so I think we still need more knowledge about it before fully writing it up and/or recommending it.

cpytel avatar Mar 08 '12 20:03 cpytel

Since I bought the book, you can guess I'm a backbone n00b, and everything below boils down to conjecture based on investigation...

First of all, here's what I set out to do: generate a simple scaffold with rails, then implement the same functionality in backbone. The way I'm going about it currently is simply initializing the backbone app in the application layout if appropriate. (I.e. model data is fetched from the server by backbone when it could be harvested from the HTML, which I realize is suboptimal, but this is a work in progress :D)

Since I have a functional app that I'm enhancing with backbone (and using pushState), the links all have real href attributes leading to actual HTML pages. I hijack those links ('show', 'edit', 'back', etc.) with this function (where Protocolonel is the app's name):

navigateLink: function (e) {
    var target = $(e.currentTarget);

    if( ! target.attr('data-method')){ // don't change delete links
      e.preventDefault();
      Protocolonel.router.navigate(target.attr('href'), { trigger: true });
    }
  }

Then, in my views, I simply wire up a click events on links to fire that function. Here's the interesting bit that happens if I don't attach the router to an existing DOM element:

  • if I go to the app's root path (by entering the path in the address bar), everything works great
  • if I navigate to a deeper page (e.g. the show action for a model instance), and I click on a link (that should trigger the navigateLink function), the proper function isn't triggered and the full page is fetched from the server (although backbone initializes properly). I seems like somehow the events defined in the backbone view aren't getting bound properly
  • if I do as above and click (e.g.) the 'edit' link, I land on the edit page after a full page refresh (as the page gets fetched from the server). Then when I click the 'back' button on the browser, I see the JSON for the model instance that backbone fetched, instead of the view for the show action.

But if I attach the router to a DOM element, everything works as expected.

The only reason I've started to attach the router to a DOM element is because I saw that being done in your example app...

I've tried to describe the issue as best I can, so let me know if you need more info. I you want to try it yourself, you can get the code here : https://github.com/davidsulc/protocolonel

So if you could look into this, I'd be most interested in the hows and whys of the matter... Thanks !

davidsulc avatar Mar 09 '12 19:03 davidsulc

@davidsulc, providing router.el isn't specific to pushState, but applies to SwappingRouter in general.

In general, in Backbone, when you hit a route the router is going to do some logic that probably ends with "...and then put a new Backbone view on the page somewhere."

SwappingRouter does this by having the idea of a root DOM element and "swapping" different views into that root DOM element. It does that here https://github.com/thoughtbot/backbone-support/blob/master/lib/assets/javascripts/backbone-support/swapping_router.js#L12 in #swap(), which gets called from your Protocols router https://github.com/davidsulc/protocolonel/blob/master/app/assets/javascripts/routers/protocols.js.

jasonm avatar Mar 27 '12 12:03 jasonm

@davidsulc I've read through your last comment, and I think I understand what's going on. Basically, your approach to intercepting clicks only works for <a> tags that are rendered by Backbone views, and does not intercept clicks for <a> tags rendered by Rails (e.g. app/views/protocols/*). Any events bound by declaring an entry in a Backbone.View events hash will only capture those events for elements that are children of the view's el - so, only links rendered inside your Backbone views, not links rendered by Rails.

That explains why in step #1 above (go to the app's root path), everything works: because you are interacting 100% through the Backbone views.

Then, in step #2, you navigate directly to a deeper page, so all links on that page are rendered by Rails. Since none of them are intercepted and routed through pushState, it's all regular HTML link clicking....

...until you click "back", where pushState-enabled browsers fire an event called "popstate". Backbone subscribes to this, calls its internal Backbone.history.checkUrl function, which eventually triggers Backbone.history.navigate. At that point, your router is called, Backbone stuff gets rendered, and we're full steam ahead as we were in step #1.

To fix this, I'd advise you not bind to navigateLink inside your Backbone views, but globally, as @cpytel commented earlier https://github.com/thoughtbot/backbone-js-on-rails/issues/55#issuecomment-4104985

Does this make sense? & help? Thanks!

jasonm avatar Mar 27 '12 12:03 jasonm

@JeanMertz I haven't actually written a transparently upgrading pushState app like you're aiming for, but I'll throw my hat in the ring.

One benefit of allowing your server to respond to the same URL structure as pushState URLs is so you can use pretty URLs without breaking user bookmarks. For those users, a server response that just boots up the Backbone app and invokes the correct route fragment is perfectly serviceable. You also have the opportunity here to deliver more bootstrap data in the initial page request, if you are interested in optimizing this request.

In this case, I don't see anything wrong with a catch-all route. A minor concern, but worth mentioning: If all of your Backbone URLs don't share a common prefix (namespace), you might consider adding one so that you only need one catch-all route, and so you're less likely to accidentally match too much.

A second reason you might be doing this is to support search engine indexing. In that case, you might want to serve up a fully rendered HTML page that's easy to index. Maybe that's conditional based on user agent - you can deliver simple indexable content to the search engines, and users get the same "boot up the Backbone app and invoke the route fragment" page as discussed above.

Or, maybe you always build a full HTML page and then progressively enhance it with Backbone.

jasonm avatar Mar 27 '12 14:03 jasonm