├── .github └── workflows │ └── tests.yml ├── .gitignore ├── Makefile ├── README.md ├── TODO.md ├── VISION.md ├── deps.edn ├── doc └── state-reactivity.md ├── epl-v10.html ├── karma.conf.js ├── package-lock.json ├── package.json ├── public └── index.html ├── shadow-cljs.edn └── src └── reseda ├── demo.cljs ├── demo ├── bmi.cljs ├── lifecycle.cljs ├── nasa_apod.cljs ├── nasa_apod_17.cljs ├── pokemon.cljs ├── pokemon2.cljs ├── ssr.cljs ├── transitions.cljs ├── util.cljs └── wikipedia.cljs ├── react.cljs ├── react └── experimental.cljs ├── react_test.cljs └── state.cljc /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@master 10 | with: 11 | fetch-depth: 1 12 | - uses: actions/setup-java@v1 13 | with: 14 | java-version: 11 15 | - uses: DeLaGuardo/setup-clojure@master 16 | with: 17 | tools-deps: "1.10.1.708" 18 | - name: Run Makefile 19 | run: make test 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .cpcache 3 | .shadow-cljs/ 4 | .clj-kondo/.cache 5 | .lsp 6 | out/ 7 | public/js/ 8 | public-test/ 9 | .DS_Store 10 | .calva/output-window 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | @echo "Installing dependencies" 4 | npm install 5 | npm install --only=dev 6 | @echo "Compiling for tests" 7 | clojure -M:test:shadow-cljs compile ci 8 | @echo "Running karma" 9 | ./node_modules/.bin/karma start --single-run 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reseda 2 | 3 | ![tests](https://github.com/orestis/reseda/workflows/tests/badge.svg) 4 | 5 | A Clojure-y state management library for modern React, from the future 🚀 6 | 7 | ## Rationale 8 | 9 | For a long time, React applications in ClojureScript would rely on comprehensive libraries such as [Om](https://github.com/omcljs/om), [Reagent](https://github.com/reagent-project/reagent) and [re-frame](https://github.com/day8/re-frame), [Fulcro](https://fulcro.fulcrologic.com), etc. for a big chunk of web-application concerns, while treating React as purely a view layer. In particular, on top of state management, these libraries would also deal with reactivity: making sure components re-render when a piece of state changes. 10 | 11 | With the introduction of [Context](https://reactjs.org/docs/context.html), [Hooks](https://reactjs.org/docs/hooks-intro.html), and the (currently work-in-progress) [Concurrent Mode](https://reactjs.org/docs/concurrent-mode-intro.html), React is getting more and more opinionated about state management, while at the same time exposing lower-level primitives that can allow fine-grained control over reactivity. 12 | 13 | Reseda explores the space of using Clojure's philosophy of [Identity, State, and Values](https://www.infoq.com/presentations/Value-Identity-State-Rich-Hickey/) for state management, while leaning on React for reactivity. 14 | 15 | The result is a state managment library that works with plain React components, is REPL friendly, uses plain Clojure atoms as the underlying storage mechanism, can be used both for global and local state, and can be used whenever `useState` is inadequate. 16 | 17 | In addition, by fully embracing [Suspense for Data Fetching](https://reactjs.org/docs/concurrent-mode-suspense.html), Reseda allows you to build User Interfaces without asynchronous data fetching concerns, resulting in less moving parts in your application. And by being compatible with React Stable, it gives you a glimpse of the future today 😎 18 | 19 | ## Status 20 | 21 | Used in production at [Nosco](https://nos.co), and evolves as we explore various use cases. 22 | 23 | The API might change (but probably not), and there's some unit test coverage but still – use at your own risk. 24 | 25 | Happily accepting issues to discuss use cases, bugs, new features etc. 26 | 27 | See [Vision](./VISION.md) for the original vision and potential future additions. 28 | 29 | ## React version compatibility 30 | 31 | Reseda is forwards compatible with React 18, and backwards compatible with React 17. 32 | It should also work fine with React 16.8 (with hooks), but that's not a goal anymore. 33 | 34 | ## Usage 35 | 36 | Use `deps.edn` to get a reference to the latest commit, e.g.: 37 | 38 | {orestis/reseda {:git/url "https://github.com/orestis/reseda.git" 39 | :sha ""}} 40 | 41 | Install [use-sync-external-store](https://www.npmjs.com/package/use-sync-external-store) from npm. 42 | This is a compatibility shim that makes Reseda compatible with React 17, 43 | but also takes advantage of React 18 native APIs if running under React 18. 44 | 45 | ## Getting Started 46 | 47 | ### Setting up the store 48 | 49 | At the core, Reseda exposes a `Store` that wraps an `IWatchable` (most often a Clojure atom). 50 | 51 | ```clojure 52 | (:require [reseda.state]) 53 | 54 | ;; create the atom 55 | (defonce backing-store (atom {})) 56 | 57 | ;; create a new store 58 | (defonce store (reseda.state/new-store backing-store)) 59 | ``` 60 | 61 | Having the store at hand, you can subscribe to be notified whenever something in the backing store changes: 62 | 63 | ```clojure 64 | ;; whenever the value under :fred changes, print it 65 | (subscribe store :fred println) 66 | 67 | ;; you can pass in any function as the selector, not just keywords: 68 | (subscribe store identity tap>) 69 | (subscribe store (fn [m] (select-keys m [:fred :wilma])) tap>) 70 | 71 | ;; as a convenience, you can also pass a vector of keywords as the selector, like get-in 72 | (subscribe store [:fred :name] #(log %)) 73 | ``` 74 | 75 | Note that the value you get is whatever the selector returns, and Clojure's equality via `=` is used to determine if a change was made. The `on-change` function receives a single argument, the new value. 76 | 77 | Obviously, you can put whatever you want inside the backing store - most usually it will be a map, but it might be a vector or anything else that you can put in an atom. 78 | 79 | You can implement the `IWatchable` protocl (CLJS) or `clojure.lang.IRef` interface (Clojure) for something fancier -- e.g. to trigger subscriptions based on a websocket connection etc. 80 | 81 | ### React integration 82 | 83 | So far the code is cross-platform, but the main use of Reseda is for building UIs with React. 84 | 85 | The basic setup is exactly the same: 86 | 87 | ```clojure 88 | (ns reseda.readme 89 | (:require [reseda.state] 90 | [reseda.react] 91 | [hx.react :refer [defnc]])) 92 | 93 | ;; be sure to use defonce or your state will be gone if you use hot-reloading on save 94 | (defonce backing-store (atom {:user {:name "Fred" 95 | :email "fred@example.com"}})) 96 | (defonce store (reseda.state/new-store backing-store)) 97 | ``` 98 | 99 | You can then re-render a component whenever something changes in the store via the `useStore` hook: 100 | 101 | ```clojure 102 | ;; The first render will give you whatever is in the store, and 103 | ;; from then on, your component will re-render whenever the value changes 104 | (defnc Name [] 105 | (let [name (reseda.react/useStore store [:user :name])] 106 | [:div "The user's name is: " name])) 107 | ``` 108 | 109 | Note that [hx](https://github.com/lilactown/hx) is used for the examples, but any library that can 110 | make use of React Hooks can be used. 111 | 112 | To make changes, simply change the underlying backing store however you see fit, e.g.: 113 | 114 | ```clojure 115 | (defnc EditName [] 116 | (let [name (reseda.react/useStore store [:user :name])] 117 | [:input {:value name 118 | :on-change #(swap! backing-store assoc-in 119 | [:user :name] 120 | (-> % .-target .-value))}])) 121 | ``` 122 | 123 | You have the entire Clojure toolbox at your disposal to make changes. Use plain maps, a statechart library, a Datascript database, whatever fits your use case. 124 | 125 | **Note:** `useStore` uses the new `use-sync-external-store` shim by React. This is backwards compatible with React <18 (any version with hooks), but uses the native functionality in React 18. 126 | 127 | **Note:** The selector function passed to `useStore` has to be stable, that is, 128 | not recreated on every render - otherwise you'll end up in an infinite render loop. Be sure to wrap these selectors in a `useCallback`, or define them as top-level functions. Plain keywords and vectors are automatically wrapped so most of the time you can just forget about this. 129 | 130 | ### Suspense Integration 131 | 132 | Reseda supports [Suspense for Data Fetching](https://reactjs.org/docs/concurrent-mode-suspense.html) even in React Stable (16.13), even though it's technically not supported yet, even in React 18. 133 | 134 | This allows you to avoid a whole bunch of asynchronous code by allowing React to suspend rendering if some remote value hasn't arrived yet. 135 | 136 | At the core of this support is the `Suspending` type, which you can construct by giving it a Promise: 137 | 138 | ```clojure 139 | (ns reseda.readme 140 | (:require [reseda.state] 141 | [reseda.react] 142 | ["react" :as React])) 143 | 144 | (defn fetch-api [] 145 | ;; fetch a remote resource and return a Javascript Promise 146 | ,,,) 147 | 148 | (defonce backing-store (atom {:data (-> (fetch-api) 149 | (reseda.react/suspending-value))})) 150 | (defonce store (reseda.state/new-store backing-store)) 151 | 152 | ;; :data now contains a Suspending that wraps the Promise 153 | (realized? (:data @backing-store)) 154 | ;;=> false 155 | 156 | ;; after some time passes, the Promise resolves: 157 | (realized? (:data @backing-store)) 158 | ;;=> true 159 | 160 | ;; you can now deref the Suspending to get the actual data: 161 | (deref (:data @backing-store)) 162 | ;;=> 163 | 164 | ;; If the value might be nil, use deref* to get back nil 165 | (reseda.react/deref* (:missing-data @backing-store)) 166 | ``` 167 | 168 | The magic happens you combine a Suspending with a React Suspense Boundary: 169 | 170 | ```clojure 171 | 172 | (defnc RemoteName [] 173 | ;; using a trailing * for reader clarity -- 174 | ;; this is a Suspending and you need to deref it 175 | (let [data* (reseda.react/useStore store :data)] 176 | ;; notice the @ that derefs the Suspending 177 | [:div "The remote data is: " @data*])) 178 | 179 | (defnc Root [] 180 | ;; see note about Suspense boundaries 181 | ;; -- you cannot have them in the same component that suspends 182 | [React/Suspense {:fallback (hx/f [:div "Loading..."])} 183 | [RemoteName]]) 184 | ``` 185 | 186 | When React tries to render `RemoteName`, and the data hasn't fetched yet, the `deref` of the Suspending will cause React to "suspend". This means that the closest `Suspense` component will show its fallback element instead of its children. When the promise resolves, React will try to re-render, in which case the data will have been loaded and rendering proceeds normally (or until a child component suspends). 187 | 188 | Note that the result of the `Promise` is not used by React to render anything. The Promise resolving only signals React to re-render the component. 189 | 190 | Read more about [Suspense in the official React Docs](https://reactjs.org/docs/react-api.html#reactsuspense). 191 | 192 | #### useCachedSuspending 193 | 194 | The final trick that Reseda provides, is the ability to show *previous versions* of a Suspending value. This is useful in "refreshing" contexts, where some content is already visible to the user, and replacing that will a fallback will make for a jarring user experience. 195 | 196 | 197 | ```clojure 198 | (defnc SearchResults [{:keys [results*]}] 199 | [:div (for [v @results*]) 200 | [Row {:value v}]]) 201 | 202 | (defnc SearchList [] 203 | (let [results* (reseda.react/useStore store :results)] 204 | [SearchBox {:on-change 205 | (fn [text] 206 | (swap! backing-store :results (fetch-new-results text)))}] 207 | [React/Suspense 208 | [SearchResults {:results* results*}]])) 209 | ``` 210 | 211 | The moment you change the `:results` value of the backing store to a new Suspending, Reseda will make your component re-render, which in turn will make React suspend, meaning the previous results will be gone from the screen. Not cool. 212 | 213 | To avoid this, wrap the `Suspending` value with a `useCachedSuspending` hook like so: 214 | 215 | ```clojure 216 | (defnc SearchList [] 217 | (let [[results* loading?] (-> (reseda.react/useStore store :results) 218 | (reseda.react/useCachedSuspending))] 219 | [SearchBox {:show-spinner loading? 220 | :on-change 221 | (fn [text] 222 | (swap! backing-store :results (fetch-new-results text)))}] 223 | [React/Suspense 224 | [SearchResults {:results* results* 225 | :loading? loading?}]])) 226 | ``` 227 | 228 | `useCachedSuspending` will return a vector of the suspending plus a boolean that indicates if a new value is on the way. 229 | 230 | In React 18, this is simply a wrapper over `useDeferredValue`. In React 17, it's keeping track of the last resolved `Suspending` and returning that until the next one resolves. 231 | 232 | **Note:** In React <18, `useCachedSuspending` will add a callback to the underlying Promise of the `Suspending`. This should be harmless and only does side-effects related to React. The actual value is passed-through unchanged. 233 | 234 | 235 | ### Local state 236 | 237 | While all the examples so far were dealing with global atoms and stores, you can also use Reseda for local state. You just need to make sure that React doesn't throw away your local state. You can do that with a `useRef`: 238 | 239 | ```clojure 240 | (defnc ComplexComponent [] 241 | (let [backing-ref (React/useRef (atom {}) 242 | backing (.-current backing-ref) 243 | store-ref (React/useRef (reseda.state/new-store backing))) 244 | store (.-current store-ref)] 245 | [ReadOnlyComponent {:store store} 246 | [WriteOnlyComponent {:backing backing} 247 | [ReadWriteComponent {:store store :backing backing}]]])) 248 | ``` 249 | 250 | React will make sure that the atom and the wrapping store will stay the same during the lifecycle of the component (ie from mount to unmount), so you can pass the "current" value around as props to any child components that may need them. 251 | 252 | The separation of store and backing also makes it clear if a component is just reading values from the store or also writing values into it. 253 | 254 | ### Gotchas and advanced topics 255 | 256 | Due to the way React works, you need to keep in mind a few things: 257 | 258 | #### Selector functions 259 | 260 | Selector functions should have a consistent identity, (i.e. not re-created every render), otherwise you may go into a render-loop. 261 | 262 | * Keywords and vectors of keywords "just work" 263 | * Functions defined via `defn` also work, since their identity doesn't change 264 | * Anything else has to be wrapped inside a `useCallback` 265 | 266 | 267 | Reseda doesn't try to do any batching or asynchronicity of subscriptions. This means that one change to the underlying atomwill trigger one subscription (assuming of course the selected value *does* change). 268 | 269 | This is fine in practice, since often React will batch the DOM updates, but if you care to avoid multiple renders, make sure you `swap!` just once. 270 | 271 | #### Caching, derivative values and extra logic 272 | 273 | Reseda doesn't do any caching and will naively re-run all your subscriptions every time the underlying atom changes. 274 | 275 | If some selector functions are expensive, you would probably want to either pre-calculate their values and store them in a different place. You can do this in numerous ways, e.g.: 276 | 277 | * By doing all the work during a simple `swap!` 278 | * By adding an extra watch (via plain `add-watch`) *before* you create the Reseda `Store`. This way you can catch changes to the store before the Reseda subscriptions run. 279 | 280 | #### Suspense Boundaries 281 | 282 | You cannot have a Suspense boundary inside the component that does the Suspending. This is because React walks the component tree upwards to find the next Suspense boundary, and will throw away the results of the current render (that put the inline Suspense boundary in place). 283 | 284 | 285 | ## Non-goals 286 | 287 | * Creating React elements via hiccup or other means. There is already a lot of exploration happening in the space, with libraries such as [hx](https://github.com/Lokeh/hx) and [helix](https://github.com/Lokeh/helix), and Hiccup parsers such as [Hicada](https://github.com/rauhs/hicada) and [Sablono](https://github.com/r0man/sablono). 288 | 289 | * Server-side rendering (Node or JVM). I'm not personally interested in this for the kind of applications I develop. However once the progressive hydration story of React stabilises, it might be interesting to revisit. 290 | 291 | ## Demo 292 | 293 | There's various demos at [src/reseda/demo](src/reseda/demo). You can follow along at https://reseda.orestis.gr or run `clojure -M:demo:shadow-cljs watch demo` 294 | 295 | ## License 296 | 297 | ``` 298 | Copyright (c) Orestis Markou. All rights reserved. The use and 299 | distribution terms for this software are covered by the Eclipse 300 | Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) 301 | which can be found in the file epl-v10.html at the root of this 302 | distribution. By using this software in any fashion, you are 303 | agreeing to be bound by the terms of this license. You must 304 | not remove this notice, or any other, from this software. 305 | ``` 306 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | * Fix the failing React 18 tests 2 | * Address the issue of "initial value" 3 | 4 | When we first render, ideally the store would be prepopulated 5 | by the parent component. 6 | But often times, that's not practical, so we rely on a useEffect. 7 | Which means that exists a brief time that the store of the value is nil 8 | It used to be that we can't suspend with a promise that never resolves 9 | (React 16.8 was hanging for some reason). 10 | Revisit if we can do that today with react 17 or 18. -------------------------------------------------------------------------------- /VISION.md: -------------------------------------------------------------------------------- 1 | # Vision 2 | 3 | Reseda started out as a catch-all proejct to explore various web-related domains as a small, decomposed problems. Examples include: 4 | 5 | * [State & Reactivity](doc/state-reactivity.md) 6 | * Changing state 7 | * Event-based architecture 8 | * Side-effects (IO/timeouts/etc) 9 | * Routing, code-splitting, [Suspense](https://reactjs.org/docs/react-api.html#suspense) & [Render-as-you-fetch](https://reactjs.org/docs/concurrent-mode-suspense.html#approach-3-render-as-you-fetch-using-suspense) 10 | 11 | With the goal being to provide one library per problem. The libraries should be composable with each other to build bespoke frameworks (a la carte). Perhaps a natural framework will also arise from all this work, perhaps not. 12 | 13 | 14 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {} 3 | :aliases 4 | {:test 5 | {:extra-deps {athos/kitchen-async {:sha "1f28eab4ef7126e0b8b6fd8d8887beeab7430fea" 6 | :git/url "https://github.com/athos/kitchen-async"}}} 7 | :demo 8 | {:extra-deps {cljs-bean/cljs-bean {:mvn/version "1.8.0"}}} 9 | :shadow-cljs 10 | {:extra-deps {thheller/shadow-cljs {:mvn/version "2.18.0"}} 11 | :main-opts ["-m" "shadow.cljs.devtools.cli"]}}} 12 | -------------------------------------------------------------------------------- /doc/state-reactivity.md: -------------------------------------------------------------------------------- 1 | # State & Reactivity 2 | 3 | Clojure already provides a perfectly good approach to state: immutable data structures inside of mutable containers, i.e. atoms. Atoms already provide watches, so observing changes of atoms is built-in. Therefore it makes sense to use plain Clojure atoms as the "backing store" of all state in a web application. 4 | 5 | On top of this backing store, with some simple React Hooks, components can selectively re-render when the part of the state they are interested in has changed. This is an abstraction that encapsulates an atom and deals with subscriptions. 6 | 7 | ## REPL and development experience 8 | 9 | It's a goal to be able to use the REPL to inspect and manipulate the state. This is reflected by exposing the backing store atom directly, and by having protocol methods that can inspect and manipulate the state of the store. 10 | 11 | ## Subscriptions & Selectors 12 | 13 | We don't want to re-render the whole component tree when the root atom changes, so components should be able to subscribe to only part of the state, using a "selector" that takes the whole state and returns the interesting part. For maximum flexibility, selectors are just functions. Supporting path-based selectors (for `get-in` like usage) or even more complex selectors ([Specter](https://github.com/redplanetlabs/specter), [Meander](https://github.com/noprompt/meander)) can be handled at the next abstraction level or even the application level. 14 | 15 | ## Updating state (also: cursors?) 16 | 17 | Updating the state is done using also the atom interface (`swap!`, `reset!`) etc. Coupling the selectors to updates (a-la cursors) is unnecessary and can be done at another level of abstraction or at the application level. 18 | 19 | ## Multiple atoms 20 | 21 | While putting all application state in a single atom makes sense from a low complexity point of view, there's no need to enforce that. Having the option to create and dispose many different atoms allows for more flexibility, and moves the decision to application authors. 22 | 23 | ## References to state 24 | 25 | State can either be global (e.g. a `defonce`d var) or can be component-local (using perhaps [`useRef`](https://reactjs.org/docs/hooks-reference.html#useref)) and could also be passed via [`Context`](https://reactjs.org/docs/context.html). 26 | 27 | ## Performance 28 | 29 | There might be a need to cache previous results, de-duplicate selectors, and other performance optimisations. Before going there, adding some performance metrics to measure how long do state updates take and exposing that to the application is a good starting point. 30 | -------------------------------------------------------------------------------- /epl-v10.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Eclipse Public License - Version 1.0 8 | 25 | 26 | 27 | 28 | 29 | 30 |

Eclipse Public License - v 1.0

31 | 32 |

THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE 33 | PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR 34 | DISTRIBUTION OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS 35 | AGREEMENT.

36 | 37 |

1. DEFINITIONS

38 | 39 |

"Contribution" means:

40 | 41 |

a) in the case of the initial Contributor, the initial 42 | code and documentation distributed under this Agreement, and

43 |

b) in the case of each subsequent Contributor:

44 |

i) changes to the Program, and

45 |

ii) additions to the Program;

46 |

where such changes and/or additions to the Program 47 | originate from and are distributed by that particular Contributor. A 48 | Contribution 'originates' from a Contributor if it was added to the 49 | Program by such Contributor itself or anyone acting on such 50 | Contributor's behalf. Contributions do not include additions to the 51 | Program which: (i) are separate modules of software distributed in 52 | conjunction with the Program under their own license agreement, and (ii) 53 | are not derivative works of the Program.

54 | 55 |

"Contributor" means any person or entity that distributes 56 | the Program.

57 | 58 |

"Licensed Patents" mean patent claims licensable by a 59 | Contributor which are necessarily infringed by the use or sale of its 60 | Contribution alone or when combined with the Program.

61 | 62 |

"Program" means the Contributions distributed in accordance 63 | with this Agreement.

64 | 65 |

"Recipient" means anyone who receives the Program under 66 | this Agreement, including all Contributors.

67 | 68 |

2. GRANT OF RIGHTS

69 | 70 |

a) Subject to the terms of this Agreement, each 71 | Contributor hereby grants Recipient a non-exclusive, worldwide, 72 | royalty-free copyright license to reproduce, prepare derivative works 73 | of, publicly display, publicly perform, distribute and sublicense the 74 | Contribution of such Contributor, if any, and such derivative works, in 75 | source code and object code form.

76 | 77 |

b) Subject to the terms of this Agreement, each 78 | Contributor hereby grants Recipient a non-exclusive, worldwide, 79 | royalty-free patent license under Licensed Patents to make, use, sell, 80 | offer to sell, import and otherwise transfer the Contribution of such 81 | Contributor, if any, in source code and object code form. This patent 82 | license shall apply to the combination of the Contribution and the 83 | Program if, at the time the Contribution is added by the Contributor, 84 | such addition of the Contribution causes such combination to be covered 85 | by the Licensed Patents. The patent license shall not apply to any other 86 | combinations which include the Contribution. No hardware per se is 87 | licensed hereunder.

88 | 89 |

c) Recipient understands that although each Contributor 90 | grants the licenses to its Contributions set forth herein, no assurances 91 | are provided by any Contributor that the Program does not infringe the 92 | patent or other intellectual property rights of any other entity. Each 93 | Contributor disclaims any liability to Recipient for claims brought by 94 | any other entity based on infringement of intellectual property rights 95 | or otherwise. As a condition to exercising the rights and licenses 96 | granted hereunder, each Recipient hereby assumes sole responsibility to 97 | secure any other intellectual property rights needed, if any. For 98 | example, if a third party patent license is required to allow Recipient 99 | to distribute the Program, it is Recipient's responsibility to acquire 100 | that license before distributing the Program.

101 | 102 |

d) Each Contributor represents that to its knowledge it 103 | has sufficient copyright rights in its Contribution, if any, to grant 104 | the copyright license set forth in this Agreement.

105 | 106 |

3. REQUIREMENTS

107 | 108 |

A Contributor may choose to distribute the Program in object code 109 | form under its own license agreement, provided that:

110 | 111 |

a) it complies with the terms and conditions of this 112 | Agreement; and

113 | 114 |

b) its license agreement:

115 | 116 |

i) effectively disclaims on behalf of all Contributors 117 | all warranties and conditions, express and implied, including warranties 118 | or conditions of title and non-infringement, and implied warranties or 119 | conditions of merchantability and fitness for a particular purpose;

120 | 121 |

ii) effectively excludes on behalf of all Contributors 122 | all liability for damages, including direct, indirect, special, 123 | incidental and consequential damages, such as lost profits;

124 | 125 |

iii) states that any provisions which differ from this 126 | Agreement are offered by that Contributor alone and not by any other 127 | party; and

128 | 129 |

iv) states that source code for the Program is available 130 | from such Contributor, and informs licensees how to obtain it in a 131 | reasonable manner on or through a medium customarily used for software 132 | exchange.

133 | 134 |

When the Program is made available in source code form:

135 | 136 |

a) it must be made available under this Agreement; and

137 | 138 |

b) a copy of this Agreement must be included with each 139 | copy of the Program.

140 | 141 |

Contributors may not remove or alter any copyright notices contained 142 | within the Program.

143 | 144 |

Each Contributor must identify itself as the originator of its 145 | Contribution, if any, in a manner that reasonably allows subsequent 146 | Recipients to identify the originator of the Contribution.

147 | 148 |

4. COMMERCIAL DISTRIBUTION

149 | 150 |

Commercial distributors of software may accept certain 151 | responsibilities with respect to end users, business partners and the 152 | like. While this license is intended to facilitate the commercial use of 153 | the Program, the Contributor who includes the Program in a commercial 154 | product offering should do so in a manner which does not create 155 | potential liability for other Contributors. Therefore, if a Contributor 156 | includes the Program in a commercial product offering, such Contributor 157 | ("Commercial Contributor") hereby agrees to defend and 158 | indemnify every other Contributor ("Indemnified Contributor") 159 | against any losses, damages and costs (collectively "Losses") 160 | arising from claims, lawsuits and other legal actions brought by a third 161 | party against the Indemnified Contributor to the extent caused by the 162 | acts or omissions of such Commercial Contributor in connection with its 163 | distribution of the Program in a commercial product offering. The 164 | obligations in this section do not apply to any claims or Losses 165 | relating to any actual or alleged intellectual property infringement. In 166 | order to qualify, an Indemnified Contributor must: a) promptly notify 167 | the Commercial Contributor in writing of such claim, and b) allow the 168 | Commercial Contributor to control, and cooperate with the Commercial 169 | Contributor in, the defense and any related settlement negotiations. The 170 | Indemnified Contributor may participate in any such claim at its own 171 | expense.

172 | 173 |

For example, a Contributor might include the Program in a commercial 174 | product offering, Product X. That Contributor is then a Commercial 175 | Contributor. If that Commercial Contributor then makes performance 176 | claims, or offers warranties related to Product X, those performance 177 | claims and warranties are such Commercial Contributor's responsibility 178 | alone. Under this section, the Commercial Contributor would have to 179 | defend claims against the other Contributors related to those 180 | performance claims and warranties, and if a court requires any other 181 | Contributor to pay any damages as a result, the Commercial Contributor 182 | must pay those damages.

183 | 184 |

5. NO WARRANTY

185 | 186 |

EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS 187 | PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS 188 | OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, 189 | ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY 190 | OR FITNESS FOR A PARTICULAR PURPOSE. Each Recipient is solely 191 | responsible for determining the appropriateness of using and 192 | distributing the Program and assumes all risks associated with its 193 | exercise of rights under this Agreement , including but not limited to 194 | the risks and costs of program errors, compliance with applicable laws, 195 | damage to or loss of data, programs or equipment, and unavailability or 196 | interruption of operations.

197 | 198 |

6. DISCLAIMER OF LIABILITY

199 | 200 |

EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT 201 | NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, 202 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING 203 | WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF 204 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 205 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OR 206 | DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY RIGHTS GRANTED 207 | HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.

208 | 209 |

7. GENERAL

210 | 211 |

If any provision of this Agreement is invalid or unenforceable under 212 | applicable law, it shall not affect the validity or enforceability of 213 | the remainder of the terms of this Agreement, and without further action 214 | by the parties hereto, such provision shall be reformed to the minimum 215 | extent necessary to make such provision valid and enforceable.

216 | 217 |

If Recipient institutes patent litigation against any entity 218 | (including a cross-claim or counterclaim in a lawsuit) alleging that the 219 | Program itself (excluding combinations of the Program with other 220 | software or hardware) infringes such Recipient's patent(s), then such 221 | Recipient's rights granted under Section 2(b) shall terminate as of the 222 | date such litigation is filed.

223 | 224 |

All Recipient's rights under this Agreement shall terminate if it 225 | fails to comply with any of the material terms or conditions of this 226 | Agreement and does not cure such failure in a reasonable period of time 227 | after becoming aware of such noncompliance. If all Recipient's rights 228 | under this Agreement terminate, Recipient agrees to cease use and 229 | distribution of the Program as soon as reasonably practicable. However, 230 | Recipient's obligations under this Agreement and any licenses granted by 231 | Recipient relating to the Program shall continue and survive.

232 | 233 |

Everyone is permitted to copy and distribute copies of this 234 | Agreement, but in order to avoid inconsistency the Agreement is 235 | copyrighted and may only be modified in the following manner. The 236 | Agreement Steward reserves the right to publish new versions (including 237 | revisions) of this Agreement from time to time. No one other than the 238 | Agreement Steward has the right to modify this Agreement. The Eclipse 239 | Foundation is the initial Agreement Steward. The Eclipse Foundation may 240 | assign the responsibility to serve as the Agreement Steward to a 241 | suitable separate entity. Each new version of the Agreement will be 242 | given a distinguishing version number. The Program (including 243 | Contributions) may always be distributed subject to the version of the 244 | Agreement under which it was received. In addition, after a new version 245 | of the Agreement is published, Contributor may elect to distribute the 246 | Program (including its Contributions) under the new version. Except as 247 | expressly stated in Sections 2(a) and 2(b) above, Recipient receives no 248 | rights or licenses to the intellectual property of any Contributor under 249 | this Agreement, whether expressly, by implication, estoppel or 250 | otherwise. All rights in the Program not expressly granted under this 251 | Agreement are reserved.

252 | 253 |

This Agreement is governed by the laws of the State of New York and 254 | the intellectual property laws of the United States of America. No party 255 | to this Agreement will bring a legal action under this Agreement more 256 | than one year after the cause of action arose. Each party waives its 257 | rights to a jury trial in any resulting litigation.

258 | 259 | 260 | 261 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | config.set({ 3 | browsers: ['ChromeHeadless'], 4 | // The directory where the output file lives 5 | basePath: 'public-test', 6 | // The file itself 7 | files: ['ci.js'], 8 | frameworks: ['cljs-test'], 9 | plugins: ['karma-cljs-test', 'karma-chrome-launcher'], 10 | colors: true, 11 | logLevel: config.LOG_INFO, 12 | client: { 13 | args: ["shadow.test.karma.init"], 14 | singleRun: true 15 | } 16 | }) 17 | }; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@testing-library/react": "^13.0.0", 4 | "karma": "^6.3.17", 5 | "karma-chrome-launcher": "^3.1.1", 6 | "karma-cljs-test": "^0.1.0", 7 | "shadow-cljs": "^2.18.0" 8 | }, 9 | "dependencies": { 10 | "react": "^18.0.0", 11 | "react-dom": "^18.0.0", 12 | "use-sync-external-store": "^1.0.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Reseda Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /shadow-cljs.edn: -------------------------------------------------------------------------------- 1 | {:deps true 2 | :builds 3 | {:test {:target :browser-test 4 | :compiler-options {:infer-externs :auto} 5 | :test-dir "public-test" 6 | :devtools 7 | {:http-root "public-test" 8 | :http-port 8021}} 9 | 10 | :ci {:target :karma 11 | :compiler-options {:infer-externs :auto} 12 | :output-to "public-test/ci.js"} 13 | 14 | :ssr {:target :node-script 15 | :main reseda.demo.ssr/main 16 | :output-to "out/ssr/main.js" 17 | :devtools {:before-load-async reseda.demo.ssr/stop 18 | :after-load reseda.demo.ssr/start}} 19 | 20 | :demo {:target :browser 21 | :output-dir "public/js" 22 | :asset-path "/js" 23 | :compiler-options {:infer-externs :auto} 24 | 25 | :modules 26 | {:main ;; <- becomes public/js/main.js 27 | {:entries [reseda.demo]}} 28 | 29 | ;; start a development http server on http://localhost:8020 30 | :devtools 31 | {:http-root "public" 32 | :http-port 8020}}}} 33 | -------------------------------------------------------------------------------- /src/reseda/demo.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.demo 2 | (:require 3 | [reseda.demo.util :refer [$]] 4 | [reseda.demo.bmi :as bmi] 5 | [reseda.demo.nasa-apod-17 :as nasa-apod] 6 | [reseda.demo.lifecycle :as lifecycle] 7 | [reseda.demo.wikipedia :as wikipedia] 8 | [reseda.demo.pokemon :as pokemon] 9 | [reseda.demo.pokemon2 :as pokemon2] 10 | ;[reseda.demo.transitions :as transitions] 11 | [clojure.string :as string] 12 | ["react-dom" :as react-dom] 13 | ["react-dom/client" :as react-dom-client])) 14 | 15 | 16 | (def react-18? (string/starts-with? (.-version react-dom) 17 | "18.")) 18 | 19 | 20 | (defn react-root [el] 21 | (if react-18? 22 | (react-dom-client/createRoot el) 23 | el)) 24 | 25 | (defn react-render [root component] 26 | (if react-18? 27 | (.render root component) 28 | (react-dom/render component root))) 29 | 30 | (defn Main [] 31 | ($ "main" nil 32 | ($ "header" nil ($ "h1" nil "Reseda Demos")) 33 | ($ "article" nil 34 | ($ pokemon2/PokemonDemo)) 35 | ($ "article" nil 36 | ($ pokemon/PokemonDemo)) 37 | ($ "article" nil 38 | ($ wikipedia/WikiSearchDemo)) 39 | #_($ "article" nil 40 | ($ transitions/TransitionsDemo) 41 | ($ transitions/TransitionsDemoStore)) 42 | ($ "hr") 43 | ($ "article" nil ($ lifecycle/LifecycleDemo)) 44 | ($ "hr") 45 | ($ "article" nil 46 | ($ bmi/StoreDemo)) 47 | ($ "hr") 48 | ($ "article" nil 49 | ($ nasa-apod/NasaApodDemo)))) 50 | 51 | (defonce root 52 | (delay (-> (js/document.getElementById "app") 53 | (react-root)))) 54 | 55 | (defn ^:dev/after-load start [] 56 | (js/console.log "start") 57 | (react-render @root ($ Main))) 58 | 59 | (defn ^:export init [] 60 | (js/console.log "init") 61 | (start)) -------------------------------------------------------------------------------- /src/reseda/demo/bmi.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.demo.bmi 2 | (:require 3 | [cljs-bean.core :refer [bean]] 4 | [reseda.demo.util :refer [$]] 5 | [reseda.state :refer [new-store]] 6 | [reseda.react :refer [useStore]])) 7 | 8 | 9 | (defn calc-bmi [{:keys [height weight bmi] :as data}] 10 | (let [h (/ height 100)] 11 | (if (nil? bmi) 12 | (assoc data :bmi (/ weight (* h h))) 13 | (assoc data :weight (* bmi h h))))) 14 | 15 | ;; This is the "backing store" atom. It's a plain old Clojure atom, you can 16 | ;; do whatever you want with it. 17 | (defonce bmi-data (atom (calc-bmi {:height 180 :weight 80}))) 18 | 19 | ;; This is a store backed by the above atom. It provides a way 20 | ;; to subscribe to "selectors" via callback functions. 21 | (defonce bmi-store (new-store bmi-data)) 22 | 23 | (defn Slider [props] 24 | (let [{:keys [param value min max invalidates]} (bean props)] 25 | ($ "input" #js {:type "range" 26 | :value value 27 | :min min 28 | :max max 29 | :style #js {:width "100%"} 30 | :onChange (fn [e] 31 | (let [new-value (js/parseInt (.. e -target -value))] 32 | ;; At the lowest level of abstraction, we can just 33 | ;; manipulate the backing atom directly. 34 | (swap! bmi-data 35 | (fn [data] 36 | (-> data 37 | (assoc param new-value) 38 | (dissoc invalidates) 39 | calc-bmi)))))}))) 40 | 41 | (defn BmiComponent [_props] 42 | ;; `useStore` is the react-aware subscription hook, to observe 43 | ;; the Reseda store. In this example we observe the entire store 44 | ;; so the selector is identity. 45 | (let [{:keys [weight height bmi]} (useStore bmi-store identity) 46 | [color diagnose] (cond 47 | (< bmi 18.5) ["orange" "underweight"] 48 | (< bmi 25) ["inherit" "normal"] 49 | (< bmi 30) ["orange" "overweight"] 50 | :else ["red" "obese"])] 51 | ($ "form" nil 52 | ($ "fieldset" nil 53 | ($ "legend" nil "BMI calculator") 54 | ($ "label" nil "Height: " (int height) "cm") 55 | ($ Slider #js {:param :height 56 | :value height 57 | :min 100 58 | :max 220 59 | :invalidates :bmi}) 60 | ($ "div" nil 61 | ($ "label" nil "Weight: " (int weight) "kg") 62 | ($ Slider #js {:param :weight 63 | :value weight 64 | :min 30 65 | :max 150 66 | :invalidates :bmi})) 67 | ($ "div" nil 68 | ($ "label" nil "BMI: " (int bmi) " ") 69 | ($ "span" #js {:style #js {:color color}} diagnose) 70 | ($ Slider #js {:param :bmi 71 | :value bmi 72 | :min 10 73 | :max 50 74 | :invalidates :weight})))))) 75 | 76 | (defn StoreDemo [] 77 | ($ "div" nil 78 | ($ "h2" nil "BMI Calculator") 79 | ($ "div" #js {:style #js {:width "30em"}} 80 | ($ BmiComponent)))) 81 | 82 | (comment 83 | @(.-subs bmi-store)) -------------------------------------------------------------------------------- /src/reseda/demo/lifecycle.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.demo.lifecycle 2 | (:require 3 | [reseda.demo.util :refer [$]] 4 | [reseda.state :refer [new-store]] 5 | [reseda.react :refer [useStore]])) 6 | 7 | (defonce data (atom {:counter 0 8 | :stable nil})) 9 | (defonce store (new-store data)) 10 | 11 | (defn change-data [k f] 12 | (js/setTimeout #(swap! data update k f) 0)) 13 | 14 | (defn CounterRenderer [_props] 15 | (let [c (useStore store :counter)] 16 | ($ "div" nil "Count is " c))) 17 | 18 | 19 | (defn LifecycleButtons [] 20 | ($ "div" nil 21 | ($ "button" #js 22 | {:onClick (fn [] 23 | (change-data :counter inc))} 24 | "Increase") 25 | ($ "button" #js 26 | {:onClick (fn [] 27 | (change-data :counter dec))} 28 | "Decrease"))) 29 | 30 | (defn LifcycleCounters [] 31 | (let [c (useStore store :counter)] 32 | (for [i (range c)] 33 | ($ "div" #js {:key i} 34 | ($ CounterRenderer))))) 35 | 36 | (defn LifecycleDemo [_props] 37 | (useStore store :stable) 38 | ($ "section" nil 39 | ($ "h2" nil "Lifecycle") 40 | ($ LifecycleButtons) 41 | ($ LifcycleCounters))) -------------------------------------------------------------------------------- /src/reseda/demo/nasa_apod.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.demo.nasa-apod 2 | (:require [reseda.demo.util :refer [$] :as util] 3 | [reseda.state :as rs] 4 | [reseda.react :as rr] 5 | [reseda.react.experimental :as rre] 6 | [cljs-bean.core :refer [bean]] 7 | ["react" :as react])) 8 | 9 | (def api-key "HquDsZLQArdVX1iaFoZGnWMD1AvoOkUEhlTtboCe" #_"DEMO_KEY") 10 | 11 | (defn date->query [date] 12 | (let [d (.getDate date) 13 | m (-> (.getMonth date) 14 | inc) 15 | y (.getFullYear date)] 16 | (str y "-" (when (< m 10) "0") m "-" (when (< d 10) "0") d))) 17 | 18 | (defn query-url [date] 19 | (str "https://api.nasa.gov/planetary/apod?api_key=" 20 | api-key 21 | "&date=" (date->query date))) 22 | 23 | (def day-in-millis (* 24 60 60 1000)) 24 | 25 | (defn change-date [d amount] 26 | (-> (.getTime d) 27 | (+ (* amount day-in-millis)) 28 | (js/Date.))) 29 | 30 | (defn fetch-apod [date] 31 | (-> date 32 | (query-url) 33 | (util/make-request) 34 | (.then (fn [text] 35 | (-> text (js/JSON.parse) (js->clj :keywordize-keys true)))) 36 | (.then (fn [apod] 37 | (if (and (:url apod) (= "image" (:media_type apod))) 38 | (assoc apod :suspense-url (rr/suspending-image (:url apod))) 39 | apod))) 40 | (rr/suspending-value))) 41 | 42 | (def now (js/Date.)) 43 | (defonce app-state 44 | (atom {:date now 45 | :apod (fetch-apod now)})) 46 | 47 | (defonce app-store (rs/new-store app-state)) 48 | (defonce ms-store (rre/wrap-store app-store)) 49 | (def the-store ms-store) 50 | (def useStore rre/useStore) 51 | 52 | (defn date-button-clicked [current-date direction] 53 | (let [new-date (change-date current-date direction)] 54 | (when (<= new-date (js/Date.)) 55 | (swap! app-state assoc 56 | :date new-date 57 | :apod (fetch-apod new-date))))) 58 | 59 | (defn DatePicker [] 60 | (let [current-date (useStore the-store :date) 61 | [startTransition isPending] (react/unstable_useTransition #js {:timeoutMs 1500})] 62 | ($ "div" #js {:style #js {:display "flex" 63 | :justifyContent "space-between" 64 | :alignItems "center"}} 65 | ($ "button" #js {:onClick (fn [] 66 | (startTransition #(date-button-clicked current-date -1)))} 67 | "Previous Day") 68 | ($ "strong" #js {:style (when isPending 69 | #js {:opacity "50%"})} 70 | (str (date->query current-date))) 71 | ($ "button" #js {:onClick (fn [] 72 | (startTransition #(date-button-clicked current-date +1)))} 73 | "Next Day")))) 74 | 75 | (defn ApodMedia [props] 76 | (let [{:keys [suspense-url url media_type]} (bean props)] 77 | (case media_type 78 | "image" ($ "img" #js {:style #js {:width "100%"} 79 | :src @suspense-url}) 80 | "video" ($ "iframe" #js {:src url 81 | :type "text/html" 82 | :width "640px" 83 | :height "360px"}) 84 | ($ "pre" nil "Unknown media type: " media_type url) 85 | ))) 86 | 87 | 88 | 89 | (defn ApodComponent [props] 90 | (let [apod (:apod (bean props))] 91 | ($ "article" #js {:style #js {:width "100%"}} 92 | ($ "h4" nil (:title apod)) 93 | ($ "section" nil 94 | ($ "figure" nil 95 | ($ ApodMedia #js {:url (:url apod) 96 | :suspense-url (:suspense-url apod) 97 | :media_type (:media_type apod)}) 98 | ($ "figcaption" nil 99 | (:date apod) " " 100 | "Copyright: " (:copyright apod))) 101 | ($ "p" nil (:explanation apod)))))) 102 | 103 | (defn ApodLoader [] 104 | (let [apod (useStore the-store :apod)] 105 | ($ ApodComponent #js {:apod @apod}))) 106 | 107 | 108 | (defn NasaApodDemo [] 109 | ($ "section" nil 110 | ($ "h2" nil "Astronomy Picture of the day") 111 | ($ "div" #js {} 112 | ($ DatePicker) 113 | ($ "hr") 114 | ($ react/Suspense #js {:fallback ($ "div" nil "Loading apod...")} 115 | ($ ApodLoader))))) 116 | 117 | (comment 118 | (:apod @app-state) 119 | (swap! app-state assoc :date (js/Date.) :apod (fetch-apod now)) 120 | 121 | (js/console.log @(:suspense-url @(:apod @app-state))) 122 | ) -------------------------------------------------------------------------------- /src/reseda/demo/nasa_apod_17.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.demo.nasa-apod-17 2 | (:require [reseda.demo.util :refer [$] :as util] 3 | [reseda.state :as rs] 4 | [reseda.react :as rr :refer [useStore useCachedSuspending]] 5 | [cljs-bean.core :refer [bean]] 6 | ["react" :as react])) 7 | 8 | (def api-key "HquDsZLQArdVX1iaFoZGnWMD1AvoOkUEhlTtboCe" #_"DEMO_KEY") 9 | 10 | (defn date->query [date] 11 | (let [d (.getDate date) 12 | m (-> (.getMonth date) 13 | inc) 14 | y (.getFullYear date)] 15 | (str y "-" (when (< m 10) "0") m "-" (when (< d 10) "0") d))) 16 | 17 | (defn query-url [date] 18 | (str "https://api.nasa.gov/planetary/apod?api_key=" 19 | api-key 20 | "&date=" (date->query date))) 21 | 22 | (def day-in-millis (* 24 60 60 1000)) 23 | 24 | (defn change-date [d amount] 25 | (-> (.getTime d) 26 | (+ (* amount day-in-millis)) 27 | (js/Date.))) 28 | 29 | (defn fetch-apod [date] 30 | (-> date 31 | (query-url) 32 | (util/make-request) 33 | (.then (fn [text] 34 | (-> text (js/JSON.parse) (js->clj :keywordize-keys true)))) 35 | (.then (fn [apod] 36 | (if (and (:url apod) (= "image" (:media_type apod))) 37 | (assoc apod :suspense-url (rr/suspending-image (:url apod))) 38 | apod))) 39 | (rr/suspending-value))) 40 | 41 | (def now (js/Date.)) 42 | (defonce app-state 43 | (atom {:date now 44 | :apod (fetch-apod now)})) 45 | 46 | (defonce app-store (rs/new-store app-state)) 47 | 48 | (defn date-button-clicked [current-date direction] 49 | (let [new-date (change-date current-date direction)] 50 | (when (<= new-date (js/Date.)) 51 | (swap! app-state assoc 52 | :date new-date 53 | :apod (fetch-apod new-date))))) 54 | 55 | (defn DatePicker [] 56 | (let [current-date (useStore app-store :date) 57 | [_ isPending] (useCachedSuspending (useStore app-store :apod))] 58 | ($ "div" #js {:style #js {:display "flex" 59 | :justifyContent "space-between" 60 | :alignItems "center"}} 61 | ($ "button" #js {:onClick #(date-button-clicked current-date -1)} 62 | "Previous Day") 63 | ($ "strong" #js {:style (when isPending 64 | #js {:opacity "50%"})} 65 | (str (date->query current-date))) 66 | ($ "button" #js {:onClick #(date-button-clicked current-date +1)} 67 | "Next Day")))) 68 | 69 | (defn ApodMedia [props] 70 | (let [{:keys [suspense-url url media_type loading-media]} (bean props)] 71 | (case media_type 72 | "image" ($ "img" #js {:style #js {:width "100%" 73 | :opacity (if loading-media 0.1 1)} 74 | :src @suspense-url}) 75 | "video" ($ "iframe" #js {:src url 76 | :type "text/html" 77 | :width "640px" 78 | :height "360px"}) 79 | ($ "pre" nil "Unknown media type: " media_type url)))) 80 | 81 | 82 | 83 | (defn ApodComponent [props] 84 | (let [{:keys [apod loading]} (bean props) 85 | [suspense-url loading-media] (useCachedSuspending (:suspense-url apod))] 86 | ($ "article" #js {:style #js {:width "100%" 87 | :opacity (if loading 0.5 1)}} 88 | ($ "h4" nil (:title apod)) 89 | ($ "section" nil 90 | ($ "figure" nil 91 | ($ ApodMedia #js {:url (:url apod) 92 | :suspense-url suspense-url 93 | :loading-media loading-media 94 | :media_type (:media_type apod)}) 95 | ($ "figcaption" nil 96 | (:date apod) " " 97 | "Copyright: " (:copyright apod))) 98 | ($ "p" nil (:explanation apod)))))) 99 | 100 | (defn ApodLoader [] 101 | (let [[apod loading] (useCachedSuspending (useStore app-store :apod))] 102 | ($ ApodComponent #js {:apod @apod 103 | :loading loading}))) 104 | 105 | 106 | (defn NasaApodDemo [] 107 | ($ "section" nil 108 | ($ "h2" nil "Astronomy Picture of the day") 109 | ($ "div" #js {} 110 | ($ DatePicker) 111 | ($ "hr") 112 | ($ react/Suspense #js {:fallback ($ "div" nil "Loading apod...")} 113 | ($ ApodLoader))))) 114 | 115 | (comment 116 | (:apod @app-state) 117 | (swap! app-state assoc :date (js/Date.) :apod (fetch-apod now)) 118 | 119 | (js/console.log @(:suspense-url @(:apod @app-state)))) 120 | -------------------------------------------------------------------------------- /src/reseda/demo/pokemon.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.demo.pokemon 2 | (:require ["react" :as react] 3 | [cljs-bean.core :refer [->clj bean]] 4 | [clojure.string :as string] 5 | [reseda.demo.util :refer [$]] 6 | [reseda.react :refer [useStore deref*]] 7 | [reseda.state :refer [new-store]])) 8 | 9 | (def query-prefix "https://pokeapi.co/api/v2/pokemon/") 10 | 11 | (def DELAY 1000) 12 | 13 | (defn fetch-pokemon [id] 14 | (let [qs (str query-prefix id)] 15 | (-> (js/fetch qs ) 16 | (.then (fn [r] 17 | (.json r))) 18 | (.then (fn [r] 19 | (js/Promise. (fn [resolve _reject] 20 | (js/setTimeout 21 | #(resolve (->clj r)) 22 | DELAY)))))))) 23 | 24 | 25 | (defonce first-pokemon (reseda.react/suspending-value (fetch-pokemon 1))) 26 | 27 | (defn RenderPokemon [props] 28 | (let [{:keys [is-pending start-transition pok set-pok*]} (bean props)] 29 | ($ "article" #js {} 30 | ($ "h4" #js {:style #js {:opacity (if is-pending 0.5 1)}} (:name pok)) 31 | ($ "button" #js {:onClick #(start-transition 32 | (fn [] (set-pok* 33 | (-> 34 | (fetch-pokemon (inc (:id pok))) 35 | (reseda.react/suspending-value)))))} 36 | "Next"))) 37 | ) 38 | 39 | (defn PokemonDetail [props] 40 | (let [[pok* set-pok*] (react/useState first-pokemon) 41 | [is-pending start-transition] (react/useTransition) 42 | pok (deref* pok*)] 43 | ($ RenderPokemon #js {:pok pok 44 | :is-pending is-pending 45 | :start-transition start-transition 46 | :set-pok* set-pok*}) 47 | )) 48 | 49 | (defonce data (atom first-pokemon)) 50 | (defonce store (new-store data)) 51 | 52 | (defn PokemonDetailStore [props] 53 | (let [pok* (reseda.react/useStore store identity) 54 | set-pok* (fn [x] (reset! data x)) 55 | [is-pending start-transition] (react/useTransition) 56 | pok (deref* pok*)] 57 | ($ RenderPokemon #js {:pok pok 58 | :is-pending is-pending 59 | :start-transition start-transition 60 | :set-pok* set-pok*}))) 61 | 62 | (defn PokemonDetailStoreDeferred [props] 63 | (let [pok* (reseda.react/useStore store identity) 64 | set-pok* (fn [x] (reset! data x)) 65 | deferred-pok* (react/useDeferredValue pok*) 66 | is-pending (not (identical? pok* deferred-pok*)) 67 | start-transition apply 68 | pok (deref* deferred-pok*)] 69 | ($ RenderPokemon #js {:pok pok 70 | :is-pending is-pending 71 | :start-transition start-transition 72 | :set-pok* set-pok*}))) 73 | 74 | (defn PokemonDemo [_props] 75 | ($ "section" nil 76 | ($ "h2" nil "Pokemon Transitions") 77 | ($ "aside" nil "Inspired by https://www.youtube.com/watch?v=Kd0d-9RQHSw") 78 | ($ "hr") 79 | ($ "h3" nil "Pokemon Demo (useState + useTransition)") 80 | ($ react/Suspense #js {:fallback ($ "div" nil "Fetching pokemeon...")} 81 | ($ PokemonDetail)) 82 | ($ "hr") 83 | ($ "h3" nil "Pokemon Demo (useStore + useDeferredValue)") 84 | ($ react/Suspense #js {:fallback ($ "div" nil "Fetching pokemeon...")} 85 | ($ PokemonDetailStoreDeferred)) 86 | ($ "hr") 87 | ($ "h3" nil "Pokemon Demo (useStore + useTransition)") 88 | ($ react/Suspense #js {:fallback ($ "div" nil "Fetching pokemeon...")} 89 | ($ PokemonDetailStore)))) -------------------------------------------------------------------------------- /src/reseda/demo/pokemon2.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.demo.pokemon2 2 | (:require ["react" :as react] 3 | [cljs-bean.core :refer [->clj bean]] 4 | [clojure.string :as string] 5 | [reseda.demo.util :refer [$]] 6 | [reseda.react :refer [useStore deref*]] 7 | [reseda.state :refer [new-store]])) 8 | 9 | (def query-prefix "https://pokeapi.co/api/v2/pokemon/") 10 | 11 | (def DELAY 1000) 12 | 13 | (defn fetch-pokemon [id] 14 | (let [qs (str query-prefix id)] 15 | (-> (js/fetch qs ) 16 | (.then (fn [r] 17 | (.json r))) 18 | (.then (fn [r] 19 | (js/Promise. (fn [resolve _reject] 20 | (js/setTimeout 21 | #(resolve (->clj r)) 22 | DELAY)))))))) 23 | 24 | 25 | (defonce first-pokemon (reseda.react/suspending-forever)) 26 | 27 | 28 | (defn use-first-render [set-pok*] 29 | (react/useEffect 30 | (fn [] 31 | (set-pok* (-> 32 | (fetch-pokemon 1) 33 | (reseda.react/suspending-value))) 34 | js/undefined) 35 | #js [])) 36 | 37 | (defn RenderPokemon [props] 38 | (let [{:keys [is-pending start-transition pok* set-pok*]} (bean props) 39 | pok (deref* pok*)] 40 | ($ "article" #js {} 41 | ($ "h4" #js {:style #js {:opacity (if is-pending 0.5 1)}} (:name pok)) 42 | ($ "button" #js {:onClick #(start-transition 43 | (fn [] (set-pok* 44 | (-> 45 | (fetch-pokemon (inc (:id pok))) 46 | (reseda.react/suspending-value)))))} 47 | "Next")))) 48 | 49 | (defn PokemonDetail [props] 50 | (let [[pok* set-pok*] (react/useState first-pokemon) 51 | [is-pending start-transition] (react/useTransition)] 52 | (use-first-render set-pok*) 53 | ($ react/Suspense #js {:fallback ($ "div" nil "Fetching pokemeon...")} 54 | ($ RenderPokemon #js {:pok* pok* 55 | :is-pending is-pending 56 | :start-transition start-transition 57 | :set-pok* set-pok*})))) 58 | 59 | (defonce data (atom first-pokemon)) 60 | (defonce store (new-store data)) 61 | 62 | (defn PokemonDetailStore [props] 63 | (let [pok* (reseda.react/useStore store identity) 64 | set-pok* (fn [x] (reset! data x)) 65 | [is-pending start-transition] (react/useTransition)] 66 | (use-first-render set-pok*) 67 | ($ react/Suspense #js {:fallback ($ "div" nil "Fetching pokemeon...")} 68 | ($ RenderPokemon #js {:pok* pok* 69 | :is-pending is-pending 70 | :start-transition start-transition 71 | :set-pok* set-pok*})))) 72 | 73 | (defn PokemonDetailStoreDeferred [props] 74 | (let [pok* (reseda.react/useStore store identity) 75 | set-pok* (fn [x] (reset! data x)) 76 | deferred-pok* (react/useDeferredValue pok*) 77 | is-pending (not (identical? pok* deferred-pok*)) 78 | start-transition apply] 79 | (use-first-render set-pok*) 80 | ($ react/Suspense #js {:fallback ($ "div" nil "Fetching pokemeon...")} 81 | ($ RenderPokemon #js {:pok* deferred-pok* 82 | :is-pending is-pending 83 | :start-transition start-transition 84 | :set-pok* set-pok*})))) 85 | 86 | (defn PokemonDemo [_props] 87 | ($ "section" nil 88 | ($ "h2" nil "Pokemon Transitions") 89 | ($ "aside" nil "Inspired by https://www.youtube.com/watch?v=Kd0d-9RQHSw") 90 | ($ "hr") 91 | ($ "h3" nil "Pokemon Demo (useState + useTransition)") 92 | ($ PokemonDetail) 93 | ($ "hr") 94 | ($ "h3" nil "Pokemon Demo (useStore + useDeferredValue)") 95 | ($ PokemonDetailStoreDeferred) 96 | ($ "hr") 97 | ($ "h3" nil "Pokemon Demo (useStore + useTransition)") 98 | ($ PokemonDetailStore))) -------------------------------------------------------------------------------- /src/reseda/demo/ssr.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.demo.ssr 2 | (:require 3 | [reseda.demo.util :refer [$]] 4 | [cljs-bean.core :refer [->clj bean]] 5 | ["http" :as http] 6 | ["react" :as react] 7 | [reseda.react :refer [deref*]] 8 | ["react-dom/server" :as react-dom-server])) 9 | 10 | (def query-prefix "https://pokeapi.co/api/v2/pokemon/") 11 | 12 | (def DELAY 1000) 13 | 14 | (defn fetch-pokemon [id] 15 | (let [qs (str query-prefix id)] 16 | (-> (js/fetch qs ) 17 | (.then (fn [r] 18 | (.json r))) 19 | (.then (fn [r] 20 | (js/Promise. (fn [resolve _reject] 21 | (js/setTimeout 22 | #(resolve (->clj r)) 23 | DELAY)))))))) 24 | 25 | 26 | (defn use-first-render [set-pok*] 27 | (react/useEffect 28 | ;; useEffect won't fire in SSR, so this does nothing 29 | (fn [] 30 | (set-pok* (-> 31 | (fetch-pokemon 1) 32 | (reseda.react/suspending-value))) 33 | js/undefined) 34 | #js [])) 35 | 36 | (defn RenderPokemon [props] 37 | (let [{:keys [is-pending start-transition pok* set-pok*]} (bean props) 38 | pok (deref* pok*)] 39 | ($ "article" #js {} 40 | ($ "h4" #js {:style #js {:opacity (if is-pending 0.5 1)}} (:name pok)) 41 | ($ "button" #js {:onClick #(start-transition 42 | (fn [] (set-pok* 43 | (-> 44 | (fetch-pokemon (inc (:id pok))) 45 | (reseda.react/suspending-value)))))} 46 | "Next")))) 47 | 48 | (defn PokemonDetail [props] 49 | (let [[pok* set-pok*] (react/useState (reseda.react/suspending-forever)) 50 | [is-pending start-transition] (react/useTransition)] 51 | (use-first-render set-pok*) 52 | ($ react/Suspense #js {:fallback ($ "div" nil "Fetching pokemeon...")} 53 | ($ RenderPokemon #js {:pok* pok* 54 | :is-pending is-pending 55 | :start-transition start-transition 56 | :set-pok* set-pok*})))) 57 | 58 | 59 | 60 | (defn App [props] 61 | ($ "html" nil 62 | ($ "head" nil) 63 | ($ "body" nil 64 | ($ "h1" nil "Hello SSR!") 65 | ($ react/Suspense #js {:fallback ($ "div" nil "Fetching pokemeon...")} 66 | ($ PokemonDetail nil)) 67 | ($ "p" nil "Pokemeon above")))) 68 | 69 | 70 | (defn request-handler [^js req ^js res] 71 | (let [did-error (atom false) 72 | stream (atom nil)] 73 | (reset! 74 | stream 75 | (react-dom-server/renderToPipeableStream 76 | (App #js {}) 77 | #js {:onError 78 | (fn [e] 79 | (reset! did-error true) 80 | (js/console.error "Error rendering" e)) 81 | :onAllReady 82 | (fn [] 83 | (js/console.log "ALL READY")) 84 | :onShellError 85 | (fn [err] 86 | (set! (.-statusCode res) 500) 87 | (.send res (str "ERROR:" err))) 88 | :onShellReady 89 | (fn [] 90 | (set! (.-statusCode res) (if @did-error 500 200)) 91 | (.setHeader res "Content-Type" "text/html") 92 | (.pipe ^js @stream res) 93 | (.end res))})))) 94 | 95 | (defonce server-ref 96 | (volatile! nil)) 97 | 98 | (defn main [& args] 99 | (js/console.log "starting server") 100 | (let [server (http/createServer #(request-handler %1 %2))] 101 | 102 | (.listen server 3000 103 | (fn [err] 104 | (if err 105 | (js/console.error "server start failed") 106 | (js/console.info "http server running")))) 107 | 108 | (vreset! server-ref server))) 109 | 110 | (defn start 111 | "Hook to start. Also used as a hook for hot code reload." 112 | [] 113 | (js/console.warn "start called") 114 | (main)) 115 | 116 | (defn stop 117 | "Hot code reload hook to shut down resources so hot code reload can work" 118 | [done] 119 | (js/console.warn "stop called") 120 | (when-some [srv @server-ref] 121 | (.close srv 122 | (fn [err] 123 | (js/console.log "stop completed" err) 124 | (done))))) 125 | 126 | (js/console.log "__filename" js/__filename) -------------------------------------------------------------------------------- /src/reseda/demo/transitions.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.demo.transitions 2 | (:require 3 | [reseda.demo.util :refer [$] :as util] 4 | [reseda.state :as rs] 5 | [reseda.react :as rr] 6 | [reseda.react.experimental :as rre] 7 | [cljs-bean.core :refer [bean]] 8 | ["react" :as react])) 9 | 10 | 11 | (defn fetch-data [section] 12 | (-> (util/timeout-promise (str (:title section) " data") 2000) 13 | (rr/suspending-value))) 14 | 15 | (def SUSPENSE-CONFIG #js {:timeoutMs 3000}) 16 | 17 | (defn Button [^js props] 18 | (let [[startTransition isPending] (react/unstable_useTransition SUSPENSE-CONFIG)] 19 | ($ "button" #js {:onClick (fn [] ((.-onClick props) startTransition)) 20 | :style #js {:fontWeight (if (.-active props) "bold" "normal")}} 21 | ($ (if isPending "em" "span") nil 22 | (.-title props))))) 23 | 24 | 25 | (def sections 26 | [{:id 1 :title "First"} 27 | {:id 2 :title "Second"} 28 | {:id 3 :title "Third"}]) 29 | 30 | (defn Data [props] 31 | ($ "div" nil @(.-data props))) 32 | 33 | (defn TransitionsDemo [] 34 | (let [[active setActive] (react/useState 1) 35 | [data setData] (react/useState (fetch-data (first sections)))] 36 | ($ "section" nil 37 | ($ "h2" nil "useTransition setState") 38 | ($ "div" #js {} 39 | (for [section sections] 40 | ($ Button #js {:title (:title section) 41 | :active (= active (:id section)) 42 | :key (:id section) 43 | :onClick (fn [t] 44 | (setActive (:id section)) 45 | (t 46 | #(setData (fetch-data section))) 47 | )})) 48 | ($ "hr") 49 | ($ react/Suspense #js {:fallback ($ "div" nil "Loading...")} 50 | ($ Data #js {:data data})))))) 51 | 52 | (defonce app-state 53 | (atom {:active 1 54 | :data (fetch-data (first sections))})) 55 | 56 | (defonce app-store (rs/new-store app-state)) 57 | (defonce ms-store (rre/wrap-store app-store)) 58 | (def the-store ms-store) 59 | (def useStore rre/useStore) 60 | 61 | (defn TransitionsDemoStore [] 62 | (let [active (useStore the-store :active) 63 | data (useStore the-store :data) 64 | setActive #(swap! app-state assoc :active %) 65 | setData #(swap! app-state assoc :data %)] 66 | ($ "section" nil 67 | ($ "h2" nil "useTransition Store") 68 | ($ "div" #js {} 69 | (for [section sections] 70 | ($ Button #js {:title (:title section) 71 | :active (= active (:id section)) 72 | :key (:id section) 73 | :onClick (fn [t] 74 | (setActive (:id section)) 75 | (t 76 | #(setData (fetch-data section))))})) 77 | ($ "hr") 78 | ($ react/Suspense #js {:fallback ($ "div" nil "Loading...")} 79 | ($ Data #js {:data data})))))) -------------------------------------------------------------------------------- /src/reseda/demo/util.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.demo.util 2 | (:require ["react" :as react])) 3 | 4 | (defn $ 5 | ([el] 6 | ($ el nil)) 7 | ([el props & children] 8 | (apply react/createElement el props children))) 9 | 10 | (defn make-request 11 | ([url] (make-request url "GET")) 12 | ([url method] 13 | (let [r (js/XMLHttpRequest.) 14 | p (js/Promise. 15 | (fn [resolve reject] 16 | (.addEventListener r "load" (fn [] 17 | (when (= 4 (.-readyState r)) 18 | (if (and (<= 200 (.-status r) 299)) 19 | (resolve (.-response r)) 20 | (reject r))))) 21 | (.open r method url) 22 | (.send r)))] 23 | 24 | p))) 25 | 26 | 27 | (defn timeout-promise [value ms] 28 | (js/Promise. (fn [resolve reject] 29 | (js/setTimeout #(resolve value) ms)))) -------------------------------------------------------------------------------- /src/reseda/demo/wikipedia.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.demo.wikipedia 2 | (:require ["react" :as react] 3 | [cljs-bean.core :refer [->clj bean]] 4 | [clojure.string :as string] 5 | [reseda.demo.util :refer [$]] 6 | [reseda.react :refer [useStore deref*]] 7 | [reseda.state :refer [new-store]])) 8 | 9 | (defonce data (atom {})) 10 | (defonce store (new-store data)) 11 | 12 | (def query-prefix "https://en.wikipedia.org/w/api.php?action=query&origin=*&list=search&format=json&srsearch=") 13 | 14 | (defn fetch-wiki [query] 15 | (let [qs (str query-prefix (js/encodeURIComponent query))] 16 | (-> (js/fetch qs #js {:mode "cors" 17 | :headers #js {"Content-Type" "application/json"}}) 18 | (.then (fn [r] 19 | (.json r))) 20 | (.then (fn [r] 21 | (get-in (->clj r) 22 | [:query :search])))))) 23 | 24 | (defn fetch-wiki! [query] 25 | (swap! data assoc :results 26 | (reseda.react/suspending-value (fetch-wiki query)))) 27 | 28 | (defn SearchInput [props] 29 | (let [start-transition (:start-transition (bean props))] 30 | ($ "div" nil 31 | ($ "input" #js {:type "search" 32 | :value (str (useStore store :query)) 33 | :onChange (fn [e] 34 | (let [q (-> e .-target .-value)] 35 | (swap! data assoc :query q) 36 | #_(fetch-wiki! q) 37 | (start-transition #(fetch-wiki! q))))})))) 38 | 39 | (defn SearchResults [props] 40 | (let [results (react/useDeferredValue (useStore store :results)) 41 | loading (:loading (bean props)) 42 | query (useStore store :query) 43 | results (deref* results)] 44 | (if (and (not (seq results)) 45 | (not (string/blank? query))) 46 | ($ "div" nil "No results") 47 | ($ "ul" #js {} 48 | (for [r results] 49 | ($ "li" #js {:key (:title r) 50 | :style #js {:opacity (if loading 0.5 1.0)}} 51 | (:title r))))))) 52 | 53 | (defn WikiSearchDemo [_props] 54 | (let [[is-pending startTransition] (react/useTransition)] 55 | ($ "section" nil 56 | ($ "h2" nil "Wikipedia Search") 57 | ($ SearchInput #js {:start-transition startTransition}) 58 | ($ react/Suspense #js {:fallback ($ "div" nil "Fetching...")} 59 | ($ SearchResults #js {:loading is-pending}))))) -------------------------------------------------------------------------------- /src/reseda/react.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.react 2 | (:require 3 | [reseda.state :as rs] 4 | ["use-sync-external-store/shim/with-selector" :refer [useSyncExternalStoreWithSelector]] 5 | ["react" :as react])) 6 | 7 | ;; borrowed from hx 8 | (defn- useValue 9 | "Caches `x`. When a new `x` is passed in, returns new `x` only if it is 10 | not structurally equal to the previous `x`. 11 | Useful for optimizing `<-effect` et. al. when you have two values that might 12 | be structurally equal by referentially different." 13 | [x] 14 | (let [-x (react/useRef x)] 15 | ;; if they are equal, return the prev one to ensure ref equality 16 | (let [x' (if (= x (.-current -x)) 17 | (.-current -x) 18 | x)] 19 | ;; Set the ref to be the last value that was succesfully used to render 20 | (react/useEffect (fn [] 21 | (set! (.-current -x) x) 22 | js/undefined) 23 | #js [x']) 24 | x'))) 25 | 26 | 27 | (defn useStore 28 | "React hook that will re-render the component whenever the value returned by `selector` changes. 29 | NOTE: `selector` should be a stable function (not defined in-line, e.g. with 30 | useCallback) or keyword to avoid infinite re-renders. `selector` can also be a vector for `get-in`" 31 | [^rs/IStore store selector] 32 | (let [selector-clj (useValue selector) 33 | selector-js (react/useCallback (fn [x] 34 | (rs/-get-value store x selector-clj)) 35 | #js [store selector-clj]) 36 | subscribe (react/useCallback (fn [cb] 37 | (let [k (rs/subscribe store selector-clj cb)] 38 | (fn unsub [] 39 | (rs/unsubscribe store k)))) 40 | #js [store selector-clj]) 41 | get-snapshot (react/useCallback #(rs/-get-value store identity) 42 | #js [store]) 43 | value (useSyncExternalStoreWithSelector subscribe 44 | get-snapshot 45 | nil 46 | selector-js 47 | =)] 48 | (react/useDebugValue value) 49 | value)) 50 | 51 | 52 | (defprotocol ISuspending 53 | (-resolved? [this] "Is the underlying promise resolved?") 54 | (-rejected? [this] "Is the underlying promise rejected?")) 55 | 56 | (deftype Suspending [loaded value promise error] 57 | IPrintWithWriter 58 | (-pr-writer [new-obj writer _] 59 | (write-all writer "#reseda.react.Suspending " 60 | (pr-str {:loaded loaded :value value :error error :promise promise}))) 61 | ISuspending 62 | (-resolved? [this] 63 | (boolean (.-loaded this))) 64 | (-rejected? [this] 65 | (boolean (.-error this))) 66 | IPending 67 | (-realized? [this] 68 | (or (-resolved? this) (-rejected? this))) 69 | IDeref 70 | (-deref [this] 71 | (cond 72 | (.-loaded this) (.-value this) 73 | (.-error this) (throw (.-error this)) 74 | :else (throw (.-promise this))))) 75 | 76 | 77 | (defn suspending-value 78 | "Given a promise, return a Suspending that will suspend until the promise is resolved." 79 | [promise] 80 | (let [s (Suspending. false nil promise nil)] 81 | (.then promise 82 | (fn [value] 83 | (set! (.-value s) value) 84 | (set! (.-loaded s) true) 85 | value) 86 | (fn [error] 87 | (set! (.-error s) error) 88 | error)) 89 | s)) 90 | 91 | (defn suspending-value-noerror [promise] 92 | (let [s (Suspending. false nil promise nil)] 93 | (.then promise 94 | (fn [value] 95 | (set! (.-value s) value) 96 | (set! (.-loaded s) true) 97 | value) 98 | (fn [error] 99 | error)) 100 | s)) 101 | 102 | 103 | (defn suspending-image 104 | "Return a Suspending that will wrap an image URL. Can be used to make sure a Suspending component 105 | is shown only when the image is also shown. 106 | 107 | Note: naive implementation, will never time out." 108 | [url] 109 | (let [img (js/Image.) 110 | p (js/Promise. 111 | (fn [resolve reject] 112 | (.addEventListener img "load" 113 | (fn [] 114 | (resolve url))) 115 | (.addEventListener img "error" 116 | (fn [] 117 | (reject url))) 118 | (set! (.-src img) url)))] 119 | (suspending-value p))) 120 | 121 | (defn suspending-resolved 122 | "Return a resolved Suspending that contains the value v. 123 | Different than just calling (suspending-reslved (js/Promise.resolve v)) 124 | since it bypasses the Promise microTick queue." 125 | [v] 126 | (let [p (js/Promise.resolve v) 127 | s (Suspending. true v p nil)] 128 | s)) 129 | 130 | (defn suspending-error 131 | "Return a resolved Suspening that contains the error e. 132 | See `suspending-resolved` for semantics." 133 | [e] 134 | (let [p (js/Promise.reject e) 135 | s (Suspending. false nil p e)] 136 | s)) 137 | 138 | (defn suspending-nil 139 | "Convenience, returns a resolved Suspending that contains nil." 140 | [] 141 | (suspending-resolved nil)) 142 | 143 | 144 | (defn suspending-forever 145 | "Convenience, return a Suspending that will never resolve." 146 | [] 147 | (let [p (js/Promise. (fn [_ _]))] 148 | (suspending-value p))) 149 | 150 | (defn- useForceRender [] 151 | (let [[_ set-state] (react/useState 0) 152 | force-render! #(set-state inc)] 153 | force-render!)) 154 | 155 | (defn- update-refs [^Suspending susp current-ref last-realized-ref is-pending force-render! mounted-ref] 156 | ;; the susp has changed, keep the current version around in a ref 157 | (set! (.-current current-ref) susp) 158 | (if (or (nil? susp) (realized? susp)) 159 | ;; if it's nil or already realized, immediately bail out and let usual render take place 160 | (do 161 | (set! (.-current is-pending) false) 162 | (set! (.-current last-realized-ref) susp)) 163 | (do 164 | ;; otherwise, add a callback to the promise, to make us store it... 165 | (.then (.-promise susp) 166 | (fn [x] 167 | ;; make sure we only re-render if the latest susp is the one we subscribed to 168 | ;; and of course if we're still mounted 169 | (when (and (identical? susp (.-current current-ref)) 170 | (.-current mounted-ref)) 171 | (set! (.-current is-pending) false) 172 | (set! (.-current last-realized-ref) susp) 173 | (force-render!)) 174 | x)) 175 | (when (.-current mounted-ref) 176 | ;; and also re-render the component to turn on "is-pending" 177 | (set! (.-current is-pending) true) 178 | (force-render!))))) 179 | 180 | (defn useCachedSuspending17 [^Suspending value] 181 | ;; keep track of the last realized suspending 182 | (let [last-realized-ref (react/useRef value) 183 | current-ref (react/useRef value) 184 | is-pending (react/useRef false) 185 | mounted-ref (react/useRef) 186 | force-render! (useForceRender)] 187 | (react/useLayoutEffect (fn [] 188 | (set! (.-current mounted-ref) true) 189 | (fn [] (set! (.-current mounted-ref) false))) 190 | #js []) 191 | (when-not 192 | (identical? (.-current current-ref) value) 193 | (update-refs value current-ref last-realized-ref is-pending force-render! mounted-ref)) 194 | [(.-current last-realized-ref) (.-current is-pending)])) 195 | 196 | (defn useCachedSuspending18 [^Suspending value] 197 | (let [deferred (react/useDeferredValue value) 198 | pending (not (identical? deferred value))] 199 | [deferred pending])) 200 | 201 | (defn useCachedSuspending 202 | "Return a vector of [deferred loading], with deferred being the last resolved Suspending 203 | and loading an boolean showing if a new value is on the way. 204 | Components that `useCachedSuspending` will only suspend once, during the initial 205 | fetching of the data." 206 | [^Suspending value] 207 | (if (.-useDeferredValue react) 208 | (useCachedSuspending18 value) 209 | (useCachedSuspending17 value))) 210 | 211 | (def ^:deprecated useSuspending 212 | "Alias for useCachedSuspending" useCachedSuspending) 213 | 214 | (defn deref* 215 | "Deref v, iff it's a Suspending, otherwise return v." 216 | [v] 217 | (if (instance? Suspending v) 218 | (deref v) 219 | v)) -------------------------------------------------------------------------------- /src/reseda/react/experimental.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.react.experimental 2 | (:require 3 | [reseda.state :as rs] 4 | [reseda.react :as rr] 5 | ["react" :as react])) 6 | 7 | (defn wrap-store [store] 8 | (let [ms (react/createMutableSource store (fn get-version [^rs/IStore store] 9 | (rs/-get-value store identity)))] 10 | ms)) 11 | 12 | 13 | (defn useStore [ms selector] 14 | (let [selector' (rr/useValue selector) 15 | subscribe (react/useCallback (fn [^rs/IStore store cb] 16 | (let [k (rs/subscribe store selector' cb)] 17 | (fn unsub [] 18 | (rs/unsubscribe store k)))) 19 | #js [selector']) 20 | get-snapshot (react/useCallback #(rs/-get-value % selector') #js [selector']) 21 | value (react/useMutableSource ms get-snapshot subscribe)] 22 | value)) 23 | 24 | -------------------------------------------------------------------------------- /src/reseda/react_test.cljs: -------------------------------------------------------------------------------- 1 | (ns reseda.react-test 2 | (:require [clojure.test :as t :refer [deftest is testing]] 3 | [clojure.string :as string] 4 | [kitchen-async.promise :as p] 5 | [reseda.state :as rs] 6 | [reseda.react :as rr] 7 | ["react" :as react] 8 | ["@testing-library/react" :as rtl])) 9 | 10 | (js/console.log "Testing under React" (.-version react)) 11 | 12 | (t/use-fixtures :each 13 | {:after rtl/cleanup}) 14 | 15 | (defn $ 16 | ([el] 17 | ($ el nil)) 18 | ([el props & children] 19 | (apply react/createElement el props children))) 20 | 21 | (defn ValueRender [js-props] 22 | (let [{:keys [store selector f]} (.-props js-props) 23 | value (rr/useStore store selector)] 24 | ($ "div" nil 25 | (f value)))) 26 | 27 | (defn render-store 28 | ([store selector] (render-store store selector str)) 29 | ([store selector f] 30 | ($ ValueRender #js {:props {:store store 31 | :selector selector 32 | :f f}}))) 33 | 34 | (defn query-by-text [x] 35 | (-> rtl/screen 36 | (.queryByText x))) 37 | 38 | (defn node-text [node] 39 | (rtl/getNodeText node)) 40 | 41 | 42 | (defn act [cb] 43 | (rtl/act (fn [] (cb) js/undefined))) 44 | 45 | (deftest use-store-keywords 46 | (testing "useStore can use selector keywords" 47 | (let [state (atom {:v 1}) 48 | store (rs/new-store state) 49 | c (render-store store :v)] 50 | 51 | (rtl/render c) 52 | (is (= (-> (query-by-text "1") 53 | node-text) "1")) 54 | (act #(swap! state update :v inc)) 55 | (is (= (-> (query-by-text "2") 56 | node-text) "2")) 57 | ))) 58 | 59 | (deftest use-store-vectors 60 | (testing "useStore can use vectors" 61 | (let [state (atom {:v {:v 1}}) 62 | store (rs/new-store state) 63 | c (render-store store [:v :v])] 64 | (rtl/render c) 65 | (is (= (-> (query-by-text "1") 66 | (node-text)) "1")) 67 | 68 | (act #(swap! state update-in [:v :v] inc)) 69 | (is (= (-> (query-by-text "2") 70 | (node-text)) "2"))))) 71 | 72 | (deftest use-store-equality 73 | (testing "clojure equality means we don't re-render" 74 | (let [state (atom {:v [1 2 3]}) 75 | store (rs/new-store state) 76 | renders (atom 0) 77 | f (fn [v] (swap! renders inc) (str v)) 78 | c (render-store store :v f)] 79 | (rtl/render c) 80 | (is (= @renders 1)) 81 | (act #(swap! state assoc :v [1 2 3 4])) 82 | (is (= @renders 2)) 83 | (act #(swap! state assoc :v (conj [1 2 3] 4))) 84 | (is (= @renders 2))))) 85 | 86 | (deftest use-store-functions 87 | (testing "equality is based on the return value of the selector" 88 | (let [state (atom {:v "a"}) 89 | store (rs/new-store state) 90 | selector (comp string/lower-case :v) 91 | renders (atom 0) 92 | f (fn [v] (swap! renders inc) (str v)) 93 | c (render-store store selector f)] 94 | 95 | (rtl/render c) 96 | (is (= (-> (query-by-text "a") 97 | node-text) "a")) 98 | (is (= @renders 1)) 99 | 100 | (act #(swap! state assoc :v "b")) 101 | (is (= (-> (query-by-text "b") 102 | node-text) "b")) 103 | (is (= @renders 2)) 104 | (act #(swap! state assoc :v "B")) 105 | (is (= (-> (query-by-text "b") 106 | node-text) "b")) 107 | (is (= @renders 2))))) 108 | 109 | 110 | (deftest lifecycle 111 | (let [state (atom {:v 1}) 112 | store (rs/new-store state) 113 | selector :v 114 | renders (atom 0) 115 | f (fn [v] (swap! renders inc) (str v)) 116 | c (render-store store selector f)] 117 | (act #(swap! state update :v inc)) 118 | (is (= @renders 0) "no renders until component is actually mounted") 119 | (let [rtl-fns (rtl/render c)] 120 | (is (= @renders 1) "first render") 121 | (act #(swap! state update :v inc)) 122 | (is (= @renders 2) "render on update") 123 | (act #(.unmount rtl-fns)) 124 | (is (= @renders 2) "no render on unmount") 125 | (act #(swap! state update :v inc)) 126 | (is (= @renders 2) "no render after unmount")))) 127 | 128 | (deftest lifecycle-no-multi-renders 129 | ;; this test crashes with infinite loops if the clojure 130 | ;; equality semantics aren't respected in useStore 131 | ;; we trigger this by creating a new CLJ object every 132 | ;; time in the selector 133 | (let [state (atom {:v {:foo :bar 134 | :count 0}}) 135 | store (rs/new-store state) 136 | selector (fn [state] 137 | (let [{:keys [foo count]} (get state :v)] 138 | {:foo foo 139 | :count count})) 140 | renders (atom 0) 141 | f (fn [v] (swap! renders inc) (str v)) 142 | c (render-store store selector f)] 143 | (act #(swap! state update-in [:v :count] inc)) 144 | (is (= @renders 0) "no renders until component is actually mounted") 145 | (let [rtl-fns (rtl/render c)] 146 | (is (= @renders 1) "first render") 147 | (act #(swap! state update-in [:v :count] inc)) 148 | (is (= @renders 2) "render on update") 149 | (act #(swap! state assoc-in [:v :foo] :bar)) 150 | (is (= @renders 2) "no render when selector yields equivalent value") 151 | (act #(.unmount rtl-fns)) 152 | (is (= @renders 2) "no render on unmount") 153 | (act #(swap! state update :v inc)) 154 | (is (= @renders 2) "no render after unmount")))) 155 | (defn make-promise [] 156 | (let [fs (atom nil) 157 | p (js/Promise. (fn [resolve reject] 158 | (reset! fs {:resolve resolve 159 | :reject reject})))] 160 | (assoc @fs :promise p))) 161 | 162 | (defn SuspendImpl [js-props] 163 | (let [{:keys [susp f]} (.-props js-props)] 164 | (f @susp))) 165 | 166 | (defn SuspenseRender [js-props] 167 | ($ "div" nil 168 | ($ react/Suspense #js {:fallback ($ "div" nil "FB")} 169 | ($ SuspendImpl js-props)))) 170 | 171 | (defn render-susp 172 | ([susp] (render-susp susp str)) 173 | ([susp f] 174 | ($ SuspenseRender #js {:props {:susp susp 175 | :f f}}))) 176 | 177 | (deftest suspending-resolve 178 | (t/async 179 | done! 180 | (let [{:keys [promise resolve]} (make-promise) 181 | susp (rr/suspending-value promise)] 182 | (is (= false (rr/-resolved? susp))) 183 | (is (= false (rr/-rejected? susp))) 184 | (is (= false (realized? susp))) 185 | (try 186 | (deref susp) 187 | (catch :default x 188 | (is (= true (identical? x promise))))) 189 | 190 | (resolve :foo) 191 | (p/try 192 | promise 193 | (is (= true (rr/-resolved? susp))) 194 | (is (= false (rr/-rejected? susp))) 195 | (is (= true (realized? susp))) 196 | (is (= :foo (deref susp))) 197 | (p/finally 198 | (done!)))))) 199 | 200 | (deftest suspending-reject 201 | (t/async 202 | done! 203 | (let [{:keys [promise reject]} (make-promise) 204 | susp (rr/suspending-value promise) 205 | err (js/Error. "foo")] 206 | (is (= false (rr/-resolved? susp))) 207 | (is (= false (rr/-rejected? susp))) 208 | (is (= false (realized? susp))) 209 | 210 | (reject err) 211 | (p/try 212 | promise 213 | (p/catch js/Error x 214 | (is (= false (rr/-resolved? susp))) 215 | (is (= true (rr/-rejected? susp))) 216 | (is (= true (realized? susp))) 217 | (is (thrown-with-msg? js/Error 218 | #"foo" 219 | (deref susp))) 220 | (is (= err x))) 221 | (p/finally 222 | (done!)))))) 223 | 224 | (deftest suspending-noerror-resolve 225 | (t/async 226 | done! 227 | (let [{:keys [promise resolve]} (make-promise) 228 | susp (rr/suspending-value-noerror promise)] 229 | (is (= false (rr/-resolved? susp))) 230 | (is (= false (rr/-rejected? susp))) 231 | (is (= false (realized? susp))) 232 | (try 233 | (deref susp) 234 | (catch :default x 235 | (is (= true (identical? x promise))))) 236 | 237 | (resolve :foo) 238 | (p/try 239 | promise 240 | (is (= true (rr/-resolved? susp))) 241 | (is (= false (rr/-rejected? susp))) 242 | (is (= true (realized? susp))) 243 | (is (= :foo (deref susp))) 244 | (p/finally 245 | (done!)))))) 246 | 247 | (deftest suspending-noerror-reject 248 | (t/async 249 | done! 250 | (let [{:keys [promise reject]} (make-promise) 251 | susp (rr/suspending-value-noerror promise) 252 | err (js/Error. "foo")] 253 | (is (= false (rr/-resolved? susp))) 254 | (is (= false (rr/-rejected? susp))) 255 | (is (= false (realized? susp))) 256 | 257 | (reject err) 258 | 259 | (is (= false (rr/-resolved? susp))) 260 | (is (= false (rr/-rejected? susp))) 261 | (is (= false (realized? susp))) 262 | 263 | (try 264 | (deref susp) 265 | (catch :default x 266 | (is (= true (identical? x promise))))) 267 | (done!)))) 268 | 269 | (deftest suspending-integration 270 | (t/async 271 | done! 272 | (let [{:keys [promise resolve]} (make-promise) 273 | susp (rr/suspending-value promise) 274 | c (render-susp susp)] 275 | (rtl/render c) 276 | (is (= (-> (query-by-text "FB") 277 | node-text) "FB")) 278 | (p/try 279 | (rtl/act #(do (resolve "done") 280 | promise)) 281 | (rtl/waitFor #(query-by-text "done")) 282 | (is (= (-> (query-by-text "done") 283 | node-text) "done")) 284 | (p/finally 285 | (done!)))))) 286 | -------------------------------------------------------------------------------- /src/reseda/state.cljc: -------------------------------------------------------------------------------- 1 | (ns reseda.state) 2 | 3 | 4 | (defn nano-id [] 5 | (str (random-uuid))) 6 | 7 | ;; TODO: expose subscriptions 8 | ;; TODO: timing? 9 | ;; TODO: custom equality 10 | ;; TODO: caching / deduplication 11 | (defprotocol IStore 12 | (-trigger-subs [this old-state new-state]) 13 | (-get-value [this selector] [this backing selector]) 14 | 15 | (destroy [this] "Remove all references to subscriptions and the underlying watchable.") 16 | (subscribe [this selector on-change] 17 | "Subscribe to changes in the underlying watchable, using a selector function. 18 | Whenever the old value and the new value (as returned by the selector) differ (based on Clojure equality), 19 | call on-change with the new value. Return a unique key that can be passed to `unsubscribe`") 20 | (unsubscribe [this k] "Destroy the subscription under `key`")) 21 | 22 | ;; TODO: no need for atom for subs, could use 23 | ;; a mutable field for performance 24 | (deftype Store [backing subs watch-key] 25 | IStore 26 | (destroy [this] 27 | (reset! subs {}) 28 | (remove-watch backing watch-key)) 29 | 30 | (-trigger-subs [this old-state new-state] 31 | (doseq [[selector on-change] (vals @subs)] 32 | (let [oldv (-get-value this old-state selector) 33 | newv (-get-value this new-state selector)] 34 | (when-not (= oldv newv) 35 | (on-change newv))))) 36 | 37 | (-get-value [this selector] 38 | (-get-value this @backing selector)) 39 | 40 | (-get-value [this x selector] 41 | (if (vector? selector) 42 | (get-in x selector) 43 | (selector x))) 44 | 45 | (subscribe [this selector on-change] 46 | (let [k (nano-id)] 47 | (swap! subs assoc k [selector on-change]) 48 | k)) 49 | 50 | (unsubscribe [this k] 51 | (swap! subs dissoc k) 52 | k)) 53 | 54 | 55 | (defn new-store [backing] 56 | (let [watch-key (nano-id) 57 | store (Store. backing (atom {}) watch-key) 58 | watch-fn (fn [_ _ old-state new-state] 59 | (-trigger-subs store old-state new-state))] 60 | (add-watch backing watch-key watch-fn) 61 | store)) 62 | --------------------------------------------------------------------------------