Search code examples
web-applicationsroutesclojurescriptom

ClojureScript & Om: Best practice for writing document.hash


I have a "Hello, World!" app in ClojureScript using Om (generated from the "Chestnut" lein template).

The goal is to have it set up such that:

  • The document.location.hash value reflects changes to the (:route app-state) vector.
  • The (:route app-state) vector reflects changes to the document.location.hash value.
  • The app re-renders when (:route app-state) changes.

Note that I intend for the (:route app-state) vector to be the only source of truth for the app about the app's current state. One mechanism of changing it is by the user modifying the url.

Where and how should I attach this behavior to Om?


Here's my "Hello, World!" app.

(ns demo.core
  (:require [om.core :as om :include-macros true]
            [om.dom :as dom :include-macros true]
            [clojure.string :as string]))

(defonce app-state (atom {:text "Hello, World!"
                          :route ["some" "app" "route"]}))

(defn update-location-hash [app owner]
  (reify
    om/IRender
    (render [_]
      (set! js/window.location.hash
            (string/join "/" (flatten ["#" (:route app)])))
            (dom/div nil ""))))

(om.core/root
  update-location-hash
  app-state
  {:target (. js/document (getElementById "app"))})


(defn main []
  (om/root
    (fn [app owner]
      (reify
        om/IRender
        (render [_]
          (dom/h1 nil (:text app)))))
    app-state
    {:target (. js/document (getElementById "app"))}))

This successfully writes the document.hash on page load. Eventually this will be a single-page app that uses hash navigation to make view changes.

This feels dirty to me because of having to return a DOM element in the (render ) function of update-location-hash that doesn't have any purpose other than to fulfill the requirements of the render function.


Solution

  • Ok, I figured out what I needed. It seems pretty clean to me and works. For completeness, I would just need to add an on-page-load type listener that checks for the hash and a renderer that renders state changes to the hash. That should be pretty straight-forward (based on my prior code above).

    (ns demo.core
      (:require-macros [cljs.core.async.macros :refer [go]])
      (:require [goog.events :as events]
                [goog.events.EventType :as EventType]
                [cljs.core.async :as async :refer [>! <! put! chan]]
                [om.core :as om :include-macros true]
                [om.dom :as dom :include-macros true]
                [clojure.string :as string]))
    
    ;; Not sure what this does.
    (enable-console-print!)
    
    (defn listen
      "An event listener factory.  Given an element and an event type, return a
      channel that can be polled for event outputs."
      [el type]
      (let [out (chan)] (events/listen el type #(put! out %)) out))
    
    (defonce app-state
      (atom {:text "Hello, World!"
             :mouse [0 0]
             :route ["some" "app" "route"]}))
    
    (defn url-to-route
      "Given a url, parse the hash value as an app route.  In general, the
      resultant route has these properties:
    
      * route components are split on a solidus
      * empty components are ignored (nill or all-whitespace)
    
      A route like this:
    
        ['foo' 'bar' 'baz']
    
      will be produced by all of the following urls (and others):
    
        http://my-app.com/#/foo/bar/baz
        http://my-app.com/#foo/bar/baz
        http://my-app.com/#/foo// /bar//baz
        http://my-app.com/#/ / / /foo/bar////baz"
      [url]
      ;; Split the url at a hash followed by zero or more slashes and
      ;; whitespace.  Then take anything after the hash and split it on one or
      ;; more slashes (ignoring whitespace).
      (string/split (second (string/split url #"#[/\s]{0,}" 2)) #"[/\s]+"))
    
    (defn layout
      "The central application layout component.  This registers global event
      listeners and renders the application's root DOM nodes."
      [app owner]
      (reify
        om/IWillMount
        (will-mount [_]
          ;; Handle various events.  When an event is triggered, format the
          ;; response.
          (let [;; Listen for changes to the mouse position
                mouse-chan (async/map
                             (fn [event] [(.-clientX event) (.-clientY event)])
                             [(listen js/window EventType/MOUSEMOVE)])
    
                ;; Listen for changes to the URL's hash
                hash-chan (async/map
                            (fn [event] (url-to-route (-> event .-event_ .-newURL)))
                            [(listen js/window EventType/HASHCHANGE)])]
            ;; Watch the stream and update the application state whenever
            ;; anything changes.
            (do
              (go (while true (om/update! app :route (<! hash-chan))))
              (go (while true (om/update! app :mouse (<! mouse-chan)))))))
    
        om/IRender
        (render [_]
          (dom/div
            nil
            (dom/div nil (when-let [route (:route app)] (pr-str (:route app))))
            (dom/div nil (when-let [pos (:mouse app)] (pr-str (:mouse app))))
            (dom/h1 nil (:text app))))))
    
    (defn main []
      (om/root
        layout
        app-state
        {:target (. js/document (getElementById "app"))}))