diff --git a/doc/user-guide.md b/doc/user-guide.md index bec1b34..a155d21 100644 --- a/doc/user-guide.md +++ b/doc/user-guide.md @@ -1,9 +1,8 @@ # User Guide -Simple and Decomplected UI library based on React >= 18 focused on -performance. +Simple and decomplexed UI library based on React >= 18 focused on performance. -Add to deps.edn: +Add to `deps.edn`: ```clojure funcool/rumext @@ -14,35 +13,32 @@ funcool/rumext ## First Steps -Function components as it's name says, are defined using plain -functions. Rumext exposes a lighweigh macro over a `fn` that convert -props from js-object to cljs map (shallow) and exposes a facility for -docorate (wrap) with other higher-order components. +Function components, as their name says, are defined using plain +functions. Rumext exposes a lightweight macro over a `fn` that adds +some additional facilities. -Let's see a example of how to define a component: +Let's see an example of how to define a component: ```clojure (require '[rumext.v2 :as mf]) -(mf/defc title +(mf/defc title* [{:keys [name] :as props}] [:div {:class "label"} name]) ``` -For performance reasons, you most likely want the props to arrive as -is, as a javascript object. For this case, you should use the metadata -`::mf/props :obj` for completly avoid props wrapping overhead (see the -next section, where it goes into more depth on the topic). +The received props are just plain JavaScript objects, so instead of +destructuring, you can access props directly using an imperative +approach (just a demonstrative example): ```clojure -(mf/defc title - {::mf/props :obj} +(mf/defc title* [props] (let [name (unchecked-get props "name")] [:div {:class "label"} name])) ``` -And finally, we mount the component on the dom: +And finally, we mount the component onto the DOM: ```clojure (ns myname.space @@ -51,99 +47,60 @@ And finally, we mount the component on the dom: [rumext.v2 :as mf])) (def root (mf/create-root (dom/getElement "app"))) -(mf/render! root (mf/element title #js {:title "hello wolrd"})) +(mf/render! root (mf/element title* #js {:title "hello world"})) ``` -## Props & Destructuring - -There are two way to approach props and its destructuring. By default -(if not explicitly set by metadata) the props objects has the clojure -hash-map type, and follows the already known clojure approach for -destructuring. - -```clojure -(mf/defc title - [{:keys [name] :as props}] - (assert (map? props) "expected map") - (assert (string? name) "expected string") - - [:div {:class "label"} name]) -``` - -Not passing any value for `::mf/props` is equivalent to passing -`::mf/props :clj`. So this code is equivalent: - -```clojure -(mf/defc title - {::mf/props :clj} - [{:keys [name] :as props}] - (assert (map? props) "expected map") - (assert (string? name) "expected string") - - [:div {:class "label"} name]) -``` +**NOTE**: Important: the `*` in the name is a mandatory convention for +proper visual distinction of React components and Clojure functions. -That approach is very convenient because when you start prototyping, -the received props obeys the already known idioms, and all works in a -way like the component is a simple clojure function. +**NOTE**: It also enables the current defaults on how props are +handled. If you don't use the `*` suffix, the component will behave in +legacy mode. -But, this approach has inconvenience of the need to transform from js -object to clojure hash-map on each render and this has performance -penalization. In the majority of cases this has no isues at all. - -But in cases when performance is important, it is recommended to use -the `::mf/props :obj` which completly removes the transformation -overhead. +## Props & Destructuring -The component functions with `::mf/props :obj` also has support for -the already familiar destructuring idiom. Internally, this compiles to -code that directly accesses properties within the props object. The -only thing to keep in mind, whether you use destructuring or not, **is -that the props object is a flat js object and not a clojure -hash-map**. +The destructuring works very similar to the Clojure map destructuring +with small differences and convenient enhancements for making working +with React props and idioms easy. ```clojure -(mf/defc title - {::mf/props :obj} +(mf/defc title* [{:keys [name] :as props}] (assert (object? props) "expected object") (assert (string? name) "expected string") - (assert (unchecked-get props "name") "expected string") + (assert (= (unchecked-get props "name") + name) + "expected string") [:label {:class "label"} name]) ``` -An additional idiom, only available in case of using destructuring -with props as a js object: is the possibility of obtaining an object -with all properties not destructured: +An additional idiom (specific to the Rumext component macro and not +available in standard Clojure destructuring) is the ability to obtain +an object with all non-destructured props: ```clojure -(mf/defc title - {::mf/props :obj} - [{:keys [name] :as props :rest other}] +(mf/defc title* + [{:keys [name] :rest props}] (assert (object? props) "expected object") - (assert (object? other) "expected map") - (assert (nil? (unchecked-get other "name")) "no name in other") + (assert (nil? (unchecked-get props "name")) "no name in props") ;; The `:>` will be explained later - [:> :label other name]) + [:> :label props name]) ``` This allows you to extract the props that the component has control of -and leave the rest in an object that can be passed as is to the next +and leave the rest in an object that can be passed as-is to the next element. - ## JSX / Hiccup -You may be already familiar with hiccup syntax (which is equivalent to -the react JSX) for defining the react dom. The intention on this -section is explain only the essential part of it and the peculiarities -of rumext. +You may already be familiar with Hiccup syntax (which is equivalent to +the React JSX) for defining the React DOM. The intention of this +section is to explain only the essential part of it and the +peculiarities of Rumext. -### Introduction - -Lets start with simple generic elements like `div`: +Let's start with simple generic elements like `div`: ```clojure [:div {:class "foobar" @@ -152,77 +109,35 @@ Lets start with simple generic elements like `div`: "Hello World"] ``` -The props and the style are transformed at compile time to a js object -transforming all keys from lisp-case to camelCase (and rename `:class` -to `className`); so the compilation result to something like this: +The props and the style are transformed at compile time into a JS +object, transforming all keys from lisp-case to camelCase (and +renaming `:class` to `className`); so the compilation results in +something like this: ```js const h = React.createElement; h("div", {className: "foobar", style: {"backgroundColor": "red"}, - onClick=someFn}, + onClick: someFn}, "Hello World"); ``` It should be noted that this transformation is only done to properties -that are keyword type and that properties that begin with `data-` and -`aria-` are left as is without transforming just like the string +that are keyword types and that properties that begin with `data-` and +`aria-` are left as-is without transforming, just like string keys. The properties can be passed directly using camelCase syntax (as -react nativelly expects) if you want. - - -### Handlers & Call Conventions - -There are times when we'll need: - -- the element name to be chosen dynamically or constructed in runtime; -- the props to be build dinamically -- create an element from user defined component - -For this purpose, rumext exposes a special handlers, each one exposing -its own call convention: `:&` and `:>`. +React natively expects) if you want. -Lets start with `:&` handler. We will use it when we have 100% control -of the props and we do not want any type of transformation to be done -to them (usually when we are talking about large components, you -probably do not reuse that they represent a page or a section of that -page, but not limited to). All props passed to the element will be -passed as-is, without any kind of transformations to the prop keys nor -values. +There are times when we'll need the element name to be chosen +dynamically or constructed at runtime; the props to be built +dynamically or created as an element from a user-defined component. -```clojure -(mf/defc title - {::mf/props :obj} - [{:keys [name on-click]}] - [:div {:class "label" :on-click on-click} name]) - -(mf/defc my-big-component - [] - [:& title {:name "foobar" :on-click some-fn}]) -``` - -And for completeness, an example without destructuring: - -```clojure -(mf/defc title - {::mf/props :obj} - [props] - (let [name (unchecked-get props "name") - on-click (unchecked-get props "on-click")] - [:div {:class "label" :on-click on-click} name])) - -(mf/defc my-big-component - [] - [:& title {:name "foobar" :on-click some-fn}]) -``` - -This is probably the handler that you will use the most time. +For this purpose, Rumext exposes a special handler: `:>`, a +general-purpose handler for passing dynamically defined props to DOM +native elements or creating elements from user-defined components. -Then, we also have the `:>` handler. We will use it when we have the -following situations: - -- We want to decide the element name dynamicaly +Let's start with an example of how the element name can be defined dynamically: ```clojure (let [element (if something "div" "span")] @@ -232,9 +147,7 @@ following situations: "Hello World"]) ``` -- We want to build and pass props dinamically to create a DOM native - element (note that the props are always js plain objects and using - react naming convention) +The props also can be defined dynamically: ```clojure (let [props #js {:className "fooBar" @@ -243,87 +156,68 @@ following situations: [:> "div" props "Hello World"]) ``` -- we are creating a reusable component that is probably wrapping one - or more native elements of the virtual dom and we simply want to - extend its behavior controlling only a subset of props, where the - rest of the props that are not controlled would be passed as is to - the next native element. +Remember, if props are defined dynamically, they should be defined as +plain JS objects respecting the React convention for props casing +(this means we need to pass `className` instead of `:class` for +example). + +In the same way, you can create an element from a user-defined component: ```clojure -(mf/defc my-label - {::mf/props :obj} - [{:keys [name className] :rest props}] - (let [class (or className "my-label") - props (mf/spread props {:className class})] - [:> :label props name])) +(mf/defc my-label* + [{:keys [name class on-click] :rest props}] + (let [class (or class "my-label") + props (mf/spread props {:class class})] + [:span {:on-click on-click} + [:> :label props name]])) (mf/defc other-component [] - [:> my-label {:name "foobar" :on-click some-fn}]) + [:> my-label* {:name "foobar" :on-click some-fn}]) ``` -So, all the the props passed to the `:>` handler on creating an -element from `my-label` component are transformed at compile-time to -an js object following react convention (camelcasing keys, etc.); This -greatly facilitates the task of passing the props to the next element -without performing any additional transformation. - -And finally, to help preserve the code style, rumext offers a way for -the destructuring to also take into account the rules and conventions -of react for the props keys. This is achieved with the metadata -`{::mf/props :react}` or by putting the suffix `*` in the component -name. With that, the destructuring can use the lisp-case for keys and -the macro will automatically generate the appropriate access code to -the value with camelCase from the props, respecting the react -convention. - -``` -(mf/defc my-label* - {::mf/props :obj} - [{:keys [name class] :rest props}] - (let [class (or class "my-label") - props (mf/spread-props props {:class class})] - [:> :label props name])) -``` - -But remember: **the `*` only changes the behavior of -destructuring**. The call convention is determined by the used -handler: `[:&` or `[:>`. +As you can observe, in destructuring, we use a Clojure convention for +naming and casing of properties; it is the Rumext `defc` macro +responsible for the automatic handling of all naming and casing +transformations at compile time (for example: the `on-click` will +match the `onClick` on received props, and `class` will match the +`className` prop). +The `mf/spread` macro allows merging one or more JS object props +into one, always respecting the casing and naming conventions of React +for the props. Read more about it below. ## Props Checking -The rumext library comes with two approaches for checking props: +The Rumext library comes with two approaches for checking props: **simple** and **malli**. -Lets start with the **simple**, which consists on simple existence -check or plain predicate checking: +Let's start with the **simple**, which consists of simple existence +checks or plain predicate checking: ```clojure -(mf/defc button - {::mf/props :obj - ::mf/expect #{:name :on-click}} +(mf/defc button* + {::mf/expect #{:name :on-click}} [{:keys [name on-click]}] [:button {:on-click on-click} name]) ``` -The prop names obeys the same rules as the destructuring so you should +The prop names obey the same rules as the destructuring so you should use the same names in destructuring. Sometimes a simple existence -check is not enought, for that cases you can pass a map where keys are -props and values predicates: +check is not enough; for those cases, you can pass a map where keys +are props and values are predicates: ```clojure -(mf/defc button - {::mf/props :obj - ::mf/expect {:name string? +(mf/defc button* + {::mf/expect {:name string? :on-click fn?}} [{:keys [name on-click]}] [:button {:on-click on-click} name]) ``` If that is not enough, it also supports -**[malli](https://github.com/metosin/malli)** as validation mechanism -for props: +**[malli](https://github.com/metosin/malli)** as a validation +mechanism for props: ```clojure (def ^:private schema:props @@ -339,130 +233,121 @@ for props: [:button {:on-click on-click} name]) ``` -The prop names on schema obeys the destructuring rules for key casing. - - -**NOTE**: The props checking obey the `:elide-asserts` compiler option -and they are removed on production builds. - +**NOTE**: The props checking obeys the `:elide-asserts` compiler +option and by default, they will be removed in production builds if +the configuration value is not changed explicitly. ## Higher-Order Components -This is the way you have to extend/add additional functionality to a -function component. Rumext exposes one: +This is the way you extend/add additional functionality to a function +component. Rumext exposes one: - `mf/memo`: analogous to `React.memo`, adds memoization to the component based on props comparison. - `mf/memo'`: identical to the `React.memo` -In order to use the high-order components, you need wrap the component -manually or passing it as a special property in the metadata: +To use the higher-order components, you need to wrap the component +manually or pass it as a special property in the metadata: ```clojure -(mf/defc title - {::mf/wrap [mf/memo] - ::mf/props :obj} - [props] - [:div {:class "label"} (:name props)]) +(mf/defc title* + {::mf/wrap [mf/memo]} + [{:keys [name]}] + [:div {:class "label"} name]) ``` -By default `identical?` predicate is used for compare props; you can -pass a custom comparator function as second argument: +By default, the `identical?` predicate is used to compare props; you +can pass a custom comparator function as a second argument: ```clojure -(mf/defc title +(mf/defc title* {::mf/wrap [#(mf/memo % =)]} - [props] - [:div {:class "label"} (:name props)]) + [{:keys [name]}] + [:div {:class "label"} name]) ``` -If you want create a own high-order component you can use `mf/fnc` macro: +For convenience, Rumext has a special metadata `::mf/memo` that +facilitates the general case for component props memoization. If you +pass `true`, it will behave the same way as `::mf/wrap [mf/memo]` or +`React.memo(Component)`. You also can pass a set of fields; in this +case, it will create a specific function for testing the equality of +that set of props. + +If you want to create your own higher-order component, you can use the +`mf/fnc` macro: ```clojure (defn some-factory [component param] - (mf/fnc myhighordercomponent - {::mf/props :obj} + (mf/fnc my-high-order-component* [props] [:section [:> component props]])) ``` -The wrap is a generic mechanism for higher-order components, so you -can create your own wrappers when you need somethig specific. - - -### Special case for `memo` - -For convenience, rumext has a special metadata `::mf/memo` that -facilitates a bit the general case for component props memoization. If -you pass `true`, then it will behave the same way as `::mf/wrap -[mf/memo]` or `React.memo(Component)`. You also can pass a set of -fields, in this case it will create a specific function for testing -for equality of that specific set of props. - - ## Hooks -The rumext library exposes a few specific hooks and some wrappers over existing react -hooks in addition to the hooks that react offers itself. - -You can use both one and the other interchangeably, depending on which type of API you -feel most comfortable with. The react hooks are exposed as is in react, with the function -name in camelCase and the rumext hooks use the lisp-case syntax. +The Rumext library exposes a few specific hooks and some wrappers over +existing React hooks in addition to the hooks that React offers +itself. -Only a subset of available hooks is documented here, please refer to the API reference -documentation for deatailed information of available hooks. +You can use both one and the other interchangeably, depending on which +type of API you feel most comfortable with. The React hooks are +exposed as they are in React, with the function name in camelCase, and +the Rumext hooks use the lisp-case syntax. +Only a subset of available hooks is documented here; please refer to +the API reference documentation for detailed information about +available hooks. ### use-state -This is analogous hook to the `React.useState`. It exposes the same functionality but -using ClojureScript atom interface. +This is analogous to the `React.useState`. It exposes the same +functionality but uses the ClojureScript atom interface. -Calling `mf/use-state` returns an atom-like object that will deref to the current value -and you can call `swap!` and `reset!` on it for modify its state. +Calling `mf/use-state` returns an atom-like object that will deref to +the current value, and you can call `swap!` and `reset!` on it to +modify its state. The returned object always has a stable reference +(no changes between rerenders). Any mutation will schedule the component to be rerendered. ```clojure (require '[rumext.v2 as mf]) -(mf/defc local-state +(mf/defc local-state* [props] - (let [local (mf/use-state 0)] - [:div {:on-click #(swap! local inc)} - [:span "Clicks: " @local]])) - + (let [clicks (mf/use-state 0)] + [:div {:on-click #(swap! clicks inc)} + [:span "Clicks: " @clicks]])) ``` -Alternatively, you can use the react hook directly: +Alternatively, you can use the React hook directly: ```clojure -(mf/defc local-state +(mf/defc local-state* [props] - (let [[counter update-conter] (mf/useState 0)] - [:div {:on-click (partial update-conter #(inc %))} + (let [[counter update-counter] (mf/useState 0)] + [:div {:on-click (partial update-counter #(inc %))} [:span "Clicks: " counter]])) ``` ### use-var -In the same way as `use-state` returns an atom like object. The unique difference is that -updating the ref value does not schedules the component to rerender. Under the hood it -uses useRef hook. - +In the same way as `use-state` returns an atom-like object. The unique +difference is that updating the ref value does not schedule the +component to rerender. Under the hood, it uses the `useRef` hook. ### use-effect -Analgous to the `React.useEffect` hook with minimal call convention change (the order of -arguments inverted). +Analogous to the `React.useEffect` hook with a minimal call convention +change (the order of arguments is inverted). -This is a primitive that allows incorporate probably efectful code into a functional -component: +This is a primitive that allows incorporating probably effectful code +into a functional component: ```clojure -(mf/defc local-timer +(mf/defc local-timer* [props] (let [local (mf/use-state 0)] (mf/use-effect @@ -472,14 +357,15 @@ component: [:div "Counter: " @local])) ``` -The `use-effect` is a two arity function. If you pass a single callback function it acts -like there are no dependencies, so the callback will be executed once per component -(analgous to `didMount` and `willUnmount`). +The `use-effect` is a two-arity function. If you pass a single +callback function, it acts as though there are no dependencies, so the +callback will be executed once per component (analogous to `didMount` +and `willUnmount`). -If you want to pass dependencies you have two ways: +If you want to pass dependencies, you have two ways: -- passing an js array -- using `rumext.v2/deps` helper +- passing a JS array +- using the `rumext.v2/deps` helper ```clojure (mf/use-effect @@ -488,13 +374,13 @@ If you want to pass dependencies you have two ways: ``` And finally, if you want to execute it on each render, pass `nil` as -deps (much in the same way as raw useEffect works). +deps (much in the same way as raw `useEffect` works). -For convenience, there is a `mf/with-effect` macro that drops one level -of indentation: +For convenience, there is an `mf/with-effect` macro that drops one +level of indentation: ```clojure -(mf/defc local-timer +(mf/defc local-timer* [props] (let [local (mf/use-state 0)] (mf/with-effect [] @@ -503,50 +389,49 @@ of indentation: [:div "Counter: " @local])) ``` -Here, the deps must be passed as elements within the vector (the first argument). - -Obviously you can use the react hook directly via `mf/useEffect`. +Here, the deps must be passed as elements within the vector (the first +argument). +Obviously, you can use the React hook directly via `mf/useEffect`. ### use-memo -In the same line as the `use-effect`, this hook is analogous to the react `useMemo` hook -with order of arguments inverted. +In the same line as the `use-effect`, this hook is analogous to the +React `useMemo` hook with the order of arguments inverted. -The purpose of this hook is return a memoized value. +The purpose of this hook is to return a memoized value. Example: ```clojure -(mf/defc sample-component +(mf/defc sample-component* [{:keys [x]}] (let [v (mf/use-memo (mf/deps x) #(pow x 10))] - [:span "Value is:" v])) + [:span "Value is: " v])) ``` On each render, while `x` has the same value, the `v` only will be calculated once. -This also can be expressed with the `rumext.v2/with-memo` macro that removes a level of -indentantion: +This also can be expressed with the `rumext.v2/with-memo` macro that +removes a level of indentation: ```clojure -(mf/defc sample-component +(mf/defc sample-component* [{:keys [x]}] (let [v (mf/with-memo [x] (pow x 10))] - [:span "Value is:" v])) + [:span "Value is: " v])) ``` - ### use-fn Is a special case of `use-memo`. An alias for `use-callback`. - ### deref -A rumext custom hook that adds ractivity to atom changes to the component: +A Rumext custom hook that adds reactivity to atom changes to the +component: Example: @@ -554,16 +439,76 @@ Example: (def clock (atom (.getTime (js/Date.)))) (js/setInterval #(reset! clock (.getTime (js/Date.))) 160) -(mf/defc timer +(mf/defc timer* [props] (let [ts (mf/deref clock)] - [:div "Timer (deref)" ": " + [:div "Timer (deref): " [:span ts]])) ``` -Internally it uses the `react.useSyncExternalStore` API together with the ability of atom -to watch it. +Internally, it uses the `react.useSyncExternalStore` API together with +the ability of atom to watch it. + +## Helpers + +### Working with props + +The Rumext library comes with a small set of helpers that facilitate +working with props JS objects. Let's look at them: + +#### `mf/spread` + +A **macro** that allows performing a merge between two props data +structures using the JS spread operator. Always preserving the React +props casing and naming convention. + +It is commonly used this way: + +```clojure +(mf/defc my-label* + [{:keys [name class on-click] :rest props}] + (let [class (or class "my-label") + props (mf/spread props {:class class})] + [:span {:on-click on-click} + [:> :label props name]])) +``` + +The second argument should be a map literal in order to make the case +and naming transformation work correctly. If you pass there a symbol, +it should already be a correctly created props object. + +#### `mf/props` + +A helper **macro** that allows defining props objects from a map +literal. + +An example of how it can be used and combined with `mf/spread`: + +```clojure +(mf/defc my-label* + [{:keys [name class on-click] :rest props}] + (let [class (or class "my-label") + new-props (mf/props {:class class}) + all-props (mf/spread props new-props)] + [:span {:on-click on-click} + [:> :label props name]])) +``` + +If you pass a symbol literal (props defined elsewhere using a Clojure +data structure) to the `mf/props` macro, a dynamic transformation will +be emitted for this expression. +```clojure +(let [clj-props {:class "my-label"} + props (mf/props clj-props)] + [:> :label props name]) +``` + + +In this example, `props` binding will contain a plain props js object +converted dinamically from clojure map at runtime. This should be +avoided if performance is important because it adds the overhead of +dynamic conversion on each render. ## FAQ @@ -585,10 +530,21 @@ This is the list of the main differences: overhead on top of React. **WARNING**: it is mainly implemented to be used in -[penpot](https://github.com/penpot/penpot) and released as separated project for -conveniendce. Don't expect compromise for backward compatibility beyond what the penpot -project needs. +[penpot](https://github.com/penpot/penpot) and released as separated +project for conveniendce. Don't expect compromise for backward +compatibility beyond what the penpot project needs. + + +### What is the legacy mode? + +Components that name does not use `*` as a suffix behaves in legacy +mode. It means parameter will be received as clojure map without any +transformation (with some exceptions for `className`). + +That components should use `:&` handler when creating JSX/Hiccup +elements. +It is present for backward compatibility and should not be used. ## License diff --git a/examples/rumext/examples/binary_clock.cljs b/examples/rumext/examples/binary_clock.cljs index 21583c0..dc3cf26 100644 --- a/examples/rumext/examples/binary_clock.cljs +++ b/examples/rumext/examples/binary_clock.cljs @@ -6,13 +6,12 @@ (def *bclock-renders (atom 0)) -(mf/defc render-count +(mf/defc render-count* [props] (let [renders (mf/deref *bclock-renders)] [:div.stats "Renders: " renders])) -(mf/defc bit - {::mf/wrap-props false} +(mf/defc bit* [{:keys [n b]}] (mf/with-effect [n b] (swap! *bclock-renders inc)) @@ -22,7 +21,7 @@ [:td.bclock-bit {:style {:background-color color}}] [:td.bclock-bit {}]))) -(mf/defc binary-clock +(mf/defc binary-clock* [] (let [ts (mf/deref util/*clock) msec (mod ts 1000) @@ -41,41 +40,41 @@ [:table.bclock [:tbody [:tr - [:td] [:& bit {:n hl :b 3}] [:th] - [:td] [:& bit {:n ml :b 3}] [:th] - [:td] [:& bit {:n sl :b 3}] [:th] - [:& bit {:n msh :b 3}] - [:& bit {:n msm :b 3}] - [:& bit {:n msl :b 3}]] + [:td] [:> bit* {:n hl :b 3}] [:th] + [:td] [:> bit* {:n ml :b 3}] [:th] + [:td] [:> bit* {:n sl :b 3}] [:th] + [:> bit* {:n msh :b 3}] + [:> bit* {:n msm :b 3}] + [:> bit* {:n msl :b 3}]] [:tr - [:td] [:& bit {:n hl :b 2}] [:th] - [:& bit {:n mh :b 2}] - [:& bit {:n ml :b 2}] [:th] - [:& bit {:n sh :b 2}] - [:& bit {:n sl :b 2}] [:th] - [:& bit {:n msh :b 2}] - [:& bit {:n msm :b 2}] - [:& bit {:n msl :b 2}]] + [:td] [:> bit* {:n hl :b 2}] [:th] + [:> bit* {:n mh :b 2}] + [:> bit* {:n ml :b 2}] [:th] + [:> bit* {:n sh :b 2}] + [:> bit* {:n sl :b 2}] [:th] + [:> bit* {:n msh :b 2}] + [:> bit* {:n msm :b 2}] + [:> bit* {:n msl :b 2}]] [:tr - [:& bit {:n hh :b 1}] - [:& bit {:n hl :b 1}] [:th] - [:& bit {:n mh :b 1}] - [:& bit {:n ml :b 1}] [:th] - [:& bit {:n sh :b 1}] - [:& bit {:n sl :b 1}] [:th] - [:& bit {:n msh :b 1}] - [:& bit {:n msm :b 1}] - [:& bit {:n msl :b 1}]] + [:> bit* {:n hh :b 1}] + [:> bit* {:n hl :b 1}] [:th] + [:> bit* {:n mh :b 1}] + [:> bit* {:n ml :b 1}] [:th] + [:> bit* {:n sh :b 1}] + [:> bit* {:n sl :b 1}] [:th] + [:> bit* {:n msh :b 1}] + [:> bit* {:n msm :b 1}] + [:> bit* {:n msl :b 1}]] [:tr - [:& bit {:n hh :b 0}] - [:& bit {:n hl :b 0}] [:th] - [:& bit {:n mh :b 0}] - [:& bit {:n ml :b 0}] [:th] - [:& bit {:n sh :b 0}] - [:& bit {:n sl :b 0}] [:th] - [:& bit {:n msh :b 0}] - [:& bit {:n msm :b 0}] - [:& bit {:n msl :b 0}]] + [:> bit* {:n hh :b 0}] + [:> bit* {:n hl :b 0}] [:th] + [:> bit* {:n mh :b 0}] + [:> bit* {:n ml :b 0}] [:th] + [:> bit* {:n sh :b 0}] + [:> bit* {:n sl :b 0}] [:th] + [:> bit* {:n msh :b 0}] + [:> bit* {:n msm :b 0}] + [:> bit* {:n msl :b 0}]] [:tr [:th hh] [:th hl] @@ -91,11 +90,11 @@ [:th msl]] [:tr [:th {:col-span 8} - [:& render-count {}]]]]])) + [:> render-count* {}]]]]])) (defonce root (mf/create-root (dom/getElement "binary-clock"))) (defn ^:after-load mount! [] - (mf/render! root (mf/element binary-clock))) + (mf/render! root (mf/element binary-clock*))) diff --git a/examples/rumext/examples/controls.cljs b/examples/rumext/examples/controls.cljs index c26465f..050af9f 100644 --- a/examples/rumext/examples/controls.cljs +++ b/examples/rumext/examples/controls.cljs @@ -1,10 +1,10 @@ (ns rumext.examples.controls - (:require [goog.dom :as dom] - [rumext.v2 :as mf] - [rumext.examples.util :as util])) + (:require + [goog.dom :as dom] + [rumext.v2 :as mf] + [rumext.examples.util :as util])) -;; generic “atom editor” component -(mf/defc input +(mf/defc input* [{:keys [color] :as props}] (let [value (mf/deref color)] [:input {:type "text" @@ -13,21 +13,21 @@ :on-change #(reset! color (.. % -target -value))}])) ;; Raw top-level component, everything interesting is happening inside -(mf/defc controls +(mf/defc controls* [props] [:dl [:dt "Color: "] [:dd - [:& input {:color util/*color}]] + [:> input* {:color util/*color}]] ;; Binding another component to the same atom will keep 2 input boxes in sync [:dt "Clone: "] [:dd - (mf/jsx input #js {:color util/*color})] + (mf/jsx input* #js {:color util/*color})] [:dt "Color: "] [:dd {} (util/watches-count {:iref util/*color}) " watches"] [:dt "Tick: "] - [:dd [:& input {:color util/*speed}] " ms"] + [:dd [:> input* {:color util/*speed}] " ms"] [:dt "Time:"] [:dd {} (util/watches-count {:iref util/*clock}) " watches"] ]) @@ -36,5 +36,5 @@ (mf/create-root (dom/getElement "controls"))) (defn ^:after-load mount! [] - (mf/render! root (mf/element controls))) + (mf/render! root (mf/element controls*))) diff --git a/examples/rumext/examples/local_state.cljs b/examples/rumext/examples/local_state.cljs index 155a864..9dd0c5d 100644 --- a/examples/rumext/examples/local_state.cljs +++ b/examples/rumext/examples/local_state.cljs @@ -3,6 +3,7 @@ [goog.dom :as dom] [malli.core :as m] [rumext.v2 :as mf] + [rumext.v2.util :as mfu] [rumext.examples.util :as util])) (def schema:label @@ -11,11 +12,9 @@ [:title string?] [:n number?]]) -(mf/defc label +(mf/defc label* {::mf/memo true - ::mf/props :react - ::mf/schema schema:label - } + ::mf/schema schema:label} [{:keys [class title n] :as props :rest others}] (let [ref (mf/use-var nil) props (mf/spread-props others {:class (or class "my-label")})] @@ -36,12 +35,13 @@ :n 0} :counter2 {:title "Counter 2" :n 0}}))] + [:section {:class "counters" :style {:-webkit-border-radius "10px"}} [:hr] (let [{:keys [title n]} (:counter1 @local)] - [:> label {:n n :title title :data-foobar 1 :on-click identity :id "foobar"}]) + [:> label* {:n n :title title :data-foobar 1 :on-click identity :id "foobar"}]) (let [{:keys [title n]} (:counter2 @local)] - [:> label {:title title :n n :on-click identity}]) + [:> label* {:title title :n n :on-click identity}]) [:button {:on-click #(swap! local update-in [:counter1 :n] inc)} "Increment Counter 1"] [:button {:on-click #(swap! local update-in [:counter2 :n] inc)} "Increment Counter 2"]])) diff --git a/examples/rumext/examples/portals.cljs b/examples/rumext/examples/portals.cljs index 32b9048..814c555 100644 --- a/examples/rumext/examples/portals.cljs +++ b/examples/rumext/examples/portals.cljs @@ -4,7 +4,6 @@ [goog.dom :as dom])) (mf/defc portal* - {::mf/props :obj} [{:keys [state]}] [:div {:on-click (fn [_] (swap! state inc)) :style { :user-select "none", :cursor "pointer" }} diff --git a/examples/rumext/examples/util.cljs b/examples/rumext/examples/util.cljs index f15ce17..8245ea0 100644 --- a/examples/rumext/examples/util.cljs +++ b/examples/rumext/examples/util.cljs @@ -23,12 +23,10 @@ (mf/defc watches-count [{:keys [iref] :as props}] (let [state (mf/use-state 0)] - (mf/use-effect - (mf/deps iref) - (fn [] - (let [sem (js/setInterval #(swap! state inc) 1000)] - #(do - (js/clearInterval sem))))) + (mf/with-effect [iref] + (let [sem (js/setInterval #(swap! state inc) 1000)] + #(do + (js/clearInterval sem)))) [:span (.-size (.-watches ^js iref))])) diff --git a/src/rumext/v2.clj b/src/rumext/v2.clj index d296972..218cc51 100644 --- a/src/rumext/v2.clj +++ b/src/rumext/v2.clj @@ -160,7 +160,6 @@ [props params] (if (seq k-props) (reduce (fn [[props params] [ks kp]] - ;; (prn "KKK" ks kp) (let [kp (if react-props? (util/ident->prop kp) (name kp))] @@ -326,6 +325,11 @@ ~(when-let [registry (::register meta)] `(swap! ~registry (fn [state#] (assoc state# ~(::register-as meta (keyword (str cname))) ~cname))))))) +(defmacro deps + "A convenience macro version of mf/deps function" + [& params] + `(cljs.core/array ~@(map (fn [s] `(rumext.v2/adapt ~s)) params))) + (defmacro with-memo "A convenience syntactic abstraction (macro) for `useMemo`" [deps & body] @@ -442,51 +446,49 @@ [props#] [:> (deref loadable#) props#]))))))))) -(defmacro spread - "A helper for create spread js object operations. Leaves the keys - untouched." - [target & [other :as rest]] +(defmacro spread-object + "A helper for spread two js objects, adapting compile time known + keys to cameCase. + + You can pass `:rumext.v2/transform false` on `other` metadata + for disable key casing transformation." + [target other] (assert (or (symbol? target) (map? target)) "only symbols or maps accepted on target") - (assert (or (= (count rest) 0) - (and (= (count rest) 1) - (or (symbol? other) - (map? other))) - (and (even? (count rest)) - (or (keyword? other) - (string? other)))) - "only symbols, map or named parameters allowed for the spread") - (let [other (cond - (> (count rest) 1) (apply hash-map rest) - (= (count rest) 0) {} - :else other)] - (hc/compile-to-js-spread target other identity))) + + (assert (or (symbol? other) + (map? other)) + "only symbols or map allowed for the spread") + + (let [transform? (get (meta other) ::transform true) + compile-prop (if transform? + (partial hc/compile-prop 2) + identity)] + (hc/compile-to-js-spread target other compile-prop))) (defmacro spread-props - "A helper for create spread js object operations. Adapts compile - time known keys to the react props standard transformations." - [target & [other :as rest]] + "A helper for spread two js objects using react conventions for + compile time known props keys names." + [target other] (assert (or (symbol? target) (map? target)) "only symbols or maps accepted on target") - (assert (or (= (count rest) 0) - (and (= (count rest) 1) - (or (symbol? other) - (map? other))) - (and (even? (count rest)) - (or (keyword? other) - (string? other)))) - "only symbols, map or named parameters allowed for the spread") - (let [other (cond - (> (count rest) 1) (apply hash-map rest) - (= (count rest) 0) {} - :else other)] - (hc/compile-to-js-spread target other hc/compile-prop))) - -(defmacro js - "A helper for convert literal datastructures recursivelly into js - data structures at compile time." - [expr] - (binding [hc/*transform-props-recursive* 0] - (hc/compile-prop-value expr))) + + (assert (or (symbol? other) + (map? other)) + "only symbols or map allowed for the spread") + + (hc/compile-to-js-spread target other hc/compile-prop)) + +(defmacro props + "A helper for convert literal datastructures into js data + structures at compile time using react props convention." + [value] + (let [recursive? (get (meta value) ::recursive false)] + (hc/compile-props-to-js value ::hc/transform-props-recursive recursive?))) + +(defmacro object + [value] + (let [recursive? (get (meta value) ::recursive true)] + (hc/compile-coll-to-js value ::hc/transform-props-recursive recursive?))) diff --git a/src/rumext/v2.cljs b/src/rumext/v2.cljs index fd2cfdb..75034e7 100644 --- a/src/rumext/v2.cljs +++ b/src/rumext/v2.cljs @@ -10,6 +10,7 @@ (:require ["react" :as react] ["react-dom" :as rdom] + ["react-dom/client" :as rdomc] ["react/jsx-runtime" :as jsxrt] [cljs.core :as c] [goog.functions :as gf] @@ -56,27 +57,18 @@ ;; --- Main Api -(def ^function mount - "Add element to the DOM tree. Idempotent. Subsequent mounts will - just update element." - rdom/render) - -(def ^function unmount - "Removes component from the DOM tree." - rdom/unmountComponentAtNode) - (def ^function portal "Render `element` in a DOM `node` that is ouside of current DOM hierarchy." rdom/createPortal) (def ^function create-root "Creates react root" - rdom/createRoot) + rdomc/createRoot) (def hydrate-root "Lets you display React components inside a browser DOM node whose HTML content was previously generated by react-dom/server" - rdom/hydrateRoot) + rdomc/hydrateRoot) (defn render! [root element] @@ -292,34 +284,35 @@ snapshot (use-fn #js [iref] #(c/deref iref))] (react/useSyncExternalStore subscribe get-state snapshot))) +(deftype State [update-fn ref] + c/IReset + (-reset! [_ value] + (^function update-fn value)) + + c/ISwap + (-swap! [self f] + (^function update-fn f)) + (-swap! [self f x] + (^function update-fn #(f % x))) + (-swap! [self f x y] + (^function update-fn #(f % x y))) + (-swap! [self f x y more] + (^function update-fn #(apply f % x y more))) + + c/IDeref + (-deref [_] (ref-val ref))) + (defn use-state "A rumext variant of `useState`. Returns an object that implements the Atom protocols." ([] (use-state nil)) ([initial] - (let [tmp (useState initial) - state (aget tmp 0) - update (aget tmp 1)] - (use-memo - #js [state] - (fn [] - (reify - c/IReset - (-reset! [_ value] - (^function update value)) - - c/ISwap - (-swap! [self f] - (^function update f)) - (-swap! [self f x] - (^function update #(f % x))) - (-swap! [self f x y] - (^function update #(f % x y))) - (-swap! [self f x y more] - (^function update #(apply f % x y more))) - - c/IDeref - (-deref [_] state))))))) + (let [tmp (useState initial) + ref (useRef nil) + value (aget tmp 0) + update-fn (aget tmp 1)] + (set-ref-val! ref value) + (use-memo #(State. update-fn ref))))) (defn use-var "A rumext custom hook that uses `useRef` under the hood. Returns an @@ -336,15 +329,15 @@ (unchecked-set self "value" initial) (unchecked-set ref "current" self) (specify! self - cljs.core/IDeref + c/IDeref (-deref [this] (.-value ^js this)) - cljs.core/IReset + c/IReset (-reset! [this v] (unchecked-set this "value" v)) - cljs.core/ISwap + c/ISwap (-swap! ([this f] (unchecked-set this "value" (f (.-value ^js this)))) @@ -387,43 +380,6 @@ "A raw variant of React.memo." react/memo) -(defn catch - "High order component that adds an error boundary" - [component {:keys [fallback on-error]}] - (let [constructor - (fn [props] - (this-as this - (unchecked-set this "state" #js {}) - (.call Component this props))) - - did-catch - (fn [error info] - (when (fn? on-error) - (on-error error info))) - - derive-state - (fn [error] - #js {:error error}) - - render - (fn [] - (this-as this - (let [state (unchecked-get this "state") - props (unchecked-get this "props") - error (unchecked-get state "error")] - (if error - (jsx fallback #js {:error error} undefined) - (jsx component props undefined))))) - - _ (goog/inherits constructor Component) - prototype (unchecked-get constructor "prototype")] - - (unchecked-set constructor "displayName" "ErrorBoundary") - (unchecked-set constructor "getDerivedStateFromError" derive-state) - (unchecked-set prototype "componentDidCatch" did-catch) - (unchecked-set prototype "render" render) - constructor)) - (def ^:private schedule (or (and (exists? js/window) js/window.requestAnimationFrame) #(js/setTimeout % 16))) diff --git a/src/rumext/v2/compiler.clj b/src/rumext/v2/compiler.clj index 314e44e..a70d611 100644 --- a/src/rumext/v2/compiler.clj +++ b/src/rumext/v2/compiler.clj @@ -355,48 +355,49 @@ m)) (defn compile-prop-value - [val] - (if (some? *transform-props-recursive*) - (binding [*transform-props-recursive* (inc *transform-props-recursive*)] - (cond - (map? val) - (->> val - (into {} (map compile-prop)) - (compile-map-to-js)) - - (vector? val) - (->> val - (mapv compile-prop-value) - (compile-vec-to-js)) - - :else val)) + [level val] + (cond + (not *transform-props-recursive*) + val + + (map? val) + (->> val + (into {} (map (partial compile-prop (inc level)))) + (compile-map-to-js)) + + (vector? val) + (->> val + (mapv (partial compile-prop-value (inc level))) + (compile-vec-to-js)) + + :else val)) (defn compile-prop - [[key val :as kvpair]] - (let [lev (or *transform-props-recursive* 1) - key (if (= lev 1) - (compile-prop-key key) - (compile-prop-inner-key key))] - (cond - (and (= lev 1) - (= key "className")) - [key (compile-class-attr-value val)] - - (and (= lev 1) - (= key "style")) - [key (-> val - (compile-style-value) - (compile-map-to-js))] - - (and (= lev 1) - (= key "htmlFor")) - [key (if (keyword? val) - (name val) - val)] - - :else - [key (compile-prop-value val)]))) + ([prop] (compile-prop 1 prop)) + ([level [key val :as kvpair]] + (let [key (if (= level 1) + (compile-prop-key key) + (compile-prop-inner-key key))] + (cond + (and (= level 1) + (= key "className")) + [key (compile-class-attr-value val)] + + (and (= level 1) + (= key "style")) + [key (-> val + (compile-style-value) + (compile-map-to-js))] + + (and (= level 1) + (= key "htmlFor")) + [key (if (keyword? val) + (name val) + val)] + + :else + [key (compile-prop-value level val)])))) (defn compile-kv-to-js "A internal method helper for compile kv data structures" @@ -440,6 +441,8 @@ form)) (defn compile-props-to-js + "Trandform a props map literal to js object props. By default not + recursive." [props & {:keys [::transform-props-recursive ::transform-props-keys] :or {transform-props-recursive false @@ -449,11 +452,34 @@ (binding [*transform-props-recursive* (if transform-props-recursive 1 nil)] (cond->> props (true? transform-props-keys) - (into {} (map compile-prop)) + (into {} (map (partial compile-prop 1))) :always (compile-map-to-js)))) +(defn compile-coll-to-js + "Transform map or vector to js object or js array. Recursive by + default." + [coll & {:keys [::transform-props-recursive + ::transform-props-keys] + :or {transform-props-recursive true + transform-props-keys true} + :as params}] + (binding [*transform-props-recursive* transform-props-recursive] + (cond + (map? coll) + (->> coll + (into {} (map (partial compile-prop 2))) + (compile-map-to-js)) + + (vector? coll) + (->> coll + (mapv (partial compile-prop-value 2)) + (compile-vec-to-js)) + + :else + (throw (ex-info "only map or vectors allowed" {}))))) + (defn compile-to-js-spread [target other compile-prop] (cond diff --git a/src/rumext/v2/util.cljc b/src/rumext/v2/util.cljc index 243e797..a36f3be 100644 --- a/src/rumext/v2/util.cljc +++ b/src/rumext/v2/util.cljc @@ -26,6 +26,14 @@ (str/capital result) result))))) +;; (defn- transform-prop-key +;; [s] +;; (let [result (js* "~{}.replace(\":\", \"-\").replace(/-./g, x=>x[1].toUpperCase())", s)] +;; (if ^boolean (gstr/startsWith s "-") +;; (gstr/capitalize result) +;; result))) + + (defn ident->prop "Compiles a keyword or symbol to string using react prop naming convention" @@ -78,23 +86,33 @@ #?(:cljs (defn map->props - [o] - (reduce-kv (fn [res k v] - (let [v (if (keyword? v) (name v) v) - k (cond - (string? k) k - (keyword? k) (ident->prop k) - :else nil)] - - (when (some? k) - (let [v (if (and (= k "style") (map? v)) - (map->props v) - v)] - (unchecked-set res k v))) - - res)) - #js {} - o))) + ([o] (map->props o false)) + ([o recursive?] + (let [level (if (true? recursive?) 1 recursive?)] + (reduce-kv (fn [res k v] + (let [v (if (keyword? v) (name v) v) + k (cond + (string? k) k + (keyword? k) (if (and (int? level) (not= 1 level)) + (ident->key k) + (ident->prop k)) + :else nil)] + + (when (some? k) + (let [v (cond + (and (= k "style") (map? v)) + (map->props v true) + + (and (int? level) (map? v)) + (map->props v (inc level)) + + :else + v)] + (unchecked-set res k v))) + + res)) + #js {} + o))))) #?(:cljs (defn wrap-props