diff --git a/examples/todomvc/src/todomvc/db.cljs b/examples/todomvc/src/todomvc/db.cljs index 3624f66..930c12c 100644 --- a/examples/todomvc/src/todomvc/db.cljs +++ b/examples/todomvc/src/todomvc/db.cljs @@ -3,7 +3,7 @@ [cljs.spec :as s])) -;; -- Spec ----------------------------------------------------------------- +;; -- Spec -------------------------------------------------------------------- ;; ;; This is a clojure.spec specification which documents the structure of app-db ;; See: http://clojure.org/guides/spec @@ -52,12 +52,12 @@ ;; But we are not to load the setting for the "showing" filter. Just the todos. ;; -(def lsk "todos-reframe") ;; localstore key +(def ls-key "todos-reframe") ;; localstore key (defn localstore->todos - "Read in todos from LS, and process into a map we can merge into app-db." + "Read in todos from localstore, and process into a map we can merge into app-db." [] - (some->> (.getItem js/localStorage lsk) + (some->> (.getItem js/localStorage ls-key) (cljs.reader/read-string) ;; stored as an EDN map. (into (sorted-map)) ;; map -> sorted-map (hash-map :todos))) ;; access via the :todos key @@ -65,5 +65,5 @@ (defn todos->local-store "Puts todos into localStorage" [todos] - (.setItem js/localStorage lsk (str todos))) ;; sorted-map writen as an EDN map + (.setItem js/localStorage ls-key (str todos))) ;; sorted-map writen as an EDN map diff --git a/examples/todomvc/src/todomvc/events.cljs b/examples/todomvc/src/todomvc/events.cljs index ef44639..53ae2a2 100644 --- a/examples/todomvc/src/todomvc/events.cljs +++ b/examples/todomvc/src/todomvc/events.cljs @@ -14,8 +14,7 @@ "throw an exception if db doesn't match the spec." [a-spec db] (when-not (s/valid? a-spec db) - (throw (ex-info "spec check failed: " {:problems - (s/explain-str a-spec db)})))) + (throw (ex-info (str "spec check failed: " (s/explain-str a-spec db)) {})))) ;; Event handlers change state, that's their job. But what happens if there's ;; a bug which corrupts app state in some subtle way? This interceptor is run after diff --git a/project.clj b/project.clj index 13c8c9d..c0e1cf7 100644 --- a/project.clj +++ b/project.clj @@ -1,4 +1,4 @@ -(defproject re-frame "0.8.0-SNAPSHOT" +(defproject re-frame "0.8.0" :description "A Clojurescript MVC-like Framework For Writing SPAs Using Reagent." :url "https://github.com/Day8/re-frame.git" :license {:name "MIT"} diff --git a/src/re_frame/cofx.cljc b/src/re_frame/cofx.cljc new file mode 100644 index 0000000..25141bc --- /dev/null +++ b/src/re_frame/cofx.cljc @@ -0,0 +1,92 @@ +(ns re-frame.cofx + (:require + [re-frame.db :refer [app-db]] + [re-frame.interceptor :refer [->interceptor]] + [re-frame.registrar :refer [get-handler clear-handlers register-handler]] + [re-frame.loggers :refer [console]])) + + +;; -- Registration ------------------------------------------------------------ + +(def kind :cofx) +(assert (re-frame.registrar/kinds kind)) +(def register (partial register-handler kind)) + +;; -- Interceptor ------------------------------------------------------------- + +(defn coeffect + "An interceptor which adds to a `context's` `:coeffects`. + + `coeffects` are the input resources required by an event handler + to perform its job. The two most obvious ones are `db` and `event`. + But sometimes a handler might need other resources. + + Perhaps a handler needs a random number or a GUID or the current datetime. + Perhaps it needs access to an in-memory DataScript database. + + If the handler directly access these resources, it stops being as + pure. It immedaitely becomes harder to test, etc. + + So the necessary resources are \"injected\" into the `coeffect` (map) + given the handler. + + Given one or more `ids`, this function will iterately lookup the + registered coeffect handlers (via `reg-cofx`) and call each of them + giving the current `:coeffect` as an argument, and expecting a + modified coeffect to be returned. + " + ;; Why? We want our handlers to be pure. If a handler calls `js/Date.` then + ;; it stops being as pure. It is harder to test. + ;; + ;; And what if a handler needs a random number? Or a GUID? These kinds of input resources are + ;; termed `coeffects` (sometimes called side-causes). + [& ids] + (->interceptor + :name :coeffects + :before (fn coeffects-before + [context] + (let [orig-coeffect (:coeffects context) + run-id (fn [coeffect id] + ((get-handler kind id) coeffect)) + new-coeffect (reduce run-id orig-coeffect ids)] + (assoc context :coeffects new-coeffect))))) + + +;; -- Standard Builtin CoEffects Handlers -------------------------------------------------------- + +;; :db +;; +;; Adds to coeffects the value in `app-db`, under the key `:db` +(register + :db + (fn db-coeffects-handler + [coeffects] + (assoc coeffects :db @app-db))) + + +;; this interceptor is so commonly used that we reify it +(def add-db (coeffect :db)) + + +;; XXX what about a coeffect which reads LocalStore. Use in todomvc example. + +;; -- Example ------------------------------------------------------------------------------------ + +;; An example coeffect handler, which adds the current datetime under +;; the `:now` key. +;; +;; Example Usage: +;; (reg-event-fx ;; `-fx` variant used in registration +;; :some-id +;; [(coeffect :now) debug] ;; notice (coeffect :now) is in interceptors +;; (fn [coeffect event] ;; -fx handlers are given the entire coeffect as 1st argument +;; (let [dt (:now coeffect)] ;; `:now` is available +;; ...))) +;; +;; As a further exercise, consider how you would write a `random` interceptor which adds a random +;; number into a handler's coeffect? +#_(register + :now + (fn now-coeffects-handler + [coeffects] + (assoc coeffect :now (js/Date.)))) diff --git a/src/re_frame/core.cljc b/src/re_frame/core.cljc index 2e9491d..b3868e5 100644 --- a/src/re_frame/core.cljc +++ b/src/re_frame/core.cljc @@ -1,14 +1,16 @@ (ns re-frame.core (:require - [re-frame.events :as events] - [re-frame.subs :as subs] - [re-frame.fx :as fx] - [re-frame.router :as router] - [re-frame.loggers :as loggers] - [re-frame.registrar :as registrar] - [re-frame.interceptor :as interceptor :refer [base - db-handler->interceptor fx-handler->interceptor - ctx-handler->interceptor]])) + [re-frame.events :as events] + [re-frame.subs :as subs] + [re-frame.fx :as fx] + [re-frame.cofx :as cofx] + [re-frame.router :as router] + [re-frame.loggers :as loggers] + [re-frame.registrar :as registrar] + [re-frame.interceptor :as interceptor] + [re-frame.std-interceptors :as std-interceptors :refer [db-handler->interceptor + fx-handler->interceptor + ctx-handler->interceptor]])) ;; -- dispatch @@ -16,14 +18,14 @@ (def dispatch-sync router/dispatch-sync) -;; XXX move certain API functions up here - to get code completion and docs +;; XXX move certain API functions up to this core level - to get code completion and docs ;; XXX add a clear all handlers -;; XXX add a push handlers for testing purposes -;; XXX Add ->interceptor assoc-coeffect etc to API +;; XXX for testing purposes, and a way to snapshot re-frame instance. Then re-instate ;; XXX on figwheel reload, should invalidate all re-frame subscriptions ;; -- interceptor related +;; useful if you are writing your own interceptors (def ->interceptor interceptor/->interceptor) (def enqueue interceptor/enqueue) (def get-coeffect interceptor/get-coeffect) @@ -33,57 +35,60 @@ ;; -- standard interceptors -(def debug interceptor/debug) -(def path interceptor/path) -(def enrich interceptor/enrich) -(def trim-v interceptor/trim-v) -(def after interceptor/after) -(def on-changes interceptor/on-changes) +(def debug std-interceptors/debug) +(def path std-interceptors/path) +(def enrich std-interceptors/enrich) +(def trim-v std-interceptors/trim-v) +(def after std-interceptors/after) +(def on-changes std-interceptors/on-changes) -;; -- subscribe +;; -- subscriptions: reading and writing (def reg-sub-raw subs/register-raw) (def reg-sub subs/reg-sub) (def subscribe subs/subscribe) -;; -- effects +;; -- effects (def reg-fx fx/register) (def clear-fx (partial registrar/clear-handlers fx/kind)) - - +;; -- coeffects +(def reg-cofx cofx/register) +(def clear-cofx (partial registrar/clear-handlers cofx/kind)) ;; -- Events ;; usage (clear-event! :some-id) -(def clear-event! (partial registrar/clear-handlers events/kind)) ;; XXX name with ! +(def clear-all-events! (partial registrar/clear-handlers events/kind)) ;; XXX name with ! (defn reg-event-db - "Register the given `id`, typically a kewyword, with the combination of + "Register the given `id`, typically a keyword, with the combination of `db-handler` and an interceptor chain. `db-handler` is a function: (db event) -> db `interceptors` is a collection of interceptors, possibly nested (needs flattenting). `db-handler` is wrapped in an interceptor and added to the end of the chain, so in the end - there is only a chain." + there is only a chain. + The necessary effects and coeffects handler are added to the front of the + interceptor chain. These interceptors ensure that app-db is available and updated." ([id db-handler] (reg-event-db id nil db-handler)) ([id interceptors db-handler] - (events/register id [base interceptors (db-handler->interceptor db-handler)]))) + (events/register id [cofx/add-db fx/do-effects interceptors (db-handler->interceptor db-handler)]))) (defn reg-event-fx ([id fx-handler] (reg-event-fx id nil fx-handler)) ([id interceptors fx-handler] - (events/register id [base interceptors (fx-handler->interceptor fx-handler)]))) + (events/register id [cofx/add-db fx/do-effects interceptors (fx-handler->interceptor fx-handler)]))) (defn reg-event-ctx ([id handler] (reg-event-ctx id nil handler)) ([id interceptors handler] - (events/register id [base interceptors (ctx-handler->interceptor handler)]))) + (events/register id [cofx/add-db fx/do-effects interceptors (ctx-handler->interceptor handler)]))) ;; -- Logging ----- diff --git a/src/re_frame/fx.cljc b/src/re_frame/fx.cljc index 87a7380..66265e3 100644 --- a/src/re_frame/fx.cljc +++ b/src/re_frame/fx.cljc @@ -2,32 +2,45 @@ (:require [re-frame.router :as router] [re-frame.db :refer [app-db]] + [re-frame.interceptor :refer [->interceptor]] + [re-frame.interop :refer [set-timeout!]] [re-frame.events :as events] [re-frame.registrar :refer [get-handler clear-handlers register-handler]] - [re-frame.interop :refer [ratom? set-timeout!]] [re-frame.loggers :refer [console]])) -;; ---- Spec schema ----------------------------------------------------------- -;; TODO use Spec to validate events for :dispatch and :dispatch-n when clj 1.9 -;; e.g. (when (= :cljs.spec/invalid (s/conform ::event val)) -;; (console :error (s/explain-str ::event val))) -;; (s/def ::event (s/and vector? (complement empty?))) -;; (s/def ::events-n (s/coll-off ::event)) ;; -- Registration ------------------------------------------------------------ - (def kind :fx) (assert (re-frame.registrar/kinds kind)) +(def register (partial register-handler kind)) -(defn register - [id handler-fn] - (register-handler kind id handler-fn)) +;; -- Interceptor ------------------------------------------------------------- +(def do-effects + "An interceptor which performs a `context's` (side) `:effects`. + + For every key in the `:effects` map, call the handler previously + registered via `reg-fx`. + + So, if `:effects` was: + {:dispatch [:hello 42] + :db {...} + :undo \"set flag\"} + call the registered effects handlers for each of the map's keys: + `:dispatch`, `:undo` and `:db`." + (->interceptor + :name :do-effects + :after (fn do-effects-after + [context] + (->> (:effects context) + (map (fn [[key val]] + (if-let [effect-fn (get-handler kind key)] + (effect-fn val)))) + doall)))) ;; -- Standard Builtin Effects Handlers -------------------------------------- - ;; :dispatch-later ;; ;; `dispatch` one or more events in given times on the future. Expects a collection diff --git a/src/re_frame/interceptor.cljc b/src/re_frame/interceptor.cljc index d58637d..15328a5 100644 --- a/src/re_frame/interceptor.cljc +++ b/src/re_frame/interceptor.cljc @@ -2,16 +2,13 @@ (:require [re-frame.interop :refer [ratom?]] [re-frame.loggers :refer [console]] - [re-frame.interop :refer [empty-queue debug-enabled?]] - [re-frame.db :refer [app-db]] - [re-frame.registrar :as registrar] - [clojure.data :as data])) + [re-frame.interop :refer [empty-queue debug-enabled?]])) +;; XXX use defrecord ?? (def mandatory-interceptor-keys #{:name :after :before}) -;; XXX use defrecord ?? (defn interceptor? [m] @@ -26,17 +23,12 @@ (if-let [unknown-keys (seq (clojure.set/difference (-> m keys set) mandatory-interceptor-keys))] - (console :error "re-frame: unknown interceptor keys: " unknown-keys))) + (console :error "re-frame: interceptor " name " has unknown keys: " unknown-keys))) {:name (or name :unnamed) :before before :after after }) -;; -- Helpers ------------------------------------------------------------------------------------ - - -(defn get-coeffect - [context key] - (get-in context [:coeffects key])) +;; -- Effect Helpers ----------------------------------------------------------------------------- (defn get-effect ([context key] @@ -44,33 +36,29 @@ ([context] (:effects context))) - (defn assoc-effect [context key value] (assoc-in context [:effects key] value)) +;; -- CoEffect Helpers --------------------------------------------------------------------------- + +(defn get-coeffect + [context key] + (get-in context [:coeffects key])) (defn assoc-coeffect [context key value] (assoc-in context [:coeffects key] value)) - ;; -- Execute Interceptor Chain ------------------------------------------------------------------ -(defn- invoke-fn +(defn- invoke-interceptor-fn [context interceptor direction] (if-let [f (get interceptor direction)] (f context) context)) -;; XXX on figwheel reload, should invalidate all re-frame subscriptions - -(defn clean-context - [context] - (if (map? context) - (dissoc context :stack :queue) - context)) (defn- invoke-interceptors "Loop over all interceptors, calling `direction` function on each, @@ -80,31 +68,41 @@ Each iteration, the next interceptor to process is obtained from context's `:queue`. After they are processed, interceptors are popped - from `:queue` and added to into `:stack`. + from `:queue` and added to `:stack`. After sufficient iteration, `:queue` will be empty, and `:stack` will contain all interceptors processed. - Returns updated context." + Returns updated `context`. Ie. the `context` which has been threaded + through all interceptor functions. + + Generally speaking, an interceptor's `:before` fucntion will (if present) + add to a `context's` `:coeffect`, while it's `:after` function + will modify the `context`'s `:effect`. Very approximately. + + But because all interceptor functions are given `context`, and can + return a modified version of it, the way is clear for an interceptor + to introspect the stack or queue, or even modify the queue + (add new interceptors via `enqueue`?). This is a very fluid arrangement." ([context direction] (loop [context context] - (let [queue (:queue context)] ;; future interceptors + (let [queue (:queue context)] ;; future interceptors (if (empty? queue) context - (let [interceptor (peek queue) + (let [interceptor (peek queue) ;; next interceptor to call stack (:stack context)] ;; already completed interceptors (recur (-> context (assoc :queue (pop queue)) (assoc :stack (conj stack interceptor)) - (invoke-fn interceptor direction))))))))) + (invoke-interceptor-fn interceptor direction))))))))) (defn enqueue - "Adds a collection of interceptors to the end of context's execution queue. - Returns updated context. + "Add a collection of `interceptors` to the end of `context's` execution `:queue`. + Returns the updated `context`. - In advanced cases, where an interceptor itself wanted to add to the queue, - it would call this function (on the context provided to it)" + In an advanced case, this function would allow an interceptor could add new + interceptors to the `:queue` of a context." [context interceptors] (update context :queue (fnil into empty-queue) @@ -112,386 +110,81 @@ (defn- context - "Return a fresh context" + "Create a fresh context" ([event interceptors] (-> {} (assoc-coeffect :event event) (enqueue interceptors))) - - ([event interceptors db] + ([event interceptors db] ;; only used in tests, probably a hack, remove ? XXX (-> (context event interceptors) (assoc-coeffect :db db)))) (defn- change-direction "Called on completion of `:before` processing, this function prepares/modifies - `context` for the backwards sweep of processing in which `:after` fns are - called. + `context` for the backwards sweep of processing in which an interceptor + chain's `:after` fns are called. At this point in processing, the `:queue` is empty and `:stack` holds all - interceptors. To enable a backwards walk, the job is to prime the `:queue` - with the reverse of what's in `:stack`" + the previously run interceptors. So this function enables the backwards walk + by priming `:queue` with what's currently in `:stack`" [context] (-> context (dissoc :queue) - (enqueue (-> context :stack )))) + (enqueue (:stack context)))) (defn execute - "Executes a queue of interceptors for a given event. + "Executes the given chain (coll) of interceptors. - An interceptor has this form: - {:before (fn [context] ...) ;; identity would be a noop - :after (fn [context] ...)} + Each interceptor has this form: + {:before (fn [context] ...) ;; returns possibly modified context + :after (fn [context] ...)} ;; `identity` would be a noop - Walk the queue of iterceptors from beginning to end calling the `:before` fn on - each, then reverse direction, and walk backwards, calling the `:after` fn on each. + Walks the queue of iterceptors from beginning to end, calling the + `:before` fn on each, then reverse direction and walk backwards, + calling the `:after` fn on each. - The last interceptor in the chain presumably wraps an event handler fn. + The last interceptor in the chain presumably wraps an event + handler fn. So the overall goal of the process is to \"handle + the given event\". Thread a `context` through all calls. `context` has this form: - {:coeffects {:event event + {:coeffects {:event [:a-query-id :some-param] :db } :effects {:db - :dispatch [:something]} ;; example of other effects + :dispatch [:an-event-id :param1]} :queue :stack } - `context` has `:coeffects` and `:effects` which, if this was a web server, would - be somewhat anologous to `request` and `response`. + `context` has `:coeffects` and `:effects` which, if this was a web + server, would be somewhat anologous to `request` and `response` + respectively. - `coeffects` contains information like `event` and the initial state of `db` - ie. the - inputs required by the event handler (sitting presumably on the end of the chain), - while handler-required side effects are assoc-ed in `:effects` including, but not limited - to, new values for `db`. + `coeffects` will contain information like `event` and the initial + state of `db` - ie. the inputs required by the event handler + (sitting presumably on the end of the chain), while handler-returned + side effects are assoc-ed in `:effects` including, but not limited to, + new values for `db`. - The first interceptor in a chain will likely have a :before function - which adds the current state of app-db into `:coeffects`. OR, it might instead - add the connection for a DataScript database, or any other inputs required. - And subsequent interceptors may further add to coeffects via their :before too. + The first few interceptor in a chain will likely have `:before` + functions which \"prime\" the `context` by adding the event, and + the current state of app-db into `:coeffects`. OR, it might instead + add the connection for a DataScript database, or any other inputs + required. - Equally, this same first interceptor will likely have an `:after` fn which can process - all the side effects accumulated into `:effects` including but, not limited to, - updates to app-db. + Equally, some interceptors in the chain will have `:after` fn + which can process the side effects accumulated into `:effects` + including but, not limited to, updates to app-db. - Through both stages (before and after), `context` contains a `:queue` of interceptors yet to be - processed, and a `:stack` of interceptors already done. In advanced cases, - these values can be modified by the functions through which the context is threaded." + Through both stages (before and after), `context` contains a `:queue` + of interceptors yet to be processed, and a `:stack` of interceptors + already done. In advanced cases, these values can be modified by the + functions through which the context is threaded." [event-v interceptors] (-> (context event-v interceptors) (invoke-interceptors :before) change-direction (invoke-interceptors :after))) - -;; -- Standard Interceptors ----------------------------------------------------------------------- -;; these could be in their own library - -(def base - "An interceptor which injects/extracts the value of app-db intto/from a context. -Used for XXXX " - (->interceptor - :name :base - :before (fn base-before - [context] - (assoc-coeffect context :db @app-db)) ;; a coeffect for the handler - :after (fn base-after - [context] - (->> (:effects context) - (map (fn [[key val]] - (if-let [effect-fn (registrar/get-handler :fx key)] ;; XXX shouldn't be using raw :fx - (effect-fn val)))) - doall)))) - - -;; XXX how to stub this out for testing purposes?? - -#_(def now - "An example interceptor (of dubious utility) which is an example of adding - to a handler's coeffects. This interceptor adds the current datetime to coeffects under - the `:now` key. - - Why? We want out handlers to be as pure as possible. If a handler calls `js/Date.` then - it stops being as pure. What if it needs a random number? These kinds of needed - \"inputs\" are referred to `coeffects` (sometimes called side-causes). - - usage: - (reg-event-fx ;; notice use of `-fx` registration - :some-id - [i1 i2 now] ;; notice use of `now` as one of the handler's interceptors - (fn [world event] ;; world is the handler's coeffect - (let [dt (:now world)] ;; `:now` is available becaue `now` put it there. - ...))) - - As an exercise, consider how you would write a `random` interceptor which adds a random - number into a handler's coeffect? - " - (->interceptor - :name :now - :before (fn now-before - [context] - (assoc-coeffect context :now (js/Date.))))) - - -(def debug - "An interceptor which logs data about the handling of an event. - - Includes a `clojure.data/diff` of the db, before vs after, showing the changes - caused by the event handler. - - You'd typically want this interceptor after (to the right of) any path interceptor. - - Warning: calling clojure.data/diff on large, complex data structures can be slow. - So, you won't want this interceptor present in production code. See the todomvc - example to see how to exclude interceptors from production code." - (->interceptor - :name :debug - :before (fn debug-before - [context] - #_(console :log "Handling re-frame event: " (-> context :coeffects :event)) - context) - :after (fn debug-after - [context] - (let [event (get-coeffect context :event) - orig-db (get-coeffect context :db) - new-db (get-effect context :db)] - (if-not new-db - (do - (console :log "no app-db changes caused by: " event) - context) - (let [[only-before only-after] (data/diff orig-db new-db) ;; diff between effe - db-changed? (or (some? only-before) (some? only-after))] - (if db-changed? - (do (console :group "db clojure.data/diff for: " event) - (console :log "only before: " only-before) - (console :log "only after : " only-after) - (console :groupEnd)) - (console :log "no app-db changes caused by: " event)) - context)))))) - - -(def trim-v - "An interceptor which removes the first element of the event vector, allowing you to write - more aesthetically pleasing db handlers. No leading underscore on the event-v! - Your event handlers will look like this: - - (defn my-handler - [db [x y z]] ;; <-- instead of [_ x y z] - ....)" - (->interceptor - :name :trim-v - :before (fn trimv-before - [context] - (->> (get-coeffect context :event) - rest - vec - (assoc-coeffect context :event))))) - - -;; -- Interceptor Factories - PART 1 --------------------------------------------------------------- -;; -;; These factories wrap the 3 kinds of handlers. -;; - -(defn db-handler->interceptor - "Returns an interceptor which wraps the kind of event handler given to `reg-event-db`. - - These handlers take two arguments; `db` and `event`, and they return `db`. - - (fn [db event] - ....) - - So, the interceptor wraps the given handler: - 1. extracts two coeffects (from context): db and event - 2. calls handler-fn - 3. stores the db result back into context's effects" - [handler-fn] - (->interceptor - :name :db-handler - :before (fn db-handler-before - [context] - (let [{:keys [db event]} (:coeffects context)] - (->> (handler-fn db event) - (assoc-effect context :db)))))) - - -(defn fx-handler->interceptor - "Returns an interceptor which wraps the kind of event handler given to `reg-event-fx`. - - These handlers take two arguments; `world` and `event`, and they return `effects`. - - (fn [world event] - {:db ... - :dispatch ...}) - - Wrap handler in an interceptor so it can be added to (the RHS) of a chain: - 1. extracts necessary coeffects - 2. call handler-fn - 3. stores the result backinto the effects" - [handler-fn] - (->interceptor - :name :fx-handler - :before (fn fx-handler-before - [context] - (let [{:keys [event] :as coeffects} (:coeffects context)] - (->> (handler-fn coeffects event) - (assoc context :effects)))))) - - -(defn ctx-handler->interceptor - "Returns an interceptor which wraps the kind of event handler given to `reg-event-ctx`. - These advanced handlers take one argument: `context` and they return a modified `context`. - Example: - (fn [context] - (enqueue context [more interceptors]))" - [handler-fn] - (->interceptor - :name :ctx-handler - :before handler-fn)) - - -;; -- Interceptors Factories - PART 2 ------------------------------------------------------------ -;; -;; I.e. functions which return interceptors -;; - - -(defn path - "An interceptor factory which supplies a sub-path of `:db` to the handler. - Is somewhat annologous to `update-in`. It grafts the return value from the handler - back into db. - - Usage: - (path :some :path) - (path [:some :path]) - (path [:some :path] :to :here) - (path [:some :path] [:to] :here) - - Implementation: - - in :before, store the original db in within `context` - - ib :after, re-establish original db with modification - - remember: path may be used twice within the one interceptor chain. - " - [& args] - (let [path (flatten args) - db-store-key :re-frame-path/original-dbs] ;; this is where, within `context`, we store the original db - (when (empty? path) - (console :error "re-frame: \"path\" interceptor given no params.")) - (->interceptor - :name :path - :before (fn - [context] - (let [original-db (get-coeffect context :db)] - (-> context - (update db-store-key conj original-db) - (assoc-coeffect :db (get-in original-db path))))) - :after (fn [context] - (let [db-store (db-store-key context) - original-db (peek db-store) - new-store (pop db-store) - full-db (->> (get-effect context :db) - (assoc-in original-db path))] - (-> context - (assoc db-store-key new-store) - (assoc-effect :db full-db))))))) - - - -;; XXX in todomvc what about a coeffect which is the value in LocalStore ?? - -(defn enrich - "Interceptor factory which runs a given function \"f\" in the \"after handler\" - position. \"f\" is (db v) -> db - - Unlike the \"after\" inteceptor which is only about side effects, \"enrich\" - expects f to process and alter the :db coeffect in some useful way, contributing - to the derived data, flowing vibe. - - Imagine that todomvc needed to do duplicate detection - if any two todos had - the same text, then highlight their background, and report them in a warning - down the bottom. - - Almost any action (edit text, add new todo, remove a todo) requires a - complete reassesment of duplication errors and warnings. Eg: that edit - update might have introduced a new duplicate or removed one. Same with a - todo removal. - - And to perform this enrichment, a function has to inspect all the todos, - possibly set flags on each, and set some overall list of duplicates. - And this duplication check might just be one check among many. - - \"f\" would need to be both adding and removing the duplicate warnings. - By applying \"f\" in middleware, we keep the handlers simple and yet we - ensure this important step is not missed." - [f] - (->interceptor - :name :enrich - :after (fn enrich-after - [context] - (let [event (get-coeffect context :event) - db (get-effect context :db)] - (->> (f db event) - (assoc-effect context :db)))))) - - - -(defn after - "Interceptor factory which runs a given function `f` in the \"after\" - position presumably for side effects. - - `f` is called with two arguments: the `effects` value of `:db` and the event. It's return - value is ignored so `f` can only side-effect. Example uses: - - `f` runs schema validation (reporting any errors found) - - `f` writes some aspect of db to localstorage." - [f] - (->interceptor - :name :after - :after (fn after-after - [context] - (let [db (get-effect context :db) - event (get-coeffect context :event)] - (f db event) ;; call f for side effects - context)))) ;; context is unchanged - - -(defn on-changes - "Interceptor factory which acts a bit like `reaction` (but it flows into `db`, rather than out) - It observes N paths in `db` and if any of them test not indentical? to their previous value - (as a result of a handler being run) then it runs 'f' to compute a new value, which is - then assoced into the given `out-path` within `db`. - - Usage: - - (defn my-f - [a-val b-val] - ... some computation on a and b in here) - - (on-changes my-f [:c] [:a] [:b]) - - Put this Interceptor on the right handlers (ones which might change :a or :b). - It will: - - call 'f' each time the value at path [:a] or [:b] changes - - call 'f' with the values extracted from [:a] [:b] - - assoc the return value from 'f' into the path [:c] - " - [f out-path & in-paths] - (->interceptor - :name :enrich - :after (fn on-change-after - [context] - (let [new-db (get-effect context :db) - old-db (get-coeffect context :db) - - ;; work out if any "inputs" have changed - new-ins (map #(get-in new-db %) in-paths) - old-ins (map #(get-in old-db %) in-paths) - changed-ins? (some false? (map identical? new-ins old-ins))] - - ;; if one of the inputs has changed, then run 'f' - (if changed-ins? - (->> (apply f new-ins) - (assoc-in new-db out-path) - (assoc-effect context :db)) - context))))) - diff --git a/src/re_frame/registrar.cljc b/src/re_frame/registrar.cljc index 9604d44..0455d90 100644 --- a/src/re_frame/registrar.cljc +++ b/src/re_frame/registrar.cljc @@ -1,10 +1,13 @@ (ns re-frame.registrar + "In many places, re-frame asks you to associate an `id` (keyword) + with a `handler` (fucntion). This namespace contains the + central registry of such associations." (:require [re-frame.interop :refer [debug-enabled?]] [re-frame.loggers :refer [console]])) ;; kinds of handlers -(def kinds #{:event :fx :sub}) +(def kinds #{:event :fx :cofx :sub}) ;; This atom contains a register of all handlers. ;; Is a map keyed first by kind (of handler), and then id. diff --git a/src/re_frame/router.cljc b/src/re_frame/router.cljc index b06aabd..b60b6ff 100644 --- a/src/re_frame/router.cljc +++ b/src/re_frame/router.cljc @@ -199,14 +199,13 @@ (-call-post-event-callbacks [_ event-v] - ;; Call each registed post-event callback. (doseq [callback (vals post-event-callback-fns)] (callback event-v queue))) (-resume [this] (-process-1st-event-in-queue this) ;; do the event which paused processing - (-run-queue this))) ;; do the rest of the queued events + (-run-queue this))) ;; do the rest of the queued events ;; --------------------------------------------------------------------------- diff --git a/src/re_frame/std_interceptors.cljc b/src/re_frame/std_interceptors.cljc new file mode 100644 index 0000000..76ded5a --- /dev/null +++ b/src/re_frame/std_interceptors.cljc @@ -0,0 +1,271 @@ +(ns re-frame.std-interceptors + "contains re-frame supplied, standard interceptors" + (:require + [re-frame.interceptor :refer [->interceptor get-effect get-coeffect assoc-coeffect assoc-effect]] + [re-frame.loggers :refer [console]] + [re-frame.registrar :as registrar] + [re-frame.db :refer [app-db]] + [clojure.data :as data])) + + +;; XXX provide a way to set what handler should be called when there is no registered handler. +;; by default this handler will simply print out a message saying no handler was found. + + +(def debug + "An interceptor which logs data about the handling of an event. + + Includes a `clojure.data/diff` of the db, before vs after, showing + the changes caused by the event handler. + + You'd typically want this interceptor after (to the right of) any + path interceptor. + + Warning: calling clojure.data/diff on large, complex data structures + can be slow. So, you won't want this interceptor present in production + code. See the todomvc example to see how to exclude interceptors from + production code." + (->interceptor + :name :debug + :before (fn debug-before + [context] + #_(console :log "Handling re-frame event: " (-> context :coeffects :event)) + context) + :after (fn debug-after + [context] + (let [event (get-coeffect context :event) + orig-db (get-coeffect context :db) + new-db (get-effect context :db)] + (if-not new-db + (do + (console :log "no app-db changes caused by: " event) + context) + (let [[only-before only-after] (data/diff orig-db new-db) ;; diff between effe + db-changed? (or (some? only-before) (some? only-after))] + (if db-changed? + (do (console :group "db clojure.data/diff for: " event) + (console :log "only before: " only-before) + (console :log "only after : " only-after) + (console :groupEnd)) + (console :log "no app-db changes caused by: " event)) + context)))))) + + +(def trim-v + "An interceptor which removes the first element of the event vector, allowing you to write + more aesthetically pleasing db handlers. No leading underscore on the event-v! + Your event handlers will look like this: + + (defn my-handler + [db [x y z]] ;; <-- instead of [_ x y z] + ....)" + (->interceptor + :name :trim-v + :before (fn trimv-before + [context] + (->> (get-coeffect context :event) + rest + vec + (assoc-coeffect context :event))))) + + +;; -- Interceptor Factories - PART 1 --------------------------------------------------------------- +;; +;; These 3 factories wrap the 3 kinds of handlers. +;; + +(defn db-handler->interceptor + "Returns an interceptor which wraps the kind of event handler given to `reg-event-db`. + + These handlers take two arguments; `db` and `event`, and they return `db`. + + (fn [db event] + ....) + + So, the interceptor wraps the given handler: + 1. extracts two `:coeffects` keys: db and event + 2. calls handler-fn + 3. stores the db result back into context's `:effects`" + [handler-fn] + (->interceptor + :name :db-handler + :before (fn db-handler-before + [context] + (let [{:keys [db event]} (:coeffects context)] + (->> (handler-fn db event) + (assoc-effect context :db)))))) + + +(defn fx-handler->interceptor + "Returns an interceptor which wraps the kind of event handler given to `reg-event-fx`. + + These handlers take two arguments; `world` and `event`, and they return `effects`. + + (fn [world event] + {:db ... + :dispatch ...}) + + Wrap handler in an interceptor so it can be added to (the RHS) of a chain: + 1. extracts `:coeffects` + 2. call handler-fn giving coeffects + 3. stores the result back into the `:effects`" + [handler-fn] +(->interceptor + :name :fx-handler + :before (fn fx-handler-before + [context] + (let [{:keys [event] :as coeffects} (:coeffects context)] + (->> (handler-fn coeffects event) + (assoc context :effects)))))) + + +(defn ctx-handler->interceptor + "Returns an interceptor which wraps the kind of event handler given to `reg-event-ctx`. + These advanced handlers take one argument: `context` and they return a modified `context`. + Example: + (fn [context] + (enqueue context [more interceptors]))" + [handler-fn] + (->interceptor + :name :ctx-handler + :before handler-fn)) + + +;; -- Interceptors Factories - PART 2 ------------------------------------------------------------ + + +(defn path + "An interceptor factory which supplies a sub-path of `:db` to the handler. + It's action is somewhat annologous to `update-in`. It grafts the return + value from the handler back into db. + + Usage: + (path :some :path) + (path [:some :path]) + (path [:some :path] :to :here) + (path [:some :path] [:to] :here) + + Remember: `path` may be used multiple times within the one interceptor chain. + " + [& args] + (let [path (flatten args) + db-store-key :re-frame-path/original-dbs] ;; this is where, within `context`, we store the original db + (when (empty? path) + (console :error "re-frame: \"path\" interceptor given no params.")) + (->interceptor + :name :path + :before (fn + [context] + (let [original-db (get-coeffect context :db)] + (-> context + (update db-store-key conj original-db) + (assoc-coeffect :db (get-in original-db path))))) + :after (fn [context] + (let [db-store (db-store-key context) + original-db (peek db-store) + new-db-store (pop db-store) + full-db (->> (get-effect context :db) + (assoc-in original-db path))] + (-> context + (assoc db-store-key new-db-store) + (assoc-effect :db full-db))))))) + + + + +(defn enrich + "Interceptor factory which runs a given function \"f\" in the \"after handler\" + position. \"f\" is (db v) -> db + + Unlike the \"after\" inteceptor which is only about side effects, \"enrich\" + expects f to process and alter the :db coeffect in some useful way, contributing + to the derived data, flowing vibe. + + Imagine that todomvc needed to do duplicate detection - if any two todos had + the same text, then highlight their background, and report them in a warning + down the bottom. + + Almost any action (edit text, add new todo, remove a todo) requires a + complete reassesment of duplication errors and warnings. Eg: that edit + update might have introduced a new duplicate or removed one. Same with a + todo removal. + + And to perform this enrichment, a function has to inspect all the todos, + possibly set flags on each, and set some overall list of duplicates. + And this duplication check might just be one check among many. + + \"f\" would need to be both adding and removing the duplicate warnings. + By applying \"f\" in middleware, we keep the handlers simple and yet we + ensure this important step is not missed." + [f] + (->interceptor + :name :enrich + :after (fn enrich-after + [context] + (let [event (get-coeffect context :event) + db (get-effect context :db)] + (->> (f db event) + (assoc-effect context :db)))))) + + + +(defn after + "Interceptor factory which runs a given function `f` in the \"after\" + position presumably for side effects. + + `f` is called with two arguments: the `effects` value of `:db` and the event. It's return + value is ignored so `f` can only side-effect. Example uses: + - `f` runs schema validation (reporting any errors found) + - `f` writes some aspect of db to localstorage." + [f] + (->interceptor + :name :after + :after (fn after-after + [context] + (let [db (get-effect context :db) + event (get-coeffect context :event)] + (f db event) ;; call f for side effects + context)))) ;; context is unchanged + + +(defn on-changes + "Interceptor factory which acts a bit like `reaction` (but it flows into `db`, rather than out) + It observes N paths in `db` and if any of them test not indentical? to their previous value + (as a result of a handler being run) then it runs 'f' to compute a new value, which is + then assoced into the given `out-path` within `db`. + + Usage: + + (defn my-f + [a-val b-val] + ... some computation on a and b in here) + + (on-changes my-f [:c] [:a] [:b]) + + Put this Interceptor on the right handlers (ones which might change :a or :b). + It will: + - call 'f' each time the value at path [:a] or [:b] changes + - call 'f' with the values extracted from [:a] [:b] + - assoc the return value from 'f' into the path [:c] + " + [f out-path & in-paths] + (->interceptor + :name :enrich + :after (fn on-change-after + [context] + (let [new-db (get-effect context :db) + old-db (get-coeffect context :db) + + ;; work out if any "inputs" have changed + new-ins (map #(get-in new-db %) in-paths) + old-ins (map #(get-in old-db %) in-paths) + changed-ins? (some false? (map identical? new-ins old-ins))] + + ;; if one of the inputs has changed, then run 'f' + (if changed-ins? + (->> (apply f new-ins) + (assoc-in new-db out-path) + (assoc-effect context :db)) + context))))) + + diff --git a/src/re_frame/subs.cljc b/src/re_frame/subs.cljc index 6e286d3..a35c183 100644 --- a/src/re_frame/subs.cljc +++ b/src/re_frame/subs.cljc @@ -36,7 +36,7 @@ (let [cache-key [query-v dynv]] ;; when this reaction is nolonger being used, remove it from the cache (add-on-dispose! r #(do (swap! query->reaction dissoc cache-key) - (console :log "Removing subscription: " cache-key))) ;; XXX remove console debug + #_(console :log "Removing subscription: " cache-key))) ;; XXX remove console debug ;; cache this reaction, so it can be used to deduplicate other, later "=" subscriptions (swap! query->reaction assoc cache-key r) r)) ;; return the actual reaction @@ -54,18 +54,18 @@ "Returns a Reagent/reaction which contains a computation" ([query-v] (if-let [cached (cache-lookup query-v)] - (do (console :log "Using cached subscription: " query-v) + (do ;(console :log "Using cached subscription: " query-v) cached) (let [query-id (first-in-vector query-v) handler-fn (get-handler kind query-id)] - (console :log "Subscription created: " query-v) + ;(console :log "Subscription created: " query-v) (if-not handler-fn (console :error "re-frame: no subscription handler registered for: \"" query-id "\". Returning a nil subscription.")) (cache-and-return query-v [] (handler-fn app-db query-v))))) ([v dynv] (if-let [cached (cache-lookup v dynv)] - (do (console :log "Using cached subscription: " v " and " dynv) + (do ;(console :log "Using cached subscription: " v " and " dynv) cached) (let [query-id (first-in-vector v) handler-fn (get-handler kind query-id)] @@ -78,7 +78,7 @@ sub (make-reaction (fn [] (handler-fn app-db v @dyn-vals)))] ;; handler-fn returns a reaction which is then wrapped in the sub reaction ;; need to double deref it to get to the actual value. - (console :log "Subscription created: " v dynv) + ;(console :log "Subscription created: " v dynv) (cache-and-return v dynv (make-reaction (fn [] @@sub))))))))) ;; -- Helper code for register-pure ------------------- diff --git a/test/re-frame/interceptor_test.cljs b/test/re-frame/interceptor_test.cljs index 0083dc4..fbaf614 100644 --- a/test/re-frame/interceptor_test.cljs +++ b/test/re-frame/interceptor_test.cljs @@ -1,8 +1,8 @@ (ns re-frame.interceptor-test (:require [cljs.test :refer-macros [is deftest]] [reagent.ratom :refer [atom]] - [re-frame.interceptor :refer [context get-coeffect assoc-effect assoc-coeffect get-effect - trim-v path on-changes + [re-frame.interceptor :refer [context get-coeffect assoc-effect assoc-coeffect get-effect]] + [re-frame.std-interceptors :refer [trim-v path on-changes db-handler->interceptor fx-handler->interceptor]])) (enable-console-print!)