Mastering ClojureScript Routing with Secretary and goog.History

Eric Normand's Newsletter
Software design, functional programming, and software engineering practices
Over 5,000 subscribers

Summary: The Google Closure Library provides a nice interface to the HTML5 History API. Coupling it with Secretary is very easy. But not all browsers support HTML5 History. In this post I'll talk about one way to make sure you have client-side routing in all browsers.

Background

About a year ago I was working for a company of three people. Two coders and one business person. I was developing a consumer product and the other programmer was building a related B2B product. We were as agile as could be: no planning meetings, no prioritized list of features, just a shared vision. I was working in Clojure and ClojureScript and getting paid to do it.

That job eventually disappeared. But the amount of code I produced and the dark corners of features I explored still surprises me. I discovered (uncovered?) a lot of gems of ClojureScript in that time. This post is about one of them.

Update: Andre Rauh pointed out that I was using a require when I should use an import for goog.history.EventType. I fixed it in the code. Thanks!

Browser History

In a project I did about a year ago, we wanted the speed of a single page application but we wanted the back button to work and we wanted the URL to reflect where the reader was in the app. We turned to the HTML5 History API.

The HTML5 History API is an API for manipulating the browser's history without making a request to the server and loading a new page. The idea is that your Javascript application can keep all of its state in memory, but still change the URLs and keep the back button working. You have to code it up yourself, but it gives you fine-grained control over what exactly the back button does.

Luckily (and not surprisingly), the Google Closure Library has a nice way to access the History API. It's in a class called goog.history.Html5History. That gives you events about when the URL changes. We used that along with Secretary to parse, interpret, and dispatch on the URL.

The code

First, we set up our ns declaration.

    (ns history.core
      (:require
       [secretary.core :as secretary :refer-macros [defroute]]
       [goog.events])
      (:import
       [goog.history Html5History EventType]))

We need a function that will get the current path fragment to switch on. We'll just use the path and the query string.

    (defn get-token []
      (str js/window.location.pathname js/window.location.search))

Now we define how to instantiate the history object.

    (defn make-history []
      (doto (Html5History.)
        (.setPathPrefix (str js/window.location.protocol
                             "//"
                             js/window.location.host))
        (.setUseFragment false)))

Let's make a couple of simple routes. I won't go into how to make routes with Secretary in this post.

    (defroute home-page "/" []
      (js/console.log "Homepage!"))

    (defroute default-route "*" []
      (js/console.log (str "unknown route: " (get-token))))

Now a handler for what to do when the URL changes.

    (defn handle-url-change [e]
      ;; log the event object to console for inspection
      (js/console.log e)
      ;; and let's see the token
      (js/console.log (str "Navigating: " (get-token)))
      ;; we are checking if this event is due to user action,
      ;; such as click a link, a back button, etc.
      ;; as opposed to programmatically setting the URL with the API
      (when-not (.-isNavigation e)
        ;; in this case, we're setting it
        (js/console.log "Token set programmatically")
        ;; let's scroll to the top to simulate a navigation
        (js/window.scrollTo 0 0))
      ;; dispatch on the token
      (secretary/dispatch! (get-token)))

Now we set up our global history object. We use defonce so we can hot reload the code.

    (defonce history (doto (make-history)
                       (goog.events/listen EventType.NAVIGATE
                                           ;; wrap in a fn to allow live reloading
                                           #(handle-url-change %))
                       (.setEnabled true)))

And we will want a function to programmatically change the URL (and add to the history).

    (defn nav! [token]
      (.setToken history token))

Om example link

Incidentally, my links look like this in Om:

    (dom/a
      #js {:href "/some/page"
           :onClick #(do
                       (.preventDefault %)
                       (nav! "/some/page"))}
      "some page")

That is, I try to follow the principle of graceful fallback. If Javascript fails for some reason, the href is still valid. It will make a request to the server and fetch the page. But if Javascript is working, we override it.

On the server side, I make sure that the same routes exist and that they return valid pages that include this script. When the page loads, the EventType.NAVIGATE event will fire, and so Secretary will route it. This usually means a repaint, but it's very quick and acceptable.

Add the requires:

       [om.core :as om]
       [om.dom :as dom]

And the Om code to render and get it started:

    (defonce state (atom {}))

    (defn cmp-link [cursor owner]
      (reify
        om/IRender
        (render [_]
          (dom/a
           #js {:href "/some/link"
                :onClick #(do
                            (.preventDefault %)
                            (nav! "/some/link"))}
           "some link"))))

    (om/root cmp-link state
             {:target (. js/document (getElementById "app"))})

When you click the link, you should see a message in the console saying it's navigating to /some/link.

A hitch

I was using this for a while when I got a message about it not working for someone. After a little investigation, it turned out they were using an older version of IE. :( IE <= 9 does not support HTML5 History. In fact, according to caniuse.com, only 88.2% of users have a browser with HTML5 support. That means that 12 out of every 100 visitors can't use what we just wrote.

What a lot of people would do at this point is just to use the hash-based history wrangling that 93% of the internet supports. But I wanted to do better without punishing people who upgrade their browsers.

Here's what I did: the server still serves content at URLs as normal. The routes on the client stay the same. But I used feature detection to determine if the browser supports HTML5 History. If it does support it, it runs the code above. If it doesn't, it uses the hash API. Lucky for me, Google Closure has a class called goog.History that is interface-compatible with goog.history.Html5History. So 90% of the work was done.

First, we need to add this import:

      [goog History]

goog.history.Html5History required a tiny little patch to work.

    ;; Replace this method:
    ;;  https://closure-library.googlecode.com/git-history/docs/local_closure_goog_history_html5history.js.source.html#line237
    (aset js/goog.history.Html5History.prototype "getUrl_"
          (fn [token]
            (this-as this
              (if (.-useFragment_ this)
                (str "#" token)
                (str (.-pathPrefix_ this) token)))))

I was very reluctant to do that, but it was the only solution I found to making it work consistently with the query string. Unfortunately, it was done a year ago and I don't remember the exact reason.

Now we need to modify get-token so it works in both cases. In the case HTML5 History is not supported, the token is everything after the # if we're on /.

    (defn get-token []
      (i
f (Html5History.isSupported)
        (str js/window.location.pathname js/window.location.search)
        (if (= js/window.location.pathname "/")
          (.substring js/window.location.hash 1)
          (str js/window.location.pathname js/window.location.search))))

make-history is different, too. If we don't support HTML5 History, we check if we're on /. If not, we redirect to / with the token. If we are, we construct an instance of goog.History.

    (defn make-history []
      (if (Html5History.isSupported)
        (doto (Html5History.)
          (.setPathPrefix (str js/window.location.protocol
                               "//"
                               js/window.location.host))
          (.setUseFragment false))
        (if (not= "/" js/window.location.pathname)
          (aset js/window "location" (str "/#" (get-token)))
          (History.))))

Everything else is the same! You can even test out what happens without the HTML5 History API by replacing the (Html5History.isSupported) with false in both places in the code above. You'll see it start to use the # fragment when you click the link!

Conclusions

I figured out all of this stuff incrementally by experimentation. I wanted to share this with you because I think it's valuable. The biggest lesson to take away is that the Google Closure Library is very complete and well-built. We should lean on it as much as we can from ClojureScript.

Sean Allen
Sean Allen
Your friendly reminder that if you aren't reading Eric's newsletter, you are missing out…
👍 ❤️
Nicolas Hery
Nicolas Hery
Lots of great content in the latest newsletter! Really glad I subscribed. Thanks, Eric, for your work.
👍 ❤️
Mathieu Gagnon
Mathieu Gagnon
Eric's newsletter is so simply great. Love it!
👍 ❤️