From 10691bf21e58be46ee9262e1e153bd66ad0a0501 Mon Sep 17 00:00:00 2001 From: Martin Kavalar Date: Fri, 28 Oct 2022 08:57:53 +0100 Subject: [PATCH] Upgrade to React 18.2 and add thin cljs hooks layer (#242) This upgrades React to 18.2 and migrates to the new `createRoot` api. In addition, we add a thin layer over react hooks for more ergonomic cljs usage: - the fn passed to `useEffect` returns `js/undefined` for any non-function value - hooks that can take a deps array default to an empty array, and convert vectors/sequences to arrays - `useState` and `useRef` return values that behave like atoms Co-authored-by: Matthew Huebert --- package.json | 8 ++- resources/viewer-js-hash | 2 +- shadow-cljs.edn | 2 +- src/nextjournal/clerk/render.cljs | 100 +++++++++++++++++++++++--- src/nextjournal/clerk/static_app.cljs | 13 ++-- 5 files changed, 108 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 5035f75ec..36054baeb 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,5 @@ -{"type": "module", +{ + "type": "module", "dependencies": { "@codemirror/autocomplete": "^6.0.2", "@codemirror/commands": "^6.0.0", @@ -22,8 +23,9 @@ "markdown-it-texmath": "^0.9.1", "markdown-it-toc-done-right": "^4.2.0", "punycode": "2.1.1", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "use-sync-external-store": "^1.2.0", "w3c-keyname": "2.2.4" }, "devDependencies": { diff --git a/resources/viewer-js-hash b/resources/viewer-js-hash index bd35b6fcb..fccb07d33 100644 --- a/resources/viewer-js-hash +++ b/resources/viewer-js-hash @@ -1 +1 @@ -3qePCgpcmuVcJNqR8mK6LSfWdRT2 \ No newline at end of file +8j4R6ZW8WbFaZJhWFH3rAv4eY2J \ No newline at end of file diff --git a/shadow-cljs.edn b/shadow-cljs.edn index 8f15f2fce..8dcfefdaa 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -2,7 +2,7 @@ :dev-http {7778 {:roots ["public" "classpath:public"]}} :nrepl false :builds {:viewer {:target :esm - :runtime :custom ;; needed when developing ssr, node errors without it + :runtime :browser ;; needed when developing ssr, node errors without it :output-dir "public/js" :release {:output-dir "build/"} :compiler-options {:source-map true} diff --git a/src/nextjournal/clerk/render.cljs b/src/nextjournal/clerk/render.cljs index 6304610ba..5b8a6d3f4 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -1,6 +1,8 @@ (ns nextjournal.clerk.render (:require ["d3-require" :as d3-require] ["react" :as react] + ["react-dom/client" :as react-client] + ["use-sync-external-store/shim" :refer [useSyncExternalStore]] [applied-science.js-interop :as j] [cljs.reader] [clojure.string :as str] @@ -19,6 +21,83 @@ [reagent.dom :as rdom] [reagent.ratom :as ratom])) +;; a type for wrapping react/useState to support reset! and swap! +(deftype WrappedState [st] + IIndexed + (-nth [coll i] (aget st i)) + (-nth [coll i nf] (or (aget st i) nf)) + IDeref + (-deref [^js this] (aget st 0)) + IReset + (-reset! [^js this new-value] + ;; `constantly` here ensures that if we reset state to a fn, + ;; it is stored as-is and not applied to prev value. + ((aget st 1) (constantly new-value))) + ISwap + (-swap! [this f] ((aget st 1) f)) + (-swap! [this f a] ((aget st 1) #(f % a))) + (-swap! [this f a b] ((aget st 1) #(f % a b))) + (-swap! [this f a b xs] ((aget st 1) #(apply f % a b xs)))) + +(defn- as-array [x] (cond-> x (not (array? x)) to-array)) + +(defn use-memo + "React hook: useMemo. Defaults to an empty `deps` array." + ([f] (react/useMemo f #js[])) + ([f deps] (react/useMemo f (as-array deps)))) + +(defn use-callback + "React hook: useCallback. Defaults to an empty `deps` array." + ([x] (use-callback x #js[])) + ([x deps] (react/useCallback x (to-array deps)))) + +(defn- wrap-effect + ;; utility for wrapping function to return `js/undefined` for non-functions + [f] #(let [v (f)] (if (fn? v) v js/undefined))) + +(defn use-effect + "React hook: useEffect. Defaults to an empty `deps` array. + Wraps `f` to return js/undefined for any non-function value." + ([f] (react/useEffect (wrap-effect f) #js[])) + ([f deps] (react/useEffect (wrap-effect f) (as-array deps)))) + +(defn use-state + "React hook: useState. Can be used like react/useState but also behaves like an atom." + [init] + (WrappedState. (react/useState init))) + +(defn- specify-atom! [ref-obj] + (specify! ref-obj + IDeref + (-deref [^js this] (.-current this)) + IReset + (-reset! [^js this new-value] (set! (.-current this) new-value)) + ISwap + (-swap! + ([o f] (reset! o (f o))) + ([o f a] (reset! o (f o a))) + ([o f a b] (reset! o (f o a b))) + ([o f a b xs] (reset! o (apply f o a b xs)))))) + +(defn use-ref + "React hook: useRef. Can also be used like an atom." + ([] (use-ref nil)) + ([init] (specify-atom! (react/useRef init)))) + +(defn use-sync-external-store [subscribe get-snapshot] + (useSyncExternalStore subscribe get-snapshot)) + +(defn use-watch + "Hook for reading value of an IWatchable. Compatible with reading Reagent reactions non-reactively." + [x] + (let [id (use-callback #js{})] + (use-sync-external-store + (use-callback + (fn [changed!] + (add-watch x id (fn [_ _ _ _] (changed!))) + #(remove-watch x id)) + #js[x]) + #(binding [reagent.ratom/*ratom-context* nil] @x)))) (when (exists? js/window) ;; conditionalized currently because this throws in node @@ -560,10 +639,13 @@ (when-let [title (and (exists? js/document) (-> doc viewer/->value :title))] (set! (.-title js/document) title))) -(defn ^:export ^:dev/after-load mount [] +(defonce react-root (when-let [el (and (exists? js/document) (js/document.getElementById "clerk"))] - #_(rdom/unmount-component-at-node el) - (rdom/render [root] el))) + (react-client/createRoot el))) + +(defn ^:export ^:dev/after-load mount [] + (when react-root + (.render react-root (r/as-element [root])))) (defn clerk-eval [form] (.ws_send ^js goog/global (pr-str form))) @@ -593,11 +675,13 @@ "React hook which resolves a promise and handles errors." [p] (let [handle-error (use-handle-error) - [v v!] (react/useState)] - (react/useEffect (fn [] (-> p - (.then #(v! (constantly %))) - (.catch handle-error)))) - v)) + !state (use-state nil)] + (use-effect (fn [] + (-> p + (.then #(reset! !state %)) + (.catch handle-error))) + #js []) + @!state)) (defn ^js use-d3-require [package] (let [p (react/useMemo #(apply d3-require/require diff --git a/src/nextjournal/clerk/static_app.cljs b/src/nextjournal/clerk/static_app.cljs index d4dc11fbb..915085431 100644 --- a/src/nextjournal/clerk/static_app.cljs +++ b/src/nextjournal/clerk/static_app.cljs @@ -1,8 +1,9 @@ (ns nextjournal.clerk.static-app - (:require [clojure.set :as set] + (:require ["react-dom/client" :as react-client] + [clojure.set :as set] [clojure.string :as str] - [nextjournal.clerk.sci-env :as sci-env] [nextjournal.clerk.render :as render] + [nextjournal.clerk.sci-env :as sci-env] [nextjournal.clerk.viewer :as v] [nextjournal.devcards :as dc] [reagent.core :as r] @@ -124,9 +125,13 @@ [view view-data] [:pre (pr-str match)])]])) -(defn ^:dev/after-load mount [] +(defonce react-root (when-let [el (and (exists? js/document) (js/document.getElementById "clerk-static-app"))] - (rdom/render [root] el))) + (react-client/createRoot el))) + +(defn ^:dev/after-load mount [] + (when react-root + (.render react-root (r/as-element [root])))) ;; next up ;; - jit compiling css