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 "" (:type el) ">"))) + + (= '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 " el :props ::core/suspense-id) + "\">")) + (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 "