Giter VIP home page Giter VIP logo

citrus's Introduction

Scrum is now Citrus as of v3.0.0 to avoid confusion with Agile term “Scrum”. Older versions are still available under the old name Scrum. To migrate to v3.0.0+ replace all occurrences of scrum with citrus.

Clojars Project cljdoc badge CircleCI

citrus logo

State management library for Rum

I am a big fan of Rum the library, as well as Rum the liquor. In almost every classic Rum-based cocktail, citrus is used as an ingredient to 1) pair with the sugar-based flavor of the Rum and 2) smooth the harshness of the alcohol flavor. Wherever you find Rum, it is almost always accompanied with some form of citrus to control and balance the cocktail. I think it is very fitting for how this library pairs with Rum. — @oakmac

Discuss on Clojurians Slack #citrus

Table of Contents

Motivation

Have a simple, re-frame like state management facilities for building web apps with Rum while leveraging its API.

Features

⚛️ Decoupled application state in a single atom

📦 No global state, everything lives in Reconciler instance

🎛 A notion of a controller to keep application domains separate

🚀 Reactive queries

📋 Side-effects are described as data

⚡️ Async batched updates for better performance

🚰 Server-side rendering with convenient state hydration

Apps built with Citrus

Installation

Add to project.clj / build.boot: [clj-commons/citrus "3.3.0"]

Usage

(ns counter.core
  (:require [rum.core :as rum]
            [citrus.core :as citrus]))

;;
;; define controller & event handlers
;;

(def initial-state 0) ;; initial state

(defmulti control (fn [event] event))

(defmethod control :init []
  {:local-storage
   {:method :get
    :key :counter
    :on-read :init-ready}}) ;; read from local storage

(defmethod control :init-ready [_ [counter]]
  (if-not (nil? counter)
    {:state (js/parseInt counter)} ;; init with saved state
    {:state initial-state})) ;; or with predefined initial state

(defmethod control :inc [_ _ counter]
  (let [next-counter (inc counter)]
    {:state next-counter ;; update state
     :local-storage
     {:method :set
      :data next-counter
      :key :counter}})) ;; persist to local storage

(defmethod control :dec [_ _ counter]
  (let [next-counter (dec counter)]
    {:state next-counter ;; update state
     :local-storage
     {:method :set
      :data next-counter
      :key :counter}})) ;; persist to local storage


;;
;; define effect handler
;;

(defn local-storage [reconciler controller-name effect]
  (let [{:keys [method data key on-read]} effect]
    (case method
      :set (js/localStorage.setItem (name key) data)
      :get (->> (js/localStorage.getItem (name key))
                (citrus/dispatch! reconciler controller-name on-read))
      nil)))


;;
;; define UI component
;;

(rum/defc Counter < rum/reactive [r]
  [:div
   [:button {:on-click #(citrus/dispatch! r :counter :dec)} "-"]
   [:span (rum/react (citrus/subscription r [:counter]))]
   [:button {:on-click #(citrus/dispatch! r :counter :inc)} "+"]])


;;
;; start up
;;

;; create Reconciler instance
(defonce reconciler
  (citrus/reconciler
    {:state
     (atom {}) ;; application state
     :controllers
     {:counter control} ;; controllers
     :effect-handlers
     {:local-storage local-storage}})) ;; effect handlers

;; initialize controllers
(defonce init-ctrl (citrus/broadcast-sync! reconciler :init))

;; render
(rum/mount (Counter reconciler)
           (. js/document (getElementById "app")))

How it works

With Citrus you build everything around a well known architecture pattern in modern SPA development:

📦 Model application state (with reconciler)

📩 Dispatch events (with dispatch!, dispatch-sync!, broadcast! and broadcast-sync!)

📬 Handle events (with :controllers functions)

🕹 Handle side effects (with :effect-handlers functions)

🚀 Query state reactively (with subscription, rum/react and rum/reactive)

Render (automatic & efficient ! profit 👍)

Reconciler

Reconciler is the core of Citrus. An instance of Reconciler takes care of application state, handles events, side effects and subscriptions, and performs async batched updates (via requestAnimationFrame):

(defonce reconciler
  (citrus/reconciler {:state (atom {})
                      :controllers {:counter control}
                      :effect-handlers {:http http}}))

:state

The value at the :state key is the initial state of the reconciler represented as an atom which holds a hash map. The atom is created and passed explicitly.

:controllers

The value at the :controllers key is a hash map from controller name to controller function. The controller stores its state in reconciler's :state atom at the key which is the name of the controller in :controllers hash map. That is, the keys in :controllers are reflected in the :state atom. This is where modeling state happens and application domains keep separated.

Usually controllers are initialized with a predefined initial state value by dispatching :init event.

NOTE: the :init event pattern isn't enforced at all in Citrus, but we consider it is a good idea for 2 reasons:

  • it separates setup of the reconciler from initialization phase, because initialization could happen in several ways (hardcoded, read from global JSON/Transit data rendered into HTML from the server, user event, etc.)
  • allows setting a global watcher on the atom for ad-hoc stuff outside of the normal Citrus cycle for maximum flexibility

:effect-handlers

The value at the :effect-handlers key is a hash map of side effect handlers. Handler function asynchronously performs impure computations such as state change, HTTP request, etc. The only built-in effects handler is :state, everything else should be implemented and provided by user.

Dispatching events

Dispatched events communicate intention to perform a side effect, whether it is updating the state or performing a network request. By default effects are executed asynchronously, use dispatch-sync! when synchronous execution is required:

(citrus.core/dispatch! reconciler :controller-name :event-name &args)
(citrus.core/dispatch-sync! reconciler :controller-name :event-name &args)

broadcast! and its synchronous counterpart broadcast-sync! should be used to broadcast an event to all controllers:

(citrus.core/broadcast! reconciler :event-name &args)
(citrus.core/broadcast-sync! reconciler :event-name &args)

Handling events

A controller is a multimethod that returns effects. It usually has at least an initial state and :init event method. An effect is key/value pair where the key is the name of the effect handler and the value is description of the effect that satisfies particular handler.

(def initial-state 0)

(defmulti control (fn [event] event))

(defmethod control :init [event args state]
  {:state initial-state})

(defmethod control :inc [event args state]
  {:state (inc state)})

(defmethod control :dec [event args state]
  {:state (dec state)})

It's important to understand that state value that is passed in won't affect the whole state, but only the part corresponding to its associated key in the :controllers map of the reconciler.

🚀 Citrus' event handling is very customizable through an (alpha level) :citrus/handler option.

Side effects

A side effect is an impure computation e.g. state mutation, HTTP request, storage access, etc. Because handling side effects is inconvenient and usually leads to cumbersome code, this operation is pushed outside of user code. In Citrus you don't perform effects directly in controllers. Instead controller methods return a hash map of effects represented as data. In every entry of the map the key is a name of the corresponding effects handler and the value is a description of the effect.

Here's an example of an effect that describes HTTP request:

{:http {:url "/api/users"
        :method :post
        :body {:name "John Doe"}
        :headers {"Content-Type" "application/json"}
        :on-success :create-user-ready
        :on-error :create-user-failed}}

And corresponding handler function:

(defn http [reconciler ctrl-name effect]
  (let [{:keys [on-success on-error]} effect]
    (-> (fetch effect)
        (then #(citrus/dispatch! reconciler ctrl-name on-success %))
        (catch #(citrus/dispatch! reconciler ctrl-name on-error %)))))

Handler function accepts three arguments: reconciler instance, the name key of the controller which produced the effect and the effect value itself.

Notice how the above effect provides callback event names to handle HTTP response/error which are dispatched once request is done. This is a frequent pattern when it is expected that an effect can produce another one e.g. update state with response body.

NOTE: :state is the only handler built into Citrus. Because state change is the most frequently used effect it is handled a bit differently, in efficient way (see Scheduling and batching section).

Subscriptions

A subscription is a reactive query into application state. It is an atom which holds a part of the state value retrieved with provided path. Optional second argument is an aggregate function that computes a materialized view. You can also do parameterized and aggregate subscriptions.

Actual subscription happens in Rum component via rum/reactive mixin and rum/react function which hooks in a watch function to update a component when an atom gets updated.

;; normal subscription
(defn fname [reconciler]
  (citrus.core/subscription reconciler [:users 0 :fname]))

;; a subscription with aggregate function
(defn full-name [reconciler]
  (citrus.core/subscription reconciler [:users 0] #(str (:fname %) " " (:lname %))))

;; parameterized subscription
(defn user [reconciler id]
  (citrus.core/subscription reconciler [:users id]))

;; aggregate subscription
(defn discount [reconciler]
  (citrus.core/subscription reconciler [:user :discount]))

(defn goods [reconciler]
  (citrus.core/subscription reconciler [:goods :selected]))

(defn shopping-cart [reconciler]
  (rum/derived-atom [(discount reconciler) (goods reconciler)] ::key
    (fn [discount goods]
      (let [price (->> goods (map :price) (reduce +))]
        (- price (* discount (/ price 100)))))))

;; usage
(rum/defc NameField < rum/reactive [reconciler]
  (let [user (rum/react (user reconciler 0))])
    [:div
     [:div.fname (rum/react (fname reconciler))]
     [:div.lname (:lname user)]
     [:div.full-name (rum/react (full-name reconciler))]
     [:div (str "Total: " (rum/react (shopping-cart reconciler)))]])

Scheduling and batching

This section describes how effects execution works in Citrus. It is considered an advanced topic and is not necessary to read to start working with Citrus.

Scheduling

Events dispatched using citrus/dispatch! are always executed asynchronously. Execution is scheduled via requestAnimationFrame meaning that events that where dispatched in 16ms timeframe will be executed sequentially by the end of this time.

;; |--×-×---×---×--|---
;; 0ms            16ms

Batching

Once 16ms timer is fired a queue of scheduled events is being executed to produce a sequence of effects. This sequence is then divided into two: state updates and other side effects. First, state updates are executed in a single swap!, which triggers only one re-render, and after that other effects are being executed.

;; queue = [state1 http state2 local-storage]

;; state-queue = [state1 state2]
;; other-queue = [http local-storage]

;; swap! reduce old-state state-queue → new-state
;; doseq other-queue

Server-side rendering

Server-side rendering in Citrus doesn't require any changes in UI components code, the API is the same. However it works differently under the hood when the code is executed in Clojure.

Here's a list of the main differences from client-side:

  • reconciler accepts a hash of subscriptions resolvers and optional :state atom
  • subscriptions are resolved synchronously
  • controllers are not used
  • all dispatching functions are disabled

Subscriptions resolvers

To understand what is subscription resolving function let's start with a small example:

;; used in both Clojure & ClojureScript
(rum/defc Counter < rum/reactive [r]
  [:div
   [:button {:on-click #(citrus/dispatch! r :counter :dec)} "-"]
   [:span (rum/react (citrus/subscription r [:counter]))]
   [:button {:on-click #(citrus/dispatch! r :counter :inc)} "+"]])
;; server only
(let [state (atom {})
      r (citrus/reconciler {:state state
                           :resolvers resolvers})] ;; create reconciler
  (->> (Counter r) ;; initialize components tree
       rum/render-html ;; render to HTML
       (render-document @state))) ;; render into document template
;; server only
(def resolvers
  {:counter (constantly 0)}) ;; :counter subscription resolving function

resolver is a hash map from subscription path's top level key, that is used when creating a subscription in UI components, to a function that returns a value. Normally a resolver would access database or any other data source used on the backend.

Resolver

A value returned from resolving function is stored in Resolver instance which is atom-like type that is used under the hood in subscriptions.

Resolved data

In the above example you may have noticed that we create state atom, pass it into reconciler and then dereference it once rendering is done. When rendering on server Citrus collects resolved data into an atom behind :state key of the reconciler, if the atom is provided. This data should be rendered into HTML to rehydrate the app once it is initialized on the client-side.

NOTE: in order to retrieve resolved data the atom should be dereferenced only after rum/render-html call.

Synchronous subscriptions

Every subscription created inside of components that are being rendered triggers corresponding resolving function which blocks rendering until a value is returned. The downside is that the more subscriptions there are down the components tree, the more time it will take to render the whole app. On the other hand it makes it possible to both render and retrieve state in one render pass. To reduce rendering time make sure you don't have too much subscriptions in components tree. Usually it's enough to have one or two in root component for every route.

Request bound caching

If you have multiple subscriptions to same data source in UI tree you'll see that data is also fetched multiple times when rendering on server. To reduce database access load it's recommended to reuse data from resolved subscriptions. Here's an implementation of a simple cache:

(defn resolve [resolver req]
  (let [cache (volatile! {})] ;; cache
    (fn [[key & p :as path]]
      (if-let [data (get-in @cache path)] ;; cache hit
        (get-in data p) ;; return data from cache
        (let [data (resolver [key] req)] ;; cache miss, resolve subscription
          (vswap! cache assoc key data) ;; cache data
          (get-in data p))))))

Managing resolvers at runtime

If you want to display different data based on certain condition, such as user role or A/B testing, it is useful to have predefined set of resolvers for every of those cases. Based on those conditions a web server can construct different resolver maps to display appropriate data.

;; resolvers
(def common
  {:thirdparty-ads get-ads
   :promoted-products get-promoted})
   
(def anonymous-user
  {:top-products get-top-products})
  
(def returning-user
  {:suggested-products get-suggested-products})

;; conditional resolver construction
(defn make-resolver [req]
  (cond
    (anonymous? req) (merge common anonymous-user)
    (returning? req) (merge comomn returning-user)
    :else common))

Best practices

  • Pass the reconciler explicity from parent components to children. Since it is a reference type it won't affect rum/static (shouldComponentUpdate) optimization. But if you prefer dependency injection, you can use React's Context API as well https://reactjs.org/docs/context.html
  • Set up the initial state value by broadcast-sync!ing an :init event before first render. This enforces controllers to keep state initialization in-place where they are defined.
  • Handle side effects using effect handlers. This allows reconciler to batch effects when needed, and also makes it easier to test controllers.

Recipes

FAQ

Passing reconciler explicitly is annoying and makes components impossible to reuse since they depend on reconciler. Can I use DI via React context to avoid this?

Yes, you can. But keep in mind that there's nothing more straightforward and simpler to understand than data passed as arguments explicitly. The argument on reusability is simply not true. If you think about it, reusable components are always leaf nodes in UI tree and everything above them is application specific UI. Those leaf components doesn't need to know about reconciler, they should provide an API which should be used by application specific components that depend on reconciler and pass in data and callbacks that interact with the reconciler.

But of course it is an idealistic way of building UI trees and in practice sometimes you really want dependency injection. For this case use React's Context API. Since React 16.3.0 the API has been officially stabilized which means it could be used safely now. Here's a quick example how you might want to use it with Rum and Citrus.

;; create Reconciler instance
(def reconciler
  (citrus/reconciler config))

;; create Context instance
;; which provides two React components: Provider and Consumer
(def reconciler-context
  (js/React.createContext))
  
;; provider function
;; that injects the reconciler
(defn provide-reconciler [child]
  (js/React.createElement
    (.-Provider reconciler-context)
    #js {:value reconciler}
    child))

;; consumer function
;; that consumes the reconciler
(defn with-reconciler [consumer-fn]
  (js/React.createElement
    (.-Consumer reconciler-context)
    nil
    consumer-fn))
    
(rum/defc MyApp []
  ;; "consume" reconciler instance
  ;; in arbitrary nested component
  (with-reconciler
    (fn [r]
      [:button {:on-click #(citrus/dispatch! r :some :event)}
        "Push"])))
    
(rum/mount
  (provide-reconciler (MyApp)) ;; "inject" reconciler instance
  (dom/getElement "root"))

Testing

Testing state management logic in Citrus is really simple. Here's what can be tested:

  • controllers output (effects)
  • state changes

NOTE: Using synchronous dispatch citrus.core/dispatch-sync! makes it easier to test state updates.

(ns app.controllers.counter)

(defmulti control (fn [event] event))

(defmethod control :init [_ [initial-state] _]
  {:state initial-state})

(defmethod control :inc [_ _ counter]
  {:state (inc counter)})

(defmethod control :dec [_ _ counter]
  {:state (dec counter)})

(defmethod control :reset-to [_ [new-value] counter]
  {:state new-value})
(ns app.test.controllers.counter-test
  (:require [clojure.test :refer :all]
            [citrus.core :as citrus]
            [app.controllers.counter :as counter]))

(def state (atom {}))

(def r
  (citrus/reconciler
    {:state state
     :controllers
     {:counter counter/control}}))

(deftest counter-control
  (testing "Should return initial-state value"
    (is (= (counter/control :init 0 nil) {:state 0})))
  (testing "Should return incremented value"
    (is (= (counter/control :inc nil 0) {:state 1})))
  (testing "Should return decremented value"
    (is (= (counter/control :dec nil 1) {:state 0})))
  (testing "Should return provided value"
    (is (= (counter/control :reset-to [5] nil) {:state 5}))))

(deftest counter-state
  (testing "Should initialize state value with 0"
    (citrus/dispatch-sync! r :counter :init 0)
    (is (zero? (:counter @state))))
  (testing "Should increment state value"
    (citrus/dispatch-sync! r :counter :inc)
    (is (= (:counter @state) 1)))
  (testing "Should deccrement state value"
    (citrus/dispatch-sync! r :counter :dec)
    (is (= (:counter @state) 0)))
  (testing "Should reset state value"
    (citrus/dispatch-sync! r :counter :reset-to 5)
    (is (= (:counter @state) 5))))

Roadmap

  • Get rid of global state
  • Make citrus isomorphic
  • Storage agnostic architecture? (Atom, DataScript, etc.)
  • Better effects handling (network, localStorage, etc.)
  • Provide better developer experience using clojure.spec

Contributing

If you've encountered an issue or want to request a feature or any other kind of contribution, please file an issue and provide detailed description.

This project is using Leiningen build tool, make sure you have it installed.

To run Clojure tests (on the JVM), execute lein test.

To run ClojureScript tests (on Firefox) you'll need Node.js and the Firefox web browser. Then execute :

  • npm install (only once, install testing dependencies locally)
  • lein cljs-test : this will open a new Firefox window to run the tests and watch for file changes.

License

Copyright © 2017 Roman Liutikov

Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version.

citrus's People

Contributors

armed avatar bfontaine avatar chpill avatar danielcompton avatar dependabot[bot] avatar djebbz avatar honzabrecka avatar mallozup avatar martinklepsch avatar otann avatar roman01la avatar shaunlebron avatar slipset avatar sneakypeet avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

citrus's Issues

Proposal: Effects handling

This is the first draft that describes possible approach to effects handling in Scrum, inspired by re-frame. Code example is available in this gist.

Controllers

A controller returns description of a side-effects as data. :state is a built-in effects handler.

Side-effects description should follow these rules:

  • description is a hash map
  • a key is an identifier of a particular effect handler (:state, :http, etc.)
  • a value is a value which satisfies particular effect handler
(defmethod control :inc [_ _ state]
  {:state (update state :count inc)
   :http {:method :put
          :uri "/api/count"
          :data (inc (:count state))
          :on-success :inc-ready
          :on-fail :inc-fail}})

Reconciler

New key :effects is added. The value is a hash map from effect handler identifier to handler function.

(def r
  (scrum/reconciler
    {:state (atom {})
     :controllers {:counter counter/control}}
     :effects {:http effects/http}))

Effect handlers

Effect handler is a function that receives...

  • an instance of Reconciler
  • controller identifier
  • effect description returned by controller
(defn http [reconciler controller {:keys [method uri data on-success on-fail]}]
  (-> (httpurr/get uri {:method method :data data})
        (p/then #(scrum/dispatch! reconciler controller on-success %))
        (p/catch #(scrum/dispatch! reconciler controller on-fail %))))

Execution flow

  • Dispatch an event (scrum/dispatch! r :user :load user-id)
  • Triggered controller returns effect description {:http {...}}
  • Reconciler executes appropriate effects handler passing in itself, controller name and effect value
  • Once effect performed it may dispatch (server response, etc.)

Advantages

  • Сonvenient async effects handling (no need to pass reconciler instance into controller to dispatch response later)
  • Pluggable & reusable effect handlers

`update-in`-style state updates?

It looks like updates using the :state effect replaces the entire state for the controller whose event returns the :state effect. I'm interested in figuring out how to perform a limited, assoc-in or update-in style update instead of clobbering the entire state for the controller.

One can write something like an update-in as an event handler, by taking the state argument, updating part of it, and emitting a :state effect to set to the update-ind state. However, if two such events are created at the same time, only one will work, because they all see and update the same starting state.

So it would be better to write an :update-in effect. From looking at the source, I'm not sure how to do that. It looks like :state is specially handled, at least in master. In particular, Reconcilers don't implement the swap interface, and I don't seem to be able just grab the (:state reconciler).

Am I overlooking an easy way to write :update-in as an effect?

Difference in subscriptions with reducers between CLJ and CLJS

Hi,

I think I spotted a bug:

  • in citrus/cursor.cljs, the IDeref implementation is always calling get-in first, then the reducer (with a fallback to identity if not provided).
  • in citrus/resolver.clj, the clojure.lang.IDeref does things differently depending on whether a reducer is present or not. If not present, it just calls get-in. But if there's a reducer, it calls reducer first, then get-in, when I think it should be the other way around.

So for a subscription like this:

(defn products-count [reconciler]
  (citrus/subscription reconciler [:products :list] count))

In CLJ it will call (-> data count (get-in [:products :list])), whereas in CLJS it will do (-> data (get-in [:products :list]) count).

Am I right ?

Cannot use custom scheduling function

Hello!

I see that the reconciler accepts a batched-update function, which default to js/requestAnimationFrame (https://github.com/roman01la/citrus/blob/056eb0ad0c5787fa26d208cf874e0eca95ce68d7/src/citrus/core.cljs#L33).

But in the scheduling code, the js/cancelAnimationFrame function is hardcoded (https://github.com/roman01la/citrus/blob/056eb0ad0c5787fa26d208cf874e0eca95ce68d7/src/citrus/reconciler.cljs#L13), which effectively prevents someone from providing another scheduling function.

Maybe we could provide the :batched-update as a map {:schedule-fn ... :release-fn ...} which would default to {:schedule-fn js/requestAnimationFrame :release-fn js/cancelAnimationFrame} ?

For example, in my current project I'd really like to be able to use {:schedule-fn js/setTimeout :release-fn js/clearTimeout} (which is more or less what reframe does).

Happy to provide a PR if you like the idea!

State changes not picked up

Not sure if I'm missing something obvious, but I am having trouble getting this to work.

When I dispatch 2 events on the same controller, one after the other, they seem to overwrite each other's state.

Looking at the code for Reconciler, it looks like this is because the state for the controller gets extracted when enqueue-ing, and because of this it does not take any changes in state into account when running each event fn.

Is this expected behaviour?

Implement default-handler option to allow full customization of event-handling

EDIT 2020-03-09: This thread discusses the introduction of a :default-handler option – please share your feedback/experiences if you've used it.


Hi all,

We’re currently looking into adapting Citrus so that all controllers methods can access the state of other controllers. In our particular use case we found that we often end up passing central information around in events such as the current users ID. This adds complexity to components because components then need to get this data out of the :users controller first.

We're interested in either something that would allow us to have "shared state" between controllers or a way that would allow us to access individual controllers' state from the methods of a different controller.

There's a few different options I've been mulling over, would be curious what others thing about those.

1. Pass entire reconciler state as 4th argument to multimethods

  • Probably a breaking change
  • Should be hidden behind a config flag I assume

Handlers would take the fourth argument

(defmethod control :foo
  [_ event controller-state reconciler-state]
  ,,,)

2. Default handler option

This is an option I'm very curious about because I think it makes Citrus very versatile and allows existing codebases to gradually migrate out of the predefined controller design while maintaining backwards compatibility. This is not to say that the controller structure is bad. But codebases grow and knowing that it's possible to break out when needed can be great for peace of mind. I know we are at a stage where we have some long-term concerns around building on top of Citrus but a rewrite is just not something that we'll prioritize over product work.

The basic idea is that whenever dispatch! is called with a controller name that doesn't exist in the reconciler it will call a default handler with the following args:

(default-handler reconciler-instance controller event-key event-args)

This would allow user-land code to basically do anything. For instance we could remove some of our controllers from the :controllers map and implement a default-handler like this:

;; let's assume we only need different behavior for the :user controller
(defn default-handler [reconciler ctrl event-key event-args]
  (if (= :user ctrl)
    ;; we pass the entire reconciler state as a fourth argument
    ;; to make everything pluggable there could also be a function that
    ;; processes effects returned by the function below, e.g.
    ;; (citrus/handle-effects reconciler (user/control ,,,))
    (user/control event-key event-args (:user @reconciler) @reconciler)
    (throw (ex-info "Not implemented." {}))))

This implements the suggestion in 1. but possibilities are endless. We could implement an interceptor chain on top of this for instance, which I believe isn't possible with the current controller/multimethod based approach.

I'm not sure what or if this would break with regards to other Citrus features but I think it would be amazing if Citrus had an escape hatch like this that would allow implementing more complex handlers than what's possible with multimethods.

Other solutions to the general problem of accessing central data

Use broadcast! more
  • Use broadcast! to make central pieces of information available to multiple controllers
  • Would require each controller to implement an appropriate handler
Custom effect to mirror data to multiple controllers
  • Implement a :shared-state effect that writes data to every controller in the reconciler
  • Would totally work but feels a little clumsy

Behaviour When Mixing Dispatch and Dispatch Sync

Hi, I brought this up on the slack, but it got lost due to no history.

There is some surprising behaviour when mixing dispatch and dispatch sync.

;; sync inside async gets lost
(defmulti controller (fn [event] event))

(defmethod controller :one []
  (println "one")
  {:state {1 true}
   :effect nil})

(defmethod controller :two [_ _ state]
  (println "two:" state)
  {:state (assoc state 2 true)
   :effect2 nil})

(defmethod controller :three [_ _ state]
  (println "three:" state)
  {:state (assoc state 3 true)
   :effect3 nil})

(defmethod controller :four [_ _ state]
  (println "four:" state)
  {:state (assoc state 4 true)})


(defn effect [r _ _]
  (citrus/dispatch-sync! r :controller :two))

(defn effect2 [r _ _]
  (citrus/dispatch-sync! r :controller :three))

(defn effect3 [r _ _]
  (citrus/dispatch-sync! r :controller :four))


(defonce recon
  (citrus/reconciler {:state (atom {})
                      :controllers {:controller controller}
                      :effect-handlers {:effect effect
                                        :effect2 effect2
                                        :effect3 effect3}}))

(citrus/dispatch! recon :controller :one)

The output of this is

one
two: nil
three: {2 true}
four: {2 true, 3 true}

and then if you print the reconciler once its all run

#object [citrus.reconciler.Reconciler {:val {:controller {1 true}}}]

The apparent behaviour is that first the update from the dispatch! isn't reflected in the subsequent dispatch-sync! method calls. However if you then check the state of the reconciler its only the result of the dispatch! that is reflected.

If you change effect to dispatch! as well, like so

(defn effect [r _ _]
  (citrus/dispatch! r :controller :two))

you get the following output

one
two: {1 true}
three: {1 true}
four: {1 true, 3 true}
#object [citrus.reconciler.Reconciler {:val {:controller {1 true, 2 true}}}]

So which indicates the "bug" is when you switch between dispatch! and dispatch-sync!

If you flip the problem and start from a dispatch-sync! and go to dispatch! it appears to work as expected.

(citrus/dispatch-sync! recon :controller :one)

(defn effect [r _ _]
  (citrus/dispatch! r :controller :two))

(defn effect2 [r _ _]
  (citrus/dispatch! r :controller :three))

(defn effect3 [r _ _]
  (citrus/dispatch! r :controller :four))

Output:

one
two: {1 true}
three: {1 true, 2 true}
four: {1 true, 2 true, 3 true}
#object [citrus.reconciler.Reconciler {:val {:controller {1 true, 2 true, 3 true, 4 true}}}]

Wanted to raise this, even if the answer is simply a warning about mixing dispatch and dispatch-sync.

keep state of reconciler when recompiling/reloading namespace

Currently when I edit some control file, figwheel re-eval that file and reconciler, so the state is set to (atom {}) again and again.

  1. Is it possible to keep the state?
  2. Is there a way to pull the whole state of reconciler? So I could save the state and load it back after eval

Namespaced keywords for components names

Hi!

I am trying to use namespaced keywords to bring more clarity to subscribing and dispatching and could not make it work.

Here is a modified example of a component:

(ns probe.scrum.counter
  (:require [rum.core :as rum]
            [scrum.core :as scrum]))

(rum/defc counter-ui < rum/reactive [r]
  [:div
   ; :counter here should be in sync with keyword in
   ; reconciler declaration -> =(
   [:button {:on-click #(scrum/dispatch! r ::state :dec)} "-"]
   [:span   (rum/react (scrum/subscription r [::state]))]
   [:button {:on-click #(scrum/dispatch! r ::state :inc)} "+"]])

(def initial-state 0)

(defmulti counter-controller (fn [action] action))
(defmethod counter-controller :init [] initial-state)
(defmethod counter-controller :inc [_ _ counter] (inc counter))
(defmethod counter-controller :dec [_ _ counter] (dec counter))

And here is core.clj

(ns probe.core
  (:require [rum.core :as r]
            [scrum.core :as scrum]
            [probe.scrum.counter :as counter]))

(defonce reconciler
  (scrum/reconciler {:state       (atom {})
                     :controllers {:counter/state counter/counter-controller}}))
(defonce init-ctrl
  (scrum/broadcast-sync! reconciler :init))

(r/mount (counter/counter-ui reconciler)
         (js/document.getElementById "app"))

Could you please help me understand is it a bug or am I doing something wrong here?

Better state-handling in citrus ?

Hi,

I want to talk about an idea I have of citrus that would be a breaking change but would IMHO make citrus-based code more
expressive, coherent and testable.

The problem

There is a strange difference between server-side and browser-side citrus.

  • In the server, a resolver returns all the state necessary at once. A resolver is a map with all the necessary keys of the state filled up with some values.
    So one can write a single function that returns all the state, or several functions that return parts of it which can just merge.
  • In the browser, controllers/event handlers are restricted to operate on a single part/domain of the state. To be more precise, a handler
    can only read this single part of the state and write to the same single part.

This restriction, while it could look good at first (this is the equivalent of Splitting reducers and combining them in Redux),
is overly restrictive, and means that events which originate from the app, from some user interaction maybe
must have their semantics tied to one specific part/subdomain of the state.

For a small example, imagine a settings form with one input. Every time a user submit the form, you want to do two things with the state:

  • save the text from the input,
  • increment a global counter of form submissions.

In the current design of citrus, it means one potentially has to dispatch two events:

:on-submit (fn [_]
            (citrus/dispatch! reconciler :settings :save input-value)
            (citrus/dispatch! reconciler :submissions :inc))

or handle the same event in 2 different controllers.

:on-submit (fn [_]
            (citrus/dispatch! reconciler :settings :settings-form-submitted)
            (citrus/dispatch! reconciler :submissions :settings-form-submitted))

Now, what if you wanted to save only when the global counter is less than 10 ? You can't do it in neither the :settings controller nor the :submissions controller!
You have to create an effect just to be able to get a handle on a reconciler so that you can read arbitrary part of the state.

The effect handler would look like this:

(let [new-submissions-counter (inc @(citrus/subscription reconciler :submissions))]
  (citrus/dispatch! reconciler :submissions :inc)
  (if (< 10 new-submissions-counter)
    (citrus/dispatch! reconciler :settings :save input-value)))

Which means an effect handler must be used whereas we do no side-effect, only manipulating state! Or you have to put this code in the UI component itself, which far from optimal.
It also means that in these cases our events look more like "RPC" calls. They don't convey the semantics of what happened, but are often named after the how to manipulate the state part.

Expressed more formally, the problem is that:

  • writing to multiple parts of the state for one event is not possible, one has to dispatch one event per state slice or split the logic into different controllers.
  • reading from several parts of the state for one event is not possible, one has to go either through dispatch -> effect -> multiple reads or logic in UI component -> multiple reads
  • logic is spread everywhere, leading to less cohesive, difficult to maintain and hard to test code
  • server-side and client-side citrus operate too differently

A proposed solution

Ideally one would dispatch! a single event like :settings-form-submitted, and do all the logic at once. To do that citrus needs to change in breaking ways. Here's what I think is the least breaking way:

  • change nothing server-side
  • client-side,
    • create a reconciler with a single controller multimethod that receive the whole state and returns only the part that changes
    • (unsure about it) still declares the top-level keys in the state and use this to check state effects.

API would roughly look like this:

;; single multimethod
(citrus/reconciler {:state (atom {:settings nil :submissions 0}) ; same as before, not empty here for the test below
                    :effect-handlers {} ;same as before
                    :controller control
                    :state-keys #{:settings :submissions}})

(defmulti control (fn [event] event))

;; coherent, cohesive
(defmethod control :settings-form-submitted [event [input-value] {:keys [submissions] :as current-state}]
    {:state {:settings input-value
             :submissions (inc submissions)}})

;; always testable
(deftest settings-form-submitted
   (citrus/dispatch-sync! reconciler :settings-form-submitted "some text")
   (is (= "some text" @(citrus/subscription reconciler [:settings]))
       (= 1 @(citrus/subscription reconciler [:submissions]))))

The top-level keys declaration is not mandatory at all to make it work, but it means without this we lose the information.
That's why we could also before setting the state check that the keys under the :state effect belong to the set of keys declared.

Pros/cons

Obvious cons: breaking change... It could exist in a fork/new version of citrus though.

Pros: more coherent code, events names only convey the semantics, every state-related change can happen in a single event handler. It would also make it easier to integrate a good logger into citrus, that would display both the event-name and the state before/after à la Redux-dev-tools.

Receiving the whole state isn't a problem, immutability got our back. And by the way this how Redux works out of the box, before you split the reducers.

The rest of citrus doesn't change. I think in terms of code size the change could be quite small.

What do you think ?

PS: sorry if there are errors/typos in this (again) long blob of text.
PS 2: pinging @jacomyal, I'd love this input (we already had a similar conversation at work, but I haven't exposed him this precise stuff).

adding `clj-commons/citrus` overrules `resources/public/index.html` script[src]

Hi,

I'm not sure if this is a bug or something everyone knows about.
But coming from an new-to-cljs perspective the following confusion happend to me:

  1. I had a cljs/figwheel-main/cider/rum setup set up
    1a) including a standard resources/public/index.html which contained some script[src="cljs-out/dev-main.js"]
    2b) the setup worked normally

  2. read about citrus, decide to try it out

  3. as soon as clj-commons/citrus is added to deps.edn, the build-in-figwheel-dev-server:9500 returns another index.html response with script[src="js/compiled/main.js"] or similar, which is not there.

  4. how and why?

Isomorphic scrum ?

Hello,

I want to start a discussion about using scrum in an isomorphic rum web app. We looked at the code and saw that a really small portion of the code uses javascript interop (mostly calls to js/requestAnimationFrame).

Why didn't you implement isomorphic scrum (with cljc) ? I know that scrum server-side brings almost no interest, but isomorphic does bring a lot.

What we understood from the code and Readme :

  • initializing state by dispatching a scrum event with broadcast-sync! already works both server and client side
  • subscription just needs a Clojure implementation that does a get-in with the given path in the state. Need to think about multithreading though... Maybe it's a problem.

I hope you engage in this discussion and that we find a way to do it. Thanks in advance !

re-frame interceptors without the global mutable state

Hi,

We had a little discussion last week on reddit about re-frame interceptors and I said I was going to open an issue the day after... Sorry for the delay!

Well I dug up so deep into re-frame interceptors that I ended up making a fork of re-frame that kind of supports rum, and without global state https://github.com/chpill/re-frankenstein.

The https://github.com/chpill/re-frankenstein/blob/master/src/re_frame/frank.cljs namespace was derived from your reconciler design. It shows how to use re-frame interceptors without the global mutable state.

Enhancement: integration with dev tools

Hello @roman01la
Thanks for this project

I tried out scrum and i like it
But for me there is some lack of integration with developer tools (aka redux dev tools)
It would be nice to have the ability to use debugger or logger for events and state changes
or maybe time travel debugger

Maybe i miss something and there is some tools for that?

Redux-like middlewares ?

Hi,

Thinking about our discussion at EuroClojure Berlin, I finally think middlewares could be useful. Indeed watching state changes is nice but doesn't allow logging/watching events and doing more advanced stuff.

So starting a discussion about them.

Global state

Hello.

Funny thing, as I was developing https://github.com/pepe/showrum yesterday, I came to the edge of what is feasible with simple hash and methods and started to think about how to continue. And in the morning I found scrum 👍. Thank you very much for it, I love OpenSource!

As I was looking into the code, I found that DB is global for the library (and makes it a framework?), which brought to my mind this issue day8/re-frame#107 by @darwin in re-frame (which I was using extensively, but abandon it lately). I am still not sure, if it is actually problem or not, I just want to bring it to your attention.

Again, thank you, I will try to move showrum to scrum ASAP.

Project renaming

Tracking migration path as proposed in #16 (comment)

  • rename to citrus
  • bump to 3.0.0
  • rename the repo
  • update README
  • Put a notice at the top of the repo readme, above the title
  • publish an artifact
  • Rename Slack channel
  • Post on clojurescript mailing list and twitter
  • Tell users to s/scrum/citrus/g relevant files
  • Migrate example projects

Best practice regarding reconciler

Simple question : for non-trivial application with deep tree of components, how do you pass the reconciler down ? Top down from parents to children ? Require it where necessary ?

I'm interested in your personal experience.

`nil` is not a valid state for `dispatch!` not `dispatch-sync!`

Hello, long time no bug report 😄

I noticed a small problem/inconsistency. When I dispatch! an event that's supposed to clear the current state by setting it to nil, the state isn't cleared at all. In fact it isn't changed.

Some code to show the problem :

(defmutli widget-controller (fn [event _ _ ] event))

(defmethod widget-controller :clear [_ _ _]
  {:state nil})

The problem comes from the Reconciler. The line (let [state-effects (filter (comp :state second) effects) ;; other bindings]) will prevent state declared with nil as a target value to be taken into account. Indeed, in the REPL :

(filter :a [{:a 1} {:a 2} {:a nil}])
;; => ({:a 1} {:a 2})

The problem doesn't happen if I dispatch-sync! instead. I think dispatch-sync! has the right behavior. nil is a totally valid value for piece of state.

Here's a solution, directly taken from the code of the binding for other-effects (just below the code linked above). First bad code example, second is solution :

;; Bad, current code
(let [effects [[:ctrl1 {:state 1 :some :effect}]
               [:ctrl2 {:state nil}]
               [:ctrl3 {:some :effect}]]] 
  (filter (comp :state second) effects))
;; => ([:ctrl1 {:state 1, :some :effect}]) ;; :ctrl2 has been filtered out ! Furthermore, the effects are still here (it's ok they're filtered just after though)

;; Good, solution proposed
(let [effects [[:ctrl1 {:state 1 :some :effect}]
               [:ctrl2 {:state nil}]
               [:ctrl3 {:some :effect}]]]
  (->> effects
       (map (fn [[cname effect]]
              [cname (select-keys effect [:state])]))
       (filter (comp seq second))))
;; => ([:ctrl1 {:state 1}] [:ctrl2 {:state nil}]) ;; :ctrl2 still here and only :state effects \o/

Note : I'm still using [org.roman01la/scrum "2.1.0-SNAPSHOT" :exclusions [rum]] but I'm not asking you to fix this old version. I'll move to citrus soon.

In the meantime, I'll just put an empty map when :clearing state.

End of bug report. Have a nice day !

3.2.1 not available in repos

My leiningen cannot find citrus 3.2.1 in any repo.

error in process sentinel: Could not start nREPL server: Could not find artifact org.roman01la:citrus:jar:3.2.1 in central (https://repo1.maven.org/maven2/)
Could not find artifact org.roman01la:citrus:jar:3.2.1 in clojars (https://repo.clojars.org/)
Could not find artifact org.roman01la:citrus:jar:3.2.1 in sonatype (https://oss.sonatype.org/content/repositories/snapshots)

I suspect it is not published?

Updating state with same values does not trigger render

Currently, when a controller returns state that is exactly the same as the previous state, a re-render does not occur, because the state-change callback is not executed. The logic being: why re-render when there are no changes?

However, I currently have a case where re-render should definitely occur, even if new state is the same as the previous state. I will explain the usecase later, but for now a code sample to demonstrate the problem:

(def state {:controller "42"})

(defmulti controller (fn [event] event))

(defmethod controller :set [_ _ current]
  {:state current})

(def reconciler
  (citrus/reconciler
   {:state (atom state)
    :controllers {:controller controller}}))

(def subscription (citrus/subscription reconciler [:controller]))

(rum/defc Editor < rum/reactive [r]
  (let [text (rum/react subscription)]
    [:input {:type "text" :value text 
                          :on-change #(citrus/dispatch-sync! r :controller :set)}]))

(rum/mount (Editor reconciler)
           (. js/document (getElementById "app")))

The code sample contains a simplified example of the issue I am experiencing: I want to prevent the end-user from entering invalid data in an editbox. In case of the sample the behaviour should be that the user can never change the value in the textbox from "42" to something else.

However, if you run the sample, you will see that it's definitely possible to change the value in the textbox from 42 to something else.

The sample seems pretty nonsensical, but in a more reallife example: imagine you want the user to only be allowed to enter certain characters (numerical, or legal email adres characters for instance). Then you want to be able to revert to the old value.

The solution that currently works for me is changing the following lines of code in Citrus:

;; in citrus.reconciler [41 - 47]
  IWatchable
  (-add-watch [this key callback]
    (add-watch state (list this key)
      (fn [_ _ oldv newv]
        (callback key this oldv newv)))
        ;was
        ;(when (not= oldv newv)
        ;  (callback key this oldv newv))))
    this)

;; in citrus.cursor [24 - 32]
  IWatchable
  (-add-watch [this key callback]
    (add-watch ref (list this key)
      (fn [_ _ oldv newv]
        (let [old (reducer (get-in oldv path))
              new (reducer (get-in newv path))]
          (callback key this old new))))
          ;was
          ;(when (not= old new)
          ;  (callback key this old new)))))
    this)

Happy to provide a PR if okay with the solution.

js/setTimeout for function using dispatch-sync!

Is there any way to use a setTimeout with a citrus reconciler.

When we create a timeout on the form #(citrus/dispatch! reconciler :event args) it seems like it wont touch the reconciler state.

The event handlers can, of course, use the reconciler with dispatches.

Controller/control function will fail unless it is a multimethod

the committ add dispatch assertions

7819582

means that controlller/control functions will blow up with a
Uncaught Error: No protocol method IMultiFn.-methods defined for type function:
if they are not a multimethod

Is this a bug or a feature?

I have a codebase that was using a simple control function to wrap a multimethod call that broke.
Was this code in violation of the citrus application structure (and therefore deserved to fail)?
Or is it an unintended oversight?

Modifying the state backend-side

In my current project, I am trying to implement a no-JS fallback to the most basic state controllers (ie. the ones that only emit :state). It would work like this:

  1. The user clicks on a button, which is actually the submit of a hidden form
  2. The browser calls the current URL as a POST call, with the controller name, the event name and the serialized params in the payload
  3. The backend instanciates the reconciler, applies the related state controller (that would modify the related state branch), then renders the HTML page and sends it back

But I have two issues:

  • First, I cannot actually manage to modify the state backend-side
  • Also, I am not sure how useful the resolvers are

Why I cannot modify the state backend side

The current implementation of the Resolver is written such that it will always resolve the state branch with the initially given resolver function:

;; ...
clojure.lang.IDeref
(deref [_]
  (let [[key & path] path
        resolve (get resolver key)
        data (resolve)]
    (when state
      (swap! state assoc key data))
    (if reducer
      (reducer (get-in data path))
      (get-in data path))))
;; ...

By the way, this is also clearly stated in the doc.

There is no way to bypass that, except by instanciating a new Reconciler, with resolvers that return the modified state branches - which looks definitely smelly.

Can you see any other way to do that, with the current implementation of Citrus?

Also, a solution would be to first check if the related state branch is already in the state, before calling the resolver:

;; ...
clojure.lang.IDeref
(deref [_]
  (let [[key & path] path
        resolve (get resolver key)
        data (if (contains? state key)
               (get state key)
               (resolve))]
    (when state
      (swap! state assoc key data))
    (if reducer
      (reducer (get-in data path))
      (get-in data path))))
;; ...

Which by the way would deal with caching issue at the same time, and allow me to transform the state by just reset!ing the state atom.


Why using resolvers at all

I understand that the purpose of resolvers is to only load data that will actually be used in the UI. But the way I see it, I think it's not the best design:

  • I have to deal with cache by myself
  • I cannot concurrently load different data branches

So the code can become quite verbose, to have something that is not necessarily done the best possible way.

Meanwhile, if Citrus would simply skip this feature:

  • I'd have to only load the data I want (which looks less good on paper, I admit)
  • I could load my data the way I want (we already do this here, by the way)
  • I would simply give the new reconciler the initial state or a fed atom

The state would no more be lazy, which would make manipulating it way easier. What do you think about it?

Bug ? Subscription path must originally exist in server-side's resolver initial state

Ouch, I just stumbled upon what I think is a bug. Not sure.

Say you have this reconciler server-side :

(scrum/reconciler {:state (atom {})
                              :resolvers {[:a] (constantly {:b "b"})
                                                [:a :b] (constantly "b")}}) ;; the weird thing !

and the following subscriptions :

(defn a [reconciler]
  (scrum/subscription reconciler [:a]))

(defn b [reconciler]
  (scrum/subscription reconciler [:a :b] #(some-function %)))

As it is the code works. But if I remove the "weird" resolver (the line with the comment) I get a NullPointerException when loading the page : (relevant stacktrace)

Caused by: java.lang.NullPointerException: null
        at scrum.resolver.Resolver.deref(resolver.clj:8)
        at clojure.core$deref.invokeStatic(core.clj:2310)
        at clojure.core$deref.invoke(core.clj:2296)
        at my.ns.my-component$fn__41563.invokeStatic(my-component.cljc:116)

I can't just remove the first resolver, it's really used somewhere else in my app.

My understanding of the code in resolver.clj is that when there's a subscription, at the moment you rum/react it (which is just clojure.core/deref server-side), it does a simple (get resolvers path). In my case path is [:a :b]. So this path must exist in the resolvers map, even if it means duplicating part of the initial state across several paths.

I know that by design, only rum/reacted subscription hence paths will have their corresponding resolving functions called. But it means that deep subscriptions must either have a top-level subscription executed before to get the child path values or have child path values duplicated in proper deep path in resolvers map.

I hope I was clear.

Edit : after some thinking, I realized I could just have this subscription instead and call it a day :

(defn b [reconciler]
  (scrum/subscription reconciler [:a] #(some-function (:b %)))

That is, reading the nested value in my aggregate function. But I think this make subscriptions mostly useless. Reading your subscriptions examples in the readme, I don't see how using paths like [:users 0 :fname] could work without the corresponding resolving function at this exact path in the reconciler.

Maybe the solution is not to do a simple get in resolver.clj but a smart reduce :

(deftype Resolver [state resolvers path reducer]

  clojure.lang.IDeref
  (deref [_]
    (let [resolve (get resolvers path) ;; use reduce instead
          data (resolve)]
      (when state
        (swap! state assoc-in path data))
      (if reducer
        (reducer data)
        data))))

That is, the reducing code should check for each fragment of path if there's a corresponding resolving function and call if it exists. When no resolving function are found, it should get at this point (or maybe get-in ?).

Long issue. End of work day gotta go. I hope this issue is helpful.

Rename to strum?

STate manage for Rum?

@escherize was telling me about this library, and I thought Scrum was a confusing name :)

Bug in aggregate subscriptions based on rum derived atoms ?

Hi,

I'm following the README to the letter and I think I found a bug. See the following minimal reproduction case in the REPL :

(require '[scrum.core :as scrum])
=> nil
(require '[rum.core :as rum])
=> nil
(let [rec (scrum/reconciler {:state (atom {})
                             :resolvers {[:a] (constantly :a)}})
      sub (fn [reconciler]
            (scrum/subscription reconciler [:a]))
      der (fn [reconciler]
            (rum/derived-atom [(sub reconciler)] ::key (constantly :derived)))]
  @(der rec))
=> ClassCastException scrum.resolver.Resolver cannot be cast to clojure.lang.IRef  clojure.core/add-watch (core.clj:2134)

Whereas plain derived-atoms in rum work fine :

(let [a (atom :a)
      b (atom :b)
      der (rum/derived-atom [a b] ::key
                            (fn [a b] 
                              (println "a" a "b" b)))]
  (reset! a :aaa)
  (reset! b :bbb))
a :a b :b
a :aaa b :b
a :aaa b :bbb
=> :bbb

The problem : rum calls add-watch on each ref, and in clojure.core add-watch has a type hint on the ref of clojure.lang.IRef.

(I think) I was able to track down the root cause : scrum's Resolver only implements clojure.lang.IDeref protocol, but clojure.lang.IRef extends IDeref and implements more methods. I think the Resolver should implement IRef as well to work just fine. Not sure, I'm not very familiar with Clojure's protocols.

In the meantime, not sure what I should do to work around the problem.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.