From 2de83d48c406bdb99c2df1d57bd35afd7a6d62d8 Mon Sep 17 00:00:00 2001 From: Juho Teperi Date: Fri, 1 Dec 2023 16:01:32 +0200 Subject: [PATCH 1/2] Add defc macro to create fn components with Fast Refresh support --- .clj-kondo/config.edn | 1 + demo/reagentdemo/dev.cljs | 10 ++++-- demo/reagentdemo/intro.cljs | 36 ++++++++++----------- package-lock.json | 16 ++++++++++ package.json | 13 +++++--- src/reagent/core.clj | 55 ++++++++++++++++++++++++++++++++- src/reagent/dev.clj | 1 + src/reagent/dev.cljs | 21 +++++++++++++ src/reagent/impl/component.cljs | 5 +++ src/reagent/impl/template.cljs | 15 +++++++-- 10 files changed, 144 insertions(+), 29 deletions(-) create mode 100644 src/reagent/dev.clj create mode 100644 src/reagent/dev.cljs diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index 4de750ff..f30df508 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -1,4 +1,5 @@ {:lint-as {reagent.core/with-let clojure.core/let + reagent.core/defc clojure.core/defn reagenttest.utils/deftest clojure.test/deftest} :linters {:unused-binding {:level :off} :missing-else-branch {:level :off} diff --git a/demo/reagentdemo/dev.cljs b/demo/reagentdemo/dev.cljs index 3ff540f9..3b470ee1 100644 --- a/demo/reagentdemo/dev.cljs +++ b/demo/reagentdemo/dev.cljs @@ -1,11 +1,15 @@ (ns reagentdemo.dev "Initializes the demo app, and runs the tests." - (:require [reagentdemo.core :as core] + (:require [reagent.dev] + [reagentdemo.core :as core] [reagenttest.runtests :as tests])) +(reagent.dev/init-fast-refresh!) + (enable-console-print!) (defn ^:dev/after-load init! [] - (core/init! (tests/init!))) + (js/console.log (reagent.dev/refresh!)) + (tests/init!)) -(init!) +(defonce _init (core/init!)) diff --git a/demo/reagentdemo/intro.cljs b/demo/reagentdemo/intro.cljs index 6ed6e86b..0ce9f27f 100644 --- a/demo/reagentdemo/intro.cljs +++ b/demo/reagentdemo/intro.cljs @@ -6,44 +6,44 @@ [simpleexample.core :as simple] [todomvc.core :as todo])) -(defn simple-component [] +(r/defc simple-component [] [:div [:p "I am a component!"] [:p.someclass "I have " [:strong "bold"] [:span {:style {:color "red"}} " and red "] "text."]]) -(defn simple-parent [] +(r/defc simple-parent [] [:div [:p "I include simple-component."] [simple-component]]) -(defn hello-component [name] +(r/defc hello-component [name] [:p "Hello, " name "!"]) -(defn say-hello [] +(r/defc say-hello [] [hello-component "world"]) -(defn lister [items] +(r/defc lister [items] [:ul (for [item items] ^{:key item} [:li "Item " item])]) -(defn lister-user [] +(r/defc lister-user [] [:div "Here is a list:" [lister (range 3)]]) (def click-count (r/atom 0)) -(defn counting-component [] +(r/defc counting-component [] [:div "The atom " [:code "click-count"] " has value: " @click-count ". " [:input {:type "button" :value "Click me!" :on-click #(swap! click-count inc)}]]) -(defn atom-input [value] +(r/defc atom-input [value] [:input {:type "text" :value @value :on-change #(reset! value (-> % .-target .-value))}]) @@ -62,7 +62,7 @@ [:div "Seconds Elapsed: " @seconds-elapsed]))) -(defn render-simple [] +(r/defc render-simple [] (rdom/render [simple-component] (.-body js/document))) @@ -75,7 +75,7 @@ (def bmi-data (r/atom (calc-bmi {:height 180 :weight 80}))) -(defn slider [param value min max invalidates] +(r/defc slider [param value min max invalidates] [:input {:type "range" :value value :min min :max max :style {:width "100%"} :on-change (fn [e] @@ -87,7 +87,7 @@ (dissoc invalidates) calc-bmi)))))}]) -(defn bmi-component [] +(r/defc bmi-component [] (let [{:keys [weight height bmi]} @bmi-data [color diagnose] (cond (< bmi 18.5) ["orange" "underweight"] @@ -114,7 +114,7 @@ (def ns-src-with-rdom (s/syntaxed "(ns example (:require [reagent.dom :as rdom]))")) -(defn intro [] +(r/defc intro [] (let [github {:href "https://github.com/reagent-project/reagent"} clojurescript {:href "https://github.com/clojure/clojurescript"} react {:href "https://reactjs.org/"} @@ -176,7 +176,7 @@ is a map). See React’s " [:a react-keys "documentation"] " for more info."]])) -(defn managing-state [] +(r/defc managing-state [] [:div.demo-text [:h2 "Managing state in Reagent"] @@ -224,7 +224,7 @@ component is updated when your data changes. Reagent assumes by default that two objects are equal if they are the same object."]]) -(defn essential-api [] +(r/defc essential-api [] [:div.demo-text [:h2 "Essential API"] @@ -240,7 +240,7 @@ ns-src-with-rdom (s/src-of [:simple-component :render-simple])]}]]) -(defn performance [] +(r/defc performance [] [:div.demo-text [:h2 "Performance"] @@ -285,7 +285,7 @@ into the browser, React automatically attaches event-handlers to the already present DOM tree."]]) -(defn bmi-demo [] +(r/defc bmi-demo [] [:div.demo-text [:h2 "Putting it all together"] @@ -301,7 +301,7 @@ (s/src-of [:calc-bmi :bmi-data :slider :bmi-component])]}]]) -(defn complete-simple-demo [] +(r/defc complete-simple-demo [] [:div.demo-text [:h2 "Complete demo"] @@ -313,7 +313,7 @@ :complete true :src (s/src-of nil "simpleexample/core.cljs")}]]) -(defn todomvc-demo [] +(r/defc todomvc-demo [] [:div.demo-text [:h2 "Todomvc"] diff --git a/package-lock.json b/package-lock.json index 6cc407e9..b692552c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "prop-types": "15.8.1", "react": "18.2.0", "react-dom": "18.2.0", + "react-refresh": "^0.14.0", "shadow-cljs": "2.20.7", "webpack": "5.65.0", "webpack-cli": "4.9.1" @@ -3491,6 +3492,15 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/react-refresh": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", + "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.5.tgz", @@ -7630,6 +7640,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "react-refresh": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", + "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", + "dev": true + }, "readable-stream": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.5.tgz", diff --git a/package.json b/package.json index ada234f1..313dceb1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { "name": "@reagent-project/reagent", "private": true, - "dependencies": {}, "scripts": { "start": "lein figwheel client-npm" }, @@ -16,11 +15,15 @@ "karma-sourcemap-loader": "0.3.8", "karma-webpack": "5.0.0", "md5-file": "5.0.0", - "shadow-cljs": "2.20.7", - "webpack": "5.65.0", - "webpack-cli": "4.9.1", "prop-types": "15.8.1", "react": "18.2.0", - "react-dom": "18.2.0" + "react-dom": "18.2.0", + "react-refresh": "^0.14.0", + "shadow-cljs": "2.20.7", + "webpack": "5.65.0", + "webpack-cli": "4.9.1" + }, + "volta": { + "node": "18.15.0" } } diff --git a/src/reagent/core.clj b/src/reagent/core.clj index d2abe46e..35439bae 100644 --- a/src/reagent/core.clj +++ b/src/reagent/core.clj @@ -1,5 +1,6 @@ (ns reagent.core - (:require [reagent.ratom :as ra])) + (:require [cljs.core :as core] + [reagent.ratom :as ra])) (defmacro with-let "Bind variables as with let, except that when used in a component @@ -24,3 +25,55 @@ [& body] `(reagent.ratom/make-reaction (fn [] ~@body))) + +;; From uix.lib +(defn parse-sig [name fdecl] + (let [[fdecl m] (if (string? (first fdecl)) + [(next fdecl) {:doc (first fdecl)}] + [fdecl {}]) + [fdecl m] (if (map? (first fdecl)) + [(next fdecl) (conj m (first fdecl))] + [fdecl m]) + fdecl (if (vector? (first fdecl)) + (list fdecl) + fdecl) + [fdecl m] (if (map? (last fdecl)) + [(butlast fdecl) (conj m (last fdecl))] + [fdecl m]) + m (conj {:arglists (list 'quote (#'cljs.core/sigs fdecl))} m) + m (conj (if (meta name) (meta name) {}) m)] + [(with-meta name m) fdecl])) + +(defmacro defc + "Creates function component" + [sym & fdecl] + ;; Just use original fdecl always for render fn. + ;; Parse for fname metadata. + (let [[fname fdecl] (parse-sig sym fdecl) + ;; FIXME: Should probably support multiple arities for components + [args & fdecl] (first fdecl) + var-sym (-> (str (-> &env :ns :name) "/" sym) symbol (with-meta {:tag 'js}))] + `(do + (def ~fname (reagent.impl.component/memo + (fn ~fname [jsprops#] + ;; FIXME: Replace functional-render with new function that takes renderFn as parameter. + ;; Need completely new component impl for that. + (let [render-fn# (fn ~'reagentRender ~args + (when ^boolean goog.DEBUG + (when-let [sig-f# (.-fast-refresh-signature ~var-sym)] + (sig-f#))) + ~@fdecl) + jsprops2# (js/Object.assign (core/js-obj "reagentRender" render-fn#) jsprops#)] + (reagent.impl.component/functional-render reagent.impl.template/*current-default-compiler* jsprops2#))))) + (reagent.dev/register ~var-sym ~(str fname)) + (when ^boolean goog.DEBUG + (let [sig# (reagent.dev/signature)] + ;; Empty signature but set forceReset flag. + (sig# ~var-sym "" true nil) + (set! (.-fast-refresh-signature ~var-sym) sig#))) + (set! (.-reagent-component ~fname) true) + (set! (.-displayName ~fname) ~(str sym))))) + +(comment + (clojure.pprint/pprint (macroexpand-1 '(defc foobar [a b] (+ a b)))) + (clojure.pprint/pprint (clojure.walk/macroexpand-all '(defc foobar [a b] (+ a b))))) diff --git a/src/reagent/dev.clj b/src/reagent/dev.clj new file mode 100644 index 00000000..f6fda86c --- /dev/null +++ b/src/reagent/dev.clj @@ -0,0 +1 @@ +(ns reagent.dev) diff --git a/src/reagent/dev.cljs b/src/reagent/dev.cljs new file mode 100644 index 00000000..a14fbbe6 --- /dev/null +++ b/src/reagent/dev.cljs @@ -0,0 +1,21 @@ +(ns reagent.dev + (:require ["react-refresh/runtime" :as refresh]) + (:require-macros [reagent.dev])) + +(defn signature [] + (refresh/createSignatureFunctionForTransform)) + +(defn register [type id] + (refresh/register type id)) + +;;;; Public API ;;;; + +(defn init-fast-refresh! + "Injects react-refresh runtime. Should be called before UI is rendered" + [] + (refresh/injectIntoGlobalHook js/window)) + +(defn refresh! + "Should be called after hot-reload, in shadow's ^:dev/after-load hook" + [] + (refresh/performReactRefresh)) diff --git a/src/reagent/impl/component.cljs b/src/reagent/impl/component.cljs index 3304b9e6..e355fec2 100644 --- a/src/reagent/impl/component.cljs +++ b/src/reagent/impl/component.cljs @@ -477,3 +477,8 @@ f (react/memo f functional-render-memo-fn)] (cache-react-class compiler tag f) f))) + +;; defc impl + +(defn memo [f] + (react/memo f functional-render-memo-fn)) diff --git a/src/reagent/impl/template.cljs b/src/reagent/impl/template.cljs index c8300b78..8389c399 100644 --- a/src/reagent/impl/template.cljs +++ b/src/reagent/impl/template.cljs @@ -26,8 +26,9 @@ (or (named? x) (string? x))) -(defn ^boolean valid-tag? [x] +(defn ^boolean valid-tag? [^clj x] (or (hiccup-tag? x) + (.-reagent-component x) (ifn? x) (instance? NativeWrapper x))) @@ -162,6 +163,13 @@ (set! (.-key jsprops) key)) (react/createElement c jsprops))) +(defn reag-element-2 [tag v] + (let [jsprops #js {}] + (set! (.-argv jsprops) (subvec v 1)) + (when-some [key (util/react-key-from-vec v)] + (set! (.-key jsprops) key)) + (react/createElement tag jsprops))) + (defn function-element [tag v first-arg compiler] (let [jsprops #js {}] (set! (.-reagentRender jsprops) tag) @@ -276,7 +284,7 @@ (when (nil? compiler) (js/console.error "vec-to-elem" (pr-str v))) (assert (pos? (count v)) (util/hiccup-err v (comp/comp-name) "Hiccup form should not be empty")) - (let [tag (nth v 0 nil)] + (let [^clj tag (nth v 0 nil)] (assert (valid-tag? tag) (util/hiccup-err v (comp/comp-name) "Invalid Hiccup form")) (case tag :> (native-element (->HiccupTag (nth v 1 nil) nil nil nil) v 2 compiler) @@ -284,6 +292,9 @@ :f> (function-element (nth v 1 nil) v 2 compiler) :<> (fragment-element v compiler) (cond + (.-reagent-component tag) + (reag-element-2 tag v) + (hiccup-tag? tag) (hiccup-element v compiler) From 6307cc3ee646e917ced202a45a22c158f606cb52 Mon Sep 17 00:00:00 2001 From: Juho Teperi Date: Fri, 1 Dec 2023 16:10:33 +0200 Subject: [PATCH 2/2] Docs --- doc/ReactRefresh.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 doc/ReactRefresh.md diff --git a/doc/ReactRefresh.md b/doc/ReactRefresh.md new file mode 100644 index 00000000..a4206c09 --- /dev/null +++ b/doc/ReactRefresh.md @@ -0,0 +1,8 @@ +# React Refresh + +- `reagent.dev` ns must be required before anything that loads `react-dom` +- Don't call `r.dom/render` after reload (e.g. shadow-cljs hook) +- Call `reagent.dev/refresh!` instead +- Only components defined using `r/defc` will refresh +- Reagent doesn't try to create Hook signatures for components, + so hook state is reset for updated components.