Skip to content

Commit d123453

Browse files
authored
Fix rendering bugs in datastar (#3847)
1 parent 8216cb6 commit d123453

File tree

5 files changed

+453
-2
lines changed

5 files changed

+453
-2
lines changed

zio-http-datastar-sdk/src/main/scala/zio/http/datastar/DatastarPackageBase.scala

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,15 @@ trait DatastarPackageBase extends Attributes {
122122
),
123123
)
124124

125-
private val executeScriptCodec = (HttpCodec.content[String]
125+
private val executeScriptCodec = (HttpCodec.Content(
126+
HttpContentCodec.Choices(
127+
ListMap(
128+
MediaType.text.`javascript` ->
129+
BinaryCodecWithSchema(zio.http.codec.TextBinaryCodec.fromSchema[String](Schema[String]), Schema[String]),
130+
),
131+
),
132+
None,
133+
)
126134
++ HttpCodec
127135
.header(Header.ContentType)
128136
.const(Header.ContentType(MediaType.text.`javascript`))

zio-http-datastar-sdk/src/main/scala/zio/http/datastar/ServerSentEventGenerator.scala

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,11 @@ object ServerSentEventGenerator {
175175
sb.append("useViewTransition true\n")
176176
}
177177

178-
sb.append("elements ").append(elements.renderMinified).append('\n')
178+
val rendered = elements.renderMinified
179+
if (rendered.contains('\n'))
180+
rendered.split('\n').foreach(line => sb.append("elements ").append(line).append('\n'))
181+
else
182+
sb.append("elements ").append(rendered).append('\n')
179183

180184
val retry = if (options.retryDuration != DefaultRetryDelay) Some(options.retryDuration) else None
181185
send(EventType.PatchElements, sb.toString(), options.eventId, retry)
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
package zio.http.datastar
2+
3+
import zio.test._
4+
5+
import zio.http._
6+
import zio.http.endpoint._
7+
import zio.http.template2._
8+
9+
/**
10+
* Tests for the datastarEventCodec to ensure proper encoding/decoding of
11+
* DatastarEvent responses, especially for ExecuteScript events where JavaScript
12+
* should not be wrapped in double quotes.
13+
*/
14+
object DatastarEndpointCodecSpec extends ZIOSpecDefault {
15+
16+
override def spec = suite("DatastarEndpointCodecSpec")(
17+
suite("executeScriptCodec")(
18+
test("should encode single-line JavaScript without wrapping in quotes") {
19+
val scriptContent = "console.log('Hello, World!');"
20+
val event = DatastarEvent.executeScript(scriptContent)
21+
val endpoint = Endpoint.datastarEvent(Method.GET / "script")
22+
val routes = Routes(endpoint.implementHandler(Handler.succeed(event)))
23+
val request = Request.get("/script")
24+
25+
for {
26+
response <- routes.runZIO(request)
27+
body <- response.body.asString
28+
} yield assertTrue(
29+
response.status == Status.Ok,
30+
response.headers.get(Header.ContentType).contains(Header.ContentType(MediaType.text.`javascript`)),
31+
body == scriptContent,
32+
!body.startsWith("\""),
33+
!body.endsWith("\""),
34+
)
35+
},
36+
test("should encode multi-line JavaScript without wrapping in quotes") {
37+
val scriptContent = """const x = 1;
38+
|const y = 2;
39+
|console.log(x + y);""".stripMargin
40+
val event = DatastarEvent.executeScript(scriptContent)
41+
val endpoint = Endpoint.datastarEvent(Method.GET / "script")
42+
val routes = Routes(endpoint.implementHandler(Handler.succeed(event)))
43+
val request = Request.get("/script")
44+
45+
for {
46+
response <- routes.runZIO(request)
47+
body <- response.body.asString
48+
} yield assertTrue(
49+
response.status == Status.Ok,
50+
response.headers.get(Header.ContentType).contains(Header.ContentType(MediaType.text.`javascript`)),
51+
body == scriptContent,
52+
!body.startsWith("\""),
53+
!body.endsWith("\""),
54+
body.contains("const x = 1;"),
55+
body.contains("const y = 2;"),
56+
body.contains("console.log(x + y);"),
57+
)
58+
},
59+
test("should encode JavaScript with quotes without double-encoding") {
60+
val scriptContent = """alert("Hello 'World'!");"""
61+
val event = DatastarEvent.executeScript(scriptContent)
62+
val endpoint = Endpoint.datastarEvent(Method.GET / "script")
63+
val routes = Routes(endpoint.implementHandler(Handler.succeed(event)))
64+
val request = Request.get("/script")
65+
66+
for {
67+
response <- routes.runZIO(request)
68+
body <- response.body.asString
69+
} yield assertTrue(
70+
response.status == Status.Ok,
71+
body == scriptContent,
72+
body.contains("""alert("Hello 'World'!");"""),
73+
// Verify it's not JSON-encoded (which would escape the quotes)
74+
!body.contains("""\"Hello"""),
75+
)
76+
},
77+
test("should encode JavaScript with special characters") {
78+
val scriptContent = """const message = "Line 1\nLine 2\tTabbed";
79+
|console.log(message);""".stripMargin
80+
val event = DatastarEvent.executeScript(scriptContent)
81+
val endpoint = Endpoint.datastarEvent(Method.GET / "script")
82+
val routes = Routes(endpoint.implementHandler(Handler.succeed(event)))
83+
val request = Request.get("/script")
84+
85+
for {
86+
response <- routes.runZIO(request)
87+
body <- response.body.asString
88+
} yield assertTrue(
89+
response.status == Status.Ok,
90+
body == scriptContent,
91+
body.contains("Line 1\\nLine 2\\tTabbed"),
92+
)
93+
},
94+
test("should encode JavaScript function without quotes") {
95+
val scriptContent = "function greet(name) {\n return 'Hello, ' + name + '!';\n}\nconsole.log(greet('World'));"
96+
val event = DatastarEvent.executeScript(scriptContent)
97+
val endpoint = Endpoint.datastarEvent(Method.GET / "script")
98+
val routes = Routes(endpoint.implementHandler(Handler.succeed(event)))
99+
val request = Request.get("/script")
100+
101+
for {
102+
response <- routes.runZIO(request)
103+
body <- response.body.asString
104+
} yield assertTrue(
105+
response.status == Status.Ok,
106+
body == scriptContent,
107+
body.contains("function greet(name)"),
108+
body.contains("Hello,"),
109+
)
110+
},
111+
test("should encode empty JavaScript") {
112+
val scriptContent = ""
113+
val event = DatastarEvent.executeScript(scriptContent)
114+
val endpoint = Endpoint.datastarEvent(Method.GET / "script")
115+
val routes = Routes(endpoint.implementHandler(Handler.succeed(event)))
116+
val request = Request.get("/script")
117+
118+
for {
119+
response <- routes.runZIO(request)
120+
body <- response.body.asString
121+
} yield assertTrue(
122+
response.status == Status.Ok,
123+
body == scriptContent,
124+
body.isEmpty,
125+
)
126+
},
127+
test("should preserve whitespace in JavaScript") {
128+
val scriptContent = """ const x = 1;
129+
| const y = 2;
130+
| console.log(x + y);""".stripMargin
131+
val event = DatastarEvent.executeScript(scriptContent)
132+
val endpoint = Endpoint.datastarEvent(Method.GET / "script")
133+
val routes = Routes(endpoint.implementHandler(Handler.succeed(event)))
134+
val request = Request.get("/script")
135+
136+
for {
137+
response <- routes.runZIO(request)
138+
body <- response.body.asString
139+
} yield assertTrue(
140+
response.status == Status.Ok,
141+
body == scriptContent,
142+
body.startsWith(" const x = 1;"),
143+
)
144+
},
145+
),
146+
suite("patchSignalsCodec")(
147+
test("should encode signals as JSON string") {
148+
val event = DatastarEvent.patchSignals(Seq("user" -> "John", "count" -> "42"))
149+
val endpoint = Endpoint.datastarEvent(Method.GET / "signals")
150+
val routes = Routes(endpoint.implementHandler(Handler.succeed(event)))
151+
val request = Request.get("/signals")
152+
153+
for {
154+
response <- routes.runZIO(request)
155+
body <- response.body.asString
156+
} yield assertTrue(
157+
response.status == Status.Ok,
158+
response.headers.get(Header.ContentType).contains(Header.ContentType(MediaType.application.`json`)),
159+
body.contains("user"),
160+
body.contains("John"),
161+
body.contains("count"),
162+
body.contains("42"),
163+
)
164+
},
165+
),
166+
suite("patchElementsCodec")(
167+
test("should encode HTML elements") {
168+
val element = div(id := "test")("Hello")
169+
val event = DatastarEvent.patchElements(element)
170+
val endpoint = Endpoint.datastarEvent(Method.GET / "elements")
171+
val routes = Routes(endpoint.implementHandler(Handler.succeed(event)))
172+
val request = Request.get("/elements")
173+
174+
for {
175+
response <- routes.runZIO(request)
176+
body <- response.body.asString
177+
} yield assertTrue(
178+
response.status == Status.Ok,
179+
response.headers.get(Header.ContentType).contains(Header.ContentType(MediaType.text.`html`)),
180+
body.contains("Hello"),
181+
)
182+
},
183+
test("should encode multi-line HTML elements") {
184+
val element = div(
185+
script("""console.log('line 1');
186+
|console.log('line 2');""".stripMargin),
187+
)
188+
val event = DatastarEvent.patchElements(element)
189+
val endpoint = Endpoint.datastarEvent(Method.GET / "elements")
190+
val routes = Routes(endpoint.implementHandler(Handler.succeed(event)))
191+
val request = Request.get("/elements")
192+
193+
for {
194+
response <- routes.runZIO(request)
195+
body <- response.body.asString
196+
} yield assertTrue(
197+
response.status == Status.Ok,
198+
body.contains("console.log('line 1');"),
199+
body.contains("console.log('line 2');"),
200+
)
201+
},
202+
),
203+
suite("datastarEventCodec alternatives")(
204+
test("should route ExecuteScript to javascript content type") {
205+
val scriptEvent = DatastarEvent.executeScript("console.log('test');")
206+
val endpoint = Endpoint.datastarEvent(Method.GET / "event")
207+
val routes = Routes(endpoint.implementHandler(Handler.succeed(scriptEvent)))
208+
val request = Request.get("/event")
209+
210+
for {
211+
response <- routes.runZIO(request)
212+
_ <- response.body.asString
213+
} yield assertTrue(
214+
response.headers.get(Header.ContentType).contains(Header.ContentType(MediaType.text.`javascript`)),
215+
)
216+
},
217+
test("should route PatchSignals to json content type") {
218+
val signalEvent = DatastarEvent.patchSignals("test" -> "true")
219+
val endpoint = Endpoint.datastarEvent(Method.GET / "event")
220+
val routes = Routes(endpoint.implementHandler(Handler.succeed(signalEvent)))
221+
val request = Request.get("/event")
222+
223+
for {
224+
response <- routes.runZIO(request)
225+
_ <- response.body.asString
226+
} yield assertTrue(
227+
response.headers.get(Header.ContentType).contains(Header.ContentType(MediaType.application.`json`)),
228+
)
229+
},
230+
test("should route PatchElements to html content type") {
231+
val elementEvent = DatastarEvent.patchElements(div("test"))
232+
val endpoint = Endpoint.datastarEvent(Method.GET / "event")
233+
val routes = Routes(endpoint.implementHandler(Handler.succeed(elementEvent)))
234+
val request = Request.get("/event")
235+
236+
for {
237+
response <- routes.runZIO(request)
238+
_ <- response.body.asString
239+
} yield assertTrue(
240+
response.headers.get(Header.ContentType).contains(Header.ContentType(MediaType.text.`html`)),
241+
)
242+
},
243+
),
244+
)
245+
246+
}

zio-http-datastar-sdk/src/test/scala/zio/http/datastar/DatastarRequestSpec.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,8 @@ object DatastarRequestSpec extends ZIOSpecDefault {
486486
)
487487
val request = DatastarRequest(Method.GET, url"/api/users", options)
488488

489+
println(request.render)
490+
489491
assertTrue(
490492
request.render.contains("@get"),
491493
request.render.contains("/api/users"),

0 commit comments

Comments
 (0)