diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index fa452b2..6a20489 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -1,5 +1,6 @@ ;; A defui body is browser code, so it calls things that only exist there. ;; Reagami is loaded by index.html and never required on this side. {:lint-as {buzz.core/defui clojure.core/defn + buzz.core/defn clojure.core/defn buzz.core/defpart clojure.core/defn} :linters {:unresolved-namespace {:exclude [reagami js]}}} diff --git a/README.md b/README.md index 5e5c32d..bf81247 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,9 @@ values, call server actions, and keep local state: - `(client expr)` passes a browser value to a `server!` action. - `(local-state init)` creates a browser-local atom. Use `deref`, `reset!`, and `swap!` to read and change it. Each mount keeps its own atom across - renders. The initial value can use a `server` expression. + renders. The initial value can use a `server` expression. It is also + computed for the first paint, so browser-only code in it needs `host`: + `(local-state (host :cljs (js/Date.)))` starts as nil on the first paint. Use `reply` inside `server!` to return a value to the browser. Supply a Ring response map as the second argument to set a cookie or other response headers: @@ -94,18 +96,20 @@ response map as the second argument to set a cookie or other response headers: (server! (reply :ok {:headers {"Set-Cookie" "session=abc; HttpOnly; Path=/"}})) ``` -## Parts +## Functions -Use `defpart` to extract reusable UI functions from a component: +Use `buzz/defn` to define a function for the browser and the server: ```clojure -(defpart row [item] +(buzz/defn row [item] [:li (:title item)]) ``` -Call `(row item)` inside `defui` or another part. Parts can call themselves -recursively and use `server!` for actions. Define `server` and `local-state` -in `defui`, then pass their values as arguments. See [doc/parts.md](doc/parts.md). +Call `(row item)` inside `defui` or another `buzz/defn`. These functions can +call themselves recursively and use `server!` for actions. Define `server` and +`local-state` in `defui`, then pass their values as arguments. Use `host` +where the browser and the server need different code. See +[doc/defn.md](doc/defn.md). ## Mounting @@ -263,5 +267,5 @@ Omit `` to render that component only after the browser connects. bb dev # the demo, plus an nrepl on 1667 -Re-evaluate a `defui` or `defpart` to update open pages. Local state survives +Re-evaluate a `defui` or `buzz/defn` to update open pages. Local state survives updates and reconnects when the number of `local-state` forms stays the same. diff --git a/doc/defn.md b/doc/defn.md new file mode 100644 index 0000000..c25dc27 --- /dev/null +++ b/doc/defn.md @@ -0,0 +1,112 @@ +# Functions + +Use `buzz/defn` to define a function for the browser and the server. Squint +compiles it for the browser. The first paint and server code call the function +compiled on the JVM. The function can return Hiccup or any other value, and +can call itself: + +```clojure +(buzz/defn node [r] + [:li (:label r) + [:button {:on-click (fn [_] (server! (bump! (client (:id r)))))} "!"] + (when (seq (:children r)) + [:ul (for [c (:children r)] (node c))])]) + +(defui viewer [] + [:ul (node (server @tree))]) +``` + +`defpart` is the former name of `buzz/defn`. + +A plain `defn` is not compiled for the browser. Calling one from `defui` fails +in the browser with a ReferenceError. + +## Arguments + +A `buzz/defn` cannot contain `(server ...)` or `(local-state ...)`. Use these +forms in `defui` and pass their results as arguments: + +```clojure +(buzz/defn row [item selected] + [:li {:class (when (= item @selected) "selected") + :on-click (fn [_] (reset! selected item))} + item]) + +(defui shelf [store] + (let [items (server @store) + selected (local-state nil)] + [:ul (for [item items] + (row item selected))])) +``` + +`selected` is browser state. The function receives the atom as an argument and +can read or update it. + +## Handlers + +A `buzz/defn` can contain `(server! ...)`. Wrap browser values in +`(client ...)` when sending them to the server. + +Use `(buzz/request)` in a handler to access connection-scoped state: + +```clojure +(defonce carts (atom {})) + +(buzz/defn clear-button [] + [:button + {:on-click (fn [_] + (server! (swap! carts assoc + (buzz/connection (buzz/request)) + [])))} + "clear"]) +``` + +## Browser and server branches + +Use `host` where the browser and the server need different code. The browser +runs the `:cljs` branch. Server calls, including the first paint, run the +`:clj` branch. A missing branch uses `:default`, or nil: + +```clojure +(buzz/defn parse-number [s] + (host :clj (Double/parseDouble s) :cljs (js/parseFloat s))) +``` + +A `:cljs` branch on its own makes a browser-only function: + +```clojure +(buzz/defn commit! [pending* k] + (host :cljs (fn [raw] + (let [v (js/parseFloat raw)] + (when-not (js/isNaN v) + (server! (save! (client k) (client v)))) + (swap! pending* dissoc k))))) +``` + +`host` is valid in `defui` and `buzz/defn` bodies, including event handlers. +It is refused inside `server` and `server!`. Outside `defui` and `buzz/defn`, +it uses the `:clj` branch, with the same fallback. + +Use `host` for browser-only code in a `local-state` initial value. Initial +values are also computed for the first paint. + +Inside a `fn`, `js/` interop throws if called during the first paint. Event +handlers do not run during the first paint and need no `host` for `js/` +interop. Outside a `fn`, wrap browser-only code in `host :cljs` to load the +definition. + +Wrap calls to Squint-only functions, such as `clj->js`, in `host :cljs`, +including inside event handlers. + +Read server state with `server` and run server actions with `server!`. +Use `host` to select code for each runtime. + +## Editing in the REPL + +Re-evaluate a `buzz/defn` in the REPL to hot-reload open pages. + +## Limitations + +- A `buzz/defn` takes one arity and a fixed number of arguments. +- A `buzz/defn` can call only functions that are already defined. Mutual + recursion requires re-evaluating the first definition after both exist. diff --git a/doc/parts.md b/doc/parts.md deleted file mode 100644 index f55f79e..0000000 --- a/doc/parts.md +++ /dev/null @@ -1,79 +0,0 @@ -# Parts - -`defpart` defines a Hiccup function that runs in the browser. Parts can call -themselves: - -```clojure -(defpart node [r] - [:li (:label r) - [:button {:on-click (fn [_] (server! (bump! (client (:id r)))))} "!"] - (when (seq (:children r)) - [:ul (for [c (:children r)] (node c))])]) - -(defui viewer [] - [:ul (node (server @tree))]) -``` - -The server calls the same function for the first render. - -## Arguments - -A `defpart` cannot contain `(server ...)` or `(local-state ...)`. Use these -forms in `defui` and pass their results to the part: - -```clojure -(defpart row [item selected] - [:li {:class (when (= item @selected) "selected") - :on-click (fn [_] (reset! selected item))} - item]) - -(defui shelf [store] - (let [items (server @store) - selected (local-state nil)] - [:ul (for [item items] - (row item selected))])) -``` - -`selected` is browser state. The part receives the atom as an argument and can -read or update it. - -## Handlers - -A part can contain `(server! ...)`. Wrap browser values in `(client ...)` when -sending them to the server. - -Use `(buzz/request)` in a part handler to access connection-scoped state: - -```clojure -(defonce carts (atom {})) - -(defpart clear-button [] - [:button - {:on-click (fn [_] - (server! (swap! carts assoc - (buzz/connection (buzz/request)) - [])))} - "clear"]) -``` - -## Editing a part in the REPL - -Re-evaluate a `defpart` in the REPL to hot-reload open pages. - -## Limitations - -- A `defpart` can call only parts that are already defined. Mutual recursion - requires re-evaluating the first definition after both parts exist. -- Functions passed to parts must also compile on the JVM for server rendering. - Keep `js/` and `await` code in the part itself: - - ```clojure - (defpart submit-button [on-submit] - [:button {:on-click (fn [e] - (js/console.log "saving") - (on-submit e))} - "save"]) - - (defui editor [] - (submit-button (fn [_] (server! (persist!))))) - ``` diff --git a/examples/datalevin/src/buzz/dlv.clj b/examples/datalevin/src/buzz/dlv.clj index e753d78..44eac26 100644 --- a/examples/datalevin/src/buzz/dlv.clj +++ b/examples/datalevin/src/buzz/dlv.clj @@ -1,7 +1,7 @@ (ns buzz.dlv "A Datalevin browser over a MusicBrainz sample: a query editor with canned queries, results as a table, and a query log shared by every viewer." - (:require [buzz.core :as buzz :refer [client defpart defui local-state observe reply server server!]] + (:require [buzz.core :as buzz :refer [client defui local-state observe reply server server!]] [buzz.dlv.source :as dlv] [clojure.edn :as edn] [clojure.java.io :as io] @@ -105,7 +105,7 @@ (sort-by :label) vec)}) -(defpart result-view [r] +(buzz/defn result-view [r] (cond (nil? r) [:p.hint "Run a query, or click one on the left."] (:error r) [:div.error (:error r)] diff --git a/examples/tap-viewer/src/buzz/tap_viewer.clj b/examples/tap-viewer/src/buzz/tap_viewer.clj index 48b7f17..6753619 100644 --- a/examples/tap-viewer/src/buzz/tap_viewer.clj +++ b/examples/tap-viewer/src/buzz/tap_viewer.clj @@ -1,6 +1,6 @@ (ns buzz.tap-viewer "View `tap>` values in a browser." - (:require [buzz.core :as buzz :refer [client defpart defui local-state reply request server server!]] + (:require [buzz.core :as buzz :refer [client defui local-state reply request server server!]] [clojure.java.io :as io] [clojure.string :as str] [org.httpkit.server :as http])) @@ -186,7 +186,7 @@ ;; A node renders its children by calling itself, so folding a branch drops the ;; whole subtree. -(defpart tree-node [n folded said] +(buzz/defn tree-node [n folded said] [:div {:key (:path n)} [:div.row (if (:branch n) @@ -210,7 +210,7 @@ (when (and (:branch n) (not (get @folded (:path n)))) [:div.kids (for [c (:children n)] (tree-node c folded said))])]) -(defpart entry-item [e open folded said] +(buzz/defn entry-item [e open folded said] [:li {:key (:id e)} [:div.head [:button.toggle {:on-click (fn [_] (swap! open (fn [m] (assoc m (:id e) diff --git a/resources/clj-kondo.exports/io.github.borkdude/buzz/config.edn b/resources/clj-kondo.exports/io.github.borkdude/buzz/config.edn index e357ac8..d547295 100644 --- a/resources/clj-kondo.exports/io.github.borkdude/buzz/config.edn +++ b/resources/clj-kondo.exports/io.github.borkdude/buzz/config.edn @@ -1,4 +1,5 @@ ;; Shipped with the library, so a project using it does not have to know that -;; defui and defpart bind their arguments. +;; defui, defn and defpart bind their arguments. {:lint-as {buzz.core/defui clojure.core/defn + buzz.core/defn clojure.core/defn buzz.core/defpart clojure.core/defn}} diff --git a/src/buzz/app.clj b/src/buzz/app.clj index 8628555..5e62d15 100644 --- a/src/buzz/app.clj +++ b/src/buzz/app.clj @@ -1,6 +1,6 @@ (ns buzz.app (:require [babashka.nrepl.server :as nrepl] - [buzz.core :as buzz :refer [client defpart defui local-state observe reply + [buzz.core :as buzz :refer [client defui local-state observe reply server server!]] [clojure.string :as str] [org.httpkit.server :as http])) @@ -44,7 +44,7 @@ ;; browser gets an `rpc!` call carrying `id` — which is a binding the browser ;; itself introduced, in the `for`. -(defpart todo-row [{:keys [id title done]}] +(buzz/defn todo-row [{:keys [id title done]}] [:li {:key id} [:input {:type "checkbox" :checked done diff --git a/src/buzz/bench.clj b/src/buzz/bench.clj index bb6b30d..6338e14 100644 --- a/src/buzz/bench.clj +++ b/src/buzz/bench.clj @@ -3,7 +3,7 @@ over HTTP so the browser knows when it asked, and can time the whole loop: server work, wire, and render." (:require [babashka.nrepl.server :as nrepl] - [buzz.core :as buzz :refer [client defpart defui observe server server!]] + [buzz.core :as buzz :refer [client defui observe server server!]] [clojure.string :as str] [org.httpkit.server :as http])) @@ -37,7 +37,7 @@ (defn clear! [] (reset! rows [])) -(defpart row [{:keys [id label]}] +(buzz/defn row [{:keys [id label]}] [:tr {:key id} [:td.id id] [:td.label label] diff --git a/src/buzz/core.clj b/src/buzz/core.clj index 582e1c1..b0e7672 100644 --- a/src/buzz/core.clj +++ b/src/buzz/core.clj @@ -13,10 +13,10 @@ What remains is compiled to JavaScript by Squint. Later renders send only server values." + (:refer-clojure :exclude [defn]) (:require [buzz.impl.hub :as hub] [buzz.impl.page :as page] [buzz.impl.parts :as parts] - [clojure.string :as str] [clojure.walk :as walk] [squint.compiler :as squint])) @@ -75,9 +75,38 @@ [& _] (throw (ex-info "(request) used outside (server ...) or (server! ...)" {}))) +(def ^:private host-keys #{:clj :cljs :default}) + +(defn- host-branches + [args] + (when (odd? (count args)) + (throw (ex-info "(host ...) takes :clj, :cljs and :default branches in pairs" + {:args (vec args)}))) + (let [m (apply hash-map args)] + (when-let [k (some #(when-not (host-keys %) %) (keys m))] + (throw (ex-info (str "(host ...) takes :clj, :cljs and :default, not " (pr-str k)) + {:args (vec args)}))) + m)) + +(defn- host-branch + [m k] + (get m k (get m :default))) + +(defmacro host + "Picks the branch for the side the code runs on, like `#?` does for the + reader. In `defui` and `buzz/defn` the browser runs `:cljs` and the first + paint runs `:clj`. A missing branch is `:default`, or nil. Outside those + forms the code is JVM code and `:clj` is the result. + + (buzz/defn parse [s] + (host :clj (Double/parseDouble s) :cljs (js/parseFloat s)))" + [& args] + (host-branch (host-branches args) :clj)) + (def ^:private marks {#'server :server, #'server! :server!, #'reply :reply, - #'client :client, #'local-state :local-state, #'request :request}) + #'client :client, #'local-state :local-state, #'request :request, + #'host :host}) (defn- mark [head] @@ -90,32 +119,54 @@ (declare ^:private split-part-body) (declare ^:private handlers-form) +(defmacro defn + "Defines a function for the browser and the server. Squint compiles it for + the browser. The first paint and server code call the function compiled + here. Takes one arity, a fixed number of arguments and an optional + docstring. The function can recurse. Define server values and local state + in `defui` and pass them as arguments. + + (buzz/defn row [item] + [:li (:title item)])" + [nm & more] + (let [[doc more] (if (string? (first more)) + [(first more) (rest more)] + [nil more]) + [argv & body] more] + (when-not (vector? argv) + (throw (ex-info (str "buzz/defn " nm " takes one arity: (buzz/defn name [args] body)") + {:part nm :form &form}))) + (when (some #{'&} argv) + (throw (ex-info (str "buzz/defn " nm " takes a fixed number of arguments, so no &") + {:part nm :params argv}))) + (when-let [p (some #(when (:server (meta %)) %) argv)] + (throw (ex-info (str "^:server parameters are not supported in " nm ": " p + ". Pass the value or handler from defui.") + {:part nm :param p}))) + (let [qualified (symbol (str *ns*) (str nm)) + nm (cond-> nm doc (vary-meta assoc :doc doc)) + {:keys [js ssr-forms handlers parts req-sym]} + (binding [*self* {:name nm :qualified qualified :arity (count argv)}] + (split-part-body qualified argv body))] + `(do (let [was# (when-let [v# (resolve '~nm)] (when (bound? v#) @v#))] + (def ~nm (with-meta (fn ~argv ~@ssr-forms) + (parts/fn-part-meta + {:buzz/name '~qualified + :buzz/arity ~(count argv) + :buzz/js ~js + :buzz/parts '~(vec parts) + :buzz/handlers ~(handlers-form handlers req-sym)}))) + ;; Recompile callers only when the argument count changes. + (if (and (parts/fn-part? was#) + (not= ~(count argv) (:buzz/arity (meta was#)))) + (recompile!) + (touch!))) + (var ~nm))))) + (defmacro defpart - "Defines a Hiccup function that runs in the browser. Parts can recurse. - Define server values and local state in `defui` and pass them as arguments." - [nm argv & body] - (when-let [p (some #(when (:server (meta %)) %) argv)] - (throw (ex-info (str "^:server parameters are not supported in " nm ": " p - ". Pass the value or handler from defui.") - {:part nm :param p}))) - (let [qualified (symbol (str *ns*) (str nm)) - {:keys [js ssr-forms handlers parts req-sym]} - (binding [*self* {:name nm :qualified qualified :arity (count argv)}] - (split-part-body qualified argv body))] - `(do (let [was# (when-let [v# (resolve '~nm)] (when (bound? v#) @v#))] - (def ~nm (with-meta (fn ~argv ~@ssr-forms) - (parts/fn-part-meta - {:buzz/name '~qualified - :buzz/arity ~(count argv) - :buzz/js ~js - :buzz/parts '~(vec parts) - :buzz/handlers ~(handlers-form handlers req-sym)}))) - ;; Recompile callers only when the argument count changes. - (if (and (parts/fn-part? was#) - (not= ~(count argv) (:buzz/arity (meta was#)))) - (recompile!) - (touch!))) - (var ~nm)))) + "The former name of `buzz/defn`." + [& form] + `(defn ~@form)) (defn- part-var "The var a head symbol names, if it names one and is not shadowed." @@ -124,6 +175,16 @@ (when-let [v (try (resolve head) (catch Exception _ nil))] (when (and (var? v) (bound? v)) v)))) +(def ^:private host-sym 'buzz.core/host*) + +(defn- host-form? [x] + (and (seq? x) (= host-sym (first x)))) + +(defn- browser-forms + "Keeps the `:cljs` side of every host form." + [form] + (walk/postwalk (fn [x] (if (host-form? x) (nth x 2) x)) form)) + (def ^:private lambda-heads '#{fn fn*}) (def ^:private let-heads '#{let let* loop loop* when-let if-let when-some if-some}) (def ^:private seq-heads '#{for doseq}) @@ -156,6 +217,13 @@ expr)] [out @used])) +(defn- refuse-host + "Throws when server code contains a `(host ...)` form." + [expr where] + (when (some #(and (seq? %) (= :host (mark (first %)))) (tree-seq coll? seq expr)) + (throw (ex-info (str "(host ...) picks a browser side, so it has no place in " where) + {:expr expr})))) + (defn- slot! "`(server ...)` in value position. Hoists the expression to a parameter of the client function. Nothing crosses from the browser here: a slot is evaluated @@ -164,6 +232,7 @@ (when (some #(and (seq? %) (= :client (mark (first %)))) (tree-seq coll? seq expr)) (throw (ex-info "(client ...) only works inside a handler, not in value position" {:expr expr}))) + (refuse-host expr "(server ...)") (let [sym (gensym "slot__") [expr req?] (lift-request expr (:req-sym @acc))] (when req? (swap! acc assoc :slot-request? true)) @@ -213,6 +282,7 @@ (throw (ex-info "(reply ...) must be the last form of a (server! ...)" {:forms (vec forms)}))) (let [[server-expr pairs] (lift-client expr) + _ (refuse-host server-expr "(server! ...)") [server-expr req?] (lift-request server-expr (:req-sym @acc)) id (str comp-id "/" (count (:handlers @acc)))] (swap! acc update :handlers conj @@ -325,6 +395,14 @@ (= :request mk) (throw (ex-info "(request) is only valid inside (server ...) or (server! ...)" {:form form})) + + ;; Both sides stay in the form until the split: `browser-forms` keeps + ;; the `:cljs` branch for Squint and `ssr-form` keeps `:clj`. + (= :host mk) + (let [m (host-branches args)] + (list host-sym + (host-branch m :clj) + (conv (host-branch m :cljs) scope lambda? comp-id acc))) (= 'quote head) form (lambda-heads head) (conv-fn form scope comp-id acc) (let-heads head) (conv-let form scope lambda? comp-id acc) @@ -355,30 +433,76 @@ (set? form) (into #{} (mapv #(conv % scope lambda? comp-id acc) form)) :else form)) -(defn- ssr-form - "The same form, but renderable here. Reagami's ssr drops `:key`, `:on-render` - and every `on*` attribute by name whatever the value, so blanking a handler - changes no output — it only removes browser code that would otherwise have to - analyse on the JVM, which `(set! (.. e -target -value) \"\")` does not." +(defn- js-symbol + "The first `js/` symbol in `form` outside a quote, if any. A `host` form + left for the JVM to expand counts only its `:clj` branch." [form] (cond + (and (seq? form) (= 'quote (first form))) nil + (and (seq? form) (= :host (mark (first form)))) + (js-symbol (host-branch (host-branches (rest form)) :clj)) + (coll? form) (some js-symbol form) + (and (symbol? form) (= "js" (namespace form))) form + :else nil)) + +(defn- refuse-js + "Throws when code the JVM compiles for the first paint refers to `js/`." + [forms where] + (when-let [s (some js-symbol forms)] + (throw (ex-info (str s " in " where " runs on the first paint too. " + "Wrap browser-only code in (host :cljs ...)") + {:symbol s :forms (vec forms)})))) + +(clojure.core/defn browser-only + "Throws. The first paint called code that only runs in the browser. Public + because `defui` expands into a call to it." + [what] + (throw (ex-info (str what " runs in the browser only. Wrap it in (host :cljs ...)") + {:form what}))) + +(defn- js-sym? [x] + (and (symbol? x) (= "js" (namespace x)))) + +(defn- browser-only-form? + "`js/` symbols, `set!` on a `js/` symbol or an interop target, and `new` of a + `js/` class. None of these compile on the JVM." + [x] + (or (js-sym? x) + (and (seq? x) (= 'set! (first x)) + (or (seq? (second x)) (js-sym? (second x)))) + (and (seq? x) (= 'new (first x)) (js-sym? (second x))))) + +(defn- lambda-form? [x] + (and (seq? x) (contains? lambda-heads (first x)))) + +(defn- ssr-walk + "Inside a `fn`, browser-only forms become stubs that throw when called. + Outside one they stay, for `refuse-js` to report. A `host` form leaves its + `:clj` branch as written." + [form lambda?] + (cond + (and lambda? (browser-only-form? form)) + (list `browser-only (if (symbol? form) (str form) (str "(" (first form) " ...)"))) + (map? form) - (into {} (mapv (fn [[k v]] - [k (if (and (keyword? k) - (str/starts-with? (name k) "on")) - nil - (ssr-form v))]) - form)) - - (vector? form) (mapv ssr-form form) - (set? form) (into #{} (mapv ssr-form form)) + (into {} (mapv (fn [[k v]] [(ssr-walk k lambda?) (ssr-walk v lambda?)]) form)) + + (vector? form) (mapv #(ssr-walk % lambda?) form) + (set? form) (into #{} (mapv #(ssr-walk % lambda?) form)) (seq? form) (cond (= 'quote (first form)) form + (host-form? form) (second form) ;; Omit handlers passed as arguments from server rendering. (= 'rpc! (first form)) nil - :else (apply list (mapv ssr-form form))) + (lambda-form? form) (apply list (mapv #(ssr-walk % true) form)) + :else (apply list (mapv #(ssr-walk % lambda?) form))) :else form)) +(defn- ssr-form + "The same form, but renderable here." + [form] + (ssr-walk form false)) + (def revision "Revision counter incremented when a defui or defpart is evaluated." parts/revision) @@ -388,7 +512,7 @@ (def ^:dynamic ^:private *recompiling* false) -(defn register! +(clojure.core/defn register! "Records a component so that a part change can expand it again. Public because `defui` expands into a call to it, and a macro cannot reach a private var from the namespace it expands in." @@ -396,7 +520,7 @@ (swap! components assoc nm spec) (when-not *recompiling* (swap! revision inc))) -(defn recompile! +(clojure.core/defn recompile! "Expands every defui again. Called when a part's arity changes." [] (binding [*recompiling* true] @@ -443,14 +567,15 @@ (throw (ex-info (str "(local-state ...) in " nm " must be created in defui and passed as an argument") {:part qualified}))) - {:js (to-js (apply list 'fn argv forms)) + {:js (to-js (browser-forms (apply list 'fn argv forms))) ;; Restore part vars for server rendering. - :ssr-forms (mapv ssr-form (walk/postwalk-replace part-syms forms)) + :ssr-forms (doto (mapv ssr-form (walk/postwalk-replace part-syms forms)) + (refuse-js nm)) :handlers handlers :req-sym (:req-sym @acc) :parts parts})) -(defn touch! +(clojure.core/defn touch! "Increments the revision without recompiling components." [] (swap! revision inc)) @@ -459,12 +584,12 @@ "Returns metadata for every part reachable from `syms`." parts/parts-closure) -(defn part-handlers +(clojure.core/defn part-handlers "Returns the merged handlers for every part reachable from `syms`." [syms] (into {} (mapcat (comp :buzz/handlers val)) (parts-closure syms))) -(defn split-body +(clojure.core/defn split-body "Returns the pieces a component is made of. Server slots come first in the browser function's parameters, then the browser's own." [body comp-id] @@ -472,13 +597,18 @@ :req-sym (gensym "req__") :slot-request? false}) forms (mapv #(conv % #{} false comp-id acc) body) {:keys [slots handlers locals parts part-syms req-sym slot-request?]} @acc - params (into (mapv :sym slots) (mapv :sym locals))] - {:js (to-js (apply list 'fn params forms)) + params (into (mapv :sym slots) (mapv :sym locals)) + inits (mapv :init locals)] + {:js (to-js (browser-forms (apply list 'fn params forms))) ;; the initial values take the slots, so a local can start from what the ;; server sent rather than only from a literal - :init-js (to-js (list 'fn (mapv :sym slots) (mapv :init locals))) + :init-js (to-js (browser-forms (list 'fn (mapv :sym slots) inits))) + :init-syms (mapv :sym slots) + :init-ssr (doto (mapv ssr-form (walk/postwalk-replace part-syms inits)) + (refuse-js "(local-state ...)")) :locals (count locals) - :ssr-forms (mapv ssr-form (walk/postwalk-replace part-syms forms)) + :ssr-forms (doto (mapv ssr-form (walk/postwalk-replace part-syms forms)) + (refuse-js comp-id)) :slot-exprs (mapv :expr slots) :handlers handlers :req-sym req-sym @@ -492,17 +622,20 @@ {:id stable name, used as the key on the wire :js the browser function as JavaScript, compiled once :ssr the same function, compiled here, for the first paint + :init the initial local values as JavaScript, a function of the slots + :init-ssr the same function, compiled here, for the first paint :slots thunk returning the current values for that function :handlers id -> fn, called when the browser sends an :rpc}" [nm argv & body] (let [comp-id (str nm) - {:keys [js init-js locals ssr-forms slot-exprs slot-syms handlers parts - req-sym request?]} (split-body body comp-id)] + {:keys [js init-js init-syms init-ssr locals ssr-forms slot-exprs slot-syms + handlers parts req-sym request?]} (split-body body comp-id)] `(do - (defn ~nm ~argv + (clojure.core/defn ~nm ~argv {:id ~comp-id :js ~js :init ~init-js + :init-ssr (fn ~init-syms ~init-ssr) :locals ~locals :parts '~(vec parts) ;; :slots takes a request only when needed. diff --git a/src/buzz/impl/page.clj b/src/buzz/impl/page.clj index 81f7bfd..317054b 100644 --- a/src/buzz/impl/page.clj +++ b/src/buzz/impl/page.clj @@ -315,9 +315,9 @@ (defn- first-paint [spec req] (let [mount (build spec req) inst (:instance mount) - ;; Initialize browser-local atoms with nil during server rendering. - locals (repeatedly (:locals inst 0) #(atom nil))] - (ssr/render (into [(:ssr inst)] (concat (slot-vals mount) locals))))) + vals (slot-vals mount) + locals (mapv atom (apply (:init-ssr inst) vals))] + (ssr/render (into [(:ssr inst)] (concat vals locals))))) (def ^:private squint-core "https://esm.sh/squint-cljs@0.14.208/core.js") diff --git a/test/buzz/core_test.clj b/test/buzz/core_test.clj index c21db84..52b93fe 100644 --- a/test/buzz/core_test.clj +++ b/test/buzz/core_test.clj @@ -1,5 +1,5 @@ (ns buzz.core-test - (:require [buzz.core :as b :refer [client defpart defui local-state reply request server server!]] + (:require [buzz.core :as b :refer [client defpart defui host local-state reply request server server!]] [clojure.string :as str] [clojure.test :refer [deftest is testing]])) @@ -364,3 +364,179 @@ (is (= [] ((:slots inst)))) (is (empty? (:handlers inst))) (is (str/includes? (:js inst) "(1)")))) + +;; One body, two compilers. A host form gives each side its own branch. +(b/defn parse-number [s] + (host :clj (Double/parseDouble s) :cljs (js/parseFloat s))) + +(b/defn beep [s] + (host :cljs (js/alert s))) + +(defui measure [] + (let [n (local-state "1.5")] + [:p (parse-number @n) + [:button {:on-click (fn [_] (beep (host :cljs (js/String @n))))} "beep"]])) + +(deftest a-host-form-picks-a-side + (testing "the browser runs the :cljs branch" + (is (str/includes? (:buzz/js (meta parse-number)) "parseFloat(s)")) + (is (not (str/includes? (:buzz/js (meta parse-number)) "parseDouble")))) + + (testing "the server runs the :clj branch" + (is (= 1.5 (parse-number "1.5")))) + + (testing "a missing branch is nil" + (is (nil? (beep "x"))) + (is (str/includes? (:buzz/js (meta beep)) "alert(s)"))) + + (testing ":default stands in for a missing side" + (is (= 3 (host :cljs 2 :default 3)))) + + (testing "outside a component the :clj branch is the value" + (is (= 1 (host :clj 1 :cljs 2)))) + + (testing "a handler carries js/ interop through a host form" + (let [inst (measure)] + (is (str/includes? (:js inst) "String(")) + (is (str/includes? (pr-str ((:ssr inst) (atom "2"))) "2.0")))) + + (testing "server code refuses it" + (is (re-find #"no place in \(server \.\.\.\)" + (refusal '(buzz.core/defui h1 [] + [:p (buzz.core/server (buzz.core/host :clj 1))])))) + (is (re-find #"no place in \(server! \.\.\.\)" + (refusal '(buzz.core/defui h2 [] + [:button {:on-click (fn [_] (buzz.core/server! (buzz.core/host :clj 1)))}]))))) + + (testing "an unknown key is refused" + (is (re-find #"not :node" + (refusal '(buzz.core/defn h3 [] (buzz.core/host :node 1))))))) + +(b/defn described "A row." [item] [:li item]) + +(deftest buzz-defn-defines-a-function-for-both-sides + (testing "the docstring lands on the var" + (is (= "A row." (:doc (meta #'described))))) + + (testing "defpart defines the same thing" + (is (= (keys (meta fruit-row)) (keys (meta described))))) + + (testing "one arity only" + (is (re-find #"takes one arity" + (refusal '(buzz.core/defn two ([] 1) ([x] x)))))) + + (testing "a fixed number of arguments" + (is (re-find #"so no &" + (refusal '(buzz.core/defn many [& xs] xs)))))) + +(defui draft [] + (let [text (local-state "start")] + [:p @text])) + +(deftest a-local-starts-from-its-init-on-the-first-paint + (testing "a literal init" + (let [inst (draft)] + (is (= ["start"] ((:init-ssr inst)))) + (is (= [:p "start"] (apply (:ssr inst) (map atom ((:init-ssr inst)))))))) + + (testing "an init read from a server value" + (let [inst (seeded)] + (is (= [5] (apply (:init-ssr inst) ((:slots inst)))))))) + +(defui clock [] + (let [now (local-state (host :cljs (js/Date.)))] + [:p (str @now)])) + +(deftest browser-only-code-outside-a-handler-is-refused-by-name + (testing "a local-state initial value" + (is (re-find #"js/Date\. in \(local-state \.\.\.\) runs on the first paint too" + (refusal '(buzz.core/defui c1 [] + (let [t (buzz.core/local-state (js/Date.))] [:p @t])))))) + + (testing "a component body" + (is (re-find #"js/alert in c2 runs on the first paint" + (refusal '(buzz.core/defui c2 [] [:p (js/alert "x")]))))) + + (testing "a function body" + (is (re-find #"js/alert in c3 runs on the first paint" + (refusal '(buzz.core/defn c3 [] (js/alert "x")))))) + + (testing "a handler is not first-paint code" + (is (var? (eval '(buzz.core/defui c4 [] + [:button {:on-click (fn [_] (js/alert "x"))}]))))) + + (testing "a host form makes the initial value nil on the first paint" + (let [inst (clock)] + (is (= [nil] ((:init-ssr inst)))) + (is (str/includes? (:init inst) "new Date()"))))) + +(defui presence [] + (let [flags (local-state {:online true :on-call false}) + style {:one 1 :on-top 2}] + [:p {:on-click (fn [_] (js/alert "x")) :class "p"} + (str (:online @flags)) (:on-top style)])) + +(deftest maps-keep-their-on-keys-on-the-first-paint + (let [inst (presence)] + (testing "a local-state map keeps every key" + (is (= [{:online true :on-call false}] ((:init-ssr inst))))) + + (testing "a data map in the body keeps its on keys" + (let [[tag attrs & body] ((:ssr inst) (atom {:online true :on-call false}))] + (is (= :p tag)) + (is (fn? (:on-click attrs))) + (is (= "p" (:class attrs))) + (is (= ["true" 2] body)))))) + +(defui tagged [] + (let [status (local-state [:status {:online true}])] + [:p (str @status)])) + +(defui extracted [] + (let [attrs {:on-click (fn [_] (js/alert "x") (new js/Date)) + :on-input (fn [e] + (set! (.. e -target -value) "") + (set! js/window.location "/"))}] + [:button attrs "click"])) + +(defui ready [] + (let [hooks (local-state [:status {:on-ready (fn [] true)}])] + [:p (str @hooks)])) + +(defui parsed [] + [:ul (mapv (fn [x] [:li (js/parseFloat x)]) ["1"])]) + +(deftest browser-code-inside-a-fn-compiles-and-throws-when-called + (testing "a tagged data vector keeps its values" + (is (= [[:status {:online true}]] ((:init-ssr (tagged)))))) + + (testing "a fn under an on key in data survives and runs" + (let [[[_ m]] ((:init-ssr (ready)))] + (is (true? ((:on-ready m)))))) + + (testing "an attribute map bound by name keeps its handlers, which throw" + (let [[_ attrs] ((:ssr (extracted)))] + (is (fn? (:on-click attrs))) + (is (thrown-with-msg? Exception #"js/alert runs in the browser only" + ((:on-click attrs) nil))))) + + (testing "a rendering fn with js/ throws on the first paint" + (is (thrown-with-msg? Exception #"js/parseFloat runs in the browser only" + ((:ssr (parsed))))))) + +(b/defn nested-host [] + (host :clj (host :clj 1 :cljs js/NaN) + :cljs 2)) + +(deftest a-host-inside-a-clj-branch-is-expanded-by-the-jvm + (testing "the inner :cljs branch is never JVM code" + (is (= 1 (nested-host)))) + + (testing "the browser gets the outer :cljs branch" + (is (str/includes? (:buzz/js (meta nested-host)) "return 2"))) + + (testing "js/ in the inner :clj branch is still refused" + (is (re-find #"js/NaN in n2 runs on the first paint" + (refusal '(buzz.core/defn n2 [] + (buzz.core/host :clj (buzz.core/host :clj js/NaN :cljs 1) + :cljs 2))))))) diff --git a/test/buzz/handler_test.clj b/test/buzz/handler_test.clj index 38b9c81..f13de9f 100644 --- a/test/buzz/handler_test.clj +++ b/test/buzz/handler_test.clj @@ -1439,3 +1439,12 @@ (testing "the close failure is logged" (is (str/includes? out "on-close blew up"))) (finally (stop))))) + +(defui notepad [] + (let [text (local-state "first words")] + [:p @text])) + +(deftest the-first-paint-shows-a-locals-initial-value + (let [ui (handler/handler {:title "notepad" :mounts [{:el "app" :ui #'notepad}]}) + body (:body (ui {:uri "/"}))] + (is (str/includes? body "
first words
"))))