From 4627293e6b3927710a3920c34a20a65cbcd91e8f Mon Sep 17 00:00:00 2001 From: Aleksei Sotnikov Date: Thu, 16 Apr 2026 21:16:00 +0700 Subject: [PATCH 01/11] feat: use snake case + compact-uuids for frontend-facing IDs Adds `compact-uuids` library and switches frontend-facing IDs from hyphen (`-`) to underscore (`_`) separator (snake case). ## Rationale - **Snake case:** allow double-click selection of entire ID in browser devtools - **Compact UUIDs:** 26 chars vs 36 chars (30% smaller), URL-safe, no ambiguous characters (0/O, 1/l/I) ## Changes 1. **Compact UUIDs:** All UUID usages replaced with compact encoding 2. **Snake case:** Only frontend-facing IDs modified - action-id and tab-id --- deps.edn | 1 + src/hyper/actions.clj | 5 +++-- src/hyper/core.clj | 4 ++-- src/hyper/server.clj | 5 +++-- test/hyper/server_test.clj | 6 +++--- test/hyper/test_test.clj | 2 +- 6 files changed, 13 insertions(+), 10 deletions(-) diff --git a/deps.edn b/deps.edn index 7a12ce7..9b93711 100644 --- a/deps.edn +++ b/deps.edn @@ -24,6 +24,7 @@ org.clojure/core.memoize {:mvn/version "1.2.281"} camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"} ring/ring-defaults {:mvn/version "0.7.0"} + compact-uuids/compact-uuids {:mvn/version "0.2.1"} ;; JSON cheshire/cheshire {:mvn/version "6.1.0"} diff --git a/src/hyper/actions.clj b/src/hyper/actions.clj index 5e34cf4..8f59379 100644 --- a/src/hyper/actions.clj +++ b/src/hyper/actions.clj @@ -2,7 +2,8 @@ "Action handling for hyper applications. Actions are server-side functions triggered by client interactions." - (:require [taoensso.telemere :as t])) + (:require [compact-uuids.core :as uuid] + [taoensso.telemere :as t])) (defn register-action! "Register an action function and return its ID. @@ -15,7 +16,7 @@ - :as — a human-readable name for the action, useful for testing" ([app-state* session-id tab-id action-fn] (register-action! app-state* session-id tab-id action-fn - (str "action-" (java.util.UUID/randomUUID)) + (str "action-" (uuid/str (java.util.UUID/randomUUID))) nil)) ([app-state* session-id tab-id action-fn action-id] (register-action! app-state* session-id tab-id action-fn action-id nil)) diff --git a/src/hyper/core.clj b/src/hyper/core.clj index bde75af..e09b799 100644 --- a/src/hyper/core.clj +++ b/src/hyper/core.clj @@ -289,7 +289,7 @@ :hyper/router router#}] ~@body))) idx# (if context/*action-idx* (swap! context/*action-idx* inc) (hash action-fn#)) - action-id# (str "a-" tab-id# "-" idx#) + action-id# (str "a_" tab-id# "_" idx#) _# (actions/register-action! app-state*# session-id# tab-id# action-fn# action-id# ~(when as-name {:as as-name}))] (build-action-expr action-id# '~used-params ~js)))) @@ -347,7 +347,7 @@ :query-params (or query-params {})}))) nav-idx (if *action-idx* (swap! *action-idx* inc) (hash nav-fn)) action-id (actions/register-action! app-state* session-id tab-id nav-fn - (str "a-" tab-id "-" nav-idx)) + (str "a_" tab-id "_" nav-idx)) escaped-title (or (utils/escape-js-string title) "") escaped-href (utils/escape-js-string href)] {:href href diff --git a/src/hyper/server.clj b/src/hyper/server.clj index 00282f5..4fc8635 100644 --- a/src/hyper/server.clj +++ b/src/hyper/server.clj @@ -4,6 +4,7 @@ Provides Ring handler creation for hyper applications." (:require [cheshire.core :as json] [clojure.string] + [compact-uuids.core :as uuid] [dev.onionpancakes.chassis.core :as c] [hyper.actions :as actions] [hyper.brotli :as br] @@ -30,10 +31,10 @@ (:import (java.util.concurrent Semaphore))) (defn generate-session-id [] - (str "sess-" (java.util.UUID/randomUUID))) + (str "sess-" (uuid/str (java.util.UUID/randomUUID)))) (defn generate-tab-id [] - (str "tab-" (java.util.UUID/randomUUID))) + (str "tab_" (uuid/str (java.util.UUID/randomUUID)))) ;; --------------------------------------------------------------------------- ;; Per-tab renderer thread diff --git a/test/hyper/server_test.clj b/test/hyper/server_test.clj index 9d1b701..87c37cb 100644 --- a/test/hyper/server_test.clj +++ b/test/hyper/server_test.clj @@ -27,8 +27,8 @@ id2 (server/generate-tab-id)] (is (string? id1)) (is (string? id2)) - (is (.startsWith id1 "tab-")) - (is (.startsWith id2 "tab-")) + (is (.startsWith id1 "tab_")) + (is (.startsWith id2 "tab_")) (is (not= id1 id2))))) (deftest test-wrap-hyper-context-new-session @@ -46,7 +46,7 @@ (is (string? (get-in response [:cookies "hyper-session" :value]))) (is (.startsWith (get-in response [:cookies "hyper-session" :value]) "sess-")) (is (.contains (:body response) "session: sess-")) - (is (.contains (:body response) "tab: tab-"))))) + (is (.contains (:body response) "tab: tab_"))))) (deftest test-wrap-hyper-context-existing-session (testing "Middleware reuses existing session from cookie" diff --git a/test/hyper/test_test.clj b/test/hyper/test_test.clj index 3ce8c83..ea53e40 100644 --- a/test/hyper/test_test.clj +++ b/test/hyper/test_test.clj @@ -144,7 +144,7 @@ (is (= 1 (count (:actions result)))) (let [[k v] (first (:actions result))] (is (string? k)) - (is (str/starts-with? k "a-")) + (is (str/starts-with? k "a_")) (is (fn? (:fn v))))))) (deftest test-page-signals From 900e7bbc8a4dc7bfa8d2bebcfca4f6e7a019a444 Mon Sep 17 00:00:00 2001 From: Aleksei Sotnikov Date: Sun, 19 Apr 2026 14:34:20 +0700 Subject: [PATCH 02/11] fix: eliminate action-not-found race condition with RwLock Problem: cleanup-tab-actions! created a gap where :actions was empty between cleanup and re-registration. In-flight HTTP action requests during this gap would fail with "Action not found". Solution: Use ReentrantReadWriteLock (FIFO) per tab to serialize cleanup+render (write lock) with action execution (read lock). --- src/hyper/actions.clj | 34 ++++++++------ src/hyper/server.clj | 91 ++++++++++++++++++++++--------------- test/hyper/actions_test.clj | 26 ++++++----- 3 files changed, 90 insertions(+), 61 deletions(-) diff --git a/src/hyper/actions.clj b/src/hyper/actions.clj index 8f59379..6e27754 100644 --- a/src/hyper/actions.clj +++ b/src/hyper/actions.clj @@ -35,20 +35,26 @@ (defn execute-action! "Execute an action by ID with error handling. When client-params are provided they are passed to the action fn." - ([app-state* action-id] - (execute-action! app-state* action-id nil)) - ([app-state* action-id client-params] - (if-let [action-data (get-in @app-state* [:actions action-id])] - (let [{:keys [fn]} action-data] - (t/catch->error! :hyper.error/execute-action - (fn client-params)) - true) - (do - (t/log! {:level :warn - :id :hyper.error/action-not-found - :data {:hyper/action-id action-id} - :msg "Action not found"}) - (throw (ex-info "Action not found" {:hyper/action-id action-id})))))) + ([app-state* tab-id action-id] + (execute-action! app-state* tab-id action-id nil)) + ([app-state* tab-id action-id client-params] + (let [tab-read-lock (.readLock (get-in @app-state* [:tabs tab-id :renderer :rw-lock]))] + (.lock tab-read-lock) + (try + (if-let [action-data (get-in @app-state* [:actions action-id])] + (let [{:keys [fn]} action-data] + (t/catch->error! :hyper.error/execute-action + (fn client-params)) + true) + (do + (t/log! {:level :warn + :id :hyper.error/action-not-found + :data {:hyper/action-id action-id} + :msg "Action not found"}) + (throw (ex-info "Action not found" {:hyper/action-id action-id + :hyper/action-ids (keys (get-in @app-state* [:actions]))})))) + (finally + (.unlock tab-read-lock)))))) (defn cleanup-tab-actions! "Remove all actions for a tab." diff --git a/src/hyper/server.clj b/src/hyper/server.clj index 4fc8635..c80056e 100644 --- a/src/hyper/server.clj +++ b/src/hyper/server.clj @@ -28,7 +28,8 @@ [ring.middleware.params :as params] [ring.middleware.resource :as resource] [taoensso.telemere :as t]) - (:import (java.util.concurrent Semaphore))) + (:import (java.util.concurrent Semaphore) + (java.util.concurrent.locks ReentrantReadWriteLock))) (defn generate-session-id [] (str "sess-" (uuid/str (java.util.UUID/randomUUID)))) @@ -55,12 +56,13 @@ Exits when shutdown-renderer* is delivered." [app-state* session-id tab-id channel compress? ^Semaphore semaphore shutdown-renderer*] - (let [br-out (when compress? (br/byte-array-out-stream)) - br-stream (when br-out (br/compress-out-stream br-out :window-size 18)) - headers (cond-> {"Content-Type" "text/event-stream"} - compress? (assoc "Content-Encoding" "br")) - throttle-ms (long (or (get @app-state* :render-throttle-ms) - default-render-throttle-ms))] + (let [br-out (when compress? (br/byte-array-out-stream)) + br-stream (when br-out (br/compress-out-stream br-out :window-size 18)) + headers (cond-> {"Content-Type" "text/event-stream"} + compress? (assoc "Content-Encoding" "br")) + throttle-ms (long (or (get @app-state* :render-throttle-ms) + default-render-throttle-ms)) + tab-write-lock (.writeLock (get-in @app-state* [:tabs tab-id :renderer :rw-lock]))] (try ;; Send the connected event as the initial SSE response (headers + body). (let [connected-msg (render/format-connected-event tab-id) @@ -77,33 +79,49 @@ (.acquire semaphore) (.drainPermits semaphore) (when-not (realized? shutdown-renderer*) - (let [current-signals (get-in @app-state* [:tabs tab-id :signals]) - sig-patches (when (and current-signals - (not= current-signals last-sent-signals)) - (signal/changed-signals last-sent-signals current-signals)) - sent? (try - ;; Clean slate — remove stale actions before re-rendering - (actions/cleanup-tab-actions! app-state* tab-id) - (when-let [{:keys [title head-html body-html url declared-signals]} - (render/render-tab app-state* session-id tab-id)] - (let [head-event (render/format-head-update title head-html) - sig-attrs (signal/format-signal-attrs declared-signals) - div-attrs (cond-> {:id "hyper-app"} - url (assoc :data-hyper-url url) - sig-attrs (merge sig-attrs)) - wrapped-html (c/html [:div div-attrs (c/raw body-html)]) - body-event (render/format-datastar-fragment wrapped-html) - sig-event (when (seq sig-patches) - (signal/format-patch-signals-event sig-patches)) - sse-payload (str head-event body-event sig-event) - payload (if br-stream - (br/compress-stream br-out br-stream sse-payload) - sse-payload)] - (boolean (http-kit/send! channel payload false)))) - (catch Throwable e - (t/error! e {:id :hyper.error/renderer - :data {:hyper/tab-id tab-id}}) - nil))] + (let [[current-signals sig-patches render-tab-result] + ;; 1. Lock-guarded snapshot & rendering + (do + (.lock tab-write-lock) + (try + (let [current-signals (get-in @app-state* [:tabs tab-id :signals]) + sig-patches (when (and current-signals + (not= current-signals last-sent-signals)) + (signal/changed-signals last-sent-signals current-signals)) + ;; Clean slate — remove stale actions before re-rendering + _ (actions/cleanup-tab-actions! app-state* tab-id) + render-tab-result (try + (render/render-tab app-state* session-id tab-id) + (catch Throwable e + (t/error! e {:id :hyper.error/renderer + :data {:hyper/tab-id tab-id}}) + nil))] + [current-signals sig-patches render-tab-result]) + (finally (.unlock tab-write-lock)))) + ;; 2. Format events & send payload (skip if render returned nil) + sent? + (try + (when-let [{:keys [title head-html body-html url declared-signals]} render-tab-result] + (let [head-event (render/format-head-update title head-html) + sig-attrs (signal/format-signal-attrs declared-signals) + div-attrs (cond-> {:id "hyper-app"} + url (assoc :data-hyper-url url) + sig-attrs (merge sig-attrs)) + wrapped-html (c/html [:div div-attrs (c/raw body-html)]) + body-event (render/format-datastar-fragment wrapped-html) + sig-event (when (seq sig-patches) + (signal/format-patch-signals-event sig-patches)) + sse-payload (str head-event body-event sig-event) + payload (if br-stream + (br/compress-stream br-out br-stream sse-payload) + sse-payload)] + (boolean (http-kit/send! channel payload false)))) + (catch Throwable e + ;; 3. Error fallback + (t/error! e {:id :hyper.error/renderer + :data {:hyper/tab-id tab-id}}) + nil))] + ;; 4. Throttle & loop continuation ;; sent? is true (ok), nil (no render-fn or error), false (channel closed) (when-not (false? sent?) ;; Throttle: sleep so triggers during this window accumulate @@ -145,7 +163,8 @@ semaphore shutdown-renderer*)))] {:trigger-render! trigger-render! :stop! stop! - :thread thread})) + :thread thread + :rw-lock (ReentrantReadWriteLock. true)})) (defn wrap-hyper-context "Middleware that adds session-id and tab-id to the request." @@ -297,7 +316,7 @@ (push-thread-bindings {#'context/*request* req-with-state #'context/*signals* signals}) (try - (actions/execute-action! app-state* action-id client-params) + (actions/execute-action! app-state* tab-id action-id client-params) ;; 204 prevents Datastar from merging the response into signals {:status 204} diff --git a/test/hyper/actions_test.clj b/test/hyper/actions_test.clj index 3f05086..a81ba33 100644 --- a/test/hyper/actions_test.clj +++ b/test/hyper/actions_test.clj @@ -3,9 +3,13 @@ [hyper.actions :as actions] [hyper.state :as state])) +(def initialized-state (-> (state/init-state) + (assoc-in [:tabs "tab1" :renderer :rw-lock] + #java.util.concurrent.locks.ReentrantReadWriteLock[true]))) + (deftest register-action-test (testing "registers an action and returns ID" - (let [app-state* (atom (state/init-state)) + (let [app-state* (atom initialized-state) session-id "test-session-1" tab-id "test-tab-1" action-fn (fn [] :executed) @@ -18,7 +22,7 @@ (is (fn? (get-in @app-state* [:actions action-id :fn]))))) (testing "generates unique IDs" - (let [app-state* (atom (state/init-state)) + (let [app-state* (atom initialized-state) action-fn (fn [] :executed) id1 (actions/register-action! app-state* "sess1" "tab1" action-fn) id2 (actions/register-action! app-state* "sess1" "tab1" action-fn)] @@ -26,30 +30,30 @@ (deftest execute-action-test (testing "executes registered action" - (let [app-state* (atom (state/init-state)) + (let [app-state* (atom initialized-state) executed (atom false) action-fn (fn [_] (reset! executed true)) action-id (actions/register-action! app-state* "sess1" "tab1" action-fn)] - (actions/execute-action! app-state* action-id) + (actions/execute-action! app-state* "tab1" action-id) (is @executed))) (testing "action can access closures" - (let [app-state* (atom (state/init-state)) + (let [app-state* (atom initialized-state) result (atom nil) captured-value 42 action-fn (fn [_] (reset! result captured-value)) action-id (actions/register-action! app-state* "sess1" "tab1" action-fn)] - (actions/execute-action! app-state* action-id) + (actions/execute-action! app-state* "tab1" action-id) (is (= 42 @result)))) (testing "throws exception for missing action" - (let [app-state* (atom (state/init-state))] + (let [app-state* (atom initialized-state)] (is (thrown? Exception - (actions/execute-action! app-state* "nonexistent-action")))))) + (actions/execute-action! app-state* "tab1" "nonexistent-action")))))) (deftest cleanup-tab-actions-test (testing "removes all actions for a tab" - (let [app-state* (atom (state/init-state)) + (let [app-state* (atom initialized-state) tab-id "test-tab-cleanup" action-fn (fn [_] :executed) action-id-1 (actions/register-action! app-state* "sess1" tab-id action-fn) @@ -67,9 +71,9 @@ (deftest test-execute-action-with-client-params (testing "executes action with client params passed through" - (let [app-state* (atom (state/init-state)) + (let [app-state* (atom initialized-state) result (atom nil) action-fn (fn [params] (reset! result (:value params))) action-id (actions/register-action! app-state* "sess1" "tab1" action-fn)] - (actions/execute-action! app-state* action-id {:value "test-value"}) + (actions/execute-action! app-state* "tab1" action-id {:value "test-value"}) (is (= "test-value" @result))))) From 1d564bae890e01f15a0890e51b88facc46701250 Mon Sep 17 00:00:00 2001 From: Aleksei Sotnikov Date: Mon, 4 May 2026 23:01:44 +0700 Subject: [PATCH 03/11] add clojure-plus --- deps.edn | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deps.edn b/deps.edn index 9b93711..8ccc134 100644 --- a/deps.edn +++ b/deps.edn @@ -42,4 +42,5 @@ :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"}}}}} + :dev {:extra-deps {io.github.tonsky/clj-reload {:mvn/version "1.0.0"} + io.github.tonsky/clojure-plus {:mvn/version "1.7.2"}}}}} From 08ee043bfc5aba22f6d497f72be8fac30d27cdf4 Mon Sep 17 00:00:00 2001 From: Aleksei Sotnikov Date: Mon, 4 May 2026 23:01:54 +0700 Subject: [PATCH 04/11] gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index fe5eb23..34a7282 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ /.nrepl-port .envrc /.lsp/ +.idea +hyper.iml +.qwen From 59dd0919122c4e6a899137a671d2ff9bc068c42b Mon Sep 17 00:00:00 2001 From: Ryan Schmukler Date: Tue, 21 Apr 2026 12:45:56 -0400 Subject: [PATCH 05/11] docs: readme examples w/ inline cursor usage Don't re-create cursors in the examples (cherry picked from commit c59b595105418bae9bb2b20bfbe9fc5f818843ae) --- Readme.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Readme.md b/Readme.md index d2bc08b..8e2c887 100644 --- a/Readme.md +++ b/Readme.md @@ -18,7 +18,7 @@ automatically. No client-side framework, no JSON APIs, no JavaScript to write. (let [count* (h/tab-cursor :count 0)] [:div [:h1 "Count: " @count*] - [:button {:data-on:click (h/action (swap! (h/tab-cursor :count) inc))} + [:button {:data-on:click (h/action (swap! count* inc))} "Increment"]])) (def routes @@ -133,8 +133,8 @@ bound to any event attribute. (let [count* (h/tab-cursor :count 0)] [:div [:p "Count: " @count*] - [:button {:data-on:click (h/action (swap! (h/tab-cursor :count) inc))} "+1"] - [:button {:data-on:click (h/action (swap! (h/tab-cursor :count) dec))} "-1"]])) + [:button {:data-on:click (h/action (swap! count* inc))} "+1"] + [:button {:data-on:click (h/action (swap! count* dec))} "-1"]])) ``` When the button is clicked, Datastar POSTs to the server, Hyper executes the @@ -669,10 +669,10 @@ happened: [:div [:h1 "Count: " @count*] [:button {:data-on:click (h/action {:as "increment"} - (swap! (h/tab-cursor :count) inc))} + (swap! count* inc))} "+1"] [:button {:data-on:click (h/action {:as "decrement"} - (swap! (h/tab-cursor :count) dec))} + (swap! count* dec))} "-1"]])) (ht/test-page counter-page) From 095e572fcd696baae6a046cb67334e969bcaa4d0 Mon Sep 17 00:00:00 2001 From: Tobias Locsei Date: Sat, 18 Apr 2026 11:16:23 +0800 Subject: [PATCH 06/11] fix(server): SSE-friendly headers on /hyper/events Closes #35 Prevent reverse proxies such as NGINX and OpenResty from buffering or transforming Hyper's SSE stream. In some managed hosting setups, that can delay SSE patches and leave the UI stuck in transient states even though the underlying action has already completed. Send Cache-Control: no-cache, no-transform and X-Accel-Buffering: no with Hyper's built-in /hyper/events response so SSE behaves reliably behind those proxies while preserving the existing Brotli behavior. (cherry picked from commit f49074c89923cc4e050e53da16ff36d02e1ad711) # Conflicts: # src/hyper/server.clj --- Readme.md | 4 ++++ src/hyper/server.clj | 6 ++++-- test/hyper/server_test.clj | 27 ++++++++++++++++++++++++++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Readme.md b/Readme.md index 8e2c887..4a0ebda 100644 --- a/Readme.md +++ b/Readme.md @@ -633,6 +633,10 @@ tab is hidden and reopening it when visible — pass `:open-when-hidden? false`: :open-when-hidden? false)) ``` +Hyper's built-in `/hyper/events` endpoint automatically sends SSE-friendly +headers, including `Cache-Control: no-cache, no-transform` and +`X-Accel-Buffering: no`, to improve compatibility with reverse proxies. + ## Brotli compression Hyper uses [brotli4j](https://github.com/hyperxpro/Brotli4j) to compress both diff --git a/src/hyper/server.clj b/src/hyper/server.clj index c80056e..f47b6c5 100644 --- a/src/hyper/server.clj +++ b/src/hyper/server.clj @@ -58,8 +58,10 @@ ^Semaphore semaphore shutdown-renderer*] (let [br-out (when compress? (br/byte-array-out-stream)) br-stream (when br-out (br/compress-out-stream br-out :window-size 18)) - headers (cond-> {"Content-Type" "text/event-stream"} - compress? (assoc "Content-Encoding" "br")) + headers (cond-> {"Content-Type" "text/event-stream" + "Cache-Control" "no-cache, no-transform" + "X-Accel-Buffering" "no"} + compress? (assoc "Content-Encoding" "br")) throttle-ms (long (or (get @app-state* :render-throttle-ms) default-render-throttle-ms)) tab-write-lock (.writeLock (get-in @app-state* [:tabs tab-id :renderer :rw-lock]))] diff --git a/test/hyper/server_test.clj b/test/hyper/server_test.clj index 87c37cb..9c692e6 100644 --- a/test/hyper/server_test.clj +++ b/test/hyper/server_test.clj @@ -9,7 +9,8 @@ [hyper.state :as state] [hyper.watch :as watch] [matcher-combinators.matchers :as m] - [matcher-combinators.test :refer [match?]])) + [matcher-combinators.test :refer [match?]] + [org.httpkit.server :as http-kit])) (deftest test-generate-session-id (testing "Session ID generation" @@ -83,6 +84,30 @@ :type "module"}] script))))) +(deftest test-initial-sse-response-headers + (testing "sends the expected initial SSE response headers" + (doseq [[compress? expected-headers] + [[false {"Content-Type" "text/event-stream" + "Cache-Control" "no-cache, no-transform" + "X-Accel-Buffering" "no"}] + [true {"Content-Type" "text/event-stream" + "Cache-Control" "no-cache, no-transform" + "X-Accel-Buffering" "no" + "Content-Encoding" "br"}]]] + (let [captured-response (atom nil)] + (with-redefs [http-kit/send! (fn [_channel response _close-after-send?] + (reset! captured-response response) + false)] + (#'server/-renderer-loop! (atom (state/init-state)) + "sess-test" + "tab-test" + ::channel + compress? + (java.util.concurrent.Semaphore. 0) + (promise)) + (is (= expected-headers + (:headers @captured-response)))))))) + (deftest test-create-handler (testing "Creates a working ring handler" (let [app-state* (atom (state/init-state)) From 50512ee1a7245b6be4fe6edb30f261c02291bd03 Mon Sep 17 00:00:00 2001 From: Ryan Schmukler Date: Tue, 21 Apr 2026 12:55:50 -0400 Subject: [PATCH 07/11] docs: clarify commit subject length standards Clarify commit message standards to have a target max length (cherry picked from commit 03686a5fb6b14ec684b8449e08563ae179d3fdce) --- Readme.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Readme.md b/Readme.md index 4a0ebda..87100b9 100644 --- a/Readme.md +++ b/Readme.md @@ -819,4 +819,14 @@ The E2E suite covers: ## Contributing -PRs and ideas welcome! Please follow the [angular commit guidelines](https://github.com/angular/angular/blob/main/contributing-docs/commit-message-guidelines.md) with your messages. +PRs and ideas welcome! Please follow the [angular commit +guidelines](https://github.com/angular/angular/blob/main/contributing-docs/commit-message-guidelines.md) +with your messages. All subject lines should be less than 80 characters long and +avoid needless language. + +eg: + +``` bash +fix: header template rendering w/ nil data +``` + From ed32ee6e47e8fd61c0d216414a983e9fe8edbcb9 Mon Sep 17 00:00:00 2001 From: Ryan Schmukler Date: Thu, 23 Apr 2026 16:57:43 -0400 Subject: [PATCH 08/11] fix: setting signal to `nil` to clear it Fix an issue where we couldn't use nil as a value on a signal (cherry picked from commit 78bab7b5d9558a91d7ddd8674a9afa0b801f1319) --- src/hyper/signal.clj | 6 ++++-- test/hyper/signal_test.clj | 25 ++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/hyper/signal.clj b/src/hyper/signal.clj index f2ab099..a42b815 100644 --- a/src/hyper/signal.clj +++ b/src/hyper/signal.clj @@ -195,8 +195,10 @@ html-nm (signal-html-name path) st-path (signal-store-path path) signal (->Signal js-name html-nm st-path app-state* tab-id default-val)] - ;; Initialise the server-side value if not already set - (when (nil? (get-in @app-state* (into [:tabs tab-id :signals] st-path))) + ;; Initialise the server-side value if not already set. + ;; Use a sentinel to distinguish "never set" from "explicitly set to nil", + ;; so that (reset! sig nil) is not silently overwritten by the default. + (when (identical? ::not-found (get-in @app-state* (into [:tabs tab-id :signals] st-path) ::not-found)) (swap! app-state* assoc-in (into [:tabs tab-id :signals] st-path) default-val)) ;; During render, register for HTML declaration (when-let [acc context/*declared-signals*] diff --git a/test/hyper/signal_test.clj b/test/hyper/signal_test.clj index 1da4044..6c4a891 100644 --- a/test/hyper/signal_test.clj +++ b/test/hyper/signal_test.clj @@ -149,7 +149,30 @@ (let [sig (h/signal :count 0)] (is (= 0 (get-in @app-state* [:tabs tab-id :signals :count]))) (reset! sig 42) - (is (= 42 (get-in @app-state* [:tabs tab-id :signals :count])))))))) + (is (= 42 (get-in @app-state* [:tabs tab-id :signals :count]))))))) + + (testing "reset! to nil is preserved across re-renders (not overwritten by default)" + (let [app-state* (atom (state/init-state)) + tab-id "tab_6b"] + (state/get-or-create-tab! app-state* "ses_1" tab-id) + ;; First render — creates the signal with default "hello" + (binding [context/*request* {:hyper/session-id "ses_1" + :hyper/tab-id tab-id + :hyper/app-state app-state*} + context/*declared-signals* (atom [])] + (let [sig (h/signal :greeting "hello")] + (is (= "hello" (get-in @app-state* [:tabs tab-id :signals :greeting]))) + ;; Simulate an action that sets the signal to nil + (reset! sig nil) + (is (nil? (get-in @app-state* [:tabs tab-id :signals :greeting]))))) + ;; Second render — create-signal should NOT overwrite nil with default + (binding [context/*request* {:hyper/session-id "ses_1" + :hyper/tab-id tab-id + :hyper/app-state app-state*} + context/*declared-signals* (atom [])] + (h/signal :greeting "hello") + (is (nil? (get-in @app-state* [:tabs tab-id :signals :greeting])) + "nil value should be preserved, not reset to default"))))) (deftest signal-swap!-test (testing "swap! in action context uses live signal value" From 7aab58dff4559a7286a695301788489603f3dd07 Mon Sep 17 00:00:00 2001 From: Ryan Schmukler Date: Thu, 23 Apr 2026 17:20:15 -0400 Subject: [PATCH 09/11] fix: broken actions w/ lazy HTML Read *registered-action-ids* after HTML serialization. The accumulator was captured before (c/html body), so actions registered inside lazy hiccup sequences (for, map, etc.) were missed. sweep-stale-tab-actions! then removed every action for the tab, causing "Action not found" on the next click. Move (c/html body) before the deref so all lazily-realized actions are included in the live set. (cherry picked from commit 9d66c63feeab3902bb8025bb302f028969740034) --- src/hyper/render.clj | 30 +++++++++++++++++------------- test/hyper/render_test.clj | 31 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/hyper/render.clj b/src/hyper/render.clj index 2848e77..d4b0712 100644 --- a/src/hyper/render.clj +++ b/src/hyper/render.clj @@ -75,7 +75,7 @@ the document title and swaps user-provided elements. Why not morph? Morphing inner content via idiomorph can - disconnect