├── .circleci └── config.yml ├── .github └── CODEOWNERS ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── ORIGINATOR ├── README.md ├── deps.edn ├── dev └── user.clj ├── doc └── custom-handler.md ├── example └── counter │ └── core.cljs ├── logo.png ├── package-lock.json ├── package.json ├── project.clj ├── recipes └── routing │ ├── components.cljs │ ├── controllers │ └── router.cljs │ ├── core.cljs │ └── router.cljs ├── resources └── public │ └── index.html ├── slack.png ├── src └── citrus │ ├── cofx.clj │ ├── core.clj │ ├── core.cljs │ ├── cursor.cljs │ ├── macros.clj │ ├── reconciler.cljs │ └── resolver.clj └── test └── citrus ├── core_test.clj ├── core_test.cljs ├── custom_handler_test.cljs ├── resolver_test.clj └── test_runner.cljs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | workflows: 4 | build-deploy: 5 | jobs: 6 | - build: 7 | filters: 8 | tags: 9 | only: /.*/ 10 | 11 | - deploy: 12 | requires: 13 | - build 14 | filters: 15 | tags: 16 | only: /Release-.*/ 17 | context: 18 | - CLOJARS_DEPLOY 19 | 20 | jobs: 21 | build: 22 | docker: 23 | # specify the version you desire here 24 | - image: circleci/clojure:lein-2.9.1-node-browsers 25 | 26 | # Specify service dependencies here if necessary 27 | # CircleCI maintains a library of pre-built images 28 | # documented at https://circleci.com/docs/2.0/circleci-images/ 29 | # - image: circleci/postgres:9.4 30 | 31 | working_directory: ~/repo 32 | 33 | environment: 34 | LEIN_ROOT: "true" 35 | # Customize the JVM maximum heap limit 36 | JVM_OPTS: -Xmx3200m 37 | 38 | steps: 39 | - checkout 40 | 41 | # Download and cache dependencies 42 | - restore_cache: 43 | keys: 44 | - v1-dependencies-{{ checksum "project.clj" }} 45 | # fallback to using the latest cache if no exact match is found 46 | - v1-dependencies- 47 | 48 | - run: lein deps 49 | 50 | - save_cache: 51 | paths: 52 | - ~/.m2 53 | key: v1-dependencies-{{ checksum "project.clj" }} 54 | 55 | # run tests! 56 | - run: lein test 57 | 58 | # run cljs tests! 59 | - run: npm ci 60 | - run: lein doo chrome test once 61 | 62 | deploy: 63 | docker: 64 | # specify the version you desire here 65 | - image: circleci/clojure:lein-2.9.1-node-browsers 66 | # Specify service dependencies here if necessary 67 | # CircleCI maintains a library of pre-built images 68 | # documented at https://circleci.com/docs/2.0/circleci-images/ 69 | # - image: circleci/postgres:9.4 70 | 71 | working_directory: ~/repo 72 | 73 | environment: 74 | LEIN_ROOT: "true" 75 | # Customize the JVM maximum heap limit 76 | JVM_OPTS: -Xmx3200m 77 | 78 | steps: 79 | - checkout 80 | 81 | # Download and cache dependencies 82 | - restore_cache: 83 | keys: 84 | - v1-dependencies-{{ checksum "project.clj" }} 85 | # fallback to using the latest cache if no exact match is found 86 | - v1-dependencies- 87 | 88 | # Download and cache dependencies 89 | - restore_cache: 90 | keys: 91 | - v1-dependencies-{{ checksum "project.clj" }} 92 | # fallback to using the latest cache if no exact match is found 93 | - v1-dependencies- 94 | 95 | - run: 96 | name: Install babashka 97 | command: | 98 | curl -s https://raw.githubusercontent.com/borkdude/babashka/master/install -o install.sh 99 | sudo bash install.sh 100 | rm install.sh 101 | - run: 102 | name: Install deployment-script 103 | command: | 104 | curl -s https://raw.githubusercontent.com/clj-commons/infra/main/deployment/circle-maybe-deploy.bb -o circle-maybe-deploy.bb 105 | chmod a+x circle-maybe-deploy.bb 106 | 107 | - run: lein deps 108 | 109 | - run: 110 | name: Setup GPG signing key 111 | command: | 112 | GNUPGHOME="$HOME/.gnupg" 113 | export GNUPGHOME 114 | mkdir -p "$GNUPGHOME" 115 | chmod 0700 "$GNUPGHOME" 116 | 117 | echo "$GPG_KEY" \ 118 | | base64 --decode --ignore-garbage \ 119 | | gpg --batch --allow-secret-key-import --import 120 | 121 | gpg --keyid-format LONG --list-secret-keys 122 | 123 | - save_cache: 124 | paths: 125 | - ~/.m2 126 | key: v1-dependencies-{{ checksum "project.clj" }} 127 | - run: 128 | name: Deploy 129 | command: | 130 | GPG_TTY=$(tty) 131 | export GPG_TTY 132 | echo $GPG_TTY 133 | ./circle-maybe-deploy.bb lein deploy clojars 134 | 135 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @martinklepsch @roman01la 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml 5 | pom.xml.asc 6 | *.jar 7 | *.class 8 | /.lein-* 9 | /.nrepl-port 10 | .hgignore 11 | .hg/ 12 | .idea 13 | *.iml 14 | node_modules/ 15 | out/ 16 | .cpcache/ 17 | resources/public/js/ 18 | *.log 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 3.3.0 2 | 3 | - Allow full customization of event handling via a new `:citrus/handler` option 4 | that allows you to replace the standard controller/multimethod-based event 5 | handling. 6 | - See the [full documentation on custom handlers](https://github.com/clj-commons/citrus/blob/master/doc/custom-handler.md) 7 | - or see [the tests](https://github.com/clj-commons/citrus/blob/master/test/citrus/custom_handler_test.cljs) for a concise example. 8 | - This change is intended to be **fully backwards compatible**, please open an issue if you encounter any problems. 9 | 10 | ## 3.2.3 11 | - Don't evaluate default-batched-updates until necessary 0788a7f10 12 | 13 | ## 3.2.2 14 | - Fix bug where subscriptions with both nested path and reducer functions worked differently on CLJ and CLJS @DjebbZ 15 | 16 | ## 3.2.1 17 | - Removed assert that checked `-methods` on a dispatching function which implied the function to be a multimethod 18 | 19 | ## 3.2.0 20 | - Add more tests @DjebbZ 21 | - Add a single watch to the state per reconciler @DjebbZ 22 | - Add assertions for dispatching functions 23 | - Assert speced effects when corresponding spec exists 24 | 25 | ## 3.1.0 26 | - Added deps.edn 27 | - Rum 0.11.2 28 | - ClojureScript 1.10.238 29 | - Provide both a schedule-fn and release-fn to customize batched updates 4f9a07d 30 | - Implement co-effects via `citrus.core/defhandler` macro 31 | 32 | ## 1.0.0-SNAPHOST 33 | - add Reconciler type to get rid of global state [7afd576](https://github.com/roman01la/citrus/commit/7afd576b512d53f3846beb8fca1bcd06066ac289) 34 | - remove ISwap & IReset protocols impl and introduce updates batching & scheduling [00dd5cc](https://github.com/roman01la/citrus/commit/0fdbe26539ccae3a06b2b5c41c7abddf269bc2cb) 35 | - expose Reconciler API for sync updates [0fdbe26](https://github.com/roman01la/citrus/commit/926df2f4cec96185bbcfc7d0dade2f7c8b59cf1d) 36 | - make global schedule and queue local to reconciler instance [41c6e57](https://github.com/roman01la/citrus/commit/e3b6d960012738cff47e28b3181f837c0dd428a0) 37 | - add pluggable batched-updates & chunked-updates fns [e3b6d96](https://github.com/roman01la/citrus/commit/ef2c24130fbd693f24629d58f44fc5b8dd8a6280) 38 | - perform latest scheduled update [b55d48e](https://github.com/roman01la/citrus/commit/31ded3c6327d09a8c16a007ae6d28e5d84500fcf) 39 | 40 | ## 0.1.0 41 | - Initial release 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC 2 | LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM 3 | CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 4 | 5 | 1. DEFINITIONS 6 | 7 | "Contribution" means: 8 | 9 | a) in the case of the initial Contributor, the initial code and 10 | documentation distributed under this Agreement, and 11 | 12 | b) in the case of each subsequent Contributor: 13 | 14 | i) changes to the Program, and 15 | 16 | ii) additions to the Program; 17 | 18 | where such changes and/or additions to the Program originate from and are 19 | distributed by that particular Contributor. A Contribution 'originates' from 20 | a Contributor if it was added to the Program by such Contributor itself or 21 | anyone acting on such Contributor's behalf. Contributions do not include 22 | additions to the Program which: (i) are separate modules of software 23 | distributed in conjunction with the Program under their own license 24 | agreement, and (ii) are not derivative works of the Program. 25 | 26 | "Contributor" means any person or entity that distributes the Program. 27 | 28 | "Licensed Patents" mean patent claims licensable by a Contributor which are 29 | necessarily infringed by the use or sale of its Contribution alone or when 30 | combined with the Program. 31 | 32 | "Program" means the Contributions distributed in accordance with this 33 | Agreement. 34 | 35 | "Recipient" means anyone who receives the Program under this Agreement, 36 | including all Contributors. 37 | 38 | 2. GRANT OF RIGHTS 39 | 40 | a) Subject to the terms of this Agreement, each Contributor hereby grants 41 | Recipient a non-exclusive, worldwide, royalty-free copyright license to 42 | reproduce, prepare derivative works of, publicly display, publicly perform, 43 | distribute and sublicense the Contribution of such Contributor, if any, and 44 | such derivative works, in source code and object code form. 45 | 46 | b) Subject to the terms of this Agreement, each Contributor hereby grants 47 | Recipient a non-exclusive, worldwide, royalty-free patent license under 48 | Licensed Patents to make, use, sell, offer to sell, import and otherwise 49 | transfer the Contribution of such Contributor, if any, in source code and 50 | object code form. This patent license shall apply to the combination of the 51 | Contribution and the Program if, at the time the Contribution is added by the 52 | Contributor, such addition of the Contribution causes such combination to be 53 | covered by the Licensed Patents. The patent license shall not apply to any 54 | other combinations which include the Contribution. No hardware per se is 55 | licensed hereunder. 56 | 57 | c) Recipient understands that although each Contributor grants the licenses 58 | to its Contributions set forth herein, no assurances are provided by any 59 | Contributor that the Program does not infringe the patent or other 60 | intellectual property rights of any other entity. Each Contributor disclaims 61 | any liability to Recipient for claims brought by any other entity based on 62 | infringement of intellectual property rights or otherwise. As a condition to 63 | exercising the rights and licenses granted hereunder, each Recipient hereby 64 | assumes sole responsibility to secure any other intellectual property rights 65 | needed, if any. For example, if a third party patent license is required to 66 | allow Recipient to distribute the Program, it is Recipient's responsibility 67 | to acquire that license before distributing the Program. 68 | 69 | d) Each Contributor represents that to its knowledge it has sufficient 70 | copyright rights in its Contribution, if any, to grant the copyright license 71 | set forth in this Agreement. 72 | 73 | 3. REQUIREMENTS 74 | 75 | A Contributor may choose to distribute the Program in object code form under 76 | its own license agreement, provided that: 77 | 78 | a) it complies with the terms and conditions of this Agreement; and 79 | 80 | b) its license agreement: 81 | 82 | i) effectively disclaims on behalf of all Contributors all warranties and 83 | conditions, express and implied, including warranties or conditions of title 84 | and non-infringement, and implied warranties or conditions of merchantability 85 | and fitness for a particular purpose; 86 | 87 | ii) effectively excludes on behalf of all Contributors all liability for 88 | damages, including direct, indirect, special, incidental and consequential 89 | damages, such as lost profits; 90 | 91 | iii) states that any provisions which differ from this Agreement are offered 92 | by that Contributor alone and not by any other party; and 93 | 94 | iv) states that source code for the Program is available from such 95 | Contributor, and informs licensees how to obtain it in a reasonable manner on 96 | or through a medium customarily used for software exchange. 97 | 98 | When the Program is made available in source code form: 99 | 100 | a) it must be made available under this Agreement; and 101 | 102 | b) a copy of this Agreement must be included with each copy of the Program. 103 | 104 | Contributors may not remove or alter any copyright notices contained within 105 | the Program. 106 | 107 | Each Contributor must identify itself as the originator of its Contribution, 108 | if any, in a manner that reasonably allows subsequent Recipients to identify 109 | the originator of the Contribution. 110 | 111 | 4. COMMERCIAL DISTRIBUTION 112 | 113 | Commercial distributors of software may accept certain responsibilities with 114 | respect to end users, business partners and the like. While this license is 115 | intended to facilitate the commercial use of the Program, the Contributor who 116 | includes the Program in a commercial product offering should do so in a 117 | manner which does not create potential liability for other Contributors. 118 | Therefore, if a Contributor includes the Program in a commercial product 119 | offering, such Contributor ("Commercial Contributor") hereby agrees to defend 120 | and indemnify every other Contributor ("Indemnified Contributor") against any 121 | losses, damages and costs (collectively "Losses") arising from claims, 122 | lawsuits and other legal actions brought by a third party against the 123 | Indemnified Contributor to the extent caused by the acts or omissions of such 124 | Commercial Contributor in connection with its distribution of the Program in 125 | a commercial product offering. The obligations in this section do not apply 126 | to any claims or Losses relating to any actual or alleged intellectual 127 | property infringement. In order to qualify, an Indemnified Contributor must: 128 | a) promptly notify the Commercial Contributor in writing of such claim, and 129 | b) allow the Commercial Contributor to control, and cooperate with the 130 | Commercial Contributor in, the defense and any related settlement 131 | negotiations. The Indemnified Contributor may participate in any such claim 132 | at its own expense. 133 | 134 | For example, a Contributor might include the Program in a commercial product 135 | offering, Product X. That Contributor is then a Commercial Contributor. If 136 | that Commercial Contributor then makes performance claims, or offers 137 | warranties related to Product X, those performance claims and warranties are 138 | such Commercial Contributor's responsibility alone. Under this section, the 139 | Commercial Contributor would have to defend claims against the other 140 | Contributors related to those performance claims and warranties, and if a 141 | court requires any other Contributor to pay any damages as a result, the 142 | Commercial Contributor must pay those damages. 143 | 144 | 5. NO WARRANTY 145 | 146 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON 147 | AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER 148 | EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR 149 | CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A 150 | PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the 151 | appropriateness of using and distributing the Program and assumes all risks 152 | associated with its exercise of rights under this Agreement , including but 153 | not limited to the risks and costs of program errors, compliance with 154 | applicable laws, damage to or loss of data, programs or equipment, and 155 | unavailability or interruption of operations. 156 | 157 | 6. DISCLAIMER OF LIABILITY 158 | 159 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY 160 | CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, 161 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION 162 | LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 163 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 164 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 165 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY 166 | OF SUCH DAMAGES. 167 | 168 | 7. GENERAL 169 | 170 | If any provision of this Agreement is invalid or unenforceable under 171 | applicable law, it shall not affect the validity or enforceability of the 172 | remainder of the terms of this Agreement, and without further action by the 173 | parties hereto, such provision shall be reformed to the minimum extent 174 | necessary to make such provision valid and enforceable. 175 | 176 | If Recipient institutes patent litigation against any entity (including a 177 | cross-claim or counterclaim in a lawsuit) alleging that the Program itself 178 | (excluding combinations of the Program with other software or hardware) 179 | infringes such Recipient's patent(s), then such Recipient's rights granted 180 | under Section 2(b) shall terminate as of the date such litigation is filed. 181 | 182 | All Recipient's rights under this Agreement shall terminate if it fails to 183 | comply with any of the material terms or conditions of this Agreement and 184 | does not cure such failure in a reasonable period of time after becoming 185 | aware of such noncompliance. If all Recipient's rights under this Agreement 186 | terminate, Recipient agrees to cease use and distribution of the Program as 187 | soon as reasonably practicable. However, Recipient's obligations under this 188 | Agreement and any licenses granted by Recipient relating to the Program shall 189 | continue and survive. 190 | 191 | Everyone is permitted to copy and distribute copies of this Agreement, but in 192 | order to avoid inconsistency the Agreement is copyrighted and may only be 193 | modified in the following manner. The Agreement Steward reserves the right to 194 | publish new versions (including revisions) of this Agreement from time to 195 | time. No one other than the Agreement Steward has the right to modify this 196 | Agreement. The Eclipse Foundation is the initial Agreement Steward. The 197 | Eclipse Foundation may assign the responsibility to serve as the Agreement 198 | Steward to a suitable separate entity. Each new version of the Agreement will 199 | be given a distinguishing version number. The Program (including 200 | Contributions) may always be distributed subject to the version of the 201 | Agreement under which it was received. In addition, after a new version of 202 | the Agreement is published, Contributor may elect to distribute the Program 203 | (including its Contributions) under the new version. Except as expressly 204 | stated in Sections 2(a) and 2(b) above, Recipient receives no rights or 205 | licenses to the intellectual property of any Contributor under this 206 | Agreement, whether expressly, by implication, estoppel or otherwise. All 207 | rights in the Program not expressly granted under this Agreement are 208 | reserved. 209 | 210 | This Agreement is governed by the laws of the State of New York and the 211 | intellectual property laws of the United States of America. No party to this 212 | Agreement will bring a legal action under this Agreement more than one year 213 | after the cause of action arose. Each party waives its rights to a jury trial 214 | in any resulting litigation. 215 | -------------------------------------------------------------------------------- /ORIGINATOR: -------------------------------------------------------------------------------- 1 | @roman01la 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | *__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**.* 2 | 3 | [![Clojars Project](https://img.shields.io/clojars/v/clj-commons/citrus.svg)](https://clojars.org/clj-commons/citrus) 4 | [![cljdoc badge](https://cljdoc.org/badge/clj-commons/citrus)](https://cljdoc.org/d/clj-commons/citrus/CURRENT) 5 | [![CircleCI](https://circleci.com/gh/clj-commons/citrus.svg?style=svg)](https://circleci.com/gh/clj-commons/citrus) 6 | 7 | 8 | citrus logo 9 | 10 | *State management library for [Rum](https://github.com/tonsky/rum/)* 11 | 12 | > 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. 13 | 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. 14 | > — [@oakmac](https://github.com/clj-commons/citrus/issues/16#issuecomment-324111509) 15 | 16 | 17 | 18 | Discuss on Clojurians Slack #citrus 19 | 20 | 21 |

22 | 23 | 24 | 25 |

26 | 27 | ## Table of Contents 28 | 29 | - [Motivation](#motivation) 30 | - [Features](#features) 31 | - [Apps built with Citrus](#apps-built-with-citrus) 32 | - [Installation](#installation) 33 | - [Usage](#usage) 34 | - [How it works](#how-it-works) 35 | - [Reconciler](#reconciler) 36 | - [Dispatching events](#dispatching-events) 37 | - [Handling events](#handling-events) 38 | - [Side effects](#side-effects) 39 | - [Subscriptions](#subscriptions) 40 | - [Scheduling and batching](#scheduling-and-batching) 41 | - [Server-side rendering](#server-side-rendering) 42 | - [Best practices](#best-practices) 43 | - [Recipes](#recipes) 44 | - [FAQ](#faq) 45 | - [Testing](#testing) 46 | - [Roadmap](#roadmap) 47 | - [Contributing](#contributing) 48 | - [License](#license) 49 | 50 | ## Motivation 51 | 52 | Have a simple, [re-frame](https://github.com/Day8/re-frame) like state management facilities for building web apps with [Rum](https://github.com/tonsky/rum/) while leveraging its API. 53 | 54 | ## Features 55 | 56 | ⚛️ Decoupled application state in a single atom 57 | 58 | 📦 No global state, everything lives in `Reconciler` instance 59 | 60 | 🎛 A notion of a *controller* to keep application domains separate 61 | 62 | 🚀 Reactive queries 63 | 64 | 📋 Side-effects are described as data 65 | 66 | ⚡️ Async batched updates for better performance 67 | 68 | 🚰 Server-side rendering with convenient state hydration 69 | 70 | ## Apps built with Citrus 71 | - [Hacker News clone with server-side rendering](https://github.com/roman01la/scrum-ssr-example) 72 | - [“Real world” example app](https://github.com/roman01la/cljs-rum-realworld-example-app) 73 | 74 | ## Installation 75 | Add to *project.clj* / *build.boot*: `[clj-commons/citrus "3.3.0"]` 76 | 77 | ## Usage 78 | ```clojure 79 | (ns counter.core 80 | (:require [rum.core :as rum] 81 | [citrus.core :as citrus])) 82 | 83 | ;; 84 | ;; define controller & event handlers 85 | ;; 86 | 87 | (def initial-state 0) ;; initial state 88 | 89 | (defmulti control (fn [event] event)) 90 | 91 | (defmethod control :init [] 92 | {:local-storage 93 | {:method :get 94 | :key :counter 95 | :on-read :init-ready}}) ;; read from local storage 96 | 97 | (defmethod control :init-ready [_ [counter]] 98 | (if-not (nil? counter) 99 | {:state (js/parseInt counter)} ;; init with saved state 100 | {:state initial-state})) ;; or with predefined initial state 101 | 102 | (defmethod control :inc [_ _ counter] 103 | (let [next-counter (inc counter)] 104 | {:state next-counter ;; update state 105 | :local-storage 106 | {:method :set 107 | :data next-counter 108 | :key :counter}})) ;; persist to local storage 109 | 110 | (defmethod control :dec [_ _ counter] 111 | (let [next-counter (dec counter)] 112 | {:state next-counter ;; update state 113 | :local-storage 114 | {:method :set 115 | :data next-counter 116 | :key :counter}})) ;; persist to local storage 117 | 118 | 119 | ;; 120 | ;; define effect handler 121 | ;; 122 | 123 | (defn local-storage [reconciler controller-name effect] 124 | (let [{:keys [method data key on-read]} effect] 125 | (case method 126 | :set (js/localStorage.setItem (name key) data) 127 | :get (->> (js/localStorage.getItem (name key)) 128 | (citrus/dispatch! reconciler controller-name on-read)) 129 | nil))) 130 | 131 | 132 | ;; 133 | ;; define UI component 134 | ;; 135 | 136 | (rum/defc Counter < rum/reactive [r] 137 | [:div 138 | [:button {:on-click #(citrus/dispatch! r :counter :dec)} "-"] 139 | [:span (rum/react (citrus/subscription r [:counter]))] 140 | [:button {:on-click #(citrus/dispatch! r :counter :inc)} "+"]]) 141 | 142 | 143 | ;; 144 | ;; start up 145 | ;; 146 | 147 | ;; create Reconciler instance 148 | (defonce reconciler 149 | (citrus/reconciler 150 | {:state 151 | (atom {}) ;; application state 152 | :controllers 153 | {:counter control} ;; controllers 154 | :effect-handlers 155 | {:local-storage local-storage}})) ;; effect handlers 156 | 157 | ;; initialize controllers 158 | (defonce init-ctrl (citrus/broadcast-sync! reconciler :init)) 159 | 160 | ;; render 161 | (rum/mount (Counter reconciler) 162 | (. js/document (getElementById "app"))) 163 | ``` 164 | 165 | ## How it works 166 | With _Citrus_ you build everything around a well known architecture pattern in modern SPA development: 167 | 168 | 📦 *Model application state* (with `reconciler`) 169 | 170 | 📩 *Dispatch events* (with `dispatch!`, `dispatch-sync!`, `broadcast!` and `broadcast-sync!`) 171 | 172 | 📬 *Handle events* (with `:controllers` functions) 173 | 174 | 🕹 *Handle side effects* (with `:effect-handlers` functions) 175 | 176 | 🚀 *Query state reactively* (with `subscription`, `rum/react` and `rum/reactive`) 177 | 178 | ✨ *Render* (automatic & efficient ! profit :+1:) 179 | 180 | ### Reconciler 181 | 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`): 182 | 183 | ```clojure 184 | (defonce reconciler 185 | (citrus/reconciler {:state (atom {}) 186 | :controllers {:counter control} 187 | :effect-handlers {:http http}})) 188 | ``` 189 | 190 | #### :state 191 | 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. 192 | 193 | #### :controllers 194 | 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. 195 | 196 | Usually controllers are initialized with a predefined initial state value by dispatching `:init` event. 197 | 198 | *NOTE*: the `:init` event pattern isn't enforced at all in _Citrus_, but we consider it is a good idea for 2 reasons: 199 | - 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.) 200 | - allows setting a global watcher on the atom for ad-hoc stuff outside of the normal _Citrus_ cycle for maximum flexibility 201 | 202 | #### :effect-handlers 203 | 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. 204 | 205 | ### Dispatching events 206 | 207 | 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: 208 | 209 | ```clojure 210 | (citrus.core/dispatch! reconciler :controller-name :event-name &args) 211 | (citrus.core/dispatch-sync! reconciler :controller-name :event-name &args) 212 | ``` 213 | 214 | `broadcast!` and its synchronous counterpart `broadcast-sync!` should be used to broadcast an event to all controllers: 215 | 216 | ```clojure 217 | (citrus.core/broadcast! reconciler :event-name &args) 218 | (citrus.core/broadcast-sync! reconciler :event-name &args) 219 | ``` 220 | 221 | ### Handling events 222 | 223 | A controller is a multimethod that returns effects. It usually has at least an initial state and `:init` event method. 224 | 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. 225 | 226 | ```clojure 227 | (def initial-state 0) 228 | 229 | (defmulti control (fn [event] event)) 230 | 231 | (defmethod control :init [event args state] 232 | {:state initial-state}) 233 | 234 | (defmethod control :inc [event args state] 235 | {:state (inc state)}) 236 | 237 | (defmethod control :dec [event args state] 238 | {:state (dec state)}) 239 | ``` 240 | 241 | 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. 242 | 243 | > :rocket: Citrus' event handling is very customizable through an (alpha level) [`:citrus/handler` option](doc/custom-handler.md). 244 | 245 | ### Side effects 246 | 247 | 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. 248 | 249 | Here's an example of an effect that describes HTTP request: 250 | 251 | ```clojure 252 | {:http {:url "/api/users" 253 | :method :post 254 | :body {:name "John Doe"} 255 | :headers {"Content-Type" "application/json"} 256 | :on-success :create-user-ready 257 | :on-error :create-user-failed}} 258 | ``` 259 | 260 | And corresponding handler function: 261 | 262 | ```clojure 263 | (defn http [reconciler ctrl-name effect] 264 | (let [{:keys [on-success on-error]} effect] 265 | (-> (fetch effect) 266 | (then #(citrus/dispatch! reconciler ctrl-name on-success %)) 267 | (catch #(citrus/dispatch! reconciler ctrl-name on-error %))))) 268 | ``` 269 | 270 | Handler function accepts three arguments: reconciler instance, the name key of the controller which produced the effect and the effect value itself. 271 | 272 | 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. 273 | 274 | *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](#scheduling-and-batching) section). 275 | 276 | ### Subscriptions 277 | 278 | 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. 279 | 280 | 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. 281 | 282 | ```clojure 283 | ;; normal subscription 284 | (defn fname [reconciler] 285 | (citrus.core/subscription reconciler [:users 0 :fname])) 286 | 287 | ;; a subscription with aggregate function 288 | (defn full-name [reconciler] 289 | (citrus.core/subscription reconciler [:users 0] #(str (:fname %) " " (:lname %)))) 290 | 291 | ;; parameterized subscription 292 | (defn user [reconciler id] 293 | (citrus.core/subscription reconciler [:users id])) 294 | 295 | ;; aggregate subscription 296 | (defn discount [reconciler] 297 | (citrus.core/subscription reconciler [:user :discount])) 298 | 299 | (defn goods [reconciler] 300 | (citrus.core/subscription reconciler [:goods :selected])) 301 | 302 | (defn shopping-cart [reconciler] 303 | (rum/derived-atom [(discount reconciler) (goods reconciler)] ::key 304 | (fn [discount goods] 305 | (let [price (->> goods (map :price) (reduce +))] 306 | (- price (* discount (/ price 100))))))) 307 | 308 | ;; usage 309 | (rum/defc NameField < rum/reactive [reconciler] 310 | (let [user (rum/react (user reconciler 0))]) 311 | [:div 312 | [:div.fname (rum/react (fname reconciler))] 313 | [:div.lname (:lname user)] 314 | [:div.full-name (rum/react (full-name reconciler))] 315 | [:div (str "Total: " (rum/react (shopping-cart reconciler)))]]) 316 | ``` 317 | 318 | ### Scheduling and batching 319 | 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*. 320 | 321 | #### Scheduling 322 | 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. 323 | 324 | ```clojure 325 | ;; |--×-×---×---×--|--- 326 | ;; 0ms 16ms 327 | ``` 328 | 329 | #### Batching 330 | 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. 331 | 332 | ```clojure 333 | ;; queue = [state1 http state2 local-storage] 334 | 335 | ;; state-queue = [state1 state2] 336 | ;; other-queue = [http local-storage] 337 | 338 | ;; swap! reduce old-state state-queue → new-state 339 | ;; doseq other-queue 340 | ``` 341 | 342 | ### Server-side rendering 343 | 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. 344 | 345 | Here's a list of the main differences from client-side: 346 | - reconciler accepts a hash of subscriptions resolvers and optional `:state` atom 347 | - subscriptions are resolved synchronously 348 | - controllers are not used 349 | - all dispatching functions are disabled 350 | 351 | #### Subscriptions resolvers 352 | To understand what is *subscription resolving function* let's start with a small example: 353 | 354 | ```clojure 355 | ;; used in both Clojure & ClojureScript 356 | (rum/defc Counter < rum/reactive [r] 357 | [:div 358 | [:button {:on-click #(citrus/dispatch! r :counter :dec)} "-"] 359 | [:span (rum/react (citrus/subscription r [:counter]))] 360 | [:button {:on-click #(citrus/dispatch! r :counter :inc)} "+"]]) 361 | ``` 362 | 363 | ```clojure 364 | ;; server only 365 | (let [state (atom {}) 366 | r (citrus/reconciler {:state state 367 | :resolvers resolvers})] ;; create reconciler 368 | (->> (Counter r) ;; initialize components tree 369 | rum/render-html ;; render to HTML 370 | (render-document @state))) ;; render into document template 371 | ``` 372 | 373 | ```clojure 374 | ;; server only 375 | (def resolvers 376 | {:counter (constantly 0)}) ;; :counter subscription resolving function 377 | ``` 378 | 379 | `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. 380 | 381 | #### Resolver 382 | A value returned from resolving function is stored in `Resolver` instance which is atom-like type that is used under the hood in subscriptions. 383 | 384 | #### Resolved data 385 | 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. 386 | 387 | *NOTE*: in order to retrieve resolved data the atom should be dereferenced only after `rum/render-html` call. 388 | 389 | #### Synchronous subscriptions 390 | 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. 391 | 392 | #### Request bound caching 393 | 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: 394 | 395 | ```clojure 396 | (defn resolve [resolver req] 397 | (let [cache (volatile! {})] ;; cache 398 | (fn [[key & p :as path]] 399 | (if-let [data (get-in @cache path)] ;; cache hit 400 | (get-in data p) ;; return data from cache 401 | (let [data (resolver [key] req)] ;; cache miss, resolve subscription 402 | (vswap! cache assoc key data) ;; cache data 403 | (get-in data p)))))) 404 | ``` 405 | 406 | #### Managing resolvers at runtime 407 | 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. 408 | 409 | ```clojure 410 | ;; resolvers 411 | (def common 412 | {:thirdparty-ads get-ads 413 | :promoted-products get-promoted}) 414 | 415 | (def anonymous-user 416 | {:top-products get-top-products}) 417 | 418 | (def returning-user 419 | {:suggested-products get-suggested-products}) 420 | 421 | ;; conditional resolver construction 422 | (defn make-resolver [req] 423 | (cond 424 | (anonymous? req) (merge common anonymous-user) 425 | (returning? req) (merge comomn returning-user) 426 | :else common)) 427 | ``` 428 | 429 | ## Best practices 430 | 431 | - 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 432 | - 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. 433 | - Handle side effects using effect handlers. This allows reconciler to batch effects when needed, and also makes it easier to test controllers. 434 | 435 | ## Recipes 436 | 437 | - [Routing](https://github.com/clj-commons/citrus/tree/master/recipes/routing) 438 | 439 | ## FAQ 440 | 441 | > 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? 442 | 443 | 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. 444 | 445 | 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. 446 | 447 | ```clojure 448 | ;; create Reconciler instance 449 | (def reconciler 450 | (citrus/reconciler config)) 451 | 452 | ;; create Context instance 453 | ;; which provides two React components: Provider and Consumer 454 | (def reconciler-context 455 | (js/React.createContext)) 456 | 457 | ;; provider function 458 | ;; that injects the reconciler 459 | (defn provide-reconciler [child] 460 | (js/React.createElement 461 | (.-Provider reconciler-context) 462 | #js {:value reconciler} 463 | child)) 464 | 465 | ;; consumer function 466 | ;; that consumes the reconciler 467 | (defn with-reconciler [consumer-fn] 468 | (js/React.createElement 469 | (.-Consumer reconciler-context) 470 | nil 471 | consumer-fn)) 472 | 473 | (rum/defc MyApp [] 474 | ;; "consume" reconciler instance 475 | ;; in arbitrary nested component 476 | (with-reconciler 477 | (fn [r] 478 | [:button {:on-click #(citrus/dispatch! r :some :event)} 479 | "Push"]))) 480 | 481 | (rum/mount 482 | (provide-reconciler (MyApp)) ;; "inject" reconciler instance 483 | (dom/getElement "root")) 484 | ``` 485 | 486 | ## Testing 487 | 488 | Testing state management logic in *Citrus* is really simple. Here's what can be tested: 489 | - controllers output (effects) 490 | - state changes 491 | 492 | *NOTE:* Using synchronous dispatch `citrus.core/dispatch-sync!` makes it easier to test state updates. 493 | 494 | ```clojure 495 | (ns app.controllers.counter) 496 | 497 | (defmulti control (fn [event] event)) 498 | 499 | (defmethod control :init [_ [initial-state] _] 500 | {:state initial-state}) 501 | 502 | (defmethod control :inc [_ _ counter] 503 | {:state (inc counter)}) 504 | 505 | (defmethod control :dec [_ _ counter] 506 | {:state (dec counter)}) 507 | 508 | (defmethod control :reset-to [_ [new-value] counter] 509 | {:state new-value}) 510 | ``` 511 | 512 | ```clojure 513 | (ns app.test.controllers.counter-test 514 | (:require [clojure.test :refer :all] 515 | [citrus.core :as citrus] 516 | [app.controllers.counter :as counter])) 517 | 518 | (def state (atom {})) 519 | 520 | (def r 521 | (citrus/reconciler 522 | {:state state 523 | :controllers 524 | {:counter counter/control}})) 525 | 526 | (deftest counter-control 527 | (testing "Should return initial-state value" 528 | (is (= (counter/control :init 0 nil) {:state 0}))) 529 | (testing "Should return incremented value" 530 | (is (= (counter/control :inc nil 0) {:state 1}))) 531 | (testing "Should return decremented value" 532 | (is (= (counter/control :dec nil 1) {:state 0}))) 533 | (testing "Should return provided value" 534 | (is (= (counter/control :reset-to [5] nil) {:state 5})))) 535 | 536 | (deftest counter-state 537 | (testing "Should initialize state value with 0" 538 | (citrus/dispatch-sync! r :counter :init 0) 539 | (is (zero? (:counter @state)))) 540 | (testing "Should increment state value" 541 | (citrus/dispatch-sync! r :counter :inc) 542 | (is (= (:counter @state) 1))) 543 | (testing "Should deccrement state value" 544 | (citrus/dispatch-sync! r :counter :dec) 545 | (is (= (:counter @state) 0))) 546 | (testing "Should reset state value" 547 | (citrus/dispatch-sync! r :counter :reset-to 5) 548 | (is (= (:counter @state) 5)))) 549 | ``` 550 | 551 | ## Roadmap 552 | - Get rid of global state 553 | - Make citrus isomorphic 554 | - Storage agnostic architecture? (Atom, DataScript, etc.) 555 | - Better effects handling (network, localStorage, etc.) 556 | - Provide better developer experience using `clojure.spec` 557 | 558 | ## Contributing 559 | 560 | 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. 561 | 562 | This project is using [Leiningen](https://leiningen.org/) build tool, make sure you have it installed. 563 | 564 | To run Clojure tests (on the JVM), execute `lein test`. 565 | 566 | To run ClojureScript tests (on Firefox) you'll need [Node.js](https://nodejs.org/) and the [Firefox web browser](https://www.mozilla.org/en-US/firefox/). 567 | Then execute : 568 | - `npm install` (only once, install testing dependencies locally) 569 | - `lein cljs-test` : this will open a new Firefox window to run the tests and watch for file changes. 570 | 571 | ## License 572 | 573 | Copyright © 2017 Roman Liutikov 574 | 575 | Distributed under the Eclipse Public License either version 1.0 or (at 576 | your option) any later version. 577 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {org.clojure/clojure {:mvn/version "1.10.1"} 2 | org.clojure/clojurescript {:mvn/version "1.10.597"} 3 | rum/rum {:mvn/version "0.11.4"}}} 4 | -------------------------------------------------------------------------------- /dev/user.clj: -------------------------------------------------------------------------------- 1 | (ns user 2 | (:require [figwheel-sidecar.repl-api :as f])) 3 | 4 | (defn start! [] 5 | (f/start-figwheel!) 6 | (f/cljs-repl)) 7 | 8 | (defn stop! [] 9 | (f/stop-figwheel!)) 10 | -------------------------------------------------------------------------------- /doc/custom-handler.md: -------------------------------------------------------------------------------- 1 | # Citrus Handler 2 | 3 | Citrus event handling is implemented via a handler function that processes 4 | a batch of events. By default Citrus handles events with a controller/multimethod-based system. 5 | This can be customized by passing an alternative handler function as 6 | `:citrus/handler` when creating a reconciler instance. 7 | 8 | By providing a custom `:citrus/handler` you can adapt your event handling 9 | in ways that haven't been anticipated by the Citrus framework. 10 | 11 | Initially this customization option has been motivated by [the wish to access 12 | all controllers state in event handlers](https://github.com/clj-commons/citrus/issues/50). 13 | 14 | :bulb: This feature is experimental and subject to change. Please report your 15 | experiences using them [in this GitHub issue](https://github.com/clj-commons/citrus/issues/50). 16 | 17 | ### Usage 18 | 19 | When constructing a reconciler the `:citrus/handler` option can be used to 20 | pass a custom handler that processes a batch of events. 21 | 22 | - `:citrus/handler` should be a function like 23 | ```clj 24 | (fn handler [reconciler events]) 25 | ;; where `events` is a list of event tuples like this one 26 | [[ctrl-key event-key event-args] ; event 1 27 | [ctrl-key event-key event-args]] ; event 2 28 | ``` 29 | 30 | - When not passing in anything for this option, the handler will default to 31 | [`citrus.reconciler/citrus-default-handler`](https://github.com/clj-commons/citrus/blob/220d6608c62e5deb91f0efb3ea37a6e435807148/src/citrus/reconciler.cljs#L17-L55), which behaves identical to the 32 | regular event handling of Citrus as of `v3.2.3`. 33 | - The handler will process all events of the current batch before 34 | resetting the reconciler `state`. This reduces the number of watch triggers 35 | for subscriptions and similar tools using `add-watch` with the reconciler 36 | `state` atom. 37 | 38 | #### Open extension 39 | 40 | With the ability to override the `citrus/handler` the `controller` and 41 | `effect-handlers` options almost become superfluous as all behavior influenced 42 | by these options can now also be controlled via `:citrus/handler`. Some ideas 43 | for things that are now possible to build on top of Citrus that previously 44 | weren't: 45 | 46 | - Mix controller handlers with custom event handling logic, i.e. any function. This could mean: 47 | - Having handlers that use [interceptors](https://github.com/metosin/sieppari) to customize their behavior 48 | - Having handlers that can write to the full app state instead of a subtree (in contrast to controllers that can only write to their respective subtree) 49 | - Completely replace Citrus multimethod dispatching with a custom handler registry 50 | 51 | > **Note** that breaking out of controllers as Citrus provides them impacts how 52 | > Citrus' `broadcast` functions work. `broadcast!` and `broadcast-sync!` rely 53 | > on what is being passed to the reconciler as `:controllers`. 54 | 55 | ### Recipes 56 | 57 | :wave: Have you used `:citrus/handler` to do something interesting? Open a PR and share your approach here! 58 | 59 | #### Passing the entire state as fourth argument 60 | 61 | Event handlers currently take four arguments `[controller-kw event-kw 62 | event-args co-effects]`. As described above one motivation for custom handlers 63 | has been to give event handlers access to the entire state of the application. 64 | 65 | By implementing a new handler based on [`citrus.reconciler/citrus-default-handler`](https://github.com/clj-commons/citrus/blob/220d6608c62e5deb91f0efb3ea37a6e435807148/src/citrus/reconciler.cljs#L17-L55) we can change how our controller multimethods are called, replacing the `co-effects` argument with the full state of the reconciler. 66 | 67 | > Co-effects are largely undocumented right now and might be removed in a 68 | > future release. Please [add a note to this 69 | > issue](https://github.com/clj-commons/citrus/issues/51) if you are using 70 | > them. 71 | 72 | :point_right: Here's [**a commit**](https://github.com/clj-commons/citrus/commit/a620e8e77a62b16a9d6006600cccd02dda82c046) that adapts Citrus' default handler to pass the reconciler's full state as the fourth argument. Part of the diff is replicated below: 73 | 74 | ```diff 75 | diff --git a/src/citrus/reconciler.cljs b/src/citrus/reconciler.cljs 76 | index f8de8c5..5a95a77 100644 77 | --- a/src/citrus/reconciler.cljs 78 | +++ b/src/citrus/reconciler.cljs 79 | @@ -14,17 +14,15 @@ 80 | (release-fn id)) 81 | (vreset! scheduled? (schedule-fn f))) 82 | 83 | -(defn citrus-default-handler 84 | - "Implements Citrus' default event handling (as of 3.2.3). 85 | - 86 | - This function can be copied into your project and adapted to your needs. 87 | +(defn adapted-default-handler 88 | + "An adapted event handler for Citrus that passes the entire reconciler 89 | + state as fourth argument to controller methods. 90 | 91 | `events` is expected to be a list of events (tuples): 92 | 93 | [ctrl event-key event-args]" 94 | [reconciler events] 95 | (let [controllers (.-controllers reconciler) 96 | - co-effects (.-co_effects reconciler) 97 | effect-handlers (.-effect_handlers reconciler) 98 | state-atom (.-state reconciler)] 99 | (reset! 100 | @@ -36,13 +34,7 @@ 101 | (do 102 | (assert (contains? controllers ctrl) (str "Controller " ctrl " is not found")) 103 | (let [ctrl-fn (get controllers ctrl) 104 | - cofx (get-in (.-meta ctrl) [:citrus event-key :cofx]) 105 | - cofx (reduce 106 | - (fn [cofx [k & args]] 107 | - (assoc cofx k (apply (co-effects k) args))) 108 | - {} 109 | - cofx) 110 | - effects (ctrl-fn event-key event-args (get state ctrl) cofx)] 111 | + effects (ctrl-fn event-key event-args (get state ctrl) state)] 112 | (m/doseq [effect (dissoc effects :state)] 113 | (let [[eff-type effect] effect] 114 | (when (s/check-asserts?) 115 | ``` 116 | -------------------------------------------------------------------------------- /example/counter/core.cljs: -------------------------------------------------------------------------------- 1 | (ns counter.core 2 | (:require-macros [citrus.core :as citrus]) 3 | (:require [rum.core :as rum] 4 | [citrus.core :as citrus] 5 | [goog.dom :as dom] 6 | [cljs.spec.alpha :as s] 7 | [expound.alpha :as expound])) 8 | 9 | ;; 10 | ;; enable printing readable specs 11 | ;; 12 | 13 | (s/check-asserts true) 14 | (set! s/*explain-out* expound/printer) 15 | 16 | ;; 17 | ;; spec effects 18 | ;; 19 | 20 | (s/def :http/url string?) 21 | (s/def :http/on-ok keyword?) 22 | (s/def :http/on-failed keyword?) 23 | 24 | (s/def :effect/http 25 | (s/keys :req-un [:http/url :http/on-ok :http/on-failed])) 26 | 27 | ;; 28 | ;; define controller & event handlers 29 | ;; 30 | 31 | (defmulti control-github (fn [event] event)) 32 | 33 | (defmethod control-github :init [] 34 | {:state {:repos [] 35 | :loading? false 36 | :error nil}}) 37 | 38 | (defmethod control-github :fetch-repos [_ [username] state] 39 | {:effect/http {:url (str "https://api.github.com/users/" username "/repos") 40 | :on-ok :fetch-repos-ok 41 | :on-failed :fetch-repos-failed} 42 | :state (assoc state :loading? true :error nil)}) 43 | 44 | (defmethod control-github :fetch-repos-ok [_ [resp] state] 45 | {:state (assoc state :repos resp :loading? false)}) 46 | 47 | (defmethod control-github :fetch-repos-failed [_ [error] state] 48 | {:state (assoc state :repos [] :error (.-message error) :loading? false)}) 49 | 50 | 51 | ;; 52 | ;; define UI component 53 | ;; 54 | 55 | (rum/defcs App < 56 | rum/reactive 57 | (rum/local "" :github/username) 58 | [{username :github/username} 59 | r] 60 | (let [{:keys [repos loading? error]} (rum/react (citrus/subscription r [:github]))] 61 | [:div {:style {:font-size "12px" 62 | :font-family "sans-serif"}} 63 | [:form {:on-submit #(.preventDefault %)} 64 | [:input {:value @username 65 | :on-change #(reset! username (.. % -target -value)) 66 | :style {:padding "8px" 67 | :border-radius "3px" 68 | :font-size "14px" 69 | :outline "none" 70 | :border "1px solid blue"}}] 71 | [:button {:on-click #(citrus/dispatch! r :github :fetch-repos @username) 72 | :style {:border-radius "5px" 73 | :outline "none" 74 | :font-size "11px" 75 | :background-color "blue" 76 | :color "#fff" 77 | :border "none" 78 | :padding "8px 16px" 79 | :display "block" 80 | :margin "8px 0" 81 | :text-transform "uppercase" 82 | :font-weight 700}} 83 | "Fetch repos"]] 84 | (cond 85 | loading? "Fetching repos..." 86 | (some? error) error 87 | 88 | (seq repos) 89 | [:ul {:style {:margin 0}} 90 | (for [repo repos] 91 | [:li (:name repo)])] 92 | 93 | :else nil)])) 94 | 95 | 96 | ;; 97 | ;; define effects handler 98 | ;; 99 | 100 | (defn http [r c {:keys [url on-ok on-failed]}] 101 | (-> (js/fetch url) 102 | (.then #(.json %)) 103 | (.then #(js->clj % :keywordize-keys true)) 104 | (.then #(citrus/dispatch! r c on-ok %)) 105 | (.catch #(citrus/dispatch! r c on-failed %)))) 106 | 107 | 108 | ;; 109 | ;; start up 110 | ;; 111 | 112 | ;; create Reconciler instance 113 | (defonce reconciler 114 | (citrus/reconciler 115 | {:state (atom {}) 116 | :controllers {:github control-github} 117 | :effect-handlers {:effect/http http}})) 118 | 119 | ;; initialize controllers 120 | (defonce init-ctrl (citrus/broadcast-sync! reconciler :init)) 121 | 122 | ;; render 123 | (rum/mount (App reconciler) 124 | (dom/getElement "app")) 125 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clj-commons/citrus/2011fd193dab454c0d523a7495c0ad992d685bdd/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "citrus", 3 | "version": "3.0.1-SNAPSHOT", 4 | "description": "npm dependencies for Citrus", 5 | "main": "index.js", 6 | "directories": { 7 | "example": "example", 8 | "test": "test" 9 | }, 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/clj-commons/citrus.git" 16 | }, 17 | "author": "Roman Liutikov", 18 | "license": "SEE LICENSE IN LICENSE.md", 19 | "bugs": { 20 | "url": "https://github.com/clj-commons/citrus/issues" 21 | }, 22 | "homepage": "https://github.com/clj-commons/citrus#readme", 23 | "devDependencies": { 24 | "karma": "^2.0.4", 25 | "karma-chrome-launcher": "^2.2.0", 26 | "karma-cli": "^1.0.1", 27 | "karma-cljs-test": "^0.1.0", 28 | "karma-firefox-launcher": "^1.1.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject clj-commons/citrus (or (System/getenv "PROJECT_VERSION") "3.3.0") 2 | :description "State management library for Rum" 3 | :url "https://github.com/clj-commons/citrus" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :deploy-repositories [["clojars" {:url "https://repo.clojars.org" 7 | :username :env/clojars_username 8 | :password :env/clojars_password 9 | :sign-releases true}]] 10 | 11 | :dependencies [[org.clojure/clojure "1.10.1" :scope "provided"] 12 | [org.clojure/clojurescript "1.10.597" :scope "provided"] 13 | [rum "0.11.4"]] 14 | 15 | :plugins [[lein-cljsbuild "1.1.7" :exclusions [[org.clojure/clojure]]] 16 | [lein-figwheel "0.5.19" :exclusions [org.clojure/clojure]] 17 | [lein-doo "0.1.8"]] 18 | 19 | :profiles {:dev {:dependencies [[com.cemerick/piggieback "0.2.2"] 20 | [org.clojure/tools.nrepl "0.2.12"] 21 | [binaryage/devtools "0.9.10"] 22 | [figwheel-sidecar "0.5.19"] 23 | [expound "0.7.1"]] 24 | :repl-options {:nrepl-middleware [cemerick.piggieback/wrap-cljs-repl]} 25 | :source-paths ["src" "dev"]}} 26 | 27 | :aliases {"cljs-test" ["do" 28 | ["clean"] 29 | ["doo" "chrome" "test"]]} 30 | 31 | :cljsbuild {:builds 32 | [{:id "dev" 33 | :source-paths ["src" "example"] 34 | :figwheel true 35 | :compiler {:main counter.core 36 | :asset-path "js/compiled/out" 37 | :output-to "resources/public/js/compiled/main.js" 38 | :output-dir "resources/public/js/compiled/out" 39 | :compiler-stats true 40 | :parallel-build true}} 41 | 42 | {:id "min" 43 | :source-paths ["src" "example"] 44 | :compiler {:main counter.core 45 | :output-to "resources/public/js/compiled-min/main.js" 46 | :output-dir "resources/public/js/compiled-min/out" 47 | :optimizations :advanced 48 | :closure-defines {"goog.DEBUG" false} 49 | :static-fns true 50 | :elide-asserts true 51 | :output-wrapper true 52 | :compiler-stats true 53 | :parallel-build true}} 54 | {:id "test" 55 | :source-paths ["src" "test"] 56 | :compiler {:output-to "target/test.js" 57 | :main citrus.test-runner 58 | :optimizations :none}}]} 59 | 60 | :doo {:paths {:karma "./node_modules/karma/bin/karma"}}) 61 | -------------------------------------------------------------------------------- /recipes/routing/components.cljs: -------------------------------------------------------------------------------- 1 | (ns routing.components 2 | (:require [rum.core :as rum] 3 | [citrus.core :as citrus])) 4 | 5 | (rum/defc Root < rum/reactive [r] 6 | (let [{route :handler params :route-params} (rum/react (citrus/subscription r [:router]))] 7 | (case route 8 | :home "Home view" 9 | :settings "Settings view" 10 | :user (str "User #" (:id params) " view") 11 | nil))) 12 | -------------------------------------------------------------------------------- /recipes/routing/controllers/router.cljs: -------------------------------------------------------------------------------- 1 | (ns routing.controllers.router) 2 | 3 | (defmulti control (fn [action _ _] action)) 4 | 5 | (defmethod control :init [_ [route] _] 6 | {:state route}) 7 | 8 | (defmethod control :push [_ [route] _] 9 | {:state route}) 10 | -------------------------------------------------------------------------------- /recipes/routing/core.cljs: -------------------------------------------------------------------------------- 1 | (ns routing.core 2 | (:require [rum.core :as rum] 3 | [citrus.core :as citrus] 4 | [goog.dom :as dom] 5 | [routing.controllers.router :as router-ctrl] 6 | [routing.router :as router] 7 | [routing.components :refer [Root]])) 8 | 9 | (def routes 10 | ["/" [["" :home] 11 | ["settings" :settings] 12 | [["user/" :id] :user]]]) 13 | 14 | (def r 15 | (citrus/reconciler 16 | {:state (atom {}) 17 | :controllers {:router router-ctrl/control}})) 18 | 19 | (citrus/broadcast-sync! r :init) 20 | 21 | (router/start! #(citrus/dispatch! r :router :push %) routes) 22 | 23 | (rum/mount (Root r) (dom/getElement "app")) 24 | -------------------------------------------------------------------------------- /recipes/routing/router.cljs: -------------------------------------------------------------------------------- 1 | (ns routing.router 2 | (:require [bidi.bidi :as bidi] 3 | [pushy.core :as pushy])) 4 | 5 | (defn start! [on-set-page routes] 6 | (let [history (pushy/pushy on-set-page (partial bidi/match-route routes))] 7 | (pushy/start! history) 8 | history)) 9 | 10 | (defn stop! [history] 11 | (pushy/stop! history)) 12 | -------------------------------------------------------------------------------- /resources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Document 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /slack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clj-commons/citrus/2011fd193dab454c0d523a7495c0ad992d685bdd/slack.png -------------------------------------------------------------------------------- /src/citrus/cofx.clj: -------------------------------------------------------------------------------- 1 | (ns citrus.cofx 2 | (:require [clojure.spec.alpha :as s])) 3 | 4 | (defn parse-defhandler [args] 5 | (s/conform 6 | (s/cat 7 | :ctrl-name symbol? 8 | :event-name keyword? 9 | :meta (s/? map?) 10 | :args (s/spec 11 | (s/cat 12 | :event some? 13 | :args some? 14 | :state some? 15 | :cofx some?)) 16 | :body (s/* some?)) 17 | args)) 18 | 19 | (defn make-defhandler [args] 20 | (let [result (parse-defhandler args)] 21 | (if (not= result ::s/invalid) 22 | (let [{:keys [ctrl-name event-name meta args body]} result 23 | {:keys [event args state cofx]} args] 24 | `(do 25 | (set! (.-meta ~ctrl-name) (assoc-in (.-meta ~ctrl-name) [:citrus ~event-name] ~meta)) 26 | (defmethod ~ctrl-name ~event-name [~event ~args ~state ~cofx] ~@body)))))) 27 | -------------------------------------------------------------------------------- /src/citrus/core.clj: -------------------------------------------------------------------------------- 1 | (ns citrus.core 2 | (:require [citrus.resolver :as r] 3 | [citrus.cofx :as cofx])) 4 | 5 | (defn reconciler 6 | "Accepts a hash of `:state` atom & `:resolvers` hash of subscription resolvers where keys are subscription path vectors and values are data resolving functions 7 | 8 | {:state (atom {}) 9 | :resolvers {[:counter] fetch-counter}} 10 | 11 | Returns a hash of `resolvers` and `state` atom which will be populated with resolved subscriptions data during rendering" 12 | [{:keys [state resolvers]}] 13 | {:state state 14 | :resolvers resolvers}) 15 | 16 | (defn dispatch! 17 | "dummy dispatch!" 18 | [_ _ _ & _]) 19 | 20 | (defn dispatch-sync! 21 | "dummy dispatch-sync!" 22 | [_ _ _ & _]) 23 | 24 | (defn broadcast! 25 | "dummy broadcast!" 26 | [_ _ & _]) 27 | 28 | (defn broadcast-sync! 29 | "dummy broadcast-sync!" 30 | [_ _ & _]) 31 | 32 | (defn subscription 33 | "Create a subscription to state updates 34 | 35 | (citrus/subscription reconciler [:users 0] (juxt [:fname :lname])) 36 | 37 | Arguments 38 | 39 | reconciler - reconciler hash 40 | path - a vector which describes a path into resolver's result value 41 | reducer - an aggregate function which computes a materialized view of data behind the path" 42 | ([reconciler path] 43 | (subscription reconciler path nil)) 44 | ([{:keys [state resolvers]} path reducer] 45 | (r/make-resolver state resolvers path reducer))) 46 | 47 | (defmacro defhandler 48 | "Create event handler with optional meta data 49 | 50 | (citrus/defhandler control :load 51 | {:cofx [[:local-storage :key]]} 52 | [event args state coeffects] 53 | {:state (:local-storage coeffects)})" 54 | [& args] 55 | (cofx/make-defhandler args)) 56 | -------------------------------------------------------------------------------- /src/citrus/core.cljs: -------------------------------------------------------------------------------- 1 | (ns citrus.core 2 | (:require-macros [citrus.macros :as m]) 3 | (:require [citrus.reconciler :as r] 4 | [citrus.cursor :as c])) 5 | 6 | (defn- -get-default-batched-updates 7 | [] 8 | {:schedule-fn js/requestAnimationFrame 9 | :release-fn js/cancelAnimationFrame}) 10 | 11 | (defn reconciler 12 | "Creates an instance of Reconciler 13 | 14 | (citrus/reconciler {:state (atom {}) 15 | :controllers {:counter counter/control} 16 | :effect-handlers {:http effects/http} 17 | :batched-updates {:schedule-fn f :release-fn f'} 18 | :chunked-updates f}) 19 | 20 | Arguments 21 | 22 | config - a map of 23 | state - app state atom 24 | controllers - a map of state controllers 25 | citrus/handler - a function to handle incoming events (see doc/custom-handler.md) 26 | effect-handlers - a map of effects handlers 27 | batched-updates - a map of two functions used to batch reconciler updates, defaults to 28 | `{:schedule-fn js/requestAnimationFrame :release-fn js/cancelAnimationFrame}` 29 | chunked-updates - a function used to divide reconciler update into chunks, doesn't used by default 30 | 31 | Returned value supports deref, watches and metadata. 32 | The only supported option is `:meta`" 33 | [{:keys [state controllers effect-handlers co-effects batched-updates chunked-updates] 34 | :citrus/keys [handler]} 35 | & {:as options}] 36 | (binding [] 37 | (let [watch-fns (volatile! {}) 38 | rec (r/->Reconciler 39 | controllers 40 | (or handler r/citrus-default-handler) 41 | effect-handlers 42 | co-effects 43 | state 44 | (volatile! []) 45 | (volatile! nil) 46 | (or batched-updates (-get-default-batched-updates)) 47 | chunked-updates 48 | (:meta options) 49 | watch-fns)] 50 | (add-watch state (list rec :watch-fns) 51 | (fn [_ _ oldv newv] 52 | (when (not= oldv newv) 53 | (m/doseq [w @watch-fns] 54 | (let [[k watch-fn] w] 55 | (watch-fn k rec oldv newv)))))) 56 | rec))) 57 | 58 | (defn dispatch! 59 | "Invoke an event on particular controller asynchronously 60 | 61 | (citrus/dispatch! reconciler :users :load \"id\") 62 | 63 | Arguments 64 | 65 | reconciler - an instance of Reconciler 66 | controller - name of a controller 67 | event - a dispatch value of a method defined in the controller 68 | args - arguments to be passed into the controller" 69 | [reconciler controller event & args] 70 | {:pre [(instance? r/Reconciler reconciler)]} 71 | (r/dispatch! reconciler controller event args)) 72 | 73 | (defn dispatch-sync! 74 | "Invoke an event on particular controller synchronously 75 | 76 | (citrus/dispatch! reconciler :users :load \"id\") 77 | 78 | Arguments 79 | 80 | reconciler - an instance of Reconciler 81 | controller - name of a controller 82 | event - a dispatch value of a method defined in the controller 83 | args - arguments to be passed into the controller" 84 | [reconciler controller event & args] 85 | {:pre [(instance? r/Reconciler reconciler)]} 86 | (r/dispatch-sync! reconciler controller event args)) 87 | 88 | (defn broadcast! 89 | "Invoke an event on all controllers asynchronously 90 | 91 | (citrus/broadcast! reconciler :init) 92 | 93 | Arguments 94 | 95 | reconciler - an instance of Reconciler 96 | event - a dispatch value of a method defined in the controller 97 | args - arguments to be passed into the controller" 98 | [reconciler event & args] 99 | {:pre [(instance? r/Reconciler reconciler)]} 100 | (r/broadcast! reconciler event args)) 101 | 102 | (defn broadcast-sync! 103 | "Invoke an event on all controllers synchronously 104 | 105 | (citrus/broadcast! reconciler :init) 106 | 107 | Arguments 108 | 109 | reconciler - an instance of Reconciler 110 | event - a dispatch value of a method defined in the controller 111 | args - arguments to be passed into the controller" 112 | [reconciler event & args] 113 | {:pre [(instance? r/Reconciler reconciler)]} 114 | (r/broadcast-sync! reconciler event args)) 115 | 116 | 117 | (defn subscription 118 | "Create a subscription to state updates 119 | 120 | (citrus/subscription reconciler [:users 0] (juxt [:fname :lname])) 121 | 122 | Arguments 123 | 124 | reconciler - an instance of Reconciler 125 | path - a vector which describes a path into reconciler's atom value 126 | reducer - an aggregate function which computes a materialized view of data behind the path" 127 | ([reconciler path] 128 | (subscription reconciler path identity)) 129 | ([reconciler path reducer] 130 | {:pre [(instance? r/Reconciler reconciler)]} 131 | (c/reduce-cursor-in reconciler path reducer))) 132 | -------------------------------------------------------------------------------- /src/citrus/cursor.cljs: -------------------------------------------------------------------------------- 1 | (ns citrus.cursor) 2 | 3 | (deftype ReduceCursor [ref path reducer meta] 4 | Object 5 | (equiv [this other] 6 | (-equiv this other)) 7 | 8 | IAtom 9 | 10 | IMeta 11 | (-meta [_] meta) 12 | 13 | IEquiv 14 | (-equiv [this other] 15 | (identical? this other)) 16 | 17 | IDeref 18 | (-deref [_] 19 | (-> (-deref ref) 20 | (get-in path) 21 | (reducer))) 22 | 23 | IWatchable 24 | (-add-watch [this key callback] 25 | (add-watch ref (list this key) 26 | (fn [_ _ oldv newv] 27 | (let [old (reducer (get-in oldv path)) 28 | new (reducer (get-in newv path))] 29 | (when (not= old new) 30 | (callback key this old new))))) 31 | this) 32 | 33 | (-remove-watch [this key] 34 | (remove-watch ref (list this key)) 35 | this) 36 | 37 | IHash 38 | (-hash [this] (goog/getUid this)) 39 | 40 | IPrintWithWriter 41 | (-pr-writer [this writer opts] 42 | (-write writer "#object [citrus.cursor.ReduceCursor ") 43 | (pr-writer {:val (-deref this)} writer opts) 44 | (-write writer "]"))) 45 | 46 | (defn reduce-cursor-in 47 | "Given atom with deep nested value, path inside it and reducing function, creates an atom-like structure 48 | that can be used separately from main atom, but only for reading value: 49 | 50 | (def state (atom {:users {\"Ivan\" {:children [1 2 3]}}})) 51 | (def ivan (citrus.cursor/reduce-cursor-in state [:users \"Ivan\" :children] last)) 52 | (deref ivan) ;; => 3 53 | 54 | Returned value supports deref, watches and metadata. 55 | The only supported option is `:meta`" 56 | [ref path reducer & {:as options}] 57 | (ReduceCursor. ref path reducer (:meta options))) 58 | -------------------------------------------------------------------------------- /src/citrus/macros.clj: -------------------------------------------------------------------------------- 1 | (ns citrus.macros 2 | (:refer-clojure :exclude [doseq])) 3 | 4 | (defmacro doseq 5 | "Lightweight `doseq`" 6 | [[item coll] & body] 7 | `(loop [xs# ~coll] 8 | (when-some [~item (first xs#)] 9 | (do ~@body) 10 | (recur (next xs#))))) 11 | -------------------------------------------------------------------------------- /src/citrus/reconciler.cljs: -------------------------------------------------------------------------------- 1 | (ns citrus.reconciler 2 | (:require-macros [citrus.macros :as m]) 3 | (:require [cljs.spec.alpha :as s])) 4 | 5 | (defn- queue-effects! [queue f] 6 | (vswap! queue conj f)) 7 | 8 | (defn- clear-queue! [queue] 9 | (vreset! queue [])) 10 | 11 | (defn- schedule-update! [{:keys [schedule-fn release-fn]} scheduled? f] 12 | (when-let [id @scheduled?] 13 | (vreset! scheduled? nil) 14 | (release-fn id)) 15 | (vreset! scheduled? (schedule-fn f))) 16 | 17 | (defn citrus-default-handler 18 | "Implements Citrus' default event handling (as of 3.2.3). 19 | 20 | This function can be copied into your project and adapted to your needs. 21 | 22 | `events` is expected to be a list of events (tuples): 23 | 24 | [ctrl event-key event-args]" 25 | [reconciler events] 26 | (let [controllers (.-controllers reconciler) 27 | co-effects (.-co_effects reconciler) 28 | effect-handlers (.-effect_handlers reconciler) 29 | state-atom (.-state reconciler)] 30 | (reset! 31 | state-atom 32 | (loop [state @reconciler 33 | [[ctrl event-key event-args :as event] & events] events] 34 | (if (nil? event) 35 | state 36 | (do 37 | (assert (contains? controllers ctrl) (str "Controller " ctrl " is not found")) 38 | (let [ctrl-fn (get controllers ctrl) 39 | cofx (get-in (.-meta ctrl) [:citrus event-key :cofx]) 40 | cofx (reduce 41 | (fn [cofx [k & args]] 42 | (assoc cofx k (apply (co-effects k) args))) 43 | {} 44 | cofx) 45 | effects (ctrl-fn event-key event-args (get state ctrl) cofx)] 46 | (m/doseq [effect (dissoc effects :state)] 47 | (let [[eff-type effect] effect] 48 | (when (s/check-asserts?) 49 | (when-let [spec (s/get-spec eff-type)] 50 | (s/assert spec effect))) 51 | (when-let [handler (get effect-handlers eff-type)] 52 | (handler reconciler ctrl effect)))) 53 | (if (contains? effects :state) 54 | (recur (assoc state ctrl (:state effects)) events) 55 | (recur state events))))))))) 56 | 57 | (defprotocol IReconciler 58 | (dispatch! [this controller event args]) 59 | (dispatch-sync! [this controller event args]) 60 | (broadcast! [this event args]) 61 | (broadcast-sync! [this event args])) 62 | 63 | (deftype Reconciler [controllers citrus-handler effect-handlers co-effects state queue scheduled? batched-updates chunked-updates meta watch-fns] 64 | 65 | Object 66 | (equiv [this other] 67 | (-equiv this other)) 68 | 69 | IAtom 70 | 71 | IMeta 72 | (-meta [_] meta) 73 | 74 | IEquiv 75 | (-equiv [this other] 76 | (identical? this other)) 77 | 78 | IDeref 79 | (-deref [_] 80 | (-deref state)) 81 | 82 | IWatchable 83 | (-add-watch [this key callback] 84 | (vswap! watch-fns assoc key callback) 85 | this) 86 | 87 | (-remove-watch [this key] 88 | (vswap! watch-fns dissoc key) 89 | this) 90 | 91 | IHash 92 | (-hash [this] (goog/getUid this)) 93 | 94 | IPrintWithWriter 95 | (-pr-writer [this writer opts] 96 | (-write writer "#object [citrus.reconciler.Reconciler ") 97 | (pr-writer {:val (-deref this)} writer opts) 98 | (-write writer "]")) 99 | 100 | IReconciler 101 | (dispatch! [this cname event args] 102 | (assert (some? event) (str "dispatch! was called without event name:" (pr-str [cname event args]))) 103 | (queue-effects! queue [cname event args]) 104 | (schedule-update! 105 | batched-updates 106 | scheduled? 107 | (fn batch-runner [] 108 | (let [events @queue] 109 | (clear-queue! queue) 110 | (citrus-handler this events))))) 111 | 112 | (dispatch-sync! [this cname event args] 113 | (assert (some? event) (str "dispatch! was called without event name:" (pr-str [cname event args]))) 114 | (citrus-handler this [[cname event args]])) 115 | 116 | (broadcast! [this event args] 117 | (m/doseq [controller (keys controllers)] 118 | (dispatch! this controller event args))) 119 | 120 | (broadcast-sync! [this event args] 121 | (m/doseq [controller (keys controllers)] 122 | (dispatch-sync! this controller event args)))) 123 | -------------------------------------------------------------------------------- /src/citrus/resolver.clj: -------------------------------------------------------------------------------- 1 | (ns citrus.resolver) 2 | 3 | (deftype Resolver [state resolver path reducer] 4 | 5 | clojure.lang.IDeref 6 | (deref [_] 7 | (let [[key & path] path 8 | resolve (get resolver key) 9 | data (resolve)] 10 | (when state 11 | (swap! state assoc key data)) 12 | (if reducer 13 | (reducer (get-in data path)) 14 | (get-in data path)))) 15 | 16 | clojure.lang.IRef 17 | (setValidator [this vf] 18 | (throw (UnsupportedOperationException. "citrus.resolver.Resolver/setValidator"))) 19 | 20 | (getValidator [this] 21 | (throw (UnsupportedOperationException. "citrus.resolver.Resolver/getValidator"))) 22 | 23 | (getWatches [this] 24 | (throw (UnsupportedOperationException. "citrus.resolver.Resolver/getWatches"))) 25 | 26 | (addWatch [this key callback] 27 | this) 28 | 29 | (removeWatch [this key] 30 | this)) 31 | 32 | (defn make-resolver [state resolver path reducer] 33 | (Resolver. state resolver path reducer)) 34 | -------------------------------------------------------------------------------- /test/citrus/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns citrus.core-test 2 | (:require [clojure.test :refer [deftest testing is]] 3 | [citrus.core :as citrus])) 4 | 5 | (deftest reconciler 6 | (testing "Should return Reconciler hash" 7 | (let [r (citrus/reconciler {:state (atom {}) :resolvers {}})] 8 | (is (contains? r :state)) 9 | (is (contains? r :resolvers)) 10 | (is (instance? clojure.lang.Atom (:state r))) 11 | (is (= (:resolvers r) {}))))) 12 | 13 | (deftest dispatch! 14 | (testing "Should return `nil`" 15 | (is (nil? (citrus/dispatch! nil nil nil))))) 16 | 17 | (deftest dispatch-sync! 18 | (testing "Should return `nil`" 19 | (is (nil? (citrus/dispatch-sync! nil nil nil))))) 20 | 21 | (deftest broadcast! 22 | (testing "Should return `nil`" 23 | (is (nil? (citrus/broadcast! nil nil))))) 24 | 25 | (deftest broadcast-sync! 26 | (testing "Should return `nil`" 27 | (is (nil? (citrus/broadcast-sync! nil nil))))) 28 | 29 | (deftest subscription 30 | (testing "Should return Resolver instance" 31 | (is (instance? citrus.resolver.Resolver 32 | (citrus/subscription (citrus/reconciler {}) [:path])))) 33 | (testing "Should return nested data when a nested path is supplied" 34 | (let [sub (citrus/subscription (citrus/reconciler {:state (atom {}) 35 | :resolvers {:a (constantly {:b [1 2 3]})}}) [:a :b])] 36 | (is (= @sub [1 2 3])))) 37 | (testing "Should return derived data when a reducer function is supplied" 38 | (let [sub (citrus/subscription (citrus/reconciler {:state (atom {}) 39 | :resolvers {:a (constantly {:b [1 2 3]})}}) [:a] map?)] 40 | (is (= @sub true)))) 41 | (testing "Should return derived data when a reducer function *and* nested data are supplied" 42 | (let [sub (citrus/subscription (citrus/reconciler {:state (atom {}) 43 | :resolvers {:a (constantly {:b [1 2 3]})}}) [:a :b] count)] 44 | (is (= @sub 3))))) 45 | -------------------------------------------------------------------------------- /test/citrus/core_test.cljs: -------------------------------------------------------------------------------- 1 | (ns citrus.core-test 2 | (:require [clojure.test :refer [deftest testing is async]] 3 | [citrus.core :as citrus] 4 | [citrus.reconciler :as rec] 5 | [citrus.cursor :as cur] 6 | [goog.object :as obj] 7 | [rum.core :as rum])) 8 | 9 | (deftest reconciler 10 | (testing "Should return a Reconciler instance" 11 | (let [r (citrus/reconciler {:state (atom {}) :controllers {}})] 12 | (is (instance? rec/Reconciler r))))) 13 | 14 | (deftest subscription-resolver-instance 15 | (testing "Should return a Resolver instance" 16 | (let [r (citrus/reconciler {:state (atom {}) :controllers {}})] 17 | (is (instance? cur/ReduceCursor 18 | (citrus/subscription r nil)))))) 19 | 20 | 21 | 22 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 23 | ;; 24 | ;; Stateful tests 25 | ;; 26 | ;; Only one reconciler is defined for all tests for convenience 27 | ;; 28 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 29 | 30 | (defmulti dummy-controller (fn [event] event)) 31 | 32 | (defmethod dummy-controller :set-state [_ [new-state] _] 33 | {:state new-state}) 34 | 35 | (defmulti test-controller (fn [event] event)) 36 | 37 | (defmethod test-controller :set-state [_ [new-state] _] 38 | {:state new-state}) 39 | 40 | (defmethod test-controller :set-substate-a [_ [new-substate] current-state] 41 | {:state (assoc current-state :a new-substate)}) 42 | 43 | (defmethod test-controller :set-substate-b [_ [new-substate] current-state] 44 | {:state (assoc current-state :b new-substate)}) 45 | 46 | (def side-effect-atom (atom 0)) 47 | 48 | (defn side-effect [_ _ _] 49 | (swap! side-effect-atom inc)) 50 | 51 | (defmethod test-controller :side-effect [_ _ _] 52 | {:side-effect true}) 53 | 54 | 55 | (def r (citrus/reconciler {:state (atom {:test :initial-state 56 | :dummy nil}) 57 | :controllers {:test test-controller 58 | :dummy dummy-controller} 59 | :effect-handlers {:side-effect side-effect}})) 60 | 61 | (def sub (citrus/subscription r [:test])) 62 | (def dummy (citrus/subscription r [:dummy])) 63 | 64 | 65 | (deftest initial-state 66 | 67 | (testing "Checking initial state in atom" 68 | (is (= :initial-state @sub)) 69 | (is (nil? @dummy)))) 70 | 71 | 72 | (deftest dispatch-sync! 73 | 74 | (testing "One dispatch-sync! works" 75 | (citrus/dispatch-sync! r :test :set-state 1) 76 | (is (= 1 @sub))) 77 | 78 | (testing "dispatch-sync! in a series keeps the the last call" 79 | (doseq [i (range 10)] 80 | (citrus/dispatch-sync! r :test :set-state i)) 81 | (is (= 9 @sub))) 82 | 83 | (testing "dispatch-sync! a non-existing event fails" 84 | (is (thrown-with-msg? js/Error 85 | #"No method .* for dispatch value: :non-existing-event" 86 | (citrus/dispatch-sync! r :test :non-existing-event))))) 87 | 88 | 89 | (deftest broadcast-sync! 90 | 91 | (testing "One broadcast-sync! works" 92 | (citrus/broadcast-sync! r :set-state 1) 93 | (is (= 1 @sub)) 94 | (is (= 1 @dummy))) 95 | 96 | (testing "broadcast-sync! in series keeps the last value" 97 | (doseq [i (range 10)] 98 | (citrus/broadcast-sync! r :set-state i)) 99 | (is (= 9 @sub)) 100 | (is (= 9 @dummy))) 101 | 102 | (testing "broadcast-sync! a non-existing event fails" 103 | (is (thrown-with-msg? js/Error 104 | #"No method .* for dispatch value: :non-existing-event" 105 | (citrus/broadcast-sync! r :non-existing-event))))) 106 | 107 | 108 | (deftest dispatch! 109 | 110 | (testing "dispatch! works asynchronously" 111 | (citrus/dispatch-sync! r :test :set-state "sync") 112 | (citrus/dispatch! r :test :set-state "async") 113 | (is (= "sync" @sub)) 114 | (async done (js/requestAnimationFrame (fn [] 115 | (is (= "async" @sub)) 116 | (done))))) 117 | 118 | (testing "dispatch! in series keeps the last value" 119 | (doseq [i (range 10)] 120 | (citrus/dispatch! r :test :set-state i)) 121 | (async done (js/requestAnimationFrame (fn [] 122 | (is (= 9 @sub)) 123 | (done))))) 124 | 125 | (testing "dispatch! an non-existing event fails" 126 | (let [err-handler (fn [err] (is (re-find #"No method .* for dispatch value: :non-existing-dispatch" (.toString err))))] 127 | (obj/set js/window "onerror" err-handler) 128 | (citrus/dispatch! r :test :non-existing-dispatch) 129 | (async done (js/requestAnimationFrame (fn [] 130 | (obj/set js/window "onerror" nil) 131 | (done))))))) 132 | 133 | 134 | (deftest broadcast! 135 | 136 | ;; Look at the assertions in the async block... False positives, don't understand why 137 | (testing "broadcast! works asynchronously" 138 | (citrus/broadcast-sync! r :set-state "sync") 139 | (citrus/broadcast! r :set-state "async") 140 | (is (= "sync" @sub)) 141 | (is (= "sync" @dummy)) 142 | (async done (js/requestAnimationFrame (fn [] 143 | (is (= 1 "async" @sub)) 144 | (is (= 1 "async" @dummy)) 145 | (done))))) 146 | 147 | (testing "broadcast! in series keeps the last value" 148 | (doseq [i (range 10)] 149 | (citrus/broadcast! r :set-state i)) 150 | (async done (js/requestAnimationFrame (fn [] 151 | (is (= 9 @sub)) 152 | (is (= 9 @dummy)) 153 | (done))))) 154 | 155 | (testing "broadcast! an non-existing event fails" 156 | (let [err-handler (fn [err] (is (re-find #"No method .* for dispatch value: :non-existing-broadcast" (.toString err))))] 157 | (obj/set js/window "onerror" err-handler) 158 | (citrus/broadcast! r :non-existing-broadcast) 159 | (async done (js/requestAnimationFrame (fn [] 160 | (obj/set js/window "onerror" nil) 161 | (done))))))) 162 | 163 | (deftest dispatch-nil-state-issue-20 164 | ;https://github.com/roman01la/citrus/issues/20 165 | 166 | (testing "synchronously setting state as `nil` works" 167 | (citrus/dispatch-sync! r :test :set-state nil) 168 | (is (nil? @sub))) 169 | 170 | (testing "asynchronously setting state as `nil` works" 171 | (citrus/dispatch-sync! r :test :set-state "foo") 172 | (citrus/dispatch! r :test :set-state nil) 173 | (is (= "foo" @sub)) 174 | (async done (js/requestAnimationFrame (fn [] 175 | (is (nil? @sub)) 176 | (done)))))) 177 | 178 | (deftest double-dispatch-issue-23 179 | ;https://github.com/roman01la/citrus/issues/23 180 | 181 | (testing "asynchronously dispatching 2 events that change different parts of the same controller" 182 | (citrus/dispatch! r :test :set-state {:initial :state}) 183 | (citrus/dispatch! r :test :set-substate-a "a") 184 | (citrus/dispatch! r :test :set-substate-b "b") 185 | (async done (js/requestAnimationFrame (fn [] 186 | (is (= {:initial :state :a "a" :b "b"} @sub)) 187 | (done))))) 188 | 189 | (testing "synchronously dispatching 2 events that change different parts of the same controller" 190 | (citrus/dispatch-sync! r :test :set-state {:initial :state}) 191 | (citrus/dispatch-sync! r :test :set-substate-a "a") 192 | (citrus/dispatch-sync! r :test :set-substate-b "b") 193 | (is (= {:initial :state :a "a" :b "b"} @sub))) 194 | 195 | (testing "mixed dispatch of 2 events that change different part of the same controller" 196 | (citrus/dispatch-sync! r :test :set-state {:initial :state}) 197 | (citrus/dispatch! r :test :set-substate-a "a") 198 | (citrus/dispatch-sync! r :test :set-substate-b "b") 199 | (async done (js/requestAnimationFrame (fn [] 200 | (is (= {:initial :state :a "a" :b "b"} @sub)) 201 | (done)))))) 202 | 203 | (deftest side-effects 204 | 205 | (testing "Works synchronously" 206 | (is (zero? @side-effect-atom)) 207 | (citrus/dispatch-sync! r :test :side-effect) 208 | (is (= 1 @side-effect-atom))) 209 | 210 | (testing "Works asynchronously" 211 | (is (= 1 @side-effect-atom)) 212 | (citrus/dispatch! r :test :side-effect) 213 | (is (= 1 @side-effect-atom)) 214 | (async done (js/requestAnimationFrame (fn [] 215 | (is (= 2 @side-effect-atom)) 216 | (done)))))) 217 | 218 | 219 | (deftest subscription 220 | 221 | (testing "basic cases already tested above") 222 | 223 | (testing "reducer function" 224 | (let [reducer-sub (citrus/subscription r [:test] #(str %))] 225 | (citrus/dispatch-sync! r :test :set-state 1) 226 | (is (= "1" @reducer-sub)))) 227 | 228 | (testing "deep path" 229 | (let [deep-sub (citrus/subscription r [:test :a 0])] 230 | (citrus/dispatch-sync! r :test :set-state {:a [42]}) 231 | (is (= 42 @deep-sub)))) 232 | 233 | (testing "with rum's derived-atom" 234 | (let [derived-sub (rum/derived-atom [sub dummy] ::key 235 | (fn [sub-value dummy-value] 236 | (/ (+ sub-value dummy-value) 2)))] 237 | (citrus/dispatch-sync! r :test :set-state 10) 238 | (citrus/dispatch-sync! r :dummy :set-state 20) 239 | (is (= 15 @derived-sub))))) 240 | 241 | 242 | (deftest custom-scheduler 243 | 244 | (testing "a synchronous scheduler updates state synchronously" 245 | (let [r (citrus/reconciler {:state (atom {:test :initial-state}) 246 | :controllers {:test test-controller} 247 | :batched-updates {:schedule-fn (fn [f] (f)) :release-fn (fn [_])}}) 248 | sub (citrus/subscription r [:test])] 249 | (is (= :initial-state @sub)) 250 | (citrus/dispatch! r :test :set-state nil) 251 | (is (nil? @sub)))) 252 | 253 | (testing "an asynchronous scheduler updates state asynchronously" 254 | (let [async-delay 50 ;; in ms 255 | r (citrus/reconciler {:state (atom {:test :initial-state}) 256 | :controllers {:test test-controller} 257 | :batched-updates {:schedule-fn (fn [f] (js/setTimeout f async-delay)) :release-fn (fn [id] (js/clearTimeout id))}}) 258 | sub (citrus/subscription r [:test])] 259 | (is (= :initial-state @sub)) 260 | (citrus/dispatch! r :test :set-state nil) 261 | (is (= :initial-state @sub)) 262 | (async done (js/setTimeout (fn [] 263 | (is (nil? @sub)) 264 | (done)) 265 | async-delay))))) 266 | -------------------------------------------------------------------------------- /test/citrus/custom_handler_test.cljs: -------------------------------------------------------------------------------- 1 | (ns citrus.custom-handler-test 2 | (:require [clojure.test :refer [deftest testing is async]] 3 | [citrus.core :as citrus] 4 | [citrus.reconciler :as rec] 5 | [citrus.cursor :as cur] 6 | [goog.object :as gobj] 7 | [rum.core :as rum])) 8 | 9 | (defn mk-citrus-map-dispatch-handler 10 | "An example of a different default " 11 | [handlers] 12 | (fn [reconciler events] 13 | (let [state-atom (gobj/get reconciler "state")] 14 | ;; (add-watch state-atom :logger 15 | ;; (fn [_key _atom old-state new-state] 16 | ;; (js/console.log (pr-str "old:" old-state)) 17 | ;; (js/console.log (pr-str "new:" new-state)))) 18 | (reset! 19 | state-atom 20 | (loop [state @reconciler 21 | [[ctrl event-key event-args :as event] & events] events] 22 | (if (nil? event) 23 | state 24 | (if-let [handler (get handlers (keyword ctrl event-key))] 25 | (recur (handler state event-args) events) 26 | (let [e (ex-info (str "No handler for " (keyword ctrl event-key)) {})] 27 | (js/console.error e) 28 | (throw e))))) )))) 29 | 30 | (def handlers 31 | {:user/log-in (fn [state [user-name]] 32 | (assoc state :current-user user-name)) 33 | :user/log-out (fn [state _] 34 | (dissoc state :current-user)) 35 | :counters/reset (fn [state [counter-key]] 36 | (assoc-in state [:counters counter-key] 0)) 37 | :counters/inc (fn [state [counter-key]] 38 | (update-in state [:counters counter-key] (fnil inc 0))) 39 | :counters/dec (fn [state [counter-key]] 40 | (update-in state [:counters counter-key] (fnil dec 0)))}) 41 | 42 | (def r (citrus/reconciler {:state (atom {}) 43 | :citrus/handler (mk-citrus-map-dispatch-handler handlers)})) 44 | 45 | (def current-user (citrus/subscription r [:current-user])) 46 | (def counters (citrus/subscription r [:counters])) 47 | 48 | (deftest initial-state 49 | (testing "Checking initial state in atom" 50 | (is (nil? @current-user)) 51 | (is (nil? @counters)))) 52 | 53 | (deftest dispatch-sync-test 54 | (testing "One dispatch-sync! works" 55 | (citrus/dispatch-sync! r :counters :inc :a) 56 | (is (= {:a 1} @counters))) 57 | 58 | (testing "dispatch-sync! in a series keeps the the last call" 59 | (dotimes [_ 10] 60 | (citrus/dispatch-sync! r :counters :inc :x)) 61 | (is (= {:a 1 :x 10} @counters))) 62 | 63 | (testing "dispatch-sync! a non-existing event fails" 64 | (is (thrown-with-msg? js/Error 65 | #"No handler for :test/non-existing-event" 66 | (citrus/dispatch-sync! r :test :non-existing-event))))) 67 | 68 | (deftest dispatch-test 69 | (testing "dispatch! in a series preservers order" 70 | (dotimes [_ 10] 71 | (citrus/dispatch! r :counters :inc :a)) 72 | (citrus/dispatch! r :counters :reset :a) 73 | (dotimes [_ 5] 74 | (citrus/dispatch! r :counters :inc :a)) 75 | (citrus/dispatch! r :user :log-in "roman") 76 | (async done (js/requestAnimationFrame 77 | (fn [] 78 | (is (= {:a 5 :x 10} @counters)) 79 | (is (= "roman" @current-user)) 80 | (done))))) ) 81 | -------------------------------------------------------------------------------- /test/citrus/resolver_test.clj: -------------------------------------------------------------------------------- 1 | (ns citrus.resolver-test 2 | (:require [clojure.test :refer :all] 3 | [citrus.resolver :as resolver])) 4 | 5 | (deftest make-resolver 6 | (testing "Should return Resolver instance" 7 | (is (instance? citrus.resolver.Resolver 8 | (resolver/make-resolver (atom {}) {} [:path] identity))))) 9 | 10 | (deftest Resolver 11 | (testing "Should return resolved data when dereferenced" 12 | (let [r (resolver/make-resolver (atom {}) {:path (constantly 1)} [:path] nil)] 13 | (is (= @r 1)))) 14 | (testing "Should return nested resolved data when dereferenced" 15 | (let [r (resolver/make-resolver (atom {}) {:path (constantly {:value 1})} [:path :value] nil)] 16 | (is (= @r 1)))) 17 | (testing "Should apply reducer to resolved data when dereferenced" 18 | (let [r (resolver/make-resolver (atom {}) {:path (constantly 1)} [:path] inc)] 19 | (is (= @r 2)))) 20 | (testing "Should apply reducer to nested resolved data when dereferenced" 21 | (let [r (resolver/make-resolver (atom {}) {:path (constantly {:value 1})} [:path :value] inc)] 22 | (is (= @r 2)))) 23 | (testing "Should populate state with resolved data after dereferencing" 24 | (let [state (atom {})] 25 | @(resolver/make-resolver state {:path (constantly 1)} [:path] nil) 26 | (is (= @state {:path 1})))) 27 | (testing "Should populate state with resolved data after dereferencing given nested path" 28 | (let [state (atom {})] 29 | @(resolver/make-resolver state {:path (constantly {:value 1})} [:path :value] nil) 30 | (is (= @state {:path {:value 1}})))) 31 | (testing "Should populate state with resolved data after dereferencing given nested path and a reducer function" 32 | (let [state (atom {})] 33 | @(resolver/make-resolver state {:path (constantly {:value 1})} [:path :value] inc) 34 | (is (= @state {:path {:value 1}}))))) 35 | -------------------------------------------------------------------------------- /test/citrus/test_runner.cljs: -------------------------------------------------------------------------------- 1 | (ns citrus.test-runner 2 | (:require [doo.runner :refer-macros [doo-tests]] 3 | [citrus.core-test] 4 | [citrus.custom-handler-test])) 5 | 6 | (doo-tests 'citrus.core-test 'citrus.custom-handler-test) 7 | --------------------------------------------------------------------------------