From d565e3517364a4637da454315a8d418f7bf9906a Mon Sep 17 00:00:00 2001 From: Aleksei Sotnikov Date: Fri, 13 Mar 2026 00:28:36 +0700 Subject: [PATCH] fix: handle newlines in SSE events to prevent content truncation When HTML content contains newline characters (\n), the SSE event formatting was not escaping them properly. This caused SSE events to be prematurely terminated, resulting in truncated content on the client side during live-reload updates. Root cause: format-datastar-fragment was outputting HTML directly in a single 'data: elements' line without handling newlines. Any \n\n in the HTML would prematurely end the SSE event according to the SSE spec. Fix: Split HTML by \n and emit multiple 'data: elements' lines, which Datastar concatenates. This follows Datastar's documented multi-line SSE format and prevents \n\n from breaking the event stream. Tests added: - Unit tests for textarea and pre elements with newline content - E2E test for content with newlines during route redefinition --- src/hyper/render.clj | 15 +++++++++++---- test/hyper/e2e_test.clj | 16 +++++++++++++++- test/hyper/render_test.clj | 19 ++++++++++++++++++- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/hyper/render.clj b/src/hyper/render.clj index 20ace83..09d1ea2 100644 --- a/src/hyper/render.clj +++ b/src/hyper/render.clj @@ -2,7 +2,8 @@ "Rendering pipeline. Handles rendering hiccup to HTML and formatting Datastar SSE events." - (:require [dev.onionpancakes.chassis.core :as c] + (:require [clojure.string :as string] + [dev.onionpancakes.chassis.core :as c] [hyper.context :as context] [hyper.routes :as routes] [hyper.state :as state] @@ -27,10 +28,16 @@ event: datastar-patch-elements data: elements - (blank line to end event)" + For multi-line HTML content, emits multiple 'data: elements' lines + that Datastar will concatenate. This prevents \n in HTML from + prematurely terminating the SSE event." [html] - (str "event: datastar-patch-elements\n" - "data: elements " html "\n\n")) + (let [lines (string/split-lines html)] + (str "event: datastar-patch-elements\n" + (->> lines + (map (fn [line] (str "data: elements " line "\n"))) + (apply str)) + "\n"))) (defn mark-head-elements "Add `{:data-hyper-head true}` to each top-level hiccup element in a diff --git a/test/hyper/e2e_test.clj b/test/hyper/e2e_test.clj index ba4495c..0f7b4d8 100644 --- a/test/hyper/e2e_test.clj +++ b/test/hyper/e2e_test.clj @@ -578,8 +578,22 @@ (is (= "Live Reloaded!" (w/text-content "h1"))) (is (= "This content was hot-swapped" - (w/text-content "#reloaded-marker"))))) + (w/text-content "#reloaded-marker")))) + ;; Test that content with newlines renders correctly + (testing "Content with newlines preserved in route handler" + (alter-var-root #'*test-routes* + (constantly + [["/" {:name :home + :title "Newlines Test" + :get (fn [_] + [:div + [:textarea#newline-content "line1\nline2"] + [:pre#pre-content "code\nwith\n\nnew\n\nlines\n\n"]])}]])) + (w/navigate (str base-url "/")) + (wait-for-sse) + (is (= "line1\nline2" (w/text-content "#newline-content"))) + (is (= "code\nwith\n\nnew\n\nlines\n\n" (w/text-content "#pre-content"))))) (finally (close-browser! browser-info))))) diff --git a/test/hyper/render_test.clj b/test/hyper/render_test.clj index ff33f4c..30f40cd 100644 --- a/test/hyper/render_test.clj +++ b/test/hyper/render_test.clj @@ -38,7 +38,24 @@ (let [html "test" fragment (render/format-datastar-fragment html)] (is (.contains fragment html)) - (is (.startsWith fragment "event: datastar-patch-elements\n"))))) + (is (.startsWith fragment "event: datastar-patch-elements\n")))) + + (testing "HTML with 2 newlines emits multiple data lines" + (let [html "
code\nwith\nnewline
" + fragment (render/format-datastar-fragment html)] + (is (= 3 (count (re-seq #"data: elements" fragment)))) + (is (.contains fragment "
code"))
+      (is (.contains fragment "with"))
+      (is (.contains fragment "newline
")) + (is (.endsWith fragment "\n\n")))) + + (testing "HTML with double newlines emits multiple data lines" + (let [html "" + fragment (render/format-datastar-fragment html)] + (is (= 3 (count (re-seq #"data: elements" fragment)))) + (is (.contains fragment "")) + (is (.endsWith fragment "\n\n"))))) (deftest test-render-tab (testing "render-tab returns nil when no render-fn is registered"