├── .gitignore ├── README.md ├── deps.edn ├── reconciliation.md └── src └── lilactown └── react └── fiber.clj /.gitignore: -------------------------------------------------------------------------------- 1 | /.cpcache/ 2 | /.nrepl-port 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-clj 2 | 3 | This repo holds the rationale, design and (perhaps, eventually) an 4 | implementation of select parts of [ReactJS'](https://reactjs.org/) API in pure 5 | Clojure. 6 | 7 | ## Why? 8 | 9 | ReactJS is primarily used as a client-side framework for building rich user 10 | interfaces on the web. Other use cases include building UIs on mobile, desktop, 11 | and statically generating HTML on the server. Typically these other use cases 12 | presuppose you are running including a JS environment in order to use the 13 | ReactJS' runtime and code written for it. 14 | 15 | This library is targeted at those other use cases by building a runtime that 16 | reflects a large enough part of ReactJS' API to allow the building of apps that 17 | run in both JS and Clojure environments. 18 | 19 | This can enable a number of things in Clojure that are difficult today depending 20 | on the specific React wrapper library one chooses: 21 | * Building web applications which server-render the application before hydrating 22 | client-side. 23 | * Taking advantage of far-in-the-future planned features like ReactJS "server 24 | components," by rendering components to a wire protocol on the server and 25 | streaming the result to the client. 26 | * Building cross-platform applications that share UI definitions, e.g. an app 27 | that runs on the web and in a desktop (swing, javafx) or terminal environment 28 | via the JVM. 29 | 30 | ### Aren't there already a bunch of libraries that implement SSR on the JVM? 31 | 32 | There are probably a half a dozen ReactJS wrappers that support SSR on the JVM. 33 | The trouble with them so far is that: 34 | * They are typically specialized to rendering to HTML 35 | * They often bundle their own additional runtime and features (e.g. using 36 | hiccup) 37 | 38 | This makes them useful if you're building a web app with these wrappers and 39 | happen to want to render some HTML on the JVM, but are less useful for other 40 | use cases. 41 | 42 | The design of this library is meant to be much smaller and more general. Just 43 | like in JS how you can have multiple "reconcilers" which take ReactJS components 44 | and render them on whatever platform you want to target, the goal of this 45 | library is to be just as flexible while maintaining compatibility with React, 46 | allowing us to use the battle-tested library in environments where we can and 47 | using `react-clj` where we can't. 48 | 49 | ## Non-goals 50 | 51 | * Be the fastest way to generate HTML on the server 52 | * Build a reconciler or whatever for JavaFX or any other platform 53 | * Replace ReactJS in the browser 54 | 55 | ## Design 56 | 57 | ### Rendering elements 58 | 59 | ReactJS' core API is actually fairly small. We first introduce one fundamental 60 | data type and a single protocol: 61 | 62 | ```clojure 63 | (defrecord Element [type props key]) 64 | 65 | (defprotocol IRender 66 | (-render [c props] "Render a component, returning a tree of Elements")) 67 | ``` 68 | 69 | This is essentially our public API for defining components and creating elements 70 | out of components and "native" types (e.g. DOM nodes). 71 | 72 | Next there are a few special element "types" that we are going to define up 73 | front: 74 | 75 | ```clojure 76 | (def fragment 'react/fragment) 77 | 78 | (def provider 'react/provider) 79 | 80 | (def suspense 'react/suspense) 81 | ``` 82 | 83 | Others may be added later, but this is enough for us to start constructing 84 | trees of elements for a basic UI: 85 | 86 | ```clojure 87 | (extend-type clojure.lang.Fn 88 | IRender 89 | (-render [f props] (f props))) 90 | 91 | (defn my-component 92 | [{:keys [user-name]}] 93 | (->Element 94 | fragment 95 | {:children ["Hello, " user-name "!"]} 96 | nil)) 97 | 98 | (-render my-component {:user-name "Shirley"}) 99 | ;; => {:type 'react/fragment, :props {:children ["Hello, " "Shirley" "!"]}, :key nil} 100 | ``` 101 | 102 | ### Reconciliation 103 | 104 | Links: 105 | * https://reactjs.org/docs/reconciliation.html 106 | * https://github.com/acdlite/react-fiber-architecture 107 | * https://github.com/facebook/react/tree/master/packages/react-reconciler 108 | 109 | We will probably want to create a runtime similar to React Fiber which consumers 110 | can then build custom reconcilers on top of. This probably(?) isn't necessary 111 | for SSG, but may(?) be necessary for server components, and probably(?) 112 | desirable for rendering a dynamic UI in Skia, Swing or JavaFX. Who knows? 113 | 114 | Good news: we already have rich tools for doing immutable updates and 115 | concurrency-safe containers. 116 | 117 | Bad news: we live in a multithreaded world on the JVM, which requires additional 118 | thought to see if ReactJS' model works well outside of a single threaded 119 | context. 120 | 121 | TODO: Look at projects like [brisk-reconciler](https://github.com/briskml/brisk-reconciler) 122 | which implement reconciliation algos in other runtimes (OCaml). 123 | 124 | ### Hooks 125 | 126 | Implementation TBD based on reconciliation. 127 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"]} 2 | -------------------------------------------------------------------------------- /reconciliation.md: -------------------------------------------------------------------------------- 1 | # Reconciliation 2 | 3 | ReactJS' reconciler package does two things: 4 | 1. Hosts the internal runtime they use schedule work 5 | 2. Provides a public interface for extending React to be used by new host envs, 6 | e.g. iOS or desktop 7 | 8 | This document will explore both of those. 9 | 10 | ## The internal runtime 11 | 12 | The truly novel thing that ReactJS does is what they call "time slicing," where 13 | they split up work across frames of execution to allow things like: 14 | * awaiting asynchronous operations to complete before rendering 15 | * interrupting low-priority work to allow high-priority work to run before 16 | resuming 17 | 18 | The way that ReactJS does this is through their "fiber" architecture, where 19 | they essentially implement their own call graph in user land, reifying them as 20 | JS objects so that they may use cooperative concurrency algorithms to handle 21 | the execution of your ReactJS application. 22 | 23 | This makes some sense in a world where you can't do shared-memory concurrency 24 | (e.g. most JavaScript environments). The first question we should ask is: does 25 | this make sense on the JVM, where shared-memory concurrency is the status quo? 26 | 27 | If we were only interested in the prioritization and performance of taking the 28 | tree of elements, diffing and creating host nodes, then we could reasonably 29 | start out using a synchronous reconciler, or one that schedules work on a thread 30 | pool, and then try building a fancier reconciler later and compare. 31 | 32 | However, I do believe that to allow the UX of a component "suspending" while it 33 | does some IO, we do need mechanism to: 34 | 1. Detect that the tree we are rendering is "suspending" 35 | 2. Commit a partial(?) tree with a placeholder at the point where we suspended 36 | 3. Finish rendering the tree with the IO complete 37 | 4. Commit the final tree 38 | 39 | If we assume that the rendering takes place on a separate thread, and the IO may 40 | block until completion, then that still leaves the question of how we 41 | communicate to the UI thread that it should commit the tree with a placeholder, 42 | and later commit the finished tree. It is complex enough that we cannot naively 43 | call `(render tree changes)` and wait for it to return. 44 | 45 | ### Fibers 46 | 47 | [Project Loom](https://github.com/openjdk/loom) is an implementation of 48 | delimited continuations on the JVM, aka fibers in the nomenclature - they 49 | dropped that terminology when they started focusing on the higher level "virtual 50 | thread" abstraction but kept the underlying idea. 51 | 52 | Virtual threads are something that a reconciler that uses a thread pool could 53 | obviously immediately benefit from, as it would allow thousands of isolated 54 | updates to be calculated at once with no changes. It essentially does the work 55 | to implement the cooperative concurrency that ReactJS has had to handle 56 | themselves. We would have to build prioritization on top of thread pools, but 57 | this is fairly well understood on the JVM AFAICT. 58 | 59 | Another option is that we could take advantage of Loom's lower level 60 | "Continuation" class. Essentially we would standardize on Loom as our 61 | reconciliation runtime and attempt to replicate ReactJS' fiber architecture and 62 | algorithms. 63 | 64 | Some downsides to this: 65 | * This depends on Loom's Continuation class being publicly available, which they 66 | did promise at the start of the project and have backtracked to say that it 67 | _may_ be available after the initial release. 68 | * This depends on Loom's Continuations being cloneable or multishot. They did 69 | did say at the start of the project that they would be cloneable but have been 70 | coy about whether it's still on the roadmap and it is not currently 71 | implemented. 72 | * Would have to implement our own cooperative concurrency algorithms ourselves, 73 | in a shared memory world. 74 | 75 | For these reasons it seems incredibly risky to depend directly on Project Loom 76 | right now. It would be less risky to build on top of a thread pool and, in the 77 | future, swap in a virtual thread pool to finally achieve Webscale(tm). 78 | -------------------------------------------------------------------------------- /src/lilactown/react/fiber.clj: -------------------------------------------------------------------------------- 1 | (ns lilactown.react.fiber 2 | (:require 3 | [clojure.zip :as zip])) 4 | 5 | ;; 6 | ;; Types and protocols 7 | ;; 8 | 9 | 10 | (defprotocol IRender 11 | (render [c props] "Render a component, return a tree of immutable elements")) 12 | 13 | 14 | (defrecord Element [type props key]) 15 | 16 | 17 | (defrecord FiberNode [alternate type props state children]) 18 | 19 | 20 | (defn element? 21 | [x] 22 | (= Element (type x))) 23 | 24 | 25 | (defn element-type? 26 | [x] 27 | (or (= Element (type x)) 28 | (string? x) 29 | (number? x))) 30 | 31 | 32 | (defn fiber? 33 | [x] 34 | (= FiberNode (type x))) 35 | 36 | 37 | ;; 38 | ;; Zipper setup 39 | ;; 40 | 41 | 42 | (defn make-fiber 43 | [fiber children] 44 | (if (or (fiber? fiber) 45 | (element? fiber)) 46 | (->FiberNode 47 | (:alternate fiber) 48 | (:type fiber) 49 | (:props fiber) 50 | (:state fiber) 51 | children) 52 | fiber)) 53 | 54 | 55 | (defn root-fiber 56 | [alternate el] 57 | (->FiberNode alternate :root {:children [el]} nil nil)) 58 | 59 | 60 | (defn fiber-zipper 61 | [fiber] 62 | (zip/zipper 63 | fiber? 64 | :children 65 | make-fiber 66 | fiber)) 67 | 68 | 69 | ;; 70 | ;; Hooks 71 | ;; 72 | 73 | 74 | (declare ^:dynamic *hooks-context*) 75 | 76 | 77 | (defn hooks-context 78 | [alternate-state] 79 | {:state (atom {:index 0 80 | :previous alternate-state 81 | :current []})}) 82 | 83 | 84 | (defn get-previous-hook-state! 85 | [ctx] 86 | (let [{:keys [index previous]} @(:state ctx)] 87 | (nth previous index))) 88 | 89 | 90 | (defn set-current-hook-state! 91 | [ctx state] 92 | (swap! (:state ctx) 93 | (fn [hooks-state] 94 | (-> hooks-state 95 | (update :current conj state) 96 | (update :index inc))))) 97 | 98 | 99 | (defn use-ref 100 | [init] 101 | (let [ctx *hooks-context*] 102 | (or (get-previous-hook-state! ctx) 103 | (doto (atom init) 104 | (->> (set-current-hook-state! ctx)))))) 105 | 106 | 107 | (defn use-memo 108 | [f deps] 109 | (let [ctx *hooks-context* 110 | [_ prev-deps :as prev-state] (get-previous-hook-state! ctx)] 111 | (if (not= prev-deps deps) 112 | (let [v (f) 113 | state [v deps]] 114 | (set-current-hook-state! ctx state) 115 | state) 116 | prev-state))) 117 | 118 | 119 | (defn use-callback 120 | [f deps] 121 | (use-memo #(f) deps)) 122 | 123 | 124 | (defn use-reducer 125 | ([f initial] 126 | (use-reducer f initial identity)) 127 | ([_f initial init-fn] 128 | (let [ctx *hooks-context* 129 | state (or (get-previous-hook-state! ctx) 130 | ;; TODO allow implementation to be swapped in here 131 | [(init-fn initial) (fn [& _])])] 132 | (set-current-hook-state! ctx state) 133 | state))) 134 | 135 | 136 | (defn use-state 137 | [init] 138 | (let [[state dispatch] (use-reducer 139 | (fn [state [arg & args]] 140 | (if (ifn? arg) 141 | (apply arg state args) 142 | arg)) 143 | init) 144 | set-state (use-callback 145 | (fn [& args] 146 | (dispatch args)) 147 | [])] 148 | [state set-state])) 149 | 150 | 151 | (defn use-effect 152 | [f deps] 153 | (let [context *hooks-context* 154 | {:keys [index previous]} @(:state context) 155 | prev-state (nth previous index) 156 | state [f deps]] 157 | ;; deps not= 158 | (when (not= (second prev-state) deps) 159 | ;; TODO schedule effect using impl TBD 160 | nil) 161 | (set-current-hook-state! context state) 162 | nil)) 163 | 164 | 165 | (defn use-layout-effect 166 | [f deps] 167 | (let [context *hooks-context* 168 | {:keys [index previous]} @(:state context) 169 | prev-state (nth previous index) 170 | state [f deps]] 171 | ;; deps not= 172 | (when (not= (second prev-state) deps) 173 | ;; TODO schedule effect using impl TBD 174 | nil) 175 | (set-current-hook-state! context state) 176 | nil)) 177 | 178 | 179 | 180 | ;; 181 | ;; Reconciliation 182 | ;; 183 | 184 | 185 | (defn perform-work 186 | "Renders the fiber, returning child elements" 187 | [{:keys [type props] :as _node}] 188 | (cond 189 | (satisfies? IRender type) 190 | [(render type props)] 191 | 192 | ;; destructuring doesn't seem to fail if `_node` is actually a primitive 193 | ;; i.e. a string or number, so we can just check to see if `type` is 194 | ;; `nil` to know whether we are dealing with an actual element 195 | (some? type) 196 | (flatten (:children props)) 197 | 198 | :else nil)) 199 | 200 | 201 | (defn reconcile-node 202 | [node host-config] 203 | (let [hooks-context (hooks-context (-> node :previous :state)) 204 | results (binding [*hooks-context* hooks-context] 205 | (perform-work node))] 206 | (make-fiber 207 | (if (map? node) 208 | (assoc node :state (-> hooks-context :state deref :current)) 209 | node) 210 | results))) 211 | 212 | 213 | (defn reconcile 214 | [fiber host-config] 215 | (loop [loc (fiber-zipper fiber)] 216 | (if (zip/end? loc) 217 | (zip/root loc) 218 | (recur (zip/next (zip/edit loc reconcile-node host-config)))))) 219 | 220 | 221 | ;; 222 | ;; example 223 | ;; 224 | 225 | 226 | (defn $ 227 | ([t] (->Element t nil nil)) 228 | ([t arg] 229 | (if-not (element-type? arg) 230 | (->Element t arg (:key arg)) 231 | (->Element t {:children (list arg)} nil))) 232 | ([t arg & args] 233 | (if-not (element-type? arg) 234 | (->Element t (assoc arg :children args) (:key arg)) 235 | (->Element t {:children (cons arg args)} nil)))) 236 | 237 | 238 | (extend-type clojure.lang.Fn 239 | IRender 240 | (render [f props] (f props))) 241 | 242 | 243 | (defn greeting 244 | [{:keys [user-name]}] 245 | ($ "div" 246 | {:class "greeting"} 247 | "Hello, " user-name "!")) 248 | 249 | 250 | (defn counter 251 | [_] 252 | (let [[count set-count] (use-state 4)] 253 | ($ "div" 254 | ($ "button" {:on-click #(set-count inc)} "+") 255 | (for [n (range count)] 256 | ($ "div" {:key n} n))))) 257 | 258 | 259 | (defn app 260 | [{:keys [user-name]}] 261 | ($ "div" 262 | {:class "app container"} 263 | ($ "h1" "App title") 264 | "foo" 265 | ($ greeting {:user-name user-name}) 266 | ($ counter))) 267 | 268 | 269 | (def fiber0 270 | (reconcile 271 | (root-fiber nil ($ app {:user-name "Will"})) 272 | {})) 273 | 274 | 275 | #_(reconcile 276 | (root-fiber fiber0 ($ app {:user-name "Alan"})) 277 | {}) 278 | --------------------------------------------------------------------------------