├── .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 | [](https://clojars.org/clj-commons/citrus)
4 | [](https://cljdoc.org/d/clj-commons/citrus/CURRENT)
5 | [](https://circleci.com/gh/clj-commons/citrus)
6 |
7 |
8 |
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 |
--------------------------------------------------------------------------------