diff --git a/Readme.md b/Readme.md index 19c7356..60569e7 100644 --- a/Readme.md +++ b/Readme.md @@ -603,8 +603,9 @@ decides where code runs, not the syntax. Compilation happens at macro-expansion time (via the same embedded [Squint](https://github.com/squint-cljs/squint) compiler that powers [client components](#client-components-web-components)) — runtime cost is -string interpolation of spliced values only. Output is dependency-free -JavaScript suitable for Datastar's sandboxed evaluator. +string interpolation of spliced values only. Output calls Squint's core +library through the `hyper_sc` global, which hyper serves at +`/hyper/squint-core.js`. ### Actions inside expressions @@ -2043,6 +2044,25 @@ inside segments. For self-hosted/air-gapped deploys, override the Squint runtime CDN URL with the `:squint-core-url` option on `create-handler`. +### Tree shaking + +Pass `:tree-shake? true` to `create-handler` to serve only the Squint core +functions that your `h/expr` expressions use, instead of all of +`/hyper/squint-core.js` (28 KB brotli-compressed): + +```clojure +(h/create-handler #'routes :tree-shake? true) +``` + +Add esbuild to `deps.edn`: + +```clojure +org.babashka/esbuild {:mvn/version "0.1.1"} +``` + +Add `--enable-native-access=ALL-UNNAMED` to your JVM options for esbuild. +Without esbuild on the classpath, `create-handler` throws. + ### Editor indentation `defc`'s `render`/`event`/`mount`/`update`/`unmount` segments are diff --git a/deps.edn b/deps.edn index f4a2d0f..092fa67 100644 --- a/deps.edn +++ b/deps.edn @@ -36,26 +36,30 @@ cheshire/cheshire {:mvn/version "6.1.0"} ;; Client-side component compilation (CLJS-dialect -> JS) - io.github.squint-cljs/squint {:mvn/version "0.14.203"} + io.github.squint-cljs/squint {:mvn/version "0.14.209"} ;; Logging com.taoensso/telemere {:mvn/version "1.2.1"}} :aliases - {:test {:extra-paths ["test"] - :extra-deps {org.clojure/test.check {:mvn/version "1.1.3"} - lambdaisland/kaocha {:mvn/version "1.91.1392"} - nubank/matcher-combinators {:mvn/version "3.10.0"} - io.github.pfeodrippe/wally {:mvn/version "0.0.5"}} - :main-opts ["-m" "kaocha.runner"] - :exec-fn kaocha.runner/exec-fn - :exec-args {:randomize? false - :fail-fast? true}} - :dev {:extra-deps {io.github.tonsky/clj-reload {:mvn/version "1.0.0"}}} + {:test {:extra-paths ["test"] + :extra-deps {org.clojure/test.check {:mvn/version "1.1.3"} + lambdaisland/kaocha {:mvn/version "1.91.1392"} + nubank/matcher-combinators {:mvn/version "3.10.0"} + io.github.pfeodrippe/wally {:mvn/version "0.0.5"}} + :main-opts ["-m" "kaocha.runner"] + :exec-fn kaocha.runner/exec-fn + :exec-args {:randomize? false + :fail-fast? true}} + :dev {:extra-deps {io.github.tonsky/clj-reload {:mvn/version "1.0.0"}}} + + ;; Serves only the squint core functions that h/expr output uses. + :tree-shake {:extra-deps {org.babashka/esbuild {:mvn/version "0.1.1"}} + :jvm-opts ["--enable-native-access=ALL-UNNAMED"]} ;; Build, install, deploy. See dev/build.clj. ;; Invoke with: clj -T:build - :build {:deps {io.github.clojure/tools.build {:git/tag "v0.10.7" :git/sha "573711e"} - slipset/deps-deploy {:mvn/version "0.2.3"}} - :paths ["dev"] - :ns-default build}}} + :build {:deps {io.github.clojure/tools.build {:git/tag "v0.10.7" :git/sha "573711e"} + slipset/deps-deploy {:mvn/version "0.2.3"}} + :paths ["dev"] + :ns-default build}}} diff --git a/src/hyper/core.clj b/src/hyper/core.clj index 8b70d62..a77cdfd 100644 --- a/src/hyper/core.clj +++ b/src/hyper/core.clj @@ -929,6 +929,8 @@ - :squint-core-url — Override of the squint core.js URL used by the client components bundle (default: version-matched jsDelivr CDN). Point at a self-hosted copy for offline/air-gapped deploys. + - :tree-shake? - Serve only the squint core functions that h/expr output + uses. Needs babashka.esbuild on the classpath. - :head — Hiccup nodes appended to the HTML , or (fn [req] ...) -> hiccup - :webkit-sse-shim? — Inject a small client shim (into , only for WebKit/Safari user agents) that routes the GET render stream through a native @@ -1003,7 +1005,7 @@ (stop! app)" [routes & {:keys [app-state head static-resources static-dir watches datastar-script base-path middleware render-middleware - render-error squint-core-url render-guard + render-error squint-core-url tree-shake? render-guard max-file-size max-file-count upload-expires-in] :or {app-state (atom (state/init-state)) datastar-script server/default-datastar-script} @@ -1017,7 +1019,8 @@ :base-path base-path :middleware middleware :render-middleware render-middleware - :squint-core-url squint-core-url} + :squint-core-url squint-core-url + :tree-shake? tree-shake?} ;; Only forward when supplied so server defaults apply. render-error (assoc :render-error render-error) render-guard (assoc :render-guard render-guard) diff --git a/src/hyper/expr.clj b/src/hyper/expr.clj index 60f2549..3864db8 100644 --- a/src/hyper/expr.clj +++ b/src/hyper/expr.clj @@ -37,9 +37,8 @@ Datastar actions (`(@post \"/x\")`), JS interop and operators. Compilation happens at macro-expansion time via Squint — runtime cost - is string interpolation of spliced values only. Output is - dependency-free JavaScript for Datastar's sandboxed evaluator: - `and`/`or`/`not`/`str` etc. emit bare JS operators, never library calls. + is string interpolation of spliced values only. Output calls squint + core through the `hyper_sc` global, which hyper serves. The transpiler core is ported, with gratitude, from Casey Link's datastar-expressions (https://github.com/outskirtslabs/datastar-expressions, @@ -59,67 +58,7 @@ ;; --------------------------------------------------------------------------- ;; Squint special-form overrides ;; --------------------------------------------------------------------------- -;; Datastar expressions run in a sandboxed evaluator with no library -;; runtime, so boolean/control/string forms must compile to bare JS -;; operators rather than squint_core calls. These compiler macros -;; (squint's :macros option) replace the defaults. - -(defn- bool-expr [e] - (if (boolean? e) - e - (vary-meta e assoc :tag 'boolean))) - -(defn- expr-and - ([_ _] true) - ([_ _ x] x) - ([_ _ x & next] - (let [js (str/join " && " (repeat (inc (count next)) "(~{})"))] - (bool-expr (concat (list 'js* js) (cons x next)))))) - -(defn- expr-or - ([_ _] nil) - ([_ _ x] x) - ([_ _ x & next] - (let [js (str/join " || " (repeat (inc (count next)) "(~{})"))] - (bool-expr (concat (list 'js* js) (cons x next)))))) - -(defn- expr-not [_ _ x] - (bool-expr (concat (list 'js* "(!(~{}))") (list x)))) - -(defn- expr-do - ([_ _] nil) - ([_ _ & exprs] - (let [js (str/join ", " (repeat (count exprs) "(~{})"))] - (concat (list 'js* js) exprs)))) - -(defn- expr-if [_ _ test & body] - (list 'if (bool-expr test) (first body) (second body))) - -(defn- expr-when [_ _ test & body] - (list 'if (bool-expr test) (cons 'expr/do body))) - -(defn- expr-when-not [_ _ test & body] - (list 'if-not (bool-expr test) (cons 'expr/do body))) - -(defn- expr-eq - ([_ _ _x] true) - ([_ _ x y] - (bool-expr (concat (list 'js* "(~{}) === (~{})") (list x y)))) - ([_ _ x y & more] - (let [args (list* x y more) - pairs (partition 2 1 args) - js (str/join " && " (repeat (count pairs) "((~{}) === (~{}))"))] - (bool-expr (concat (list 'js* js) (mapcat identity pairs)))))) - -(defn- expr-str - ([_ _] "") - ([_ _ & exprs] - (let [js (str/join " + " (cons "''" (repeat (count exprs) "(~{})")))] - (concat (list 'js* js) exprs)))) - -(defn- expr-println [_ _ & exprs] - (let [js (str/join "," (repeat (count exprs) "(~{})"))] - (concat (list 'js* (str "console.log(" js ")")) exprs))) +;; Compiler macros (squint's :macros option) that replace the defaults. (defn- expr-raw ([_ _] (list 'js* "")) @@ -127,55 +66,13 @@ (let [js (str/join (repeat (count x) "~{}"))] (concat (list 'js* js) x)))) -(def ^:private macro-replacements - {'and 'expr/and - '&& 'expr/and - 'or 'expr/or - '|| 'expr/or - 'if 'expr/if - 'not 'expr/not - '= 'expr/= - 'str 'expr/str - 'println 'expr/println - 'when 'expr/when - 'when-not 'expr/when-not - 'expr/raw 'expr/raw}) - (def ^:private compiler-macros - {'expr {'and expr-and - 'or expr-or - 'when expr-when - 'when-not expr-when-not - 'do expr-do - 'if expr-if - 'not expr-not - '= expr-eq - 'str expr-str - 'println expr-println - 'raw expr-raw}}) + {'expr {'raw expr-raw}}) ;; --------------------------------------------------------------------------- ;; Pre-processing (form level) ;; --------------------------------------------------------------------------- -(defn- process-not-equals - "(not= x y) -> (not (= x y)) to avoid a squint_core dependency." - [form] - (walk/postwalk - (fn [node] - (if (and (seq? node) (= 'not= (first node))) - (list 'not (cons '= (rest node))) - node)) - form)) - -(defn- process-macros [form] - (walk/postwalk - (fn [node] - (if (and (seq? node) (contains? macro-replacements (first node))) - (cons (get macro-replacements (first node)) (rest node)) - node)) - form)) - (defn- process-client-params "Replace client-param symbols ($value, $key, $form-data, … and any user-registered ones) with their client-side JS, so expr shares the @@ -197,7 +94,7 @@ form))) (defn- pre-process [form] - (-> form process-not-equals process-macros process-client-params)) + (process-client-params form)) ;; --------------------------------------------------------------------------- ;; Post-processing (compiled JS level) @@ -208,13 +105,8 @@ squint_core.deref(get)(...) — restore Datastar's @action syntax." [js] (-> js - (str/replace #"squint_core\.deref\(squint_core\.(get)\)\s*\(" "@$1(") - (str/replace #"squint_core\.deref\(([a-zA-Z_$][a-zA-Z0-9_$]*)\)\s*\(" "@$1("))) - -(defn- replace-truth - "squint_core.truth_(x) -> !!(x) — no library runtime in the sandbox." - [js] - (str/replace js #"squint_core\.truth_\((.*)\)" "!!($1)")) + (str/replace #"hyper_sc\.deref\(hyper_sc\.(get)\)\s*\(" "@$1(") + (str/replace #"hyper_sc\.deref\(([a-zA-Z_$][a-zA-Z0-9_$]*)\)\s*\(" "@$1("))) (defn- collect-kebab-signals "All $signal symbols containing dashes, as name strings." @@ -341,6 +233,16 @@ ;; Compilation ;; --------------------------------------------------------------------------- +(defonce ^{:doc "Munged squint core names referenced by compiled expressions."} + core-vars* + (atom (sorted-set))) + +;; A future squint release adds the used core names to the compiler output, which replaces this regex. +(defn- record-core-vars! [js] + (when-let [names (seq (map second (re-seq #"hyper_sc\.([A-Za-z_$][\w$]*)" js)))] + (swap! core-vars* into names)) + js) + (defn compile-form "Compile a single (boundary-inferred) form to a Datastar expression string. Public for testing." @@ -353,9 +255,10 @@ :elide-exports true :top-level false :context :expr + :core-alias "hyper_sc" :macros compiler-macros}) replace-deref - replace-truth + record-core-vars! (restore-signal-casing processed) (str/replace #"\n" " ") str/trim @@ -387,6 +290,16 @@ template pairs)) +;; Datastar adds `return` after the last `;`, even one inside a nested function (datastar#1215). +(defn- join-statements + "Returns jss, the compiled forms, joined with `;`. + Returns the last form through `__hyper_r` if any form contains a `;`." + [jss] + (if (some #(str/includes? % ";") jss) + (str (str/join (map #(str % "; ") (butlast jss))) + "var __hyper_r = (" (last jss) "); return __hyper_r;") + (str/join "; " jss))) + ;; --------------------------------------------------------------------------- ;; The macro ;; --------------------------------------------------------------------------- @@ -413,7 +326,7 @@ (let [env-locals (set (keys &env)) pairs* (atom []) forms* (mapv #(infer-boundary % env-locals pairs*) forms) - template (str/join "; " (map compile-form forms*)) + template (join-statements (map compile-form forms*)) pairs @pairs*] (if (empty? pairs) template diff --git a/src/hyper/expr/bundle.clj b/src/hyper/expr/bundle.clj new file mode 100644 index 0000000..eeba564 --- /dev/null +++ b/src/hyper/expr/bundle.clj @@ -0,0 +1,43 @@ +(ns ^:no-doc hyper.expr.bundle + "Builds an ES module with the squint core functions that h/expr output uses. + Needs babashka.esbuild on the classpath." + (:require [babashka.esbuild :as esbuild] + [babashka.fs :as fs] + [clojure.java.io :as io] + [clojure.string :as str] + [hyper.brotli :as br])) + +(def ^:private core-dir + (delay + (let [dir (fs/create-temp-dir {:prefix "hyper-squint-core"})] + (with-open [in (io/input-stream (io/resource "squint/core.js"))] + (io/copy in (fs/file dir "core.js"))) + dir))) + +(def ^:private last-build (atom nil)) + +(defn- build [vars] + (let [entry (fs/file @core-dir "entry.js")] + (spit entry (str "export { " (str/join ", " (sort vars)) " } from './core.js';\n")) + (let [js (-> (esbuild/build {:entry-points [(str entry)] + :bundle true + :format :esm + :minify true}) + :outputs first :contents)] + {:js js :br (br/compress js :quality 11)}))) + +(defn core-js + "Returns a map with :js, minified JS that exports vars, and :br, the same JS brotli-compressed. + vars is a set of munged squint core names. + Keeps only the build for the last vars." + [vars] + (let [[built-vars result] @last-build] + (if (= vars built-vars) + result + (locking last-build + (let [[built-vars result] @last-build] + (if (= vars built-vars) + result + (let [result (build vars)] + (reset! last-build [vars result]) + result))))))) diff --git a/src/hyper/server.clj b/src/hyper/server.clj index 587e74a..d113bb9 100644 --- a/src/hyper/server.clj +++ b/src/hyper/server.clj @@ -13,6 +13,7 @@ [hyper.component.bundle :as component.bundle] [hyper.context :as context] [hyper.effects :as effects] + [hyper.expr :as expr] [hyper.lifecycle :as lifecycle] [hyper.reactive :as reactive] [hyper.render :as render] @@ -145,6 +146,20 @@ :headers (:headers render-result) :tab-id tab-id}))))) +(def ^:private squint-core-sha + (delay + (.formatHex (java.util.HexFormat/of) + (.digest (java.security.MessageDigest/getInstance "SHA-256") + (.getBytes ^String (slurp (io/resource "squint/core.js")) "UTF-8"))))) + +(defn- squint-core-version + "Returns a version string for the squint core that /hyper/squint-core.js serves. + The version changes if the served JS changes." + [app-state*] + (if (:tree-shake? @app-state*) + (Integer/toHexString (hash [@squint-core-sha @expr/core-vars*])) + (subs @squint-core-sha 0 12))) + (defn- handle-full-render "Perform a full page render and send the assembled SSE events (head update, body fragment, signal patches). Sweeps stale actions/reactive components @@ -175,6 +190,7 @@ (let [head-event (render/format-head-update title head-html) sig-attrs (signal/format-signal-attrs declared-signals) div-attrs (cond-> {:id "hyper-app"} + (:tree-shake? @app-state*) (assoc :data-hyper-squint-version (squint-core-version app-state*)) url (assoc :data-hyper-url url) sig-attrs (merge sig-attrs)) wrapped-html (c/html [:div div-attrs (c/raw body-html)]) @@ -834,16 +850,21 @@ window.history.replaceState({title: document.title}, '', window.location.href); var observer = new MutationObserver(function(mutations) { for (var i = 0; i < mutations.length; i++) { + if (mutations[i].attributeName === 'data-hyper-squint-version') { + var version = appEl.getAttribute('data-hyper-squint-version'); + if (window.hyper_squint_version && version && version !== window.hyper_squint_version) { + window.location.reload(); + } + } if (mutations[i].attributeName === 'data-hyper-url') { var url = appEl.getAttribute('data-hyper-url'); if (url && url !== window.location.pathname + window.location.search) { window.history.replaceState({title: document.title}, '', url); } - break; } } }); - observer.observe(appEl, { attributes: true, attributeFilter: ['data-hyper-url'] }); + observer.observe(appEl, { attributes: true, attributeFilter: ['data-hyper-url', 'data-hyper-squint-version'] }); } window.addEventListener('pageshow', function(event) { var nav = performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; @@ -878,7 +899,7 @@ Shared by page-handler and not-found-handler so both emit identical document scaffolding, differing only in `status` and `fallback-title` (the used when the result has no :title)." - [result {:keys [datastar-script open-when-hidden? base-path webkit-sse-shim?] + [result {:keys [datastar-script open-when-hidden? base-path webkit-sse-shim? core-version tree-shake?] :or {open-when-hidden? true base-path "" webkit-sse-shim? true}} @@ -887,6 +908,7 @@ title (or title fallback-title) sig-attrs (signal/format-signal-attrs declared-signals) div-attrs (cond-> {:id "hyper-app"} + tree-shake? (assoc :data-hyper-squint-version core-version) sig-attrs (merge sig-attrs)) ;; WebKit/Safari fetch-streaming strand workaround: inject the ;; EventSource shim only for affected user agents, before the datastar @@ -903,6 +925,10 @@ [:meta {:name "viewport" :content "width=device-width, initial-scale=1"}] [:title title] webkit-shim + [:script {:type "module"} + (c/raw (str "import * as sc from '" base-path "/hyper/squint-core.js?v=" core-version "';\n" + "window.hyper_sc = sc;" + (when tree-shake? (str "\nwindow.hyper_squint_version = '" core-version "';"))))] datastar-script (when head-html (c/raw head-html))] [:body @@ -943,7 +969,7 @@ ;; Ring response passthrough (e.g. a 302 redirect) (if (:status result) result - (page-response result opts req tab-id 200 "Hyper App"))))))) + (page-response result (assoc opts :core-version (squint-core-version app-state*) :tree-shake? (:tree-shake? @app-state*)) req tab-id 200 "Hyper App"))))))) (defn- not-found-handler "Reitit default-handler for unmatched routes: renders the configured @@ -969,7 +995,7 @@ ;; Ring response passthrough (e.g. a redirect from render middleware) (if (:status result) result - (page-response result opts req tab-id 404 "Not Found")))))) + (page-response result (assoc opts :core-version (squint-core-version app-state*) :tree-shake? (:tree-shake? @app-state*)) req tab-id 404 "Not Found")))))) (defn- components-js-handler "Serve the assembled client-components ES module bundle. @@ -1002,6 +1028,30 @@ :headers {"Content-Type" "text/plain"} :body "No components registered"}))) +(defn- squint-core-js-handler + "Returns a handler that serves squint's core.js from the classpath. + Serves only the functions h/expr output uses if tree-shake? is true, which needs babashka.esbuild. + Caches the response as immutable if its v query parameter is the current version." + [app-state* tree-shake?] + (let [js (slurp (io/resource "squint/core.js")) + js-br (delay (br/compress js :quality 11)) + core-js (when tree-shake? (requiring-resolve 'hyper.expr.bundle/core-js))] + (fn [req] + (let [br? (br/accepts-br? req) + body (cond + core-js (let [{:keys [js br]} (core-js (set @expr/core-vars*))] + (if br? br js)) + br? @js-br + :else js)] + {:status 200 + :headers (cond-> {"Content-Type" "text/javascript; charset=utf-8" + "Cache-Control" (if (= (get-in req [:query-params "v"]) + (squint-core-version app-state*)) + "public, max-age=31536000, immutable" + "no-cache")} + br? (assoc "Content-Encoding" "br")) + :body body})))) + (defn- -navigation-parameters "Coerces navigation parameters with the matched GET route's compiled validator." [match request] @@ -1275,7 +1325,8 @@ [(str base-path "/hyper/actions") {:post (action-handler app-state*)}] [(str base-path "/hyper/upload") {:post upload-route}] [(str base-path "/hyper/navigate") {:post (navigate-handler app-state*)}] - [(str base-path "/hyper/components.js") {:get (components-js-handler app-state*)}]] + [(str base-path "/hyper/components.js") {:get (components-js-handler app-state*)}] + [(str base-path "/hyper/squint-core.js") {:get (squint-core-js-handler app-state* (:tree-shake? opts))}]] ;; Store the routes source (Var or value) so title resolution can ;; always read the latest route metadata, even between router rebuilds. ;; Store global :watches so find-route-watches can prepend them to @@ -1295,7 +1346,8 @@ :disconnect-grace-ms disconnect-grace-ms :heartbeat-ms heartbeat-ms :open-when-hidden? (get opts :open-when-hidden? true) - :squint-core-url (:squint-core-url opts)) + :squint-core-url (:squint-core-url opts) + :tree-shake? (:tree-shake? opts)) initial-routes (if (var? routes) @routes routes) initial-handler (build-ring-handler initial-routes app-state* page-wrapper system-routes default-handler) handler (if (var? routes) diff --git a/test/hyper/core_test.clj b/test/hyper/core_test.clj index ea5a620..d532252 100644 --- a/test/hyper/core_test.clj +++ b/test/hyper/core_test.clj @@ -501,7 +501,7 @@ :hyper/app-state app-state*}] (let [action-expr (str (hy/action {:when (hy/expr (= evt.key "Enter"))} (reset! (hy/tab-cursor :val) $value)))] - (is (.startsWith action-expr "(evt.key) === (\"Enter\") && ")) + (is (.startsWith action-expr "(evt.key === \"Enter\") && ")) (is (.contains action-expr "@post(")))))) (testing ":when guard evaluating to a non-string throws" diff --git a/test/hyper/e2e_test.clj b/test/hyper/e2e_test.clj index f24322a..6f62762 100644 --- a/test/hyper/e2e_test.clj +++ b/test/hyper/e2e_test.clj @@ -9,6 +9,7 @@ [clojure.test :refer [deftest is testing use-fixtures]] [hyper.core :as h] [hyper.effects :as effects] + [hyper.server] [hyper.state :as state] [wally.main :as w]) (:import (com.microsoft.playwright Playwright BrowserType$LaunchOptions))) @@ -289,6 +290,29 @@ [:button#bump-btn {:data-on:click (h/action (swap! n* inc))} "bump"] [:button#commit-btn {:data-on:click (h/action (h/commit! w*))} "commit"]])) +(def ^:private truthiness-rows [{:name "alice"} {:name "bob"}]) + +(defn- expr-truthiness-get [_] + (let [selected* (h/signal :selected nil)] + [:div + [:h1 "Test Expr Truthiness"] + [:ul (for [[i row] (map-indexed vector truthiness-rows)] + [:li {:id (str "row-" (:name row)) :data-on:click (h/expr (reset! selected* i))} (:name row)])] + [:button#delete-btn + {:data-on:click (h/expr (when @selected* + (h/action (reset! (h/tab-cursor :deleted) + (:name (nth truthiness-rows @selected*))))))} + "Delete"] + [:span#deleted @(h/tab-cursor :deleted "none")]])) + +(defn- expr-nested-get [_] + (let [a* (h/signal :a false) + b* (h/signal :b 7)] + [:div + [:h1 "Test Expr Nested"] + [:span#when-let {:data-text (h/expr (when-let [x (or @a* @b*)] x))}] + [:span#re-find {:data-text (h/expr (let [s (or @a* "xa;by")] (re-find #"a;b" s)))}]])) + (defn default-routes [] [["/" {:name :home :title "Home" @@ -332,7 +356,15 @@ ["/optimistic" {:name :optimistic :title "Optimistic" - :get #'optimistic-get}]]) + :get #'optimistic-get}] + ["/expr-truthiness" + {:name :expr-truthiness + :title "Expr Truthiness" + :get #'expr-truthiness-get}] + ["/expr-nested" + {:name :expr-nested + :title "Expr Nested" + :get #'expr-nested-get}]]) (def ^:dynamic *test-routes* (default-routes)) @@ -352,7 +384,8 @@ (defn start-test-server! [] (reset! test-state* (atom (state/init-state))) - (let [handler (h/create-handler #'*test-routes* :app-state @test-state* :head #'test-head-var)] + (let [handler (h/create-handler #'*test-routes* :app-state @test-state* :head #'test-head-var + :tree-shake? (= "true" (System/getProperty "hyper.tree-shake")))] (reset! test-server (h/start! handler {:port test-port})))) (defn stop-test-server! [] @@ -1460,6 +1493,69 @@ (finally (close-browser! browser-info)))))) +(deftest ^:e2e expr-truthiness-test + (let [browser-info (launch-browser) + ctx (new-context browser-info) + page (new-page ctx)] + (try + (w/with-page page + (w/navigate (str base-url "/expr-truthiness")) + (wait-for-sse) + + (testing "signal value 1 runs the embedded action" + (w/click "#row-bob") + (w/click "#delete-btn") + (is (wait-for-text "#deleted" "bob"))) + + (testing "signal value 0 runs the embedded action" + (w/click "#row-alice") + (w/click "#delete-btn") + (is (wait-for-text "#deleted" "alice")))) + (finally + (close-browser! browser-info))))) + +(deftest ^:e2e squint-core-version-reload-test + (let [browser-info (launch-browser) + ctx (new-context browser-info) + page (new-page ctx) + tree-shake? (:tree-shake? @@test-state*)] + (swap! @test-state* assoc :tree-shake? true) + (try + (w/with-page page + (w/navigate (str base-url "/counters")) + (wait-for-sse) + (wait-for-text "#counter-Session h2" "Session: 0") + (eval-js "window.__sameDocument = true") + (with-redefs-fn {#'hyper.server/squint-core-version (constantly "changed")} + (fn [] + (click-counter-button "Session" ".inc") + + (testing "a changed data-hyper-squint-version reloads the page" + (is (wait-for-pred #(nil? (eval-js "window.__sameDocument")) :timeout 10000))) + + (testing "the reloaded page imports the changed squint core version" + (is (wait-for-pred #(= "changed" (eval-js "window.hyper_squint_version")) :timeout 10000)))))) + (finally + (swap! @test-state* assoc :tree-shake? tree-shake?) + (close-browser! browser-info))))) + +(deftest ^:e2e expr-nested-test + (let [browser-info (launch-browser) + ctx (new-context browser-info) + page (new-page ctx)] + (try + (w/with-page page + (w/navigate (str base-url "/expr-nested")) + (wait-for-sse) + + (testing "when-let over or renders the second operand" + (is (wait-for-text "#when-let" "7"))) + + (testing "re-find of #\"a;b\" inside let renders the match" + (is (wait-for-text "#re-find" "a;b")))) + (finally + (close-browser! browser-info))))) + (deftest ^:e2e effects-combined-test (testing "multiple effects in one action all apply" (let [browser-info (launch-browser) diff --git a/test/hyper/expr_test.clj b/test/hyper/expr_test.clj index 2d75961..9247b46 100644 --- a/test/hyper/expr_test.clj +++ b/test/hyper/expr_test.clj @@ -35,35 +35,51 @@ (deftest test-raw-datastar-style (testing "set! on $signals" - (is (= "$count = 0" (->expr (set! $count 0))))) + (is (= "($count = 0)" (->expr (set! $count 0))))) (testing "kebab-case signal names are preserved" - (is (= "$record-id = 5" (->expr (set! $record-id 5))))) + (is (= "($record-id = 5)" (->expr (set! $record-id 5))))) (testing "namespaced signals" - (is (= "$person.name = \"alice\"" (->expr (set! $person.name "alice"))))) + (is (= "($person.name = \"alice\")" (->expr (set! $person.name "alice"))))) (testing "actions" (is (= "@post(\"/update\")" (->expr (@post "/update")))) (is (= "@get(\"/poke\")" (->expr (@get "/poke"))))) (testing "multiple statements join with ;" - (is (= "$a = 1; @get(\"/x\")" (->expr (set! $a 1) (@get "/x"))))) + (is (= "($a = 1); @get(\"/x\")" (->expr (set! $a 1) (@get "/x"))))) + + (testing "a form with a ; returns its value through __hyper_r" + (let [out (->expr (when-let [x (or $a $b)] x))] + (is (str/starts-with? out "var __hyper_r = ((() => {")) + (is (str/ends-with? out "; return __hyper_r;")))) + + (testing "earlier statements precede the __hyper_r variable" + (let [out (->expr (set! $z 1) (when-let [x (or $a $b)] x))] + (is (str/starts-with? out "($z = 1); var __hyper_r = (")) + (is (str/ends-with? out "; return __hyper_r;")))) (testing "evt and el pass through as client-side symbols" - (is (= "(evt.key) === (\"Enter\")" (->expr (= evt.key "Enter")))) + (is (= "(evt.key === \"Enter\")" (->expr (= evt.key "Enter")))) (is (str/includes? (->expr (.focus el)) "el.focus()")))) ;; --------------------------------------------------------------------------- -;; Sandbox-safe operators (no squint_core in output, ever) +;; Core references use the hyper_sc alias ;; --------------------------------------------------------------------------- (deftest test-sandbox-safe-output - (testing "boolean and equality forms compile to bare JS operators" - (is (= "(($a) === (1)) && (($b) || (evt.shiftKey))" - (->expr (and (= $a 1) (or $b evt.shiftKey))))) - (is (= "(!(($name) === (\"\")))" (->expr (not= $name "")))) - (is (= "'' + (\"n=\") + ($n)" (->expr (str "n=" $n))))) + (testing "= with a literal compiles to ===" + (let [out (->expr (and (= $a 1) (or $b evt.shiftKey)))] + (is (str/includes? out "($a === 1)")) + (is (str/includes? out "&&")) + (is (str/includes? out "evt.shiftKey")))) + + (testing "not= compiles to hyper_sc._EQ_" + (is (= "!hyper_sc._EQ_($name, \"\")" (->expr (not= $name ""))))) + + (testing "str compiles to a template literal" + (is (= "`${\"n=\"}${$n??''}`" (->expr (str "n=" $n))))) (testing "when compiles to a ternary" (let [out (->expr (when $open (@post "/x")))] @@ -91,27 +107,28 @@ (deftest test-signal-atom-vocabulary (testing "swap! with not — the toggle idiom" (let [open?* (local-sig "open" false)] - (is (= "$_open = (!($_open))" (->expr (swap! open?* not)))))) + (is (= "($_open = hyper_sc.not($_open))" (->expr (swap! open?* not)))))) (testing "swap! with inc and with extra args" (let [n* (local-sig "n" 0)] - (is (= "$_n = ($_n + 1)" (->expr (swap! n* inc)))) - (is (= "$_n = ($_n + 5)" (->expr (swap! n* + 5)))))) + (is (= "($_n = ($_n + 1))" (->expr (swap! n* inc)))) + (is (= "($_n = ($_n + 5))" (->expr (swap! n* + 5)))))) (testing "reset! from a client-side value" (let [query* (sig "searchQuery")] - (is (= "$searchQuery = evt.target.value" + (is (= "($searchQuery = evt.target.value)" (->expr (reset! query* evt.target.value)))))) (testing "deref reads as a signal reference" (let [name* (sig "userName")] - (is (= "(!(($userName) === (\"\")))" (->expr (not= @name* "")))) + (is (= "!hyper_sc._EQ_($userName, \"\")" (->expr (not= @name* "")))) (is (= "$userName" (->expr @name*))))) (testing "the full guard + assign + action composition" (let [query* (sig "q")] - (is (= (str "(((evt.key) === (\"Enter\")) ? " - "(($q = evt.target.value), (@post(\"/search\"))) : (null))") + (is (= (str "var __hyper_r = ((((evt.key === \"Enter\")) ? " + "((() => { $q = evt.target.value; return @post(\"/search\") })()) : (null))); " + "return __hyper_r;") (->expr (when (= evt.key "Enter") (reset! query* evt.target.value) (@post "/search"))))))) @@ -129,22 +146,22 @@ (testing "locals splice as JS literals" (let [factor 2 label "a'b"] - (is (= "$scaled = ($raw * 2)" (->expr (set! $scaled (* $raw factor))))) - (is (= "$label = 'a\\'b'" (->expr (set! $label label))) + (is (= "($scaled = ($raw * 2))" (->expr (set! $scaled (* $raw factor))))) + (is (= "($label = 'a\\'b')" (->expr (set! $label label))) "strings are escaped JS literals"))) (testing "keyword-call forms evaluate as Clojure" (let [person {:id 1234 :name "alice"}] - (is (= "$person-id = 1234" (->expr (set! $person-id (:id person))))) - (is (= "$person-name = 'alice'" (->expr (set! $person-name (:name person))))))) + (is (= "($person-id = 1234)" (->expr (set! $person-id (:id person))))) + (is (= "($person-name = 'alice')" (->expr (set! $person-name (:name person))))))) (testing "explicit unquote still works as the escape hatch" (let [v 42] - (is (= "$x = 43" (->expr (set! $x ~(inc v))))))) + (is (= "($x = 43)" (->expr (set! $x ~(inc v))))))) (testing "collections splice as JS literals" (let [xs [1 2 3]] - (is (= "$items = [1, 2, 3]" (->expr (set! $items xs))))))) + (is (= "($items = [1, 2, 3])" (->expr (set! $items xs))))))) ;; --------------------------------------------------------------------------- ;; Compile-once semantics @@ -168,9 +185,9 @@ (deftest test-deref-signal-var (testing "deref of a top-level var holding a signal splices to a $ref" - (is (= "(!($_hyperConnected))" + (is (= "hyper_sc.not($_hyperConnected)" (->expr (not @signal/connected?*)))) - (is (= "($_hyperConnection) === (\"reconnecting\")" + (is (= "($_hyperConnection === \"reconnecting\")" (->expr (= @signal/connection* :reconnecting))))) (testing "keyword and string tokens compile identically" @@ -179,7 +196,7 @@ (testing "deref of a non-signal var is left to the client (no splice)" (is (str/includes? (->expr (not @some-undefined-thing)) - "squint_core.deref")))) + "hyper_sc.deref")))) ;; --------------------------------------------------------------------------- ;; DatastarExpr splicing — signals and actions ride one dispatch @@ -217,7 +234,7 @@ (rendering app-state* (let [out (h/expr (when (= evt.key "Enter") (h/action (reset! (h/tab-cursor :q) 2))))] - (is (str/includes? out "(evt.key) === (\"Enter\")")) + (is (str/includes? out "evt.key === \"Enter\"")) (is (str/includes? out "@post('/hyper/actions?action-id=a_t_1')")) (is (str/includes? out "?")) (is (not (str/includes? out "action("))))))) @@ -234,7 +251,7 @@ (deftest test-client-params-in-expr (testing "client-param symbols expand to their client-side JS" - (is (= "(evt.key) === (\"Enter\")" (->expr (= $key "Enter")))) + (is (= "(evt.key === \"Enter\")" (->expr (= $key "Enter")))) (is (= "evt.target.checked" (->expr $checked))) (is (= "evt.detail" (->expr $detail))) (is (= "Object.fromEntries(new FormData(evt.target.closest('form')))" @@ -246,7 +263,7 @@ (->expr (reset! q* $value)))))) (testing "non-param $signals still pass through untouched" - (is (= "$count = 0" (->expr (set! $count 0)))) + (is (= "($count = 0)" (->expr (set! $count 0)))) (let [name* (sig "userName")] (is (= "$userName" (->expr @name*))))) @@ -255,5 +272,5 @@ (rendering app-state* (let [out (h/expr (when (= $key "Enter") (h/action (reset! (h/tab-cursor :q) $value))))] - (is (str/includes? out "(evt.key) === (\"Enter\")")) + (is (str/includes? out "evt.key === \"Enter\"")) (is (str/includes? out "hyper.encodeClientParams({value:evt.target.value})"))))))) diff --git a/test/hyper/server_test.clj b/test/hyper/server_test.clj index 08d4f3f..dd7c67e 100644 --- a/test/hyper/server_test.clj +++ b/test/hyper/server_test.clj @@ -268,6 +268,27 @@ (is (= 200 (:status response))) (is (= "static-ok\n" (slurp (:body response))))))) +(deftest test-squint-core-js + (testing "repeated br requests return the identical compressed byte array" + (let [handler (server/create-handler [["/" {:get (fn [_] [:div])}]] (atom (state/init-state))) + request {:uri "/hyper/squint-core.js" + :request-method :get + :headers {"accept-encoding" "br"}} + response (handler request)] + (is (= "br" (get-in response [:headers "Content-Encoding"]))) + (is (bytes? (:body response))) + (is (identical? (:body response) (:body (handler request))))))) + +(deftest test-squint-core-version + (testing "the versioned squint core URL of a page is cached as immutable" + (let [handler (server/create-handler [["/" {:get (fn [_] [:div])}]] (atom (state/init-state))) + page (:body (handler {:uri "/" :request-method :get})) + v (second (re-find #"squint-core\.js\?v=([0-9a-f]+)" page))] + (is (= "public, max-age=31536000, immutable" + (get-in (handler {:uri "/hyper/squint-core.js" :request-method :get + :query-string (str "v=" v)}) + [:headers "Cache-Control"])))))) + (deftest test-open-when-hidden (testing "Default includes openWhenHidden: true in data-init" (let [app-state* (atom (state/init-state)) diff --git a/test/hyper/signal_test.clj b/test/hyper/signal_test.clj index c29456e..8620d29 100644 --- a/test/hyper/signal_test.clj +++ b/test/hyper/signal_test.clj @@ -499,8 +499,8 @@ "data-bind=\"sessionCols0Width\""))) (testing "expr treats it as a signal" - (is (= "$sessionCols0Width = 5" (->expr (reset! w* 5)))) - (is (= "$sessionCols0Width = ($sessionCols0Width + 1)" (->expr (swap! w* inc)))) + (is (= "($sessionCols0Width = 5)" (->expr (reset! w* 5)))) + (is (= "($sessionCols0Width = ($sessionCols0Width + 1))" (->expr (swap! w* inc)))) (is (= "$sessionCols0Width" (->expr @w*)))) (testing "declares the signal with the current committed value"