diff --git a/CHANGELOG.md b/CHANGELOG.md index 64acb201..da842173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ [Squint](https://github.com/squint-cljs/squint): Light-weight ClojureScript dialect +## Unreleased + +- `defmulti` defines the multimethod with `defonce`, so a REPL or vite HMR module reload keeps the registered methods, like CLJS + ## 0.14.206 - Fix [#960](https://github.com/squint-cljs/squint/issues/960): the nREPL server no longer logs requests, responses and eval errors by default. Enable with `--debug` (CLI), `:debug` in squint.edn, or `:debug` (`startServer`) diff --git a/e2e/browser_repl_test.cljs b/e2e/browser_repl_test.cljs index e5cacf6b..fefa6363 100644 --- a/e2e/browser_repl_test.cljs +++ b/e2e/browser_repl_test.cljs @@ -114,6 +114,25 @@ (str/includes? (await (.innerHTML page "#plain")) "3px dashed")) (check "hot swap without page reload" true (await (.evaluate page "!!window.__e2e_no_reload")))) +(defn ^:async check-multimethod-hmr + "defmulti is defonce, like CLJS: hot-swapping the module that defines the + multimethod (shared.cljs) must keep methods registered from other modules + (the defmethod in plain.cljs), not reset to an empty method table." + [page shared-file orig-shared] + (check "multimethod cross-module dispatch" "circle-label" + (await (.textContent page "#mm"))) + (.writeFileSync fs shared-file (str/replace orig-shared "(+ 1 2 3)" "(+ 3 4)")) + (await (with-timeout 20000 "shared.cljs hot swap" + (.waitForFunction page "document.querySelector('#reagami').textContent.includes('Shared: 7')"))) + ;; a stale #mm would still show the old text after a broken swap (the render + ;; throws before setting innerHTML), so force a fresh render through the + ;; multimethod: click the counter and require the new count to appear + (await (.click page "#plain button")) + (await (with-timeout 20000 "plain render after provider hot swap" + (.waitForFunction page "document.querySelector('#plain').textContent.includes('Counted: 2')"))) + (check "multimethod survives provider hot swap" "circle-label" + (await (.textContent page "#mm")))) + ;; ------------------------------------------------------------------ run ---- ;; Hard safety net: never let the process hang past 2 min (unref'd so a fast @@ -134,12 +153,15 @@ macro-file (path/join EXDIR "src" "e2e_macros.cljc") consumer-file (path/join EXDIR "src" "plain.cljs") ui-file (path/join EXDIR "src" "ui.cljs") + shared-file (path/join EXDIR "src" "shared.cljs") orig-macro (.readFileSync fs macro-file "utf8") orig-consumer (.readFileSync fs consumer-file "utf8") orig-ui (.readFileSync fs ui-file "utf8") + orig-shared (.readFileSync fs shared-file "utf8") restore-files! (fn [] (try (.writeFileSync fs macro-file orig-macro) (.writeFileSync fs consumer-file orig-consumer) (.writeFileSync fs ui-file orig-ui) + (.writeFileSync fs shared-file orig-shared) (catch :default _ nil))) ;; safety net: the hard-timeout path calls process.exit and skips the ;; finally below, so restore on exit too (sync, idempotent) @@ -170,6 +192,7 @@ (await (check-counter page "reagami (hiccup)" "#reagami")) (await (check-macro-reload page macro-file consumer-file orig-consumer)) (await (check-after-load page ui-file orig-ui)) + (await (check-multimethod-hmr page shared-file orig-shared)) (let [client (await (with-timeout 10000 "nrepl connect" (make-client NREPL-PORT))) clone (await (with-timeout 10000 "nrepl clone" (nrepl-request client #js {:op "clone"}))) session (some (fn [m] (aget m "new-session")) (js/Array.from clone)) diff --git a/examples/browser-repl/src/plain.cljs b/examples/browser-repl/src/plain.cljs index 34a0acc4..696050f9 100644 --- a/examples/browser-repl/src/plain.cljs +++ b/examples/browser-repl/src/plain.cljs @@ -1,7 +1,10 @@ (ns plain - (:require [ui :refer [btn]]) + (:require [shared] + [ui :refer [btn]]) (:require-macros [e2e-macros :refer [tag]])) +(defmethod shared/shape-label :circle [_] "circle-label") + ;; Plain squint, no framework: build an HTML string with #html, set it as ;; innerHTML, and re-attach the click listener on each render. State is an atom; ;; add-watch re-renders. (The most manual of the three.) @@ -14,6 +17,7 @@ [:div {:style (assoc ui/label :color "#0a7d5a")} "plain squint - #html"] [:div {:style ui/counted} "Counted: " @state] [:div {:id "macro-tag"} (tag)] + [:div {:id "mm"} (shared/shape-label {:type :circle})] [:button {:id "plain-btn" :style btn} "Click me!"]]) (.addEventListener (.querySelector el "#plain-btn") "click" (fn [_] (swap! state inc))))) diff --git a/examples/browser-repl/src/shared.cljs b/examples/browser-repl/src/shared.cljs index e23b1690..8a7c3b86 100644 --- a/examples/browser-repl/src/shared.cljs +++ b/examples/browser-repl/src/shared.cljs @@ -1,4 +1,6 @@ (ns shared) (defn shared-function [] - (+ 1 2 3 )) + (+ 1 2 3)) + +(defmulti shape-label :type) diff --git a/src/squint/compiler_common.cljc b/src/squint/compiler_common.cljc index 66ed32c5..d8109661 100644 --- a/src/squint/compiler_common.cljc +++ b/src/squint/compiler_common.cljc @@ -1835,7 +1835,14 @@ break;}" body) outer-html? (:outer-html (meta expr))] (when unsafe-html? (reset! has-dynamic-expr? true)) (if (and (not html?) (:jsx env) (:jsx-runtime env)) - (let [single-child? (= 1 (count elts))] + (let [single-child? (= 1 (count elts)) + ;; on a DOM tag, a literal map value of a well-known attr is a + ;; plain-JS contract too: ::js makes cherry emit a JS object + ;; literal (no-op for squint). Only literals: dynamic values and + ;; component props are left to the user. + attrs (if (and (keyword? tag) (map? attrs) (map? (:style attrs))) + (update attrs :style vary-meta assoc ::js true) + attrs)] (emit (list (if single-child? '_jsx '_jsxs) (cond fragment? "_Fragment" @@ -1847,10 +1854,15 @@ break;}" body) children (if single-child? (first elts) - (vec elts))] - (cond-> (or attrs {}) - (seq children) - (assoc :children children)))) + ;; the jsx runtime wants a plain JS children array; + ;; ::js makes cherry emit one (no-op for squint) + (with-meta (vec elts) {::js true}))] + ;; props are a plain-JS contract for the jsx runtime; + ;; ::js makes cherry emit a JS object (no-op for squint) + (-> (cond-> (or attrs {}) + (seq children) + (assoc :children children)) + (vary-meta assoc ::js true)))) env)) (let [expr-env* (assoc (expr-env env) :unsafe-html unsafe-html?) ret (str diff --git a/src/squint/internal/multi.cljc b/src/squint/internal/multi.cljc index fc3b73bb..b935e949 100644 --- a/src/squint/internal/multi.cljc +++ b/src/squint/internal/multi.cljc @@ -35,7 +35,9 @@ (contains? opts :hierarchy) (assoc "hierarchy" (:hierarchy opts))) m (merge (if docstring {:doc docstring} {}) attr-map) mm-name* (if (seq m) (with-meta mm-name m) mm-name)] - `(def ~mm-name* ~(multi-call "defmulti" [(str mm-name) dispatch-fn opts-js])))) + ;; defonce so a module re-run (REPL reload, vite HMR hot swap) keeps the + ;; method table and cross-module defmethods alive, like CLJS + `(defonce ~mm-name* ~(multi-call "defmulti" [(str mm-name) dispatch-fn opts-js])))) (core/defn core-defmethod "(defmethod multifn dispatch-val [args*] body)" diff --git a/test/squint/multi_test.cljs b/test/squint/multi_test.cljs index 7a07c27a..77e7d9c5 100644 --- a/test/squint/multi_test.cljs +++ b/test/squint/multi_test.cljs @@ -117,13 +117,13 @@ (deftest vector-dispatch-and-remove-test (t/async done (-> (eval-repl " -(defmulti conv (fn [from to _] [from to])) -(defmethod conv [:km :m] [_ _ x] (* x 1000)) -(defmethod conv [:m :cm] [_ _ x] (* x 100)) -(let [before (count (methods conv)) - a (conv :km :m 5) - _ (remove-method conv [:m :cm]) - after (count (methods conv))] +(defmulti conv2 (fn [from to _] [from to])) +(defmethod conv2 [:km :m] [_ _ x] (* x 1000)) +(defmethod conv2 [:m :cm] [_ _ x] (* x 100)) +(let [before (count (methods conv2)) + a (conv2 :km :m 5) + _ (remove-method conv2 [:m :cm]) + after (count (methods conv2))] [a before after])") (.then (fn [v] (is (= [5000 2 1] (vec v))))) (.finally done))))