├── .gitignore ├── .travis.yml ├── README.md ├── build.boot ├── src └── alandipert │ └── storage_atom.cljs └── test └── alandipert └── storage_atom └── test.cljs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /lib 3 | /classes 4 | /checkouts 5 | pom.xml 6 | *.jar 7 | *.class 8 | .lein-deps-sum 9 | .lein-failures 10 | .lein-plugins 11 | /public/test.js -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: clojure 2 | jdk: 3 | - oraclejdk8 4 | install: 5 | - wget -O boot https://github.com/boot-clj/boot/releases/download/2.3.0/boot.sh 6 | - chmod 755 boot 7 | - export PATH="$PWD:$PATH" 8 | script: boot test-cljs | perl -pe 'END { exit $n } $n=1 if /FAIL/;' 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # storage-atom 2 | 3 | storage-atom is a 4 | [ClojureScript](https://github.com/clojure/clojurescript) library that 5 | provides an easy way to create atoms backed by 6 | [HTML5 Web Storage](http://en.wikipedia.org/wiki/Web_storage). 7 | 8 | Any change in the atom will be saved into the web storage. 9 | 10 | The reverse is also true. This means that an atom modified in a tab 11 | or a window will also be modified in all of them. 12 | 13 | [![Build Status](https://travis-ci.org/alandipert/storage-atom.png?branch=master)](https://travis-ci.org/alandipert/storage-atom) 14 | 15 | ## Usage 16 | 17 | ### Dependency 18 | 19 | ```clojure 20 | [alandipert/storage-atom "1.2.4"] 21 | ``` 22 | 23 | Or, to try the latest version that uses [Transit](https://github.com/cognitect/transit-cljs): 24 | 25 | ```clojure 26 | [alandipert/storage-atom "2.0.1"] 27 | ``` 28 | 29 | ### Example 30 | 31 | ```clojure 32 | ;; Require or use alandipert.storage-atom in your namespace. 33 | ;; The primary functions it provides are html-storage, session-storage and local-storage. 34 | ;; It also provides the IStorageBackend protocol. 35 | 36 | (ns your-ns 37 | (:require [alandipert.storage-atom :refer [local-storage]])) 38 | 39 | ;; Persist atom to HTML localStorage. The local-storage function takes an 40 | ;; atom and a key to store with, and returns the atom. If the key in storage 41 | ;; isn't set it will be initialized with the value obtained by dereferencing 42 | ;; the provided atom. Otherwise the atom's value will be reset! with the value 43 | ;; obtained from localStorage. All subsequent swap! and reset! operations on 44 | ;; the atom will cause the value in localStorage to be updated. 45 | 46 | (def prefs (local-storage (atom {}) :prefs)) 47 | 48 | ;; You can use the atom normally now - values are transparently persisted. 49 | 50 | (add-watch prefs 51 | :new 52 | (fn [_ _ _ v] 53 | (.log js/console "new preference" v))) 54 | 55 | (swap! prefs assoc :bg-color "red") 56 | 57 | (:bg-color @prefs) ;=> "red" 58 | 59 | ;; Check that current value has been stored in localStorage. 60 | 61 | (.getItem js/localStorage ":prefs") ;=> "{:bg-color \"red\"}" 62 | 63 | ;; To remove an item or clear all storage, use the provided methods instead 64 | ;; of calling the js method. This ensures that affected atoms are updated. 65 | 66 | (alandipert.storage-atom/remove-local-storage! :prefs) ;; remove single value 67 | (alandipert.storage-atom/clear-local-storage!) ;; clear all values 68 | 69 | ;; Note: clearing a value will reset it to the initial value of the atom passed 70 | ;; to `local-storage`, not nil. This is probably what you want. 71 | ``` 72 | 73 | ## Notes 74 | 75 | Because web storage keys and values are stored as strings, only values 76 | that can be printed readably may be used as storage keys or values. 77 | 78 | I haven't done any performance testing, but this approach is much 79 | slower than using web storage directly because the entire atom contents 80 | are written on every `swap!`. 81 | 82 | To prevent superfluous writes to the local storage, there is a 10 ms 83 | debounce (when a bunch of changes happen quickly, the *last* value is 84 | committed). It can be modified with the `storage-delay` atom or the 85 | `*storage-delay*` dynamic var. : 86 | 87 | ```clj 88 | (reset! alandipert.storage-atom/storage-delay 100) ;; permanently 89 | ;; increase 90 | ;; debounce to 100 91 | ;; ms 92 | 93 | (binding [alandipert.storage-atom/*storage-delay* 500] 94 | ... do some stuff ... ) ;; temporarily increase debounce to 95 | ;; 500 ms 96 | 97 | ``` 98 | 99 | 100 | [enduro](https://github.com/alandipert/enduro) is a Clojure library 101 | that provides similar functionality by using files or a database for 102 | storage. 103 | 104 | The cross-window propagation doesn't always work if browsing the 105 | `.html` directly instead of passing throught a webserver. 106 | (Yes Chrome, I'm looking at you...) 107 | 108 | ## Testing 109 | 110 | [PhantomJS](http://phantomjs.org/) 1.7.0 is used for unit testing. 111 | With it installed, you can run the tests like so: 112 | 113 | boot test-cljs 114 | 115 | ## License 116 | 117 | Copyright © Alan Dipert & Contributors 118 | 119 | Distributed under the Eclipse Public License, the same as Clojure. 120 | -------------------------------------------------------------------------------- /build.boot: -------------------------------------------------------------------------------- 1 | (set-env! 2 | :dependencies '[[org.clojure/clojure "1.7.0" :scope "provided"] 3 | [org.clojure/clojurescript "1.7.122" :scope "test"] 4 | [adzerk/boot-cljs "1.7.48-3" :scope "test"] 5 | [adzerk/bootlaces "0.1.10" :scope "test"] 6 | [crisptrutski/boot-cljs-test "0.2.0-SNAPSHOT" :scope "test"] 7 | [com.cognitect/transit-cljs "0.8.225"]] 8 | :source-paths #{"test"} 9 | :resource-paths #{"src"}) 10 | 11 | (require '[adzerk.bootlaces :refer :all] 12 | '[crisptrutski.boot-cljs-test :refer [test-cljs]]) 13 | 14 | (def +version+ "2.0.1") 15 | 16 | (bootlaces! +version+) 17 | 18 | (task-options! 19 | test-cljs {:js-env :phantom 20 | :exit? true 21 | :namespaces '#{alandipert.storage-atom.test}} 22 | pom {:project 'alandipert/storage-atom 23 | :version +version+ 24 | :description "ClojureScript atoms backed by HTML5 web storage." 25 | :url "https://github.com/alandipert/storage-atom" 26 | :scm {:url "https://github.com/alandipert/storage-atom"} 27 | :license {"Eclipse Public License" "http://www.eclipse.org/legal/epl-v10.html"}}) 28 | -------------------------------------------------------------------------------- /src/alandipert/storage_atom.cljs: -------------------------------------------------------------------------------- 1 | (ns alandipert.storage-atom 2 | (:require [cognitect.transit :as t] 3 | [goog.Timer :as timer] 4 | [clojure.string :as string])) 5 | 6 | 7 | (def transit-read-handlers (atom {})) 8 | 9 | (def transit-write-handlers (atom {})) 10 | 11 | (defn clj->json [x] 12 | (t/write (t/writer :json {:handlers @transit-write-handlers}) x)) 13 | 14 | (defn json->clj [x] 15 | (t/read (t/reader :json {:handlers @transit-read-handlers}) x)) 16 | 17 | (defprotocol IStorageBackend 18 | "Represents a storage resource." 19 | (-get [this not-found]) 20 | (-commit! [this value] "Commit value to storage at location.")) 21 | 22 | (deftype StorageBackend [store key] 23 | IStorageBackend 24 | (-get [this not-found] 25 | (if-let [existing (.getItem store (clj->json key))] 26 | (json->clj existing) 27 | not-found)) 28 | (-commit! [this value] 29 | (.setItem store (clj->json key) (clj->json value)))) 30 | 31 | 32 | (defn debounce-factory 33 | "Return a function that will always store a future call into the 34 | same atom. If recalled before the time is elapsed, the call is 35 | replaced without being executed." [] 36 | (let [f (atom nil)] 37 | (fn [func ttime] 38 | (when @f 39 | (timer/clear @f)) 40 | (reset! f (timer/callOnce func ttime))))) 41 | 42 | (def storage-delay 43 | "Delay in ms before a change is committed to the local storage. If a 44 | new change occurs before the time is elapsed, the old change is 45 | discarded an only the new one is committed." 46 | (atom 10)) 47 | 48 | (def ^:dynamic *storage-delay* nil) 49 | 50 | (def ^:dynamic *watch-active* true) 51 | ;; To prevent a save/load loop when changing the values quickly. 52 | 53 | (defn store 54 | [atom backend] 55 | (let [existing (-get backend ::none) 56 | debounce (debounce-factory)] 57 | (if (= ::none existing) 58 | (-commit! backend @atom) 59 | (reset! atom existing)) 60 | (doto atom 61 | (add-watch ::storage-watch 62 | #(when (and *watch-active* 63 | (not= %3 %4)) 64 | (debounce (fn [](-commit! backend %4)) 65 | (or *storage-delay* 66 | @storage-delay))))))) 67 | 68 | (defn maybe-update-backend 69 | [atom storage k default e] 70 | (when (identical? storage (.-storageArea e)) 71 | (if (empty? (.-key e)) ;; is all storage is being cleared? 72 | (binding [*watch-active* false] 73 | (reset! atom default)) 74 | (try 75 | (when-let [sk (json->clj (.-key e))] 76 | (when (= sk k) ;; is the stored key the one we are looking for? 77 | (binding [*watch-active* false] 78 | (reset! atom (let [value (.-newValue e)] ;; new value, or is key being removed? 79 | (if-not (string/blank? value) 80 | (json->clj value) 81 | default)))))) 82 | (catch :default e))))) 83 | 84 | (defn link-storage 85 | [atom storage k] 86 | (let [default @atom] 87 | (.addEventListener js/window "storage" 88 | #(maybe-update-backend atom storage k default %)))) 89 | 90 | (defn dispatch-remove-event! 91 | "Create and dispatch a synthetic StorageEvent. Expects key to be a string. 92 | An empty key indicates that all storage is being cleared." 93 | [storage key] 94 | (let [event (.createEvent js/document "StorageEvent")] 95 | (.initStorageEvent event "storage" false false key nil nil 96 | (-> js/window .-location .-href) 97 | storage) 98 | (.dispatchEvent js/window event) 99 | nil)) 100 | 101 | ;;; mostly for tests 102 | 103 | (defn load-html-storage 104 | [storage k] 105 | (-get (StorageBackend. storage k) nil)) 106 | 107 | (defn load-local-storage [k] 108 | (load-html-storage js/localStorage k)) 109 | 110 | (defn load-session-storage [k] 111 | (load-html-storage js/sessionStorage k)) 112 | 113 | ;;; main API 114 | 115 | (defn html-storage 116 | [atom storage k] 117 | (link-storage atom storage k) 118 | (store atom (StorageBackend. storage k))) 119 | 120 | (defn local-storage 121 | [atom k] 122 | (html-storage atom js/localStorage k)) 123 | 124 | (defn session-storage 125 | [atom k] 126 | (html-storage atom js/sessionStorage k)) 127 | 128 | ;; Methods to safely remove items from storage or clear storage entirely. 129 | 130 | (defn clear-html-storage! 131 | "Clear storage and also trigger an event on the current window 132 | so its atoms will be cleared as well." 133 | [storage] 134 | (.clear storage) 135 | (dispatch-remove-event! storage "")) 136 | 137 | (defn clear-local-storage! [] 138 | (clear-html-storage! js/localStorage)) 139 | 140 | (defn clear-session-storage! [] 141 | (clear-html-storage! js/sessionStorage)) 142 | 143 | (defn remove-html-storage! 144 | "Remove key from storage and also trigger an event on the current 145 | window so its atoms will be cleared as well." 146 | [storage k] 147 | (let [key (clj->json k)] 148 | (.removeItem storage key) 149 | (dispatch-remove-event! storage key))) 150 | 151 | (defn remove-local-storage! [k] 152 | (remove-html-storage! js/localStorage k)) 153 | 154 | (defn remove-session-storage! [k] 155 | (remove-html-storage! js/sessionStorage k)) 156 | -------------------------------------------------------------------------------- /test/alandipert/storage_atom/test.cljs: -------------------------------------------------------------------------------- 1 | (ns alandipert.storage-atom.test 2 | (:require [alandipert.storage-atom :refer [local-storage]] 3 | [cljs.test :refer-macros [deftest is testing run-tests]])) 4 | 5 | ;;; localStorage tests 6 | 7 | (def a1 (local-storage (atom {}) "k1")) 8 | (deftest test-swap 9 | (swap! a1 assoc :x 10) 10 | (is (= (:x @a1) 10))) 11 | 12 | (def cnt (atom 0)) 13 | (deftest test-watch 14 | (add-watch a1 :x (fn [_ _ _ _] (swap! cnt inc))) 15 | (reset! a1 {}) 16 | (swap! a1 assoc "computers" "rule") 17 | (is (= 2 @cnt))) 18 | 19 | (def a2 (local-storage (atom 0 :validator even?) :foo)) 20 | (deftest test-validation 21 | (is (= @a2 0))) 22 | 23 | ;;; Can't test the 'update' event, because it's only fired 24 | ;;; when changes come from another window. 25 | 26 | (def a3 (local-storage 27 | (atom {:x {:y {:z 42}}} :meta {:some :metadata}) "k3")) 28 | 29 | (deftest test-update 30 | (is (= (get-in @a3 [:x :y :z]) 42)) 31 | (is (= (:some (meta a3)) :metadata))) 32 | 33 | 34 | (def a4 (local-storage 35 | (atom {:xs [1 2 3]}) 36 | "k4")) 37 | 38 | (deftest test-collection-types-preserved 39 | (swap! a4 update :xs conj 4) 40 | (is (= (peek (get @a4 :xs)) 4)) 41 | (swap! a4 assoc :ys [#{1 2 3}]) 42 | (is (vector? (get @a4 :ys))) 43 | (is (set? (first (get @a4 :ys)))) 44 | (is (= (get a4 :ys [#{1 2 3}])))) 45 | --------------------------------------------------------------------------------