diff --git a/examples/frontend/src/frontend/core.cljs b/examples/frontend/src/frontend/core.cljs index 1f398653..b536e575 100644 --- a/examples/frontend/src/frontend/core.cljs +++ b/examples/frontend/src/frontend/core.cljs @@ -11,10 +11,16 @@ (defn home-page [] [:div [:h2 "Welcome to frontend"] + [:button {:type "button" - :on-click #(rfe/set-token ::item {:id 3})} - "Item 3"]]) + :on-click #(rfe/push-state ::item {:id 3})} + "Item 3"] + + [:button + {:type "button" + :on-click #(rfe/replace-state ::item {:id 4})} + "Replace State Item 4"] ]) (defn about-page [] [:div diff --git a/modules/reitit-frontend/src/reitit/frontend/easy.cljs b/modules/reitit-frontend/src/reitit/frontend/easy.cljs index 1a87e43e..6f812293 100644 --- a/modules/reitit-frontend/src/reitit/frontend/easy.cljs +++ b/modules/reitit-frontend/src/reitit/frontend/easy.cljs @@ -6,7 +6,20 @@ (defonce history (atom nil)) +;; Doc-strings from reitit.frontend.history +;; remember to update both! + (defn start! + "This registers event listeners on HTML5 history and hashchange events. + When using with development workflow like Figwheel, rememeber to + remove listeners using stop! call before calling start! again. + + Parameters: + - router The Reitit routing tree. + - on-navigate Function to be called when route changes. Takes two parameters, ´token´ and ´history´ object. + + Options: + - :use-fragment (default true) If true, onhashchange and location hash are used to store the token." [routes on-navigate opts] (swap! history (fn [old-history] (rfh/stop! old-history) @@ -14,26 +27,26 @@ (defn href ([k] - (rfh/href @history k)) + (rfh/href @history k nil nil)) ([k params] - (rfh/href @history k params)) + (rfh/href @history k params nil)) ([k params query] (rfh/href @history k params query))) -(defn set-token +(defn push-state "Sets the new route, leaving previous route in history." ([k] - (rfh/set-token @history k)) + (rfh/push-state @history k nil nil)) ([k params] - (rfh/set-token @history k params)) + (rfh/push-state @history k params nil)) ([k params query] - (rfh/set-token @history k params query))) + (rfh/push-state @history k params query))) -(defn replace-token +(defn replace-state "Replaces current route. I.e. current route is not left on history." ([k] - (rfh/replace-token @history k)) + (rfh/replace-state @history k nil nil)) ([k params] - (rfh/replace-token @history k params)) + (rfh/replace-state @history k params nil)) ([k params query] - (rfh/replace-token @history k params query))) + (rfh/replace-state @history k params query))) diff --git a/modules/reitit-frontend/src/reitit/frontend/history.cljs b/modules/reitit-frontend/src/reitit/frontend/history.cljs index 5a10250e..42ce16db 100644 --- a/modules/reitit-frontend/src/reitit/frontend/history.cljs +++ b/modules/reitit-frontend/src/reitit/frontend/history.cljs @@ -6,154 +6,159 @@ [goog.dom :as dom] [reitit.core :as r] [reitit.frontend :as rf] - [reitit.impl :as impl]) - (:import goog.history.Html5History - goog.Uri)) + [reitit.impl :as impl] + [goog.events :as gevents]) + (:import goog.Uri)) -;; Token is for Closure HtmlHistory -;; Path is for reitit +(defprotocol History + (-init [this] "Create event listeners") + (-stop [this] "Remove event listeners") + (-on-navigate [this path]) + (-get-path [this]) + (-href [this path])) -(defn- token->path [history token] - (if (.-useFragment_ history) - ;; If no fragment at all, default to "/" - ;; If fragment is present, the token already is prefixed with "/" - (if (= "" token) - (.getPathPrefix history) - token) - (str (.getPathPrefix history) token))) +;; This version listens for both pop-state and hash-change for +;; compatibility for old browsers not supporting History API. +(defrecord FragmentHistory [on-navigate router listen-key last-fragment] + History + (-init [this] + ;; Link clicks and e.g. back button trigger both events, if fragment is same as previous ignore second event. + ;; For old browsers only the hash-change event is triggered. + (let [last-fragment (atom nil) + this (assoc this :last-fragment last-fragment) + handler (fn [e] + (let [path (-get-path this)] + (when (or (= goog.events.EventType.POPSTATE (.-type e)) + (not= @last-fragment path)) + (-on-navigate this path))))] + (-on-navigate this (-get-path this)) + (assoc this + :listen-key (gevents/listen js/window + #js [goog.events.EventType.POPSTATE goog.events.EventType.HASHCHANGE] + handler + false)))) + (-stop [this] + (gevents/unlistenByKey listen-key)) + (-on-navigate [this path] + (reset! last-fragment path) + (on-navigate (rf/match-by-path router path) this)) + (-get-path [this] + ;; Remove # + ;; "" or "#" should be same as "#/" + (let [fragment (subs (.. js/window -location -hash) 1)] + (if (= "" fragment) + "/" + fragment))) + (-href [this path] + (if path + (str "#" path)))) -(defn- path->token [history path] - (subs path (if (.-useFragment_ history) - 1 - (count (.getPathPrefix history))))) +(defrecord Html5History [on-navigate router listen-key click-listen-key] + History + (-init [this] + (let [handler + (fn [e] + (-on-navigate this (-get-path this))) -(defn- token->href [history token] - (if token - (str (if (.-useFragment_ history) - (str "#")) - (.getPathPrefix history) - token))) + current-domain + (if (exists? js/location) + (.getDomain (.parse Uri js/location))) -(def ^:private current-domain (if (exists? js/location) - (.getDomain (.parse Uri js/location)))) - -(defn ignore-anchor-click - "Ignore click events from a elements, if the href points to URL that is part - of the routing tree." - [router history e] - ;; Returns the next matching anchestor of event target - (when-let [el (.closest (.-target e) "a")] - (let [uri (.parse Uri (.-href el))] - (when (and (or (and (not (.hasScheme uri)) (not (.hasDomain uri))) - (= current-domain (.getDomain uri))) - (not (.-altKey e)) - (not (.-ctrlKey e)) - (not (.-metaKey e)) - (not (.-shiftKey e)) - (not (contains? #{"_blank" "self"} (.getAttribute el "target"))) - ;; Left button - (= 0 (.-button e)) - (reitit/match-by-path router (.getPath uri))) - (.preventDefault e) - (.setToken history (path->token history (str (.getPath uri) - (if (seq (.getQuery uri)) - (str "?" (.getQuery uri)))))))))) - -(impl/goog-extend - ^{:jsdoc ["@constructor" - "@extends {Html5History.TokenTransformer}"]} - TokenTransformer - Html5History.TokenTransformer - ([] - (this-as this - (.call Html5History.TokenTransformer this))) - (retrieveToken [path-prefix location] - (subs (.-pathname location) (count path-prefix))) - (createUrl [token path-prefix location] - ;; Code in Closure also adds current query params - ;; from location. - (str path-prefix token))) + ;; Prevent document load when clicking a elements, if the href points to URL that is part + ;; of the routing tree." + ignore-anchor-click + (fn ignore-anchor-click + [e] + ;; Returns the next matching anchestor of event target + (when-let [el (.closest (.-target e) "a")] + (let [uri (.parse Uri (.-href el))] + (when (and (or (and (not (.hasScheme uri)) (not (.hasDomain uri))) + (= current-domain (.getDomain uri))) + (not (.-altKey e)) + (not (.-ctrlKey e)) + (not (.-metaKey e)) + (not (.-shiftKey e)) + (not (contains? #{"_blank" "self"} (.getAttribute el "target"))) + ;; Left button + (= 0 (.-button e)) + (reitit/match-by-path router (.getPath uri))) + (.preventDefault e) + (let [path (str (.getPath uri) + (if (seq (.getQuery uri)) + (str "?" (.getQuery uri))))] + (.pushState js/window.history nil "" path) + (-on-navigate this path))))))] + (-on-navigate this (-get-path this)) + (assoc this + :listen-key (gevents/listen js/window goog.events.EventType.POPSTATE handler false) + :click-listen-key (e/listen js/document e/EventType.CLICK ignore-anchor-click)))) + (-on-navigate [this path] + (on-navigate (rf/match-by-path router path) this)) + (-stop [this] + (gevents/unlistenByKey listen-key) + (gevents/unlistenByKey click-listen-key)) + (-get-path [this] + (.. js/window -location -pathname)) + (-href [this path] + path)) (defn start! - "This registers event listeners on either haschange or HTML5 history. + "This registers event listeners on HTML5 history and hashchange events. When using with development workflow like Figwheel, rememeber to remove listeners using stop! call before calling start! again. Parameters: - - router The reitit routing tree. - - on-navigate Function to be called when route changes. + - router The Reitit routing tree. + - on-navigate Function to be called when route changes. Takes two parameters, ´match´ and ´history´ object. Options: - - :use-fragment (default true) If true, onhashchange and location hash are used to store the token. - - :path-prefix (default \"/\") If :use-fragment is false, this is prepended to all tokens, and is - removed from start of the token before matching the route." - [router - on-navigate - {:keys [path-prefix use-fragment] - :or {path-prefix "/" - use-fragment true}}] - (let [history - (doto (Html5History. nil (TokenTransformer.)) - (.setEnabled true) - (.setPathPrefix path-prefix) - (.setUseFragment use-fragment)) + - :use-fragment (default true) If true, onhashchange and location hash are used to store current route." + ([router on-navigate] + (start! router on-navigate nil)) + ([router + on-navigate + {:keys [use-fragment] + :or {use-fragment true}}] + (let [opts {:router router + :on-navigate on-navigate}] + (-init (if use-fragment + (map->FragmentHistory opts) + (map->Html5History opts)))))) - event-key - (e/listen history goog.history.EventType.NAVIGATE - (fn [e] - (on-navigate (rf/match-by-path router (token->path history (.getToken history)))))) - - click-listen-key - (if-not use-fragment - (e/listen js/document e/EventType.CLICK - (partial ignore-anchor-click router history)))] - - ;; Trigger navigate event for current route - (on-navigate (rf/match-by-path router (token->path history (.getToken history)))) - - {:router router - :history history - :close-fn (fn [] - (e/unlistenByKey event-key) - (e/unlistenByKey click-listen-key) - (.dispose history))})) - -(defn stop! [{:keys [close-fn]}] - (if close-fn - (close-fn))) - -(defn- match->token [history match k params query] - (some->> (r/match->path match query) - (path->token history))) +(defn stop! [history] + (if history + (-stop history))) (defn href - ([state k] - (href state k nil)) - ([state k params] - (href state k params nil)) - ([{:keys [router history]} k params query] - (let [match (rf/match-by-name! router k params) - token (match->token history match k params query)] - (token->href history token)))) + ([history k] + (href history k nil)) + ([history k params] + (href history k params nil)) + ([history k params query] + (let [match (rf/match-by-name! (:router history) k params)] + (-href history (r/match->path match query))))) -(defn set-token +(defn push-state "Sets the new route, leaving previous route in history." - ([state k] - (set-token state k nil)) - ([state k params] - (set-token state k params nil)) - ([{:keys [router history]} k params query] - (let [match (rf/match-by-name! router k params) - token (match->token history match k params query)] - (.setToken history token)))) + ([history k] + (push-state history k nil nil)) + ([history k params] + (push-state history k params nil)) + ([history k params query] + (let [match (rf/match-by-name! (:router history) k params) + path (r/match->path match query)] + ;; pushState and replaceState don't trigger popstate event so call on-navigate manually + (.pushState js/window.history nil "" (-href history path)) + (-on-navigate history path)))) -(defn replace-token +(defn replace-state "Replaces current route. I.e. current route is not left on history." - ([state k] - (replace-token state k nil)) - ([state k params] - (replace-token state k params nil)) - ([{:keys [router history]} k params query] - (let [match (rf/match-by-name! router k params) - token (match->token history match k params query)] - (.replaceToken history token)))) + ([history k] + (replace-state history k nil nil)) + ([history k params] + (replace-state history k params nil)) + ([history k params query] + (let [match (rf/match-by-name! (:router history) k params) + path (r/match->path match query)] + (.replaceState js/window.history nil "" (-href history path)) + (-on-navigate history path)))) diff --git a/test/cljs/reitit/frontend/history_test.cljs b/test/cljs/reitit/frontend/history_test.cljs index 36eb8cf2..81878d45 100644 --- a/test/cljs/reitit/frontend/history_test.cljs +++ b/test/cljs/reitit/frontend/history_test.cljs @@ -1,9 +1,10 @@ (ns reitit.frontend.history-test - (:require [clojure.test :refer [deftest testing is are]] + (:require [clojure.test :refer [deftest testing is are async]] [reitit.core :as r] [reitit.frontend :as rf] [reitit.frontend.history :as rfh] - [reitit.frontend.test-utils :refer [capture-console]])) + [reitit.frontend.test-utils :refer [capture-console]] + [goog.events :as gevents])) (def browser (exists? js/window)) @@ -14,6 +15,9 @@ (deftest fragment-history-test (when browser + (gevents/removeAll js/window goog.events.EventType.POPSTATE) + (gevents/removeAll js/window goog.events.EventType.HASHCHANGE) + (let [history (rfh/start! router (fn [_]) {:use-fragment true})] (testing "creating urls" @@ -29,10 +33,48 @@ (is (= nil value)) (is (= [{:type :warn :message ["missing route" ::asd]}] - messages))))))) + messages)))) + + (rfh/stop! history)))) + +(deftest fragment-history-routing-test + (when browser + (gevents/removeAll js/window goog.events.EventType.POPSTATE) + (gevents/removeAll js/window goog.events.EventType.HASHCHANGE) + + (async done + (let [n (atom 0) + history (rfh/start! router + (fn [match history] + (let [url (rfh/-get-path history)] + (case (swap! n inc) + 1 (do (is (= "/" url) + "start at root") + (rfh/push-state history ::foo)) + 2 (do (is (= "/foo" url) + "push-state") + (.back js/window.history)) + 3 (do (is (= "/" url) + "go back") + (rfh/push-state history ::bar {:id 1})) + 4 (do (is (= "/bar/1" url) + "push-state 2") + (rfh/replace-state history ::bar {:id 2})) + 5 (do (is (= "/bar/2" url) + "replace-state") + (.back js/window.history)) + 6 (do (is (= "/" url) + "go back after replace state") + (rfh/stop! history) + (done)) + (do (is false "extra event"))))) + {:use-fragment true})])))) (deftest html5-history-test (when browser + (gevents/removeAll js/window goog.events.EventType.POPSTATE) + (gevents/removeAll js/window goog.events.EventType.HASHCHANGE) + (let [history (rfh/start! router (fn [_]) {:use-fragment false})] (testing "creating urls" @@ -48,4 +90,40 @@ (is (= nil value)) (is (= [{:type :warn :message ["missing route" ::asd]}] - messages))))))) + messages)))) + + (rfh/stop! history)))) + +(deftest html5-history-routing-test + (when browser + (gevents/removeAll js/window goog.events.EventType.POPSTATE) + (gevents/removeAll js/window goog.events.EventType.HASHCHANGE) + + (async done + (let [n (atom 0) + history (rfh/start! router + (fn [match history] + (let [url (rfh/-get-path history)] + (case (swap! n inc) + 1 (do (rfh/push-state history ::frontpage)) + 2 (do (is (= "/" url) + "start at root") + (rfh/push-state history ::foo)) + 3 (do (is (= "/foo" url) + "push-state") + (.back js/window.history)) + 4 (do (is (= "/" url) + "go back") + (rfh/push-state history ::bar {:id 1})) + 5 (do (is (= "/bar/1" url) + "push-state 2") + (rfh/replace-state history ::bar {:id 2})) + 6 (do (is (= "/bar/2" url) + "replace-state") + (.back js/window.history)) + 7 (do (is (= "/" url) + "go back after replace state") + (rfh/stop! history) + (done)) + (do (is false "extra event"))))) + {:use-fragment false})]))))