From 39d74fd8351208ee8518756a8a48c569a962d177 Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Tue, 8 Sep 2026 23:27:14 +0200 Subject: [PATCH 1/7] Add buzz/defn, host, and first-paint local values --- .clj-kondo/config.edn | 1 + README.md | 9 +- doc/parts.md | 57 ++++-- examples/datalevin/src/buzz/dlv.clj | 4 +- examples/tap-viewer/src/buzz/tap_viewer.clj | 6 +- .../io.github.borkdude/buzz/config.edn | 3 +- src/buzz/app.clj | 4 +- src/buzz/bench.clj | 4 +- src/buzz/core.clj | 162 ++++++++++++++---- src/buzz/impl/page.clj | 6 +- test/buzz/core_test.clj | 80 ++++++++- test/buzz/handler_test.clj | 9 + 12 files changed, 277 insertions(+), 68 deletions(-) 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..44b9b59 100644 --- a/README.md +++ b/README.md @@ -96,16 +96,17 @@ response map as the second argument to set a cookie or other response headers: ## Parts -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). +in `defui`, then pass their values as arguments. Use `host` where the browser +and the server need different code. See [doc/parts.md](doc/parts.md). ## Mounting @@ -263,5 +264,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/parts.md b/doc/parts.md index f55f79e..d5c88ff 100644 --- a/doc/parts.md +++ b/doc/parts.md @@ -1,10 +1,12 @@ # Parts -`defpart` defines a Hiccup function that runs in the browser. Parts can call -themselves: +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. A part can return Hiccup or any other value, and can call +itself: ```clojure -(defpart node [r] +(buzz/defn node [r] [:li (:label r) [:button {:on-click (fn [_] (server! (bump! (client (:id r)))))} "!"] (when (seq (:children r)) @@ -14,15 +16,18 @@ themselves: [:ul (node (server @tree))]) ``` -The server calls the same function for the first render. +`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 `defpart` cannot contain `(server ...)` or `(local-state ...)`. Use these -forms in `defui` and pass their results to the part: +A part cannot contain `(server ...)` or `(local-state ...)`. Use these forms +in `defui` and pass their results to the part: ```clojure -(defpart row [item selected] +(buzz/defn row [item selected] [:li {:class (when (= item @selected) "selected") :on-click (fn [_] (reset! selected item))} item]) @@ -47,7 +52,7 @@ Use `(buzz/request)` in a part handler to access connection-scoped state: ```clojure (defonce carts (atom {})) -(defpart clear-button [] +(buzz/defn clear-button [] [:button {:on-click (fn [_] (server! (swap! carts assoc @@ -56,19 +61,47 @@ Use `(buzz/request)` in a part handler to access connection-scoped state: "clear"]) ``` +## Browser and server branches + +A part body runs on both sides, so it must compile on the JVM and as Squint. +Use `host` where the two sides need different code. The browser runs the +`:cljs` branch and the first paint runs the `:clj` branch. A missing branch is +`: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 those forms it returns +the `:clj` branch. + ## Editing a part in the REPL -Re-evaluate a `defpart` in the REPL to hot-reload open pages. +Re-evaluate a `buzz/defn` in the REPL to hot-reload open pages. ## Limitations -- A `defpart` can call only parts that are already defined. Mutual recursion +- A `buzz/defn` takes one arity and a fixed number of arguments. +- A `buzz/defn` 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: + Keep `js/` and `await` code in the part itself, or in a `host` form: ```clojure - (defpart submit-button [on-submit] + (buzz/defn submit-button [on-submit] [:button {:on-click (fn [e] (js/console.log "saving") (on-submit e))} 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..5d2a13c 100644 --- a/src/buzz/core.clj +++ b/src/buzz/core.clj @@ -13,6 +13,7 @@ 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] @@ -75,9 +76,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 +120,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 +176,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 +218,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 +233,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 +283,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 +396,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) @@ -374,6 +453,7 @@ (set? form) (into #{} (mapv ssr-form form)) (seq? form) (cond (= 'quote (first form)) form + (host-form? form) (ssr-form (second form)) ;; Omit handlers passed as arguments from server rendering. (= 'rpc! (first form)) nil :else (apply list (mapv ssr-form form))) @@ -388,7 +468,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 +476,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 +523,14 @@ (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)) :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 +539,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,11 +552,14 @@ :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 (mapv ssr-form (walk/postwalk-replace part-syms inits)) :locals (count locals) :ssr-forms (mapv ssr-form (walk/postwalk-replace part-syms forms)) :slot-exprs (mapv :expr slots) @@ -492,17 +575,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..6d563eb 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,81 @@ (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)))))))) 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

")))) From 3cbbb6a2ecf1be5d774cd7df74f0c0638d1147c2 Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Tue, 8 Sep 2026 23:35:59 +0200 Subject: [PATCH 2/7] Say function instead of part in the docs --- README.md | 11 +++++----- doc/{parts.md => defn.md} | 43 ++++++++++++++++++++++----------------- 2 files changed, 30 insertions(+), 24 deletions(-) rename doc/{parts.md => defn.md} (63%) diff --git a/README.md b/README.md index 44b9b59..03ebe7c 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ 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 `buzz/defn` to define a function for the browser and the server: @@ -103,10 +103,11 @@ Use `buzz/defn` to define a function for the browser and the server: [: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. Use `host` where the browser -and the server need different code. 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 diff --git a/doc/parts.md b/doc/defn.md similarity index 63% rename from doc/parts.md rename to doc/defn.md index d5c88ff..5fef053 100644 --- a/doc/parts.md +++ b/doc/defn.md @@ -1,9 +1,9 @@ -# Parts +# 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. A part can return Hiccup or any other value, and can call -itself: +compiled on the JVM. The function can return Hiccup or any other value, and +can call itself: ```clojure (buzz/defn node [r] @@ -23,8 +23,8 @@ in the browser with a ReferenceError. ## Arguments -A part cannot contain `(server ...)` or `(local-state ...)`. Use these forms -in `defui` and pass their results to the part: +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] @@ -39,15 +39,15 @@ in `defui` and pass their results to the part: (row item selected))])) ``` -`selected` is browser state. The part receives the atom as an argument and can -read or update it. +`selected` is browser state. The function 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. +A `buzz/defn` 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: +Use `(buzz/request)` in a handler to access connection-scoped state: ```clojure (defonce carts (atom {})) @@ -63,10 +63,10 @@ Use `(buzz/request)` in a part handler to access connection-scoped state: ## Browser and server branches -A part body runs on both sides, so it must compile on the JVM and as Squint. -Use `host` where the two sides need different code. The browser runs the -`:cljs` branch and the first paint runs the `:clj` branch. A missing branch is -`:default`, or nil: +A `buzz/defn` body runs on both sides, so it must compile on the JVM and as +Squint. Use `host` where the two sides need different code. The browser runs +the `:cljs` branch and the first paint runs the `:clj` branch. A missing branch +is `:default`, or nil: ```clojure (buzz/defn parse-number [s] @@ -88,17 +88,22 @@ A `:cljs` branch on its own makes a browser-only function: It is refused inside `server` and `server!`. Outside those forms it returns the `:clj` branch. -## Editing a part in the REPL +`host` does not move work to the server. Both branches compute the same value +for the same render. Read server state with `server` and run server actions +with `server!`. + +## 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 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, or in a `host` form: +- A `buzz/defn` can call only functions that are already defined. Mutual + recursion requires re-evaluating the first definition after both exist. +- Functions passed as arguments must also compile on the JVM for server + rendering. Keep `js/` and `await` code in the `buzz/defn` itself, or in a + `host` form: ```clojure (buzz/defn submit-button [on-submit] From d4fea142ec1c7dc86d05d1fb88cb7a6521a9b14e Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Tue, 8 Sep 2026 23:42:32 +0200 Subject: [PATCH 3/7] Refuse js/ outside handlers and host forms by name --- README.md | 4 +++- doc/defn.md | 19 ++++++++++--------- src/buzz/core.clj | 26 +++++++++++++++++++++++--- test/buzz/core_test.clj | 27 +++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 03ebe7c..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: diff --git a/doc/defn.md b/doc/defn.md index 5fef053..041f395 100644 --- a/doc/defn.md +++ b/doc/defn.md @@ -63,10 +63,9 @@ Use `(buzz/request)` in a handler to access connection-scoped state: ## Browser and server branches -A `buzz/defn` body runs on both sides, so it must compile on the JVM and as -Squint. Use `host` where the two sides need different code. The browser runs -the `:cljs` branch and the first paint runs the `:clj` branch. A missing branch -is `:default`, or nil: +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] @@ -85,12 +84,14 @@ A `:cljs` branch on its own makes a browser-only function: ``` `host` is valid in `defui` and `buzz/defn` bodies, including event handlers. -It is refused inside `server` and `server!`. Outside those forms it returns -the `:clj` branch. +It is refused inside `server` and `server!`. Outside `defui` and `buzz/defn`, +it uses the `:clj` branch, with the same fallback. -`host` does not move work to the server. Both branches compute the same value -for the same render. Read server state with `server` and run server actions -with `server!`. +A `local-state` initial value in `defui` is computed for the first paint as +well, so the same rule holds there. + +Read server state with `server` and run server actions with `server!`. +Use `host` to select code for each runtime. ## Editing in the REPL diff --git a/src/buzz/core.clj b/src/buzz/core.clj index 5d2a13c..0b0b86d 100644 --- a/src/buzz/core.clj +++ b/src/buzz/core.clj @@ -434,6 +434,23 @@ (set? form) (into #{} (mapv #(conv % scope lambda? comp-id acc) form)) :else form)) +(defn- js-symbol + "The first `js/` symbol in `form` outside a quote, if any." + [form] + (cond + (and (seq? form) (= 'quote (first form))) nil + (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)})))) + (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 @@ -525,7 +542,8 @@ {:part qualified}))) {: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})) @@ -559,9 +577,11 @@ ;; server sent rather than only from a literal :init-js (to-js (browser-forms (list 'fn (mapv :sym slots) inits))) :init-syms (mapv :sym slots) - :init-ssr (mapv ssr-form (walk/postwalk-replace part-syms inits)) + :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 diff --git a/test/buzz/core_test.clj b/test/buzz/core_test.clj index 6d563eb..d1cee71 100644 --- a/test/buzz/core_test.clj +++ b/test/buzz/core_test.clj @@ -442,3 +442,30 @@ (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()"))))) From ed222928846b07b1f1d9a795ccc13f9b01d7d2ab Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Tue, 8 Sep 2026 23:53:50 +0200 Subject: [PATCH 4/7] Blank handlers in Hiccup attribute maps only --- doc/defn.md | 8 ++++---- src/buzz/core.clj | 30 ++++++++++++++++++++---------- test/buzz/core_test.clj | 15 +++++++++++++++ 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/doc/defn.md b/doc/defn.md index 041f395..b680706 100644 --- a/doc/defn.md +++ b/doc/defn.md @@ -87,8 +87,8 @@ A `:cljs` branch on its own makes a browser-only function: It is refused inside `server` and `server!`. Outside `defui` and `buzz/defn`, it uses the `:clj` branch, with the same fallback. -A `local-state` initial value in `defui` is computed for the first paint as -well, so the same rule holds there. +Use `host` for browser-only code in a `local-state` initial value. Initial +values are also computed for the first paint. Read server state with `server` and run server actions with `server!`. Use `host` to select code for each runtime. @@ -103,8 +103,8 @@ Re-evaluate a `buzz/defn` in the REPL to hot-reload open pages. - A `buzz/defn` can call only functions that are already defined. Mutual recursion requires re-evaluating the first definition after both exist. - Functions passed as arguments must also compile on the JVM for server - rendering. Keep `js/` and `await` code in the `buzz/defn` itself, or in a - `host` form: + rendering. Keep `js/` and `await` code in Hiccup event handlers inside + `buzz/defn`, or in a `host :cljs` branch: ```clojure (buzz/defn submit-button [on-submit] diff --git a/src/buzz/core.clj b/src/buzz/core.clj index 0b0b86d..51a3af6 100644 --- a/src/buzz/core.clj +++ b/src/buzz/core.clj @@ -451,20 +451,30 @@ "Wrap browser-only code in (host :cljs ...)") {:symbol s :forms (vec forms)})))) +(declare ^:private ssr-form) + +(defn- ssr-attrs + "A Hiccup attribute map without its handlers. Reagami's ssr drops every + `on*` attribute by name whatever the value, so blanking a handler changes no + output. It only removes browser code the JVM would otherwise compile." + [attrs] + (into {} (mapv (fn [[k v]] + [k (if (and (keyword? k) + (str/starts-with? (name k) "on")) + nil + (ssr-form v))]) + attrs))) + (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." + "The same form, but renderable here. Handlers are blanked in attribute + position only, so a map anywhere else keeps its `on*` keys." [form] (cond (map? form) - (into {} (mapv (fn [[k v]] - [k (if (and (keyword? k) - (str/starts-with? (name k) "on")) - nil - (ssr-form v))]) - form)) + (into {} (mapv (fn [[k v]] [(ssr-form k) (ssr-form v)]) form)) + + (and (vector? form) (keyword? (first form)) (map? (second form))) + (into [(first form) (ssr-attrs (second form))] (mapv ssr-form (nnext form))) (vector? form) (mapv ssr-form form) (set? form) (into #{} (mapv ssr-form form)) diff --git a/test/buzz/core_test.clj b/test/buzz/core_test.clj index d1cee71..3957926 100644 --- a/test/buzz/core_test.clj +++ b/test/buzz/core_test.clj @@ -469,3 +469,18 @@ (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 handlers-are-blanked-in-attribute-position-only + (let [inst (presence)] + (testing "a local-state map keeps every key on the first paint" + (is (= [{:online true :on-call false}] ((:init-ssr inst))))) + + (testing "a data map in the body keeps its on keys" + (is (= [:p {:on-click nil :class "p"} "true" 2] + ((:ssr inst) (atom {:online true :on-call false}))))))) From 38d9780cfc3fda6621b6db6d3e4f5fa0bb34ceea Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Tue, 8 Sep 2026 23:59:50 +0200 Subject: [PATCH 5/7] Stub browser-only forms inside fns for the first paint --- doc/defn.md | 18 +++---------- src/buzz/core.clj | 59 ++++++++++++++++++++++++++++++----------- test/buzz/core_test.clj | 26 ++++++++++++++++++ 3 files changed, 73 insertions(+), 30 deletions(-) diff --git a/doc/defn.md b/doc/defn.md index b680706..6477b82 100644 --- a/doc/defn.md +++ b/doc/defn.md @@ -90,6 +90,10 @@ 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 compiles on the JVM and throws if the first paint +calls it. An event handler needs no `host`. Outside a `fn`, `js/` is refused +when the definition loads. + Read server state with `server` and run server actions with `server!`. Use `host` to select code for each runtime. @@ -102,17 +106,3 @@ Re-evaluate a `buzz/defn` in the REPL to hot-reload open pages. - 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. -- Functions passed as arguments must also compile on the JVM for server - rendering. Keep `js/` and `await` code in Hiccup event handlers inside - `buzz/defn`, or in a `host :cljs` branch: - - ```clojure - (buzz/defn 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/src/buzz/core.clj b/src/buzz/core.clj index 51a3af6..cea01c3 100644 --- a/src/buzz/core.clj +++ b/src/buzz/core.clj @@ -451,41 +451,68 @@ "Wrap browser-only code in (host :cljs ...)") {:symbol s :forms (vec forms)})))) -(declare ^:private ssr-form) +(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- browser-only-form? + "`js/` symbols and `set!` on interop compile in the browser only." + [x] + (or (and (symbol? x) (= "js" (namespace x))) + (and (seq? x) (= 'set! (first x)) (seq? (second x))))) + +(defn- lambda-form? [x] + (and (seq? x) (contains? lambda-heads (first x)))) + +(declare ^:private ssr-walk) (defn- ssr-attrs "A Hiccup attribute map without its handlers. Reagami's ssr drops every - `on*` attribute by name whatever the value, so blanking a handler changes no - output. It only removes browser code the JVM would otherwise compile." - [attrs] + `on*` attribute by name, so blanking a handler changes no output. It only + removes browser code the JVM would otherwise compile." + [attrs lambda?] (into {} (mapv (fn [[k v]] [k (if (and (keyword? k) - (str/starts-with? (name k) "on")) + (str/starts-with? (name k) "on") + (lambda-form? v)) nil - (ssr-form v))]) + (ssr-walk v lambda?))]) attrs))) -(defn- ssr-form - "The same form, but renderable here. Handlers are blanked in attribute - position only, so a map anywhere else keeps its `on*` keys." - [form] +(defn- ssr-walk + "Inside a `fn`, browser-only forms become stubs that throw when called. + Outside one they stay, for `refuse-js` to report." + [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]] [(ssr-form k) (ssr-form v)]) form)) + (into {} (mapv (fn [[k v]] [(ssr-walk k lambda?) (ssr-walk v lambda?)]) form)) (and (vector? form) (keyword? (first form)) (map? (second form))) - (into [(first form) (ssr-attrs (second form))] (mapv ssr-form (nnext form))) + (into [(first form) (ssr-attrs (second form) lambda?)] + (mapv #(ssr-walk % lambda?) (nnext form))) - (vector? form) (mapv ssr-form form) - (set? form) (into #{} (mapv ssr-form 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) (ssr-form (second form)) + (host-form? form) (ssr-walk (second form) lambda?) ;; 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) diff --git a/test/buzz/core_test.clj b/test/buzz/core_test.clj index 3957926..03a8b06 100644 --- a/test/buzz/core_test.clj +++ b/test/buzz/core_test.clj @@ -484,3 +484,29 @@ (testing "a data map in the body keeps its on keys" (is (= [:p {:on-click nil :class "p"} "true" 2] ((:ssr inst) (atom {:online true :on-call false}))))))) + +(defui tagged [] + (let [status (local-state [:status {:online true}])] + [:p (str @status)])) + +(defui extracted [] + (let [attrs {:on-click (fn [_] (js/alert "x")) + :on-input (fn [e] (set! (.. e -target -value) ""))}] + [:button attrs "click"])) + +(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 "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))))))) From 24028a08ea93df8b234acc339aa7ce047b2a3d41 Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Wed, 9 Sep 2026 00:06:27 +0200 Subject: [PATCH 6/7] Drop the attribute heuristic, stub browser-only forms inside fns only --- doc/defn.md | 6 +++--- src/buzz/core.clj | 37 ++++++++++++------------------------- test/buzz/core_test.clj | 25 +++++++++++++++++++------ 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/doc/defn.md b/doc/defn.md index 6477b82..a2f4d75 100644 --- a/doc/defn.md +++ b/doc/defn.md @@ -90,9 +90,9 @@ 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 compiles on the JVM and throws if the first paint -calls it. An event handler needs no `host`. Outside a `fn`, `js/` is refused -when the definition loads. +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`. Outside a +`fn`, wrap browser-only code in `host :cljs` to load the definition. Read server state with `server` and run server actions with `server!`. Use `host` to select code for each runtime. diff --git a/src/buzz/core.clj b/src/buzz/core.clj index cea01c3..fc09c37 100644 --- a/src/buzz/core.clj +++ b/src/buzz/core.clj @@ -17,7 +17,6 @@ (: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])) @@ -458,33 +457,25 @@ (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 and `set!` on interop compile in the browser only." + "`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 (and (symbol? x) (= "js" (namespace x))) - (and (seq? x) (= 'set! (first x)) (seq? (second 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)))) -(declare ^:private ssr-walk) - -(defn- ssr-attrs - "A Hiccup attribute map without its handlers. Reagami's ssr drops every - `on*` attribute by name, so blanking a handler changes no output. It only - removes browser code the JVM would otherwise compile." - [attrs lambda?] - (into {} (mapv (fn [[k v]] - [k (if (and (keyword? k) - (str/starts-with? (name k) "on") - (lambda-form? v)) - nil - (ssr-walk v lambda?))]) - attrs))) - (defn- ssr-walk "Inside a `fn`, browser-only forms become stubs that throw when called. - Outside one they stay, for `refuse-js` to report." + 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)) @@ -493,15 +484,11 @@ (map? form) (into {} (mapv (fn [[k v]] [(ssr-walk k lambda?) (ssr-walk v lambda?)]) form)) - (and (vector? form) (keyword? (first form)) (map? (second form))) - (into [(first form) (ssr-attrs (second form) lambda?)] - (mapv #(ssr-walk % lambda?) (nnext 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) (ssr-walk (second form) lambda?) + (host-form? form) (second form) ;; Omit handlers passed as arguments from server rendering. (= 'rpc! (first form)) nil (lambda-form? form) (apply list (mapv #(ssr-walk % true) form)) diff --git a/test/buzz/core_test.clj b/test/buzz/core_test.clj index 03a8b06..9411430 100644 --- a/test/buzz/core_test.clj +++ b/test/buzz/core_test.clj @@ -476,24 +476,33 @@ [:p {:on-click (fn [_] (js/alert "x")) :class "p"} (str (:online @flags)) (:on-top style)])) -(deftest handlers-are-blanked-in-attribute-position-only +(deftest maps-keep-their-on-keys-on-the-first-paint (let [inst (presence)] - (testing "a local-state map keeps every key on the first paint" + (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" - (is (= [:p {:on-click nil :class "p"} "true" 2] - ((:ssr inst) (atom {:online true :on-call false}))))))) + (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")) - :on-input (fn [e] (set! (.. e -target -value) ""))}] + (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"])]) @@ -501,6 +510,10 @@ (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))) From 5539ca244ebb9653cffa38282b5a9a2e37167b6f Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Wed, 9 Sep 2026 00:09:13 +0200 Subject: [PATCH 7/7] Scan only the :clj branch of a nested host for js/ --- doc/defn.md | 8 ++++++-- src/buzz/core.clj | 5 ++++- test/buzz/core_test.clj | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/doc/defn.md b/doc/defn.md index a2f4d75..c25dc27 100644 --- a/doc/defn.md +++ b/doc/defn.md @@ -91,8 +91,12 @@ 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`. Outside a -`fn`, wrap browser-only code in `host :cljs` to load the definition. +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. diff --git a/src/buzz/core.clj b/src/buzz/core.clj index fc09c37..b0e7672 100644 --- a/src/buzz/core.clj +++ b/src/buzz/core.clj @@ -434,10 +434,13 @@ :else form)) (defn- js-symbol - "The first `js/` symbol in `form` outside a quote, if any." + "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)) diff --git a/test/buzz/core_test.clj b/test/buzz/core_test.clj index 9411430..52b93fe 100644 --- a/test/buzz/core_test.clj +++ b/test/buzz/core_test.clj @@ -523,3 +523,20 @@ (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)))))))