From e52c2253c4835bd0ebdd6a35ce4e054df5f1fa4e Mon Sep 17 00:00:00 2001 From: Sleepful Date: Sun, 28 Dec 2025 01:07:54 -0600 Subject: [PATCH 1/5] red test --- test/vitest/basic_component.cljc | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/vitest/basic_component.cljc b/test/vitest/basic_component.cljc index 70bbe7a..963ffc7 100644 --- a/test/vitest/basic_component.cljc +++ b/test/vitest/basic_component.cljc @@ -3,7 +3,7 @@ (:require [applied-science.js-interop :as j] [solid.core :as s :refer [$ defui]] - ["vitest/browser" :refer [page userEvent]] + ["vitest/browser" :refer [page]] ["@solidjs/testing-library" :as st :refer [render]] ["vitest" :refer [expect test]])) @@ -34,3 +34,14 @@ (.then (fn [] (.click incrementButton))) (.then (fn [] (.. expect (element (.. screen (getByText "Count: 1"))) toBeInTheDocument))))))) +(defui label-with-attrs [attrs] + ($ :label attrs "the label")) + +(test + "Component receives map bound to variable", + (fn [] + (j/let [^:js {:keys [baseElement]} (render #($ label-with-attrs {:data-testid "label-with-attrs"})) + screen (.. page (elementLocator baseElement)) + label (.. screen (getByTestId "label-with-attrs"))] + (-> + (.. expect (element (.. label (getByText "the label"))) toBeInTheDocument))))) From 6300922aa82c4b4de181006a786e8a51004e36ac Mon Sep 17 00:00:00 2001 From: Sleepful Date: Mon, 29 Dec 2025 02:52:09 -0600 Subject: [PATCH 2/5] Add capacity to pass bound variables for attribute map --- README.md | 57 ++++++++++++++++++++++++++++++++ src/solid/core.clj | 15 +++++++-- test/vitest/basic_component.cljc | 11 +++--- vitest.config.ts | 2 ++ 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 45c1f9d..1aef26d 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,20 @@ Functions and macros below are a part of `solid.core` namespace: ($ button {:on-click #(prn :pressed)} "press") ``` +#### Child nodes + +```clj +;; ✅ bound +(let [title "the label" + input ($ :input) ] + ($ :label title input)) + +;; ✅ or explicit + ($ :label "the label" ($ :input))) + +;; +``` + ### Attributes The `:class` attribute supports multiple formats: @@ -89,6 +103,49 @@ The `:class` attribute supports multiple formats: :disabled @is-disabled?}} ``` +#### Bound Attributes + +Given a component declared with `defui`: + +```clj +(defui custom-label [{:keys [input-name children]} attrs] + ($ :label {:for input-name} children)) +``` + +It would be called like this: + +```clj +($ custom-label {:input-name "my-input"} "the label title") +``` + +However, to pass bound variables for the `defui` component, + +```clj +;; ❌ Using a bound variable for attributes, `attrs` is +;; indistinguishable from passing multiple child nodes for the `$` macro +(let [attrs {:input-name "my-input"} + children "the label title"] + ($ custom-label attrs children)) + +;; ✅ Wrapping the attribute variable with a vector, `[attrs]` +;; signals the macro that it should be treated as an attribute map +(let [attrs {:input-name "my-input"} + children "the label title"] + ($ custom-label [attrs] children)) +``` + +Do not use bound variables for keyword tags: + +```clj +;; ❌ Not supported for keyword tags, +;; Only supported for `defui` tags +(let [attrs {:class "my-div"} ] + ($ :div [attrs] children)) + +;; ✅ Be explicit with a map literal +($ :div {:class "my-div"} children)) +``` + ### Rendering Conditional rendering with `if` and `when`, via [ component](https://docs.solidjs.com/reference/components/show) diff --git a/src/solid/core.clj b/src/solid/core.clj index 64cdec5..4fd66a8 100644 --- a/src/solid/core.clj +++ b/src/solid/core.clj @@ -64,7 +64,8 @@ - Literals (strings, numbers, keywords, nil, booleans) - Function forms (fn, fn*/#())" [props] - (if (map? props) + (core/cond + (map? props) (reduce-kv (fn [m k v] (assoc m k @@ -77,7 +78,15 @@ :else `(solid.core/reactive-prop (fn [] ~v))))) {} props) - props)) + (vector? props) ;; attribute map is passed bound to a runtime value + `(let [v# (first ~props)] + (reduce-kv + (fn [m# k# v#] + (assoc m# k# + (solid.core/reactive-prop (fn [] v#)))) + {} + v#)) + :else props)) (defmacro $ [tag & args] (if (keyword? tag) @@ -89,7 +98,7 @@ (lint/lint-element-attrs! tag (first args) &env)) `(create-element ~(name tag) ~attrs ~@(wrap-children children)))) (let [[attrs & children] args] - (if (map? attrs) + (if ((some-fn map? vector?) attrs) `(create-element ~tag (cljs.core/js-obj "props" ~(wrap-component-props attrs)) ~@(wrap-children children)) `(create-element ~tag ~@(wrap-children args)))))) diff --git a/test/vitest/basic_component.cljc b/test/vitest/basic_component.cljc index 963ffc7..02a1bc2 100644 --- a/test/vitest/basic_component.cljc +++ b/test/vitest/basic_component.cljc @@ -34,13 +34,16 @@ (.then (fn [] (.click incrementButton))) (.then (fn [] (.. expect (element (.. screen (getByText "Count: 1"))) toBeInTheDocument))))))) -(defui label-with-attrs [attrs] - ($ :label attrs "the label")) +(defui label-with-attrs [{:keys [data-testid children]} attrs] + ($ :label {:data-testid data-testid} children)) (test - "Component receives map bound to variable", + "Defui component receives map attributes as a runtime variable, wrapped in vector literal", (fn [] - (j/let [^:js {:keys [baseElement]} (render #($ label-with-attrs {:data-testid "label-with-attrs"})) + (j/let [title "the label" + input ($ :input) + attrs {:data-testid "label-with-attrs"} + ^:js {:keys [baseElement]} (render #($ label-with-attrs [attrs] title input)) screen (.. page (elementLocator baseElement)) label (.. screen (getByTestId "label-with-attrs"))] (-> diff --git a/vitest.config.ts b/vitest.config.ts index 8a28def..4810259 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ // relies on the config at shadow-cljs.edn, which outputs these files './out/vitest/vitest.*.js', ], + // testTimeout default is between 5 to 15 seconds, but keeping it low might be productive: + testTimeout: 1_000, // https://vitest.dev/config/testtimeout.html browser: { provider: playwright(), enabled: true, From 8ce2ad0484ab119f37af00f425d3ebea590cebfa Mon Sep 17 00:00:00 2001 From: Sleepful Date: Tue, 30 Dec 2025 03:46:16 -0600 Subject: [PATCH 3/5] Allow variable-bound attribute maps --- README.md | 54 ++++++++++---------------------- src/solid/compiler.cljs | 37 ++++++++++++++++++++++ src/solid/core.clj | 53 +++++++++++++++---------------- src/solid/core.cljs | 17 +--------- test/vitest/basic_component.cljc | 46 +++++++++++++++++++++++++-- 5 files changed, 124 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 1aef26d..0929a92 100644 --- a/README.md +++ b/README.md @@ -70,20 +70,6 @@ Functions and macros below are a part of `solid.core` namespace: ($ button {:on-click #(prn :pressed)} "press") ``` -#### Child nodes - -```clj -;; ✅ bound -(let [title "the label" - input ($ :input) ] - ($ :label title input)) - -;; ✅ or explicit - ($ :label "the label" ($ :input))) - -;; -``` - ### Attributes The `:class` attribute supports multiple formats: @@ -102,36 +88,23 @@ The `:class` attribute supports multiple formats: {:class {:active @is-active? :disabled @is-disabled?}} ``` - -#### Bound Attributes - -Given a component declared with `defui`: +#### Passing attributes and child nodes ```clj (defui custom-label [{:keys [input-name children]} attrs] ($ :label {:for input-name} children)) -``` -It would be called like this: - -```clj +;; ✅ Pass the attribute map as a literal ($ custom-label {:input-name "my-input"} "the label title") -``` -However, to pass bound variables for the `defui` component, -```clj -;; ❌ Using a bound variable for attributes, `attrs` is -;; indistinguishable from passing multiple child nodes for the `$` macro (let [attrs {:input-name "my-input"} children "the label title"] - ($ custom-label attrs children)) - -;; ✅ Wrapping the attribute variable with a vector, `[attrs]` -;; signals the macro that it should be treated as an attribute map -(let [attrs {:input-name "my-input"} - children "the label title"] - ($ custom-label [attrs] children)) + ($ :<> ;; Fragment component + ($ custom-label attrs children) ;; ✅ Pass the attribute map as a bound variable + ($ custom-label children) ;; ✅ Attribute map is optional, it may be elided entirely + ($ custom-label) ;; also valid + ($ custom-label attrs))) ;; also valid ``` Do not use bound variables for keyword tags: @@ -139,13 +112,20 @@ Do not use bound variables for keyword tags: ```clj ;; ❌ Not supported for keyword tags, ;; Only supported for `defui` tags -(let [attrs {:class "my-div"} ] - ($ :div [attrs] children)) +(let [attrs {:class "my-div"} + title ($ "the label") + input ($ :input)] + ($ :label attrs title input)) ;; ✅ Be explicit with a map literal -($ :div {:class "my-div"} children)) +(let [title ($ "the label") + input ($ :input)] + ($ :label {:class "my-label"} title input)) +;; ``` + + ### Rendering Conditional rendering with `if` and `when`, via [ component](https://docs.solidjs.com/reference/components/show) diff --git a/src/solid/compiler.cljs b/src/solid/compiler.cljs index 142fc04..3158afd 100644 --- a/src/solid/compiler.cljs +++ b/src/solid/compiler.cljs @@ -3,6 +3,43 @@ (def ^:private cc-regexp (js/RegExp. "-(\\w)" "g")) +;; Marker type for reactive prop values +;; This distinguishes reactive getters from regular callbacks +(deftype ReactiveProp [getter]) + +(defn reactive-prop + "Wraps a function as a reactive prop getter. + Used internally by the $ macro for component props." + [f] + (->ReactiveProp f)) + +(defn reactive-prop? + "Returns true if x is a reactive prop wrapper." + [x] + (instance? ReactiveProp x)) + +(defn literal? + "Returns true if the expression is a compile-time literal that cannot be reactive. + Literals include: strings, numbers, keywords, nil, booleans." + [expr] + (or (string? expr) + (number? expr) + (keyword? expr) + (nil? expr) + (true? expr) + (false? expr))) + +(defn wrap-component-props + "Same intention as the compile-time `wrap-component-props`, however this + one is meant to expand into runtime values" + [attrs] + (reduce-kv + (fn [m k v] + (assoc m k + (reactive-prop (fn [] v)))) + {} + attrs)) + (defn- cc-fn [s] (str/upper-case (aget s 1))) diff --git a/src/solid/core.clj b/src/solid/core.clj index 4fd66a8..84ffb02 100644 --- a/src/solid/core.clj +++ b/src/solid/core.clj @@ -4,17 +4,6 @@ [solid.compiler :as sc] [solid.lint :as lint])) -(defn- literal? - "Returns true if the expression is a compile-time literal." - [x] - (core/or - (string? x) - (number? x) - (keyword? x) - (nil? x) - (true? x) - (false? x))) - (defmacro defui "Defines a Solid UI component. Supports docstrings and metadata. @@ -44,11 +33,14 @@ (let [~(core/or props (gensym "props")) (solid.core/-props props#)] ~@body)))) +(defn- wrap-child-node [node] + (if (sc/literal? node) + node ; Don't wrap literals - they can't be reactive + `(fn [] ~node))) + (defn- wrap-children [children] (core/for [x children] - (if (literal? x) - x ; Don't wrap literals - they can't be reactive - `(fn [] ~x)))) + (wrap-child-node x))) (defn- fn-form? "Returns true if the expression is a function literal (fn or fn*)." @@ -64,8 +56,7 @@ - Literals (strings, numbers, keywords, nil, booleans) - Function forms (fn, fn*/#())" [props] - (core/cond - (map? props) + (if (map? props) (reduce-kv (fn [m k v] (assoc m k @@ -75,18 +66,24 @@ ;; Function forms are callbacks, don't wrap (fn-form? v) v ;; Everything else gets wrapped in reactive-prop - :else `(solid.core/reactive-prop (fn [] ~v))))) + :else `(solid.compiler/reactive-prop (fn [] ~v))))) {} props) - (vector? props) ;; attribute map is passed bound to a runtime value - `(let [v# (first ~props)] - (reduce-kv - (fn [m# k# v#] - (assoc m# k# - (solid.core/reactive-prop (fn [] v#)))) - {} - v#)) - :else props)) + props)) + +(defn wrap-props-or-children + "Similar to `wrap-component-props`, this one considers that + the first value might be a prop map, or might be a child node, + so it must figure out at runtime how to treat the first value" + ([] []) + ([maybe-attrs & children] + (let [first `(doto (if (map? ~maybe-attrs) + (cljs.core/js-obj + "props" + (solid.compiler/wrap-component-props ~maybe-attrs)) + ~(wrap-child-node maybe-attrs)) #(prn "hellooo")) + rest (wrap-children children)] + (into [first] rest)))) (defmacro $ [tag & args] (if (keyword? tag) @@ -98,9 +95,9 @@ (lint/lint-element-attrs! tag (first args) &env)) `(create-element ~(name tag) ~attrs ~@(wrap-children children)))) (let [[attrs & children] args] - (if ((some-fn map? vector?) attrs) + (if (map? attrs) `(create-element ~tag (cljs.core/js-obj "props" ~(wrap-component-props attrs)) ~@(wrap-children children)) - `(create-element ~tag ~@(wrap-children args)))))) + `(create-element ~tag ~@(apply wrap-props-or-children args)))))) (defmacro if ([test then] diff --git a/src/solid/core.cljs b/src/solid/core.cljs index 03d6c14..6116380 100644 --- a/src/solid/core.cljs +++ b/src/solid/core.cljs @@ -6,21 +6,6 @@ ["solid-js/h/dist/h.js" :default h] [solid.compiler :as sc])) -;; Marker type for reactive prop values -;; This distinguishes reactive getters from regular callbacks -(deftype ReactiveProp [getter]) - -(defn reactive-prop - "Wraps a function as a reactive prop getter. - Used internally by the $ macro for component props." - [f] - (->ReactiveProp f)) - -(defn reactive-prop? - "Returns true if x is a reactive prop wrapper." - [x] - (instance? ReactiveProp x)) - (defn create-element [tag & args] (when (string? tag) (sc/with-numeric-props (first args))) (apply h tag args)) @@ -118,7 +103,7 @@ (-lookup this k nil)) (-lookup [this k not-found] (let [v (get props-map k not-found)] - (if (reactive-prop? v) + (if (sc/reactive-prop? v) ((.-getter ^ReactiveProp v)) ; Call the reactive getter v))) ; Pass through as-is (callbacks, literals, etc.) diff --git a/test/vitest/basic_component.cljc b/test/vitest/basic_component.cljc index 02a1bc2..8fe7b96 100644 --- a/test/vitest/basic_component.cljc +++ b/test/vitest/basic_component.cljc @@ -38,13 +38,55 @@ ($ :label {:data-testid data-testid} children)) (test - "Defui component receives map attributes as a runtime variable, wrapped in vector literal", + "Component receives map attributes as a runtime variable, wrapped in vector literal", (fn [] (j/let [title "the label" input ($ :input) attrs {:data-testid "label-with-attrs"} - ^:js {:keys [baseElement]} (render #($ label-with-attrs [attrs] title input)) + ^:js {:keys [baseElement]} (render #($ label-with-attrs attrs title input)) screen (.. page (elementLocator baseElement)) label (.. screen (getByTestId "label-with-attrs"))] (-> (.. expect (element (.. label (getByText "the label"))) toBeInTheDocument))))) + +(defui simple-label [{:keys [children]} attrs] + ($ :label {:data-testid "simple-label"} children)) + +(test + "Component without any props or children", + (fn [] + (j/let [^:js {:keys [baseElement]} (render #($ simple-label)) + screen (.. page (elementLocator baseElement))] + (-> + (.. expect (element (.. screen (getByTestId "simple-label"))) toBeInTheDocument))))) + +(test + "Component with a single variable-bound child-node", + (fn [] + (j/let [title "single child node" + ^:js {:keys [baseElement]} (render #($ :<> ($ simple-label title))) + screen (.. page (elementLocator baseElement))] + (-> + (.. expect (element (.. screen (getByText "single child node"))) toBeInTheDocument))))) + +(defui button-with-reactive-prop [{:keys [label sig unique-id]}] + ($ :label label + ($ :button {:on-click #(swap! sig + 1) + :data-testid unique-id} + unique-id "Count: " @sig))) + +(test + "Component signal updates on user event", + (fn [] + (j/let [sig (s/signal 0) + unique-id (s/uid) + ^:js {:keys [baseElement]} (render #($ button-with-reactive-prop + {:label "Button" + :unique-id unique-id + :sig sig})) + screen (.. page (elementLocator baseElement)) + incrementButton (.. screen (getByTestId unique-id))] + (-> + (.. expect (element (.. screen (getByText (str unique-id "Count: 0")))) toBeInTheDocument) + (.then (fn [] (.click incrementButton))) + (.then (fn [] (.. expect (element (.. screen (getByText (str unique-id "Count: 1")))) toBeInTheDocument))))))) From 7a65468cfeb38ea3dc42f17e3a31340504546d18 Mon Sep 17 00:00:00 2001 From: Sleepful Date: Tue, 30 Dec 2025 04:06:31 -0600 Subject: [PATCH 4/5] Wrap only reactive props at runtime --- src/solid/compiler.cljs | 7 +++++-- src/solid/core.clj | 10 +++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/solid/compiler.cljs b/src/solid/compiler.cljs index 3158afd..37394f7 100644 --- a/src/solid/compiler.cljs +++ b/src/solid/compiler.cljs @@ -31,12 +31,15 @@ (defn wrap-component-props "Same intention as the compile-time `wrap-component-props`, however this - one is meant to expand into runtime values" + one is meant to be used at runtime." [attrs] (reduce-kv (fn [m k v] (assoc m k - (reactive-prop (fn [] v)))) + (cond + (literal? v) v + (fn? v) v + :else (reactive-prop (fn [] v))))) {} attrs)) diff --git a/src/solid/core.clj b/src/solid/core.clj index 84ffb02..4e58575 100644 --- a/src/solid/core.clj +++ b/src/solid/core.clj @@ -77,11 +77,11 @@ so it must figure out at runtime how to treat the first value" ([] []) ([maybe-attrs & children] - (let [first `(doto (if (map? ~maybe-attrs) - (cljs.core/js-obj - "props" - (solid.compiler/wrap-component-props ~maybe-attrs)) - ~(wrap-child-node maybe-attrs)) #(prn "hellooo")) + (let [first `(if (map? ~maybe-attrs) + (cljs.core/js-obj + "props" + (solid.compiler/wrap-component-props ~maybe-attrs)) + ~(wrap-child-node maybe-attrs)) rest (wrap-children children)] (into [first] rest)))) From 031580b1f5fac848d3825338b939260b0cb7d282 Mon Sep 17 00:00:00 2001 From: Sleepful Date: Tue, 30 Dec 2025 04:39:51 -0600 Subject: [PATCH 5/5] Do not wrap 'reactive-props' at runtime, this seems to be futile, as the signals will be resolved to values (and thus lose its signal reference) before they can be captured by a wrap-component-props function --- src/solid/compiler.cljs | 40 -------------------------------- src/solid/core.clj | 4 ++-- src/solid/core.cljs | 17 +++++++++++++- test/vitest/basic_component.cljc | 3 ++- 4 files changed, 20 insertions(+), 44 deletions(-) diff --git a/src/solid/compiler.cljs b/src/solid/compiler.cljs index 37394f7..142fc04 100644 --- a/src/solid/compiler.cljs +++ b/src/solid/compiler.cljs @@ -3,46 +3,6 @@ (def ^:private cc-regexp (js/RegExp. "-(\\w)" "g")) -;; Marker type for reactive prop values -;; This distinguishes reactive getters from regular callbacks -(deftype ReactiveProp [getter]) - -(defn reactive-prop - "Wraps a function as a reactive prop getter. - Used internally by the $ macro for component props." - [f] - (->ReactiveProp f)) - -(defn reactive-prop? - "Returns true if x is a reactive prop wrapper." - [x] - (instance? ReactiveProp x)) - -(defn literal? - "Returns true if the expression is a compile-time literal that cannot be reactive. - Literals include: strings, numbers, keywords, nil, booleans." - [expr] - (or (string? expr) - (number? expr) - (keyword? expr) - (nil? expr) - (true? expr) - (false? expr))) - -(defn wrap-component-props - "Same intention as the compile-time `wrap-component-props`, however this - one is meant to be used at runtime." - [attrs] - (reduce-kv - (fn [m k v] - (assoc m k - (cond - (literal? v) v - (fn? v) v - :else (reactive-prop (fn [] v))))) - {} - attrs)) - (defn- cc-fn [s] (str/upper-case (aget s 1))) diff --git a/src/solid/core.clj b/src/solid/core.clj index 4e58575..7f85fa7 100644 --- a/src/solid/core.clj +++ b/src/solid/core.clj @@ -66,7 +66,7 @@ ;; Function forms are callbacks, don't wrap (fn-form? v) v ;; Everything else gets wrapped in reactive-prop - :else `(solid.compiler/reactive-prop (fn [] ~v))))) + :else `(solid.core/reactive-prop (fn [] ~v))))) {} props) props)) @@ -80,7 +80,7 @@ (let [first `(if (map? ~maybe-attrs) (cljs.core/js-obj "props" - (solid.compiler/wrap-component-props ~maybe-attrs)) + ~maybe-attrs) ~(wrap-child-node maybe-attrs)) rest (wrap-children children)] (into [first] rest)))) diff --git a/src/solid/core.cljs b/src/solid/core.cljs index 6116380..03d6c14 100644 --- a/src/solid/core.cljs +++ b/src/solid/core.cljs @@ -6,6 +6,21 @@ ["solid-js/h/dist/h.js" :default h] [solid.compiler :as sc])) +;; Marker type for reactive prop values +;; This distinguishes reactive getters from regular callbacks +(deftype ReactiveProp [getter]) + +(defn reactive-prop + "Wraps a function as a reactive prop getter. + Used internally by the $ macro for component props." + [f] + (->ReactiveProp f)) + +(defn reactive-prop? + "Returns true if x is a reactive prop wrapper." + [x] + (instance? ReactiveProp x)) + (defn create-element [tag & args] (when (string? tag) (sc/with-numeric-props (first args))) (apply h tag args)) @@ -103,7 +118,7 @@ (-lookup this k nil)) (-lookup [this k not-found] (let [v (get props-map k not-found)] - (if (sc/reactive-prop? v) + (if (reactive-prop? v) ((.-getter ^ReactiveProp v)) ; Call the reactive getter v))) ; Pass through as-is (callbacks, literals, etc.) diff --git a/test/vitest/basic_component.cljc b/test/vitest/basic_component.cljc index 8fe7b96..52e31b1 100644 --- a/test/vitest/basic_component.cljc +++ b/test/vitest/basic_component.cljc @@ -72,11 +72,12 @@ (defui button-with-reactive-prop [{:keys [label sig unique-id]}] ($ :label label ($ :button {:on-click #(swap! sig + 1) + :reactive-prop @sig :data-testid unique-id} unique-id "Count: " @sig))) (test - "Component signal updates on user event", + "Component receives signal as prop and updates accordingly", (fn [] (j/let [sig (s/signal 0) unique-id (s/uid)