diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index fbc8083..c226fd9 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -1,2 +1,4 @@ {:config-paths ["../resources/clj-kondo.exports/lilactown/helix"] - :lint-as {devcards.core/defcard clojure.core/def}} + :lint-as {devcards.core/defcard clojure.core/def + manifold.deferred/loop clojure.core/doseq + helix.server.dom/dfor clojure.core/for}} diff --git a/.gitignore b/.gitignore index 4f05b05..29ce1a4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,8 @@ /public/benchmark/js /public/test/js node_modules -/.shadow-cljs -/package-lock.json +.shadow-cljs +package-lock.json /pom.xml /target .clj-kondo/.cache diff --git a/bin/kaocha b/bin/kaocha index af4849e..6c6fab8 100755 --- a/bin/kaocha +++ b/bin/kaocha @@ -1,2 +1,2 @@ #!/usr/bin/env sh -clojure -M:test "$@" +clojure -M:test:ci "$@" diff --git a/demo/.gitignore b/demo/.gitignore new file mode 100644 index 0000000..98faf3f --- /dev/null +++ b/demo/.gitignore @@ -0,0 +1,6 @@ +node_modules +.shadow-cljs +.cpcache +resources/public/js +package-lock.json +.nrepl-port \ No newline at end of file diff --git a/demo/deps.edn b/demo/deps.edn new file mode 100644 index 0000000..43af6a9 --- /dev/null +++ b/demo/deps.edn @@ -0,0 +1,6 @@ +{:paths ["src" "resources"] ; include resources so that we have resources on classpath + :deps {lilactown/helix {:local/root "../"} + manifold/manifold {:mvn/version "0.2.3"} + aleph/aleph {:mvn/version "0.4.7"} + thheller/shadow-cljs {:mvn/version "2.19.0"} + metosin/reitit-ring {:mvn/version "0.5.5"}}} diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..153915c --- /dev/null +++ b/demo/package.json @@ -0,0 +1,18 @@ +{ + "name": "demo", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "shadow-cljs": "^2.19.0" + }, + "dependencies": { + "react": "^18.1.0", + "react-dom": "^18.1.0" + } +} diff --git a/demo/shadow-cljs.edn b/demo/shadow-cljs.edn new file mode 100644 index 0000000..8fb719d --- /dev/null +++ b/demo/shadow-cljs.edn @@ -0,0 +1,8 @@ +{:deps true + :builds + {:app {:target :browser + :output-dir "resources/public/js" + :asset-path "assets/js" + :modules {:main {:entries [helix.demo.ssr] + :init-fn helix.demo.ssr/start-client!}} + :dev {:compiler-options {:output-feature-set :es6}}}}} diff --git a/demo/src/helix/demo/ssr.cljc b/demo/src/helix/demo/ssr.cljc new file mode 100644 index 0000000..e142713 --- /dev/null +++ b/demo/src/helix/demo/ssr.cljc @@ -0,0 +1,116 @@ +(ns helix.demo.ssr + (:require + #?@(:clj [[aleph.http :as http] + [helix.server.core :as hx :refer [defnc $ <> suspense]] + [helix.server.dom-test :as dom :refer [$d]] + [helix.server.hooks :as hooks] + [manifold.deferred :as d] + [manifold.stream :as s] + [reitit.ring :as ring]] + :cljs [[helix.core :as hx :refer [defnc $ <> suspense]] + [helix.dom :as dom :refer [$d]] + [helix.hooks :as hooks] + ["react-dom/client" :as rdom]])) + ;; makes shadow-cljs reload our clj ns + #?(:cljs (:require-macros [helix.demo.ssr]))) + + +(def *cached? (atom #{})) + + +(defn fetch! [i] + (when-not (get @*cached? i) + (throw + #?(:clj (ex-info + "dunno" + {::hx/deferred (d/future + (Thread/sleep (* 100 i)) + (swap! *cached? conj i))}) + :cljs (js/Promise. + (fn [res _] + (prn :suspending) + (js/setTimeout + (fn [] + (swap! *cached? conj i) + (res)) + (* 100 i)))))))) + + +(defnc counter [_] + (let [[counter set-counter] (hooks/use-state 0)] + (<> ($d "button" {:on-click #(set-counter inc)} "+") counter))) + + +(defnc item [{:keys [i]}] + (if (zero? (mod i 10)) + (do (fetch! i) + ($d "div" + "hello" i + ($ counter))) + ($d "div" "hi" i))) + + +(defnc app [{:keys [count] :or {count 10}}] + ($d "div" + ($ counter) + (suspense + {:fallback ($d "div" "Loading all....")} + (for [i (range 0 count)] + #_($ item {:i i :key i}) + (suspense + {:fallback ($d "div" "Loading..") + :key i} + ($ item {:i i})))))) + + +(defnc page [_] + ($d "html" + ($d "head" + ($d "title" "Streaming test")) + ($d "body" {:style {:color "blue"}} + ($d "div" {:id "app"} + ($ app {:count 30})) + #?(:clj "" + :cljs ($d "script" {:src "/assets/js/main.js"}))))) + +(def client-root) + +#?(:cljs + (defn ^:dev/after-load render! + [] + (.render client-root ($ page)) + #_(.render client-root ($ app)))) + +#?(:cljs + (defn start-client! + [] + (set! client-root (rdom/hydrateRoot js/document + ($ page))) + #_(set! client-root (rdom/createRoot (js/document.getElementById "app"))) + #_(render!))) + + + +#?(:clj + (defn page-handler [req] + {:status 200 + :headers {"content-type" "text/html"} + :body (doto (dom/render-to-stream ($ page)) + (s/on-closed #(reset! *cached? #{})))})) + + +#?(:clj + (def router + (ring/router + [["/" {:get page-handler}] + ["/assets/*" (ring/create-resource-handler)]]))) + + + +(comment + #?(:clj (def server (http/start-server + (ring/ring-handler router) + {:port 9090}))) + + #?(:clj (.close server))) + diff --git a/deps.edn b/deps.edn index 6069d8e..abb796e 100644 --- a/deps.edn +++ b/deps.edn @@ -1,6 +1,8 @@ {:paths ["src" "resources"] - :deps {cljs-bean/cljs-bean {:mvn/version "1.5.0"}} + :deps {cljs-bean/cljs-bean {:mvn/version "1.5.0"} + manifold/manifold {:mvn/version "0.2.3"} + aleph/aleph {:mvn/version "0.4.7"}} :aliases {:test {:extra-paths ["test"] - :extra-deps {lambdaisland/kaocha {:mvn/version "1.66.1034"} - org.clojure/clojurescript {:mvn/version "LATEST"}} - :main-opts ["-m" "kaocha.runner"]}}} + :extra-deps {org.clojure/clojurescript {:mvn/version "1.11.51"}}} + :ci {:extra-deps {lambdaisland/kaocha {:mvn/version "1.66.1034"}} + :main-opts ["-m" "kaocha.runner"]}}} diff --git a/package.json b/package.json index 474abe0..d53f8e8 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "author": "", "license": "EPL-2.0", "dependencies": { + "@cljs-oss/module-deps": "^1.1.1", "karma": "^5.0.2", "karma-chrome-launcher": "^3.1.0", "karma-cljs-test": "^0.1.0", diff --git a/resources/clj-kondo.exports/lilactown/helix/config.edn b/resources/clj-kondo.exports/lilactown/helix/config.edn index e394382..246e81f 100644 --- a/resources/clj-kondo.exports/lilactown/helix/config.edn +++ b/resources/clj-kondo.exports/lilactown/helix/config.edn @@ -133,6 +133,8 @@ helix.dom/video clj-kondo.lilactown.helix/dom helix.dom/wbr clj-kondo.lilactown.helix/dom}} :lint-as {helix.core/defhook clojure.core/defn - helix.core/fnc clojure.core/fn} + helix.core/fnc clojure.core/fn + helix.server.core/defnc clojure.core/defn + helix.server.core/fnc clojure.core/fn} :linters {:helix/invalid-children {:level :error} :helix/wrap-after-args {:level :warning}}} diff --git a/src/helix/core.clj b/src/helix/core.clj index dee6a00..229ec04 100644 --- a/src/helix/core.clj +++ b/src/helix/core.clj @@ -82,10 +82,11 @@ (defmacro suspense "Creates a React Suspense boundary." - [{:keys [fallback]} & children] + [{:keys [fallback key]} & children] `^js/React.Element ($ Suspense ~@(when fallback - `({:fallback ~fallback})) + `({:fallback ~fallback + :key ~key})) ~@children)) diff --git a/src/helix/server/core.clj b/src/helix/server/core.clj new file mode 100644 index 0000000..f8415b6 --- /dev/null +++ b/src/helix/server/core.clj @@ -0,0 +1,60 @@ +(ns helix.server.core) + + +(defprotocol IRenderable + (-render [c props])) + + +(extend-protocol IRenderable + clojure.lang.IFn + (-render [f props] + (f props))) + + +(defrecord Element [type props key]) + + +(defn $ + [type & args] + (if (and (map? (first args)) (not (instance? Element (first args)))) + (let [props (first args) + children (rest args) + key (:key props)] + (->Element type (-> props (dissoc :key) (assoc :children children)) key)) + (->Element type {:children args} nil))) + + +(defn <> + [& children] + (->Element 'react/Fragment {:children children} nil)) + + +(defn provider + [{:keys [context value]} & children] + (->Element + 'react/Provider + {:context context :value value :children children} + nil)) + + +(defn suspense + [{:keys [fallback]} & children] + (->Element + 'react/Suspense + {:fallback fallback :children children} + nil)) + + +(defmacro fnc + [& body] + `(fn ~@body)) + + +(defmacro defnc + [& body] + `(defn ~@body)) + + +(defmacro defhook + [& body] + `(defn ~@body)) diff --git a/src/helix/server/dom.clj b/src/helix/server/dom.clj new file mode 100644 index 0000000..39a43c0 --- /dev/null +++ b/src/helix/server/dom.clj @@ -0,0 +1,223 @@ +(ns helix.server.dom + (:require + [clojure.java.io :as io] + [clojure.string :as string] + [helix.server.core :as core] + [helix.server.impl.props :as props] + [manifold.deferred :as d] + [manifold.stream :as s]) + (:import [helix.server.core Element])) + + +(def $d core/$) + + +(defprotocol ToString + (^String to-str [x] "Convert a value into a string.")) + + +(extend-protocol ToString + clojure.lang.Keyword (to-str [k] (name k)) + clojure.lang.Ratio (to-str [r] (str (float r))) + String (to-str [s] s) + Object (to-str [x] (str x)) + nil (to-str [_] "")) + + +(def no-close-tag? + #{"area" "base" "br" "col" "command" "embed" "hr" "img" "input" "keygen" "link" + "meta" "param" "source" "track" "wbr"}) + + +(def ^:dynamic *suspended*) ; atom +(def ^:dynamic ^:private *suspense-counter*) ; atom + + +(defn- gen-suspense-id + [] + (dec (swap! *suspense-counter* inc))) + + +(defn realize-elements + ([el] + (cond + (and (instance? Element el) + (or (string? (:type el)) ; DOM element type + (= 'react/Fragment (:type el)))) + (update-in el [:props :children] #(doall (map realize-elements %))) + + (and (instance? Element el) + (= 'react/Suspense (:type el))) + (try + (-> el + (update-in [:props :children] #(doall (map realize-elements %)))) + (catch clojure.lang.ExceptionInfo e + (if-let [d (::core/deferred (ex-data e))] + (let [suspense-id (or (-> el :props ::core/suspense-id) + (gen-suspense-id)) + el (-> el + (assoc-in [:props ::core/suspense-id] suspense-id) + (assoc-in [:props ::core/suspended?] true))] + (swap! *suspended* assoc suspense-id [d el]) + (assoc-in el [:props ::core/fallback?] true)) + (throw e)))) + + (instance? Element el) + (realize-elements (core/-render (:type el) (:props el))) + + (sequential? el) + (doall (map realize-elements el)) + + :else (to-str el)))) + + +(defn- all + [ds] + (apply d/zip (doall ds))) + + +(defmacro ^:private dfor + {:style/indent 1} + [& body] + `(all (for ~@body))) + + +(defn -render! + [el] + (let [>results (s/stream) + *suspended (atom {}) + *suspense-counter (atom 0) + result (binding [*suspended* *suspended + *suspense-counter* *suspense-counter] + (realize-elements el))] + (s/put! >results [:root result]) + + (d/loop [suspended @*suspended] + (if (seq suspended) + (let [*suspended2 (atom {})] + (d/chain + (dfor [[suspense-id [d el]] suspended] + (d/chain + d + (fn [_] + (binding [*suspended* *suspended2 + *suspense-counter* *suspense-counter] + (realize-elements el))) + (fn [next-el] + ;; in the case of a waterfall within a suspense boundary, we + ;; don't want to render the result until we're done suspending + (if (contains? @*suspended2 suspense-id) + (d/success-deferred true) + ;; back pressure + (s/put! >results [suspense-id next-el]))))) + (fn [_] + (d/recur @*suspended2)))) + (s/close! >results))) + >results)) + + +(declare put-el!) + + +(defn- put-children! + "Calls put-el! on each child. Handles splitting sequential text nodes." + [html children] + (loop [prev (doto (first children) + (->> (put-el! html))) + cur (second children) + children (rest (rest children))] + (when (some? cur) + (cond + (or (instance? Element cur) + (instance? Element prev)) + (put-el! html cur) + + ;; prev and cur is a text node + :else (do (s/put! html "") ; split them using comment + (put-el! html cur))) + (when (seq children) + (recur cur (first children) (rest children)))))) + + +(defn put-el! + [html el] + (cond + (instance? Element el) + (cond + (no-close-tag? (:type el)) + (s/put! html (str "<" (:type el) (props/props->attrs (:props el)) " />")) + + (string? (:type el)) + (do (s/put! html (str "<" (:type el) (props/props->attrs (:props el)) ">")) + (if-let [{:keys [__html]} (-> el :props :dangerously-set-inner-HTML)] + (s/put! html __html) + (put-children! html (-> el :props :children))) + (s/put! html (str ""))) + + (= 'react/Fragment (:type el)) + (doseq [child (-> el :props :children)] + (put-el! html child)) + + (= 'react/Suspense (:type el)) + (if (::core/fallback? (:props el)) + (do (s/put! html "") + (s/put! html (str "")) + (put-el! html (-> el :props :fallback)) + (s/put! html "")) + (do + (when-not (-> el :props ::core/suspended?) + ;; don't render boundary again if prev suspended + (s/put! html "")) + (put-children! html (-> el :props :children)) + (when-not (-> el :props ::core/suspended?) + (s/put! html ""))))) + + (sequential? el) + (put-children! html el) + + :else (s/put! html (to-str el)))) + + +(def complete-boundary-function + (slurp (io/resource "helix/server/rc.js"))) + + +(defn render-to-stream + [el] + (let [html (s/stream) + loaded-boundary-script? (atom false)] + (s/put! html "") + (s/connect-via + (-render! el) + (fn [[suspense-id el]] + (if (= :root suspense-id) + (put-el! html el) + (do + (s/put! html (str "")) + (if-not @loaded-boundary-script? + (do (reset! loaded-boundary-script? true) + (s/put! html (str ""))) + (s/put! html (str "")))))) + html) + html)) + + +(defn render-to-string + [el] + (let [result (binding [*suspended* (atom {}) + *suspense-counter* (atom 0)] + (realize-elements el)) + html (s/stream)] + (put-el! html result) + (s/close! html) + (->> html + (s/stream->seq) + (string/join)))) diff --git a/src/helix/server/hooks.clj b/src/helix/server/hooks.clj new file mode 100644 index 0000000..5aaa053 --- /dev/null +++ b/src/helix/server/hooks.clj @@ -0,0 +1,24 @@ +(ns helix.server.hooks) + + +(defn use-state + [initial] + [initial (fn [& args])]) + + +(defn use-ref + [initial] + (atom initial)) + + +(defmacro use-effect [deps & body]) + +(defmacro use-layout-effect [deps & body]) + +(defmacro use-memo + [deps & body] + ~@body) + +(defmacro use-callback + [deps f] + ~f) diff --git a/src/helix/server/impl/props.clj b/src/helix/server/impl/props.clj new file mode 100644 index 0000000..5428c73 --- /dev/null +++ b/src/helix/server/impl/props.clj @@ -0,0 +1,120 @@ +(ns helix.server.impl.props + (:require + [clojure.string :as string])) + +(defn- entry->style + [[k v]] + (str (if (string? k) k (name k)) ":" v)) + +(defn style-str + [styles] + (reduce + (fn [s e] + (str s ";" (entry->style e))) + (entry->style (first styles)) + (rest styles))) + +#_(style-str {:color "red"}) + +#_(style-str {"color" "blue" :flex "grow"}) + + +(defn seq-to-class [class] + (->> class + (remove nil?) + (map str) + (string/join " "))) + + +(defn props->attrs + [props] + (reduce-kv + (fn [attrs k v] + (case k + :style (str attrs " style=\"" (style-str v) "\"") + :id (str attrs " id=\"" v "\"") + :class (str attrs " class=\"" + (if (seqable? v) + (seq-to-class v) + v) "\"") + :value (if (some? v) + (str attrs " value=\"" v "\"") + attrs) + (:async :checked :controls :default :defer :disabled :hidden :loop + :multiple :muted :open :required :reversed :selected :scoped + :seamless) (if (true? v) (str attrs " " (name k) "=\"\"") attrs) + :content-editable (str attrs " contenteditable=\"" (boolean v) "\"") + :spell-check (str attrs " spellcheck=\"" (boolean v) "\"") + :draggable (str attrs " draggable=\"" (boolean v) "\"") + :allow-fullScreen (if (true? v) + (str attrs " allowfullscreen=\"\"") + attrs) + :auto-play (if (true? v) + (str attrs " autoplay=\"\"") + attrs) + :auto-focus (if (true? v) + (str attrs " autofocus=\"\"") + attrs) + :disable-picture-in-picture (if (true? v) + (str attrs " disablepictureinpicture=\"\"") + attrs) + :disable-remote-playback (if (true? v) + (str attrs " disableremoteplayback=\"\"") + attrs) + :form-no-validate (if (true? v) + (str attrs " formnovalidate=\"\"") + attrs) + :no-module (if (true? v) + (str attrs " nomodule=\"\"") + attrs) + :no-validate (if (true? v) + (str attrs " novalidate=\"\"") + attrs) + :plays-inline (if (true? v) + (str attrs " playsinline=\"\"") + attrs) + :read-only (if (true? v) + (str attrs " readonly=\"\"") + attrs) + :item-scope (if (true? v) + (str attrs " itemscope=\"\"") + attrs) + :row-span (str attrs " rowspan=\"" v "\"") + (:capture :download) (cond + (true? v) (str attrs " " (name k) "=\"\"") + (some? v) (str attrs " " (name k) "=\"" v "\"") + :else attrs) + + (str attrs " " (name k) "=\"" v "\""))) + "" + (dissoc props + :children + :dangerously-set-inner-HTML + :inner-html + :suppress-content-editable-warning + :suppress-hydration-warning + :default-checked :default-value))) + + +(comment + (props->attrs {:style {:color "red"}}) + + (props->attrs {:style {:color "red"} + :id "foo" + :class ["foo" "bar"] + :for "baz" + :accept-charset "utf8" + :http-equiv "content-security-policy" + :default-value 7 + :default-checked true + :multiple true + :muted true + :selected true + :children '("foo" "bar" "baz") + :dangerousely-set-inner-html "
" + :inner-html "" + :suppress-content-editable-warning true + :suppress-hydration-warning true + :content-editable true + :spell-check false + :draggable true})) diff --git a/src/helix/server/rc.js b/src/helix/server/rc.js new file mode 100644 index 0000000..6a00017 --- /dev/null +++ b/src/helix/server/rc.js @@ -0,0 +1,26 @@ +function $RC(a, b) { + a = document.getElementById(a); + b = document.getElementById(b); + b.parentNode.removeChild(b); + if (a) { + a = a.previousSibling; + var f = a.parentNode, + c = a.nextSibling, + e = 0; + do { + if (c && 8 === c.nodeType) { + var d = c.data; + if ("/$" === d) + if (0 === e) break; + else e--; + else ("$" !== d && "$?" !== d && "$!" !== d) || e++; + } + d = c.nextSibling; + f.removeChild(c); + c = d; + } while (c); + for (; b.firstChild; ) f.insertBefore(b.firstChild, c); + a.data = "$"; + a._reactRetry && a._reactRetry(); + } +} diff --git a/test/fixtures/kitchen_sink.cljc b/test/fixtures/kitchen_sink.cljc new file mode 100644 index 0000000..d531b0f --- /dev/null +++ b/test/fixtures/kitchen_sink.cljc @@ -0,0 +1,101 @@ +(ns fixtures.kitchen-sink + #?(:cljs + (:require + [helix.core :refer [defnc $]] + [helix.dom :as dom] + ["react-dom/server" :as rdom]) + :clj + (:require + [cljs.build.api :as b] + [clojure.java.shell :as shell] + [helix.server.core :refer [defnc $]] + [helix.server.dom :as dom] + [manifold.stream :as s] + [clojure.string :as string]))) + + +(defnc div-props + [_] + (dom/$d "div" + {:style {:color "red"} + :id "fuzzy" + :class ["foo" "bar"] + :for "baz" + :accept-charset "utf8" + :http-equiv "content-security-policy" + ;:default-value 7 + ;:default-checked true + :checked true + :value 7 + :multiple true + :muted true + :selected true + ;; can't haven children and dangerouslySetInnerHTML + ;:children '("foo" "bar" "baz") + :dangerously-set-inner-HTML #?(:clj {:__html "
"} + :cljs #js {:__html "
"}) + ;; warning when using innerHtml + ;:inner-html "" + :suppress-content-editable-warning true + :suppress-hydration-warning true + :content-editable true + :spell-check false + :draggable true + :allow-fullScreen true + :async true + :auto-focus true + :auto-play true + :controls true + :default true + :defer true + :disabled true + :disable-picture-in-picture true + :disable-remote-playback true + :form-no-validate true + :hidden true + :loop true + :no-module true + :no-validate true + :open true + :plays-inline true + :read-only true + :required true + :reversed true + :scoped true + :seamless true + :item-scope true + :cols 5 + :rows 5 + :size 5 + :span 5 + :row-span 5 + :start 5 + :capture true + :download "asdf"})) + + +(def el ($ div-props)) + + +#?(:clj + (defn make [] + (b/build {:output-dir "out" + :output-to "out/main.js" + :optimizations :none + :target :nodejs + :main 'fixtures.kitchen-sink + :npm-deps {:react "18.1.0" + :react-dom "18.1.0"}}) + {:cljs (string/trim-newline (:out (shell/sh "node" "out/main.js"))) + :clj (dom/render-to-string el)})) + +#_(make) + +#_(let [{:keys [cljs clj]} (make)] + (= (.length cljs) (.length clj))) + +(defn -main [] + #?(:clj (prn (make)) + :cljs (print (rdom/renderToString el)))) + +#?(:cljs (set! *main-cli-fn* -main)) diff --git a/test/helix/server/dom_test.clj b/test/helix/server/dom_test.clj new file mode 100644 index 0000000..92d01d8 --- /dev/null +++ b/test/helix/server/dom_test.clj @@ -0,0 +1,6 @@ +(ns helix.server.dom-test + (:require + [helix.server.core :refer [defnc $]] + [helix.server.dom :as dom])) + +