@pyreon/runtime-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,916 @@
1
+ import type { ComponentFn, VNode } from "@pyreon/core"
2
+ import { createContext, For, Fragment, h, pushContext, Suspense, useContext } from "@pyreon/core"
3
+ import { signal } from "@pyreon/reactivity"
4
+ import {
5
+ configureStoreIsolation,
6
+ renderToStream,
7
+ renderToString,
8
+ runWithRequestContext,
9
+ } from "../index"
10
+
11
+ async function collectStream(stream: ReadableStream<string>): Promise<string> {
12
+ const reader = stream.getReader()
13
+ const chunks: string[] = []
14
+ while (true) {
15
+ const { done, value } = await reader.read()
16
+ if (done) break
17
+ chunks.push(value)
18
+ }
19
+ return chunks.join("")
20
+ }
21
+
22
+ // ─── renderToString ───────────────────────────────────────────────────────────
23
+
24
+ describe("renderToString — elements", () => {
25
+ test("renders a simple element", async () => {
26
+ expect(await renderToString(h("div", null))).toBe("<div></div>")
27
+ })
28
+
29
+ test("renders void element self-closing", async () => {
30
+ expect(await renderToString(h("br", null))).toBe("<br />")
31
+ })
32
+
33
+ test("renders static text child", async () => {
34
+ expect(await renderToString(h("p", null, "hello"))).toBe("<p>hello</p>")
35
+ })
36
+
37
+ test("escapes text content", async () => {
38
+ expect(await renderToString(h("p", null, "<script>"))).toBe("<p>&lt;script&gt;</p>")
39
+ })
40
+
41
+ test("renders nested elements", async () => {
42
+ const vnode = h("ul", null, h("li", null, "a"), h("li", null, "b"))
43
+ expect(await renderToString(vnode)).toBe("<ul><li>a</li><li>b</li></ul>")
44
+ })
45
+
46
+ test("renders null as empty string", async () => {
47
+ expect(await renderToString(null)).toBe("")
48
+ })
49
+ })
50
+
51
+ describe("renderToString — props", () => {
52
+ test("renders static string prop", async () => {
53
+ expect(await renderToString(h("div", { class: "box" }))).toBe(`<div class="box"></div>`)
54
+ })
55
+
56
+ test("renders boolean prop (true → attribute name only)", async () => {
57
+ const html = await renderToString(h("input", { disabled: true }))
58
+ expect(html).toContain("disabled")
59
+ })
60
+
61
+ test("omits false prop", async () => {
62
+ const html = await renderToString(h("input", { disabled: false }))
63
+ expect(html).not.toContain("disabled")
64
+ })
65
+
66
+ test("omits event handlers", async () => {
67
+ const html = await renderToString(h("button", { onClick: () => {} }))
68
+ expect(html).not.toContain("onClick")
69
+ expect(html).not.toContain("on-click")
70
+ })
71
+
72
+ test("omits key and ref", async () => {
73
+ const html = await renderToString(h("div", { key: "k", ref: null }))
74
+ expect(html).not.toContain("key")
75
+ expect(html).not.toContain("ref")
76
+ })
77
+
78
+ test("renders style object", async () => {
79
+ const html = await renderToString(h("div", { style: { color: "red", fontSize: "16px" } }))
80
+ expect(html).toContain("color: red")
81
+ expect(html).toContain("font-size: 16px")
82
+ })
83
+ })
84
+
85
+ describe("renderToString — reactive props (signal snapshots)", () => {
86
+ test("snapshots a reactive prop getter", async () => {
87
+ const count = signal(42)
88
+ const html = await renderToString(h("span", { "data-count": () => count() }))
89
+ expect(html).toBe(`<span data-count="42"></span>`)
90
+ })
91
+
92
+ test("snapshots a reactive child getter", async () => {
93
+ const name = signal("world")
94
+ const html = await renderToString(h("p", null, () => name()))
95
+ expect(html).toBe("<p>world</p>")
96
+ })
97
+ })
98
+
99
+ describe("renderToString — Fragment", () => {
100
+ test("renders Fragment children without wrapper", async () => {
101
+ const vnode = h(Fragment, null, h("span", null, "a"), h("span", null, "b"))
102
+ expect(await renderToString(vnode)).toBe("<span>a</span><span>b</span>")
103
+ })
104
+ })
105
+
106
+ describe("renderToString — components", () => {
107
+ test("renders a component", async () => {
108
+ const Greeting = (props: { name: string }) => h("p", null, `Hello, ${props.name}!`)
109
+ const html = await renderToString(h(Greeting, { name: "Pyreon" }))
110
+ expect(html).toBe("<p>Hello, Pyreon!</p>")
111
+ })
112
+
113
+ test("renders a component returning null", async () => {
114
+ const Empty = () => null
115
+ const html = await renderToString(h(Empty, null))
116
+ expect(html).toBe("")
117
+ })
118
+ })
119
+
120
+ // ─── renderToStream ───────────────────────────────────────────────────────────
121
+
122
+ describe("renderToStream", () => {
123
+ async function collect(stream: ReadableStream<string>): Promise<string> {
124
+ const reader = stream.getReader()
125
+ let result = ""
126
+ while (true) {
127
+ const { done, value } = await reader.read()
128
+ if (done) break
129
+ result += value
130
+ }
131
+ return result
132
+ }
133
+
134
+ test("streams a simple element", async () => {
135
+ const html = await collect(renderToStream(h("div", null, "hi")))
136
+ expect(html).toBe("<div>hi</div>")
137
+ })
138
+
139
+ test("streams null as empty", async () => {
140
+ expect(await collect(renderToStream(null))).toBe("")
141
+ })
142
+
143
+ test("streams chunks progressively — opening tag before children", async () => {
144
+ // An async child that resolves after a delay.
145
+ // The parent opening tag must be enqueued BEFORE the child resolves.
146
+ const chunks: string[] = []
147
+ async function SlowChild() {
148
+ await new Promise<void>((r) => setTimeout(r, 5))
149
+ return h("span", null, "done")
150
+ }
151
+ const stream = renderToStream(h("div", null, h(SlowChild as unknown as ComponentFn, null)))
152
+ const reader = stream.getReader()
153
+ while (true) {
154
+ const { done, value } = await reader.read()
155
+ if (done) break
156
+ chunks.push(value)
157
+ }
158
+ // First chunk must be the opening tag, not the full string
159
+ expect(chunks[0]).toBe("<div>")
160
+ expect(chunks.join("")).toBe("<div><span>done</span></div>")
161
+ })
162
+
163
+ test("streams async component output", async () => {
164
+ async function Async() {
165
+ await new Promise<void>((r) => setTimeout(r, 1))
166
+ return h("p", null, "async")
167
+ }
168
+ const html = await collect(renderToStream(h(Async as unknown as ComponentFn, null)))
169
+ expect(html).toBe("<p>async</p>")
170
+ })
171
+ })
172
+
173
+ // ─── Concurrent SSR isolation ────────────────────────────────────────────────
174
+
175
+ describe("concurrent SSR — context isolation", () => {
176
+ test("two concurrent renderToString calls do not share context", async () => {
177
+ const Ctx = createContext("default")
178
+
179
+ // Each HeadInjector runs inside its own ALS scope.
180
+ // If context were shared, the second render would see the first's value.
181
+ function makeInjector(value: string): ComponentFn {
182
+ return function Injector() {
183
+ pushContext(new Map([[Ctx.id, value]]))
184
+ return h("span", null, () => useContext(Ctx))
185
+ }
186
+ }
187
+
188
+ // Stagger start slightly so they interleave
189
+ const [html1, html2] = await Promise.all([
190
+ renderToString(h(makeInjector("request-A"), null)),
191
+ renderToString(h(makeInjector("request-B"), null)),
192
+ ])
193
+
194
+ expect(html1).toBe("<span>request-A</span>")
195
+ expect(html2).toBe("<span>request-B</span>")
196
+ })
197
+
198
+ test("concurrent renders with async components stay isolated", async () => {
199
+ const Ctx = createContext("none")
200
+
201
+ function makeApp(value: string): ComponentFn {
202
+ return async function App() {
203
+ // Inject context, then yield (simulates async data loading)
204
+ pushContext(new Map([[Ctx.id, value]]))
205
+ await new Promise<void>((r) => setTimeout(r, 5))
206
+ return h("div", null, () => useContext(Ctx))
207
+ } as unknown as ComponentFn
208
+ }
209
+
210
+ const [html1, html2] = await Promise.all([
211
+ renderToString(h(makeApp("R1"), null)),
212
+ renderToString(h(makeApp("R2"), null)),
213
+ ])
214
+
215
+ expect(html1).toBe("<div>R1</div>")
216
+ expect(html2).toBe("<div>R2</div>")
217
+ })
218
+ })
219
+
220
+ // ─── Streaming Suspense SSR ───────────────────────────────────────────────────
221
+
222
+ describe("renderToStream — Suspense boundaries", () => {
223
+ test("streams fallback immediately, then resolved content with swap", async () => {
224
+ async function Slow(): Promise<ReturnType<typeof h>> {
225
+ await new Promise<void>((r) => setTimeout(r, 10))
226
+ return h("p", { id: "resolved" }, "loaded")
227
+ }
228
+
229
+ const vnode = h(Suspense, {
230
+ fallback: h("p", { id: "fallback" }, "loading..."),
231
+ children: h(Slow as unknown as unknown as ComponentFn, null),
232
+ })
233
+
234
+ const html = await collectStream(renderToStream(vnode))
235
+
236
+ // Fallback placeholder was emitted
237
+ expect(html).toContain('id="pyreon-s-0"')
238
+ expect(html).toContain("loading...")
239
+ // Resolved content emitted in template + swap
240
+ expect(html).toContain('id="pyreon-t-0"')
241
+ expect(html).toContain("loaded")
242
+ expect(html).toContain("__NS")
243
+ })
244
+
245
+ test("renderToStream emits chunks progressively (placeholder before resolution)", async () => {
246
+ const chunkOrder: string[] = []
247
+
248
+ async function SlowContent(): Promise<ReturnType<typeof h>> {
249
+ await new Promise<void>((r) => setTimeout(r, 10))
250
+ return h("span", null, "done")
251
+ }
252
+
253
+ const vnode = h(
254
+ "div",
255
+ null,
256
+ h(Suspense, {
257
+ fallback: h("span", { id: "fb" }, "wait"),
258
+ children: h(SlowContent as unknown as unknown as ComponentFn, null),
259
+ }),
260
+ h("p", null, "after"),
261
+ )
262
+
263
+ const reader = renderToStream(vnode).getReader()
264
+ while (true) {
265
+ const { done, value } = await reader.read()
266
+ if (done) break
267
+ chunkOrder.push(value)
268
+ }
269
+
270
+ const full = chunkOrder.join("")
271
+ // Placeholder chunk must appear before the resolved template chunk
272
+ const placeholderIdx = full.indexOf("pyreon-s-0")
273
+ const templateIdx = full.indexOf("pyreon-t-0")
274
+ expect(placeholderIdx).toBeGreaterThanOrEqual(0)
275
+ expect(templateIdx).toBeGreaterThan(placeholderIdx)
276
+ // "after" sibling is in the HTML (not blocked by Suspense)
277
+ expect(full).toContain("<p>after</p>")
278
+ })
279
+
280
+ test("renderToString renders Suspense children synchronously (no streaming)", async () => {
281
+ async function Data(): Promise<ReturnType<typeof h>> {
282
+ return h("span", null, "ssr-data")
283
+ }
284
+
285
+ const vnode = h(Suspense, {
286
+ fallback: h("span", null, "fb"),
287
+ children: h(Data as unknown as unknown as ComponentFn, null),
288
+ })
289
+
290
+ const html = await renderToString(vnode)
291
+ // renderToString should render fallback via Suspense's sync path
292
+ // (Suspense on server with non-lazy child just shows the fallback or children)
293
+ expect(typeof html).toBe("string")
294
+ expect(html.length).toBeGreaterThan(0)
295
+ })
296
+ })
297
+
298
+ // ─── Concurrent SSR — context isolation ───────────────────────────────────────
299
+
300
+ describe("concurrent SSR — context isolation", () => {
301
+ test("50 concurrent requests with async components don't bleed context", async () => {
302
+ const ReqIdCtx = createContext("none")
303
+
304
+ // Async component that reads context after a variable delay — simulates DB/fetch latency
305
+ async function AsyncReader(props: { delay: number }): Promise<VNode> {
306
+ await new Promise<void>((r) => setTimeout(r, props.delay))
307
+ const id = useContext(ReqIdCtx)
308
+ return h("span", null, id)
309
+ }
310
+
311
+ // Wrapper component that injects a per-request context value then renders AsyncReader
312
+ function RequestWrapper(props: { reqId: string; delay: number }): VNode {
313
+ pushContext(new Map([[ReqIdCtx.id, props.reqId]]))
314
+ return h(AsyncReader as unknown as unknown as ComponentFn, { delay: props.delay })
315
+ }
316
+
317
+ const N = 50
318
+ const results = await Promise.all(
319
+ Array.from({ length: N }, (_, i) =>
320
+ renderToString(
321
+ h(RequestWrapper, { reqId: `req-${i}`, delay: Math.floor(Math.random() * 20) }),
322
+ ),
323
+ ),
324
+ )
325
+
326
+ // Every render must see its own context value, never another request's
327
+ results.forEach((html, i) => {
328
+ expect(html).toContain(`req-${i}`)
329
+ })
330
+ })
331
+
332
+ test("context is isolated even when all requests resolve in reverse order", async () => {
333
+ const ReqIdCtx = createContext("none")
334
+
335
+ async function SlowReader(props: { delay: number }): Promise<VNode> {
336
+ await new Promise<void>((r) => setTimeout(r, props.delay))
337
+ return h("span", null, useContext(ReqIdCtx))
338
+ }
339
+
340
+ const N = 10
341
+ const results = await Promise.all(
342
+ Array.from({ length: N }, (_, i) => {
343
+ // Higher index = shorter delay → resolves first in reverse order
344
+ const delay = (N - i) * 5
345
+ return renderToString(
346
+ h(
347
+ ((props: { reqId: string; delay: number }): VNode => {
348
+ pushContext(new Map([[ReqIdCtx.id, props.reqId]]))
349
+ return h(SlowReader as unknown as unknown as ComponentFn, { delay: props.delay })
350
+ }) as unknown as ComponentFn,
351
+ { reqId: `id-${i}`, delay },
352
+ ),
353
+ )
354
+ }),
355
+ )
356
+
357
+ results.forEach((html, i) => {
358
+ expect(html).toBe(`<span>id-${i}</span>`)
359
+ })
360
+ })
361
+ })
362
+
363
+ // ─── configureStoreIsolation ────────────────────────────────────────────────
364
+
365
+ describe("configureStoreIsolation", () => {
366
+ test("activates store isolation — withStoreContext wraps in ALS", async () => {
367
+ let providerCalled = false
368
+ const mockSetProvider = (fn: () => Map<string, unknown>) => {
369
+ providerCalled = true
370
+ // The provider should return a Map
371
+ const result = fn()
372
+ expect(result).toBeInstanceOf(Map)
373
+ }
374
+ configureStoreIsolation(mockSetProvider)
375
+ expect(providerCalled).toBe(true)
376
+
377
+ // After activation, renderToString should work (it uses withStoreContext internally)
378
+ const html = await renderToString(h("div", null, "store-iso"))
379
+ expect(html).toBe("<div>store-iso</div>")
380
+ })
381
+ })
382
+
383
+ // ─── runWithRequestContext ───────────────────────────────────────────────────
384
+
385
+ describe("runWithRequestContext", () => {
386
+ test("provides isolated context for async operations", async () => {
387
+ const Ctx = createContext("default")
388
+ const result = await runWithRequestContext(async () => {
389
+ pushContext(new Map([[Ctx.id, "request-val"]]))
390
+ return useContext(Ctx)
391
+ })
392
+ expect(result).toBe("request-val")
393
+ })
394
+
395
+ test("two concurrent runWithRequestContext calls are isolated", async () => {
396
+ const Ctx = createContext("none")
397
+ const [r1, r2] = await Promise.all([
398
+ runWithRequestContext(async () => {
399
+ pushContext(new Map([[Ctx.id, "ctx-A"]]))
400
+ await new Promise<void>((r) => setTimeout(r, 5))
401
+ return useContext(Ctx)
402
+ }),
403
+ runWithRequestContext(async () => {
404
+ pushContext(new Map([[Ctx.id, "ctx-B"]]))
405
+ await new Promise<void>((r) => setTimeout(r, 5))
406
+ return useContext(Ctx)
407
+ }),
408
+ ])
409
+ expect(r1).toBe("ctx-A")
410
+ expect(r2).toBe("ctx-B")
411
+ })
412
+ })
413
+
414
+ // ─── renderToString — uncovered branches ─────────────────────────────────────
415
+
416
+ describe("renderToString — For component", () => {
417
+ test("renders For with hydration markers", async () => {
418
+ const items = signal(["a", "b", "c"])
419
+ const vnode = For({
420
+ each: items,
421
+ by: (item: unknown) => item as string,
422
+ children: (item: unknown) => h("li", null, item as string),
423
+ })
424
+ const html = await renderToString(vnode)
425
+ expect(html).toContain("<!--pyreon-for-->")
426
+ expect(html).toContain("<!--/pyreon-for-->")
427
+ expect(html).toContain("<li>a</li>")
428
+ expect(html).toContain("<li>b</li>")
429
+ expect(html).toContain("<li>c</li>")
430
+ })
431
+ })
432
+
433
+ describe("renderToString — array children", () => {
434
+ test("renders array of VNodes", async () => {
435
+ const arr = [h("span", null, "x"), h("span", null, "y")]
436
+ // Components can return arrays
437
+ const Comp: ComponentFn = () => arr as unknown as VNode
438
+ const html = await renderToString(h(Comp, null))
439
+ expect(html).toContain("<span>x</span>")
440
+ expect(html).toContain("<span>y</span>")
441
+ })
442
+ })
443
+
444
+ describe("renderToString — class and style edge cases", () => {
445
+ test("renders class as array", async () => {
446
+ const html = await renderToString(h("div", { class: ["foo", null, "bar", false, "baz"] }))
447
+ expect(html).toContain('class="foo bar baz"')
448
+ })
449
+
450
+ test("renders class as object (truthy/falsy)", async () => {
451
+ const html = await renderToString(h("div", { class: { active: true, hidden: false, bold: 1 } }))
452
+ expect(html).toContain("active")
453
+ expect(html).toContain("bold")
454
+ expect(html).not.toContain("hidden")
455
+ })
456
+
457
+ test("renders empty class as no attribute", async () => {
458
+ const html = await renderToString(h("div", { class: "" }))
459
+ expect(html).toBe("<div></div>")
460
+ })
461
+
462
+ test("renders non-standard class value (number) as no attribute", async () => {
463
+ // normalizeClass falls through to return "" for non-string/array/object
464
+ const html = await renderToString(h("div", { class: 42 }))
465
+ expect(html).toBe("<div></div>")
466
+ })
467
+
468
+ test("renders style as string", async () => {
469
+ const html = await renderToString(h("div", { style: "color: red" }))
470
+ expect(html).toContain('style="color: red"')
471
+ })
472
+
473
+ test("renders empty style object as no attribute", async () => {
474
+ // normalizeStyle with non-object/non-string falls through to return ""
475
+ const html = await renderToString(h("div", { style: 42 }))
476
+ expect(html).toBe("<div></div>")
477
+ })
478
+
479
+ test("renders className → class and htmlFor → for", async () => {
480
+ const html = await renderToString(h("label", { className: "lbl", htmlFor: "inp" }))
481
+ expect(html).toContain('class="lbl"')
482
+ expect(html).toContain('for="inp"')
483
+ })
484
+
485
+ test("renders camelCase props as kebab-case attributes", async () => {
486
+ const html = await renderToString(h("div", { dataTestId: "val" }))
487
+ expect(html).toContain('data-test-id="val"')
488
+ })
489
+ })
490
+
491
+ describe("renderToString — URL injection blocking", () => {
492
+ test("blocks javascript: in href", async () => {
493
+ const html = await renderToString(h("a", { href: "javascript:alert(1)" }))
494
+ expect(html).not.toContain("javascript")
495
+ expect(html).toBe("<a></a>")
496
+ })
497
+
498
+ test("blocks data: in src", async () => {
499
+ const html = await renderToString(h("img", { src: "data:text/html,<h1>hi</h1>" }))
500
+ expect(html).not.toContain("data:")
501
+ })
502
+ })
503
+
504
+ describe("renderToString — null/undefined/boolean prop values", () => {
505
+ test("omits null and undefined props", async () => {
506
+ const html = await renderToString(h("div", { "data-a": null, "data-b": undefined }))
507
+ expect(html).toBe("<div></div>")
508
+ })
509
+
510
+ test("renders number children", async () => {
511
+ const html = await renderToString(h("span", null, 42))
512
+ expect(html).toBe("<span>42</span>")
513
+ })
514
+
515
+ test("renders boolean true as text in children", async () => {
516
+ const html = await renderToString(h("span", null, true))
517
+ expect(html).toBe("<span>true</span>")
518
+ })
519
+
520
+ test("omits false children", async () => {
521
+ const html = await renderToString(h("span", null, false))
522
+ expect(html).toBe("<span></span>")
523
+ })
524
+ })
525
+
526
+ describe("renderToString — n- directives", () => {
527
+ test("omits n-show and custom n- directives", async () => {
528
+ const html = await renderToString(h("div", { "n-show": true, "n-custom": () => {} }))
529
+ expect(html).not.toContain("n-show")
530
+ expect(html).not.toContain("n-custom")
531
+ })
532
+ })
533
+
534
+ describe("renderToString — component with children via h()", () => {
535
+ test("mergeChildrenIntoProps passes children to component", async () => {
536
+ const Wrapper: ComponentFn = (props: Record<string, unknown>) => {
537
+ return h("div", null, props.children as VNode)
538
+ }
539
+ const html = await renderToString(h(Wrapper, null, h("span", null, "child")))
540
+ expect(html).toBe("<div><span>child</span></div>")
541
+ })
542
+
543
+ test("mergeChildrenIntoProps passes multiple children as array", async () => {
544
+ const Wrapper: ComponentFn = (props: Record<string, unknown>) => {
545
+ const kids = props.children as VNode[]
546
+ return h("div", null, ...kids)
547
+ }
548
+ const html = await renderToString(h(Wrapper, null, h("a", null, "1"), h("b", null, "2")))
549
+ expect(html).toBe("<div><a>1</a><b>2</b></div>")
550
+ })
551
+
552
+ test("does not override explicit children prop", async () => {
553
+ const Wrapper: ComponentFn = (props: Record<string, unknown>) => {
554
+ return h("div", null, props.children as VNode)
555
+ }
556
+ const html = await renderToString(h(Wrapper, { children: h("em", null, "explicit") }))
557
+ expect(html).toBe("<div><em>explicit</em></div>")
558
+ })
559
+ })
560
+
561
+ // ─── renderToStream — uncovered branches ─────────────────────────────────────
562
+
563
+ describe("renderToStream — additional coverage", () => {
564
+ async function collect(stream: ReadableStream<string>): Promise<string> {
565
+ const reader = stream.getReader()
566
+ let result = ""
567
+ while (true) {
568
+ const { done, value } = await reader.read()
569
+ if (done) break
570
+ result += value
571
+ }
572
+ return result
573
+ }
574
+
575
+ test("streams Fragment children", async () => {
576
+ const html = await collect(
577
+ renderToStream(h(Fragment, null, h("a", null, "1"), h("b", null, "2"))),
578
+ )
579
+ expect(html).toBe("<a>1</a><b>2</b>")
580
+ })
581
+
582
+ test("streams For component with markers", async () => {
583
+ const items = signal(["x", "y"])
584
+ const vnode = For({
585
+ each: items,
586
+ by: (item: unknown) => item as string,
587
+ children: (item: unknown) => h("li", null, item as string),
588
+ })
589
+ const html = await collect(renderToStream(vnode))
590
+ expect(html).toContain("<!--pyreon-for-->")
591
+ expect(html).toContain("<li>x</li>")
592
+ expect(html).toContain("<li>y</li>")
593
+ expect(html).toContain("<!--/pyreon-for-->")
594
+ })
595
+
596
+ test("streams reactive getter children", async () => {
597
+ const name = signal("streamed")
598
+ const html = await collect(renderToStream(h("p", null, () => name())))
599
+ expect(html).toContain("streamed")
600
+ })
601
+
602
+ test("streams number and boolean children", async () => {
603
+ const html = await collect(renderToStream(h("span", null, 99)))
604
+ expect(html).toContain("99")
605
+ })
606
+
607
+ test("streams array children", async () => {
608
+ const Comp: ComponentFn = () => [h("a", null, "1"), h("b", null, "2")] as unknown as VNode
609
+ const html = await collect(renderToStream(h(Comp, null)))
610
+ expect(html).toContain("<a>1</a>")
611
+ expect(html).toContain("<b>2</b>")
612
+ })
613
+
614
+ test("streams void elements", async () => {
615
+ const html = await collect(renderToStream(h("img", { src: "/pic.png" })))
616
+ expect(html).toContain("<img")
617
+ expect(html).toContain("/>")
618
+ })
619
+
620
+ test("streams component returning null", async () => {
621
+ const Empty: ComponentFn = () => null
622
+ const html = await collect(renderToStream(h(Empty, null)))
623
+ expect(html).toBe("")
624
+ })
625
+
626
+ test("streams false/null children as empty", async () => {
627
+ const html = await collect(renderToStream(h("div", null, false, null)))
628
+ expect(html).toBe("<div></div>")
629
+ })
630
+
631
+ test("streams string children directly", async () => {
632
+ const html = await collect(renderToStream(h("p", null, "text & <tag>")))
633
+ expect(html).toContain("text &amp; &lt;tag&gt;")
634
+ })
635
+
636
+ test("multiple Suspense boundaries get incrementing IDs", async () => {
637
+ async function Slow1(): Promise<VNode> {
638
+ await new Promise<void>((r) => setTimeout(r, 5))
639
+ return h("span", null, "s1")
640
+ }
641
+ async function Slow2(): Promise<VNode> {
642
+ await new Promise<void>((r) => setTimeout(r, 5))
643
+ return h("span", null, "s2")
644
+ }
645
+ const vnode = h(
646
+ "div",
647
+ null,
648
+ h(Suspense, {
649
+ fallback: h("p", null, "fb1"),
650
+ children: h(Slow1 as unknown as unknown as ComponentFn, null),
651
+ }),
652
+ h(Suspense, {
653
+ fallback: h("p", null, "fb2"),
654
+ children: h(Slow2 as unknown as unknown as ComponentFn, null),
655
+ }),
656
+ )
657
+ const html = await collect(renderToStream(vnode))
658
+ expect(html).toContain("pyreon-s-0")
659
+ expect(html).toContain("pyreon-s-1")
660
+ expect(html).toContain("pyreon-t-0")
661
+ expect(html).toContain("pyreon-t-1")
662
+ // The swap script should only be emitted once
663
+ const scriptMatches = html.match(/function __NS/g)
664
+ expect(scriptMatches).toHaveLength(1)
665
+ })
666
+ })
667
+
668
+ // ─── Concurrent SSR isolation ─────────────────────────────────────────────────
669
+
670
+ describe("concurrent SSR isolation", () => {
671
+ test("50 concurrent renders produce correct isolated output", async () => {
672
+ function Page(props: { id: number }) {
673
+ return h("div", { "data-id": props.id }, `page-${props.id}`)
674
+ }
675
+
676
+ const renders = Array.from({ length: 50 }, (_, i) =>
677
+ runWithRequestContext(() =>
678
+ renderToString(h(Page as unknown as unknown as ComponentFn, { id: i })),
679
+ ),
680
+ )
681
+ const results = await Promise.all(renders)
682
+
683
+ for (let i = 0; i < 50; i++) {
684
+ const html = results[i]
685
+ expect(html).toContain(`data-id="${i}"`)
686
+ expect(html).toContain(`page-${i}`)
687
+ }
688
+ })
689
+
690
+ test("concurrent renders with different props do not leak state", async () => {
691
+ function UserPage(props: { name: string }) {
692
+ return h("div", null, `user:${props.name}`)
693
+ }
694
+
695
+ // Launch 40 concurrent renders with alternating data
696
+ const renders = Array.from({ length: 40 }, (_, i) =>
697
+ runWithRequestContext(() => {
698
+ const name = i % 2 === 0 ? `alice-${i}` : `bob-${i}`
699
+ return renderToString(h(UserPage as unknown as unknown as ComponentFn, { name }))
700
+ }),
701
+ )
702
+ const results = await Promise.all(renders)
703
+
704
+ for (let i = 0; i < 40; i++) {
705
+ const expected = i % 2 === 0 ? `user:alice-${i}` : `user:bob-${i}`
706
+ expect(results[i]).toContain(expected)
707
+ }
708
+ })
709
+
710
+ test("concurrent renders with async components stay isolated", async () => {
711
+ async function SlowPage(props: { label: string }): Promise<VNode> {
712
+ await new Promise<void>((r) => setTimeout(r, Math.random() * 10))
713
+ return h("span", null, props.label)
714
+ }
715
+
716
+ const renders = Array.from({ length: 30 }, (_, i) =>
717
+ runWithRequestContext(() =>
718
+ renderToString(h(SlowPage as unknown as unknown as ComponentFn, { label: `item-${i}` })),
719
+ ),
720
+ )
721
+ const results = await Promise.all(renders)
722
+
723
+ for (let i = 0; i < 30; i++) {
724
+ expect(results[i]).toContain(`item-${i}`)
725
+ }
726
+ })
727
+ })
728
+
729
+ // ─── Additional coverage — edge cases ─────────────────────────────────────────
730
+
731
+ describe("renderToString — escapeHtml edge cases", () => {
732
+ test("escapes single quotes in attribute values", async () => {
733
+ const html = await renderToString(h("div", { title: "it's here" }))
734
+ expect(html).toContain("it&#39;s here")
735
+ })
736
+
737
+ test("escapes ampersand in text content", async () => {
738
+ const html = await renderToString(h("p", null, "A & B"))
739
+ expect(html).toBe("<p>A &amp; B</p>")
740
+ })
741
+
742
+ test("escapes double quotes in attribute values", async () => {
743
+ const html = await renderToString(h("div", { title: 'say "hello"' }))
744
+ expect(html).toContain("say &quot;hello&quot;")
745
+ })
746
+ })
747
+
748
+ describe("renderToStream — boolean and edge-case children", () => {
749
+ async function collect(stream: ReadableStream<string>): Promise<string> {
750
+ const reader = stream.getReader()
751
+ let result = ""
752
+ while (true) {
753
+ const { done, value } = await reader.read()
754
+ if (done) break
755
+ result += value
756
+ }
757
+ return result
758
+ }
759
+
760
+ test("streams boolean true child as 'true'", async () => {
761
+ const html = await collect(renderToStream(h("span", null, true)))
762
+ expect(html).toBe("<span>true</span>")
763
+ })
764
+
765
+ test("streams boolean false child as empty", async () => {
766
+ const html = await collect(renderToStream(h("span", null, false)))
767
+ expect(html).toBe("<span></span>")
768
+ })
769
+
770
+ test("streams props with reactive getter", async () => {
771
+ const cls = signal("active")
772
+ const html = await collect(renderToStream(h("div", { class: () => cls() })))
773
+ expect(html).toContain('class="active"')
774
+ })
775
+
776
+ test("streams element with multiple props", async () => {
777
+ const html = await collect(
778
+ renderToStream(h("input", { type: "text", placeholder: "enter", disabled: true })),
779
+ )
780
+ expect(html).toContain('type="text"')
781
+ expect(html).toContain('placeholder="enter"')
782
+ expect(html).toContain("disabled")
783
+ })
784
+ })
785
+
786
+ describe("renderToStream — Suspense edge cases", () => {
787
+ async function collect(stream: ReadableStream<string>): Promise<string> {
788
+ const reader = stream.getReader()
789
+ let result = ""
790
+ while (true) {
791
+ const { done, value } = await reader.read()
792
+ if (done) break
793
+ result += value
794
+ }
795
+ return result
796
+ }
797
+
798
+ test("Suspense boundary with no fallback prop", async () => {
799
+ async function Content(): Promise<VNode> {
800
+ await new Promise<void>((r) => setTimeout(r, 5))
801
+ return h("span", null, "content")
802
+ }
803
+
804
+ const vnode = h(Suspense, {
805
+ fallback: h("span", null, ""),
806
+ children: h(Content as unknown as ComponentFn, null),
807
+ })
808
+ const html = await collect(renderToStream(vnode))
809
+ expect(html).toContain("content")
810
+ })
811
+
812
+ test("Suspense boundary with no children prop", async () => {
813
+ const vnode = h(Suspense, {
814
+ fallback: h("span", null, "loading"),
815
+ })
816
+ const html = await collect(renderToStream(vnode))
817
+ expect(html).toContain("loading")
818
+ })
819
+ })
820
+
821
+ describe("renderToString — prop rendering edge cases", () => {
822
+ test("renders true boolean prop as attribute name only (escaped)", async () => {
823
+ const html = await renderToString(h("input", { disabled: true }))
824
+ expect(html).toContain("disabled")
825
+ // Should not contain ="true"
826
+ expect(html).not.toContain('disabled="true"')
827
+ })
828
+
829
+ test("omits props with null value", async () => {
830
+ const html = await renderToString(h("div", { "data-x": null }))
831
+ expect(html).toBe("<div></div>")
832
+ })
833
+
834
+ test("omits props with undefined value", async () => {
835
+ const html = await renderToString(h("div", { "data-x": undefined }))
836
+ expect(html).toBe("<div></div>")
837
+ })
838
+
839
+ test("blocks javascript: URI in action attribute", async () => {
840
+ const html = await renderToString(h("form", { action: "javascript:void(0)" }))
841
+ expect(html).not.toContain("javascript")
842
+ })
843
+
844
+ test("blocks data: URI in poster attribute", async () => {
845
+ const html = await renderToString(h("video", { poster: "data:image/png;base64,abc" }))
846
+ expect(html).not.toContain("data:")
847
+ })
848
+
849
+ test("allows safe URLs in href", async () => {
850
+ const html = await renderToString(h("a", { href: "https://example.com" }))
851
+ expect(html).toContain('href="https://example.com"')
852
+ })
853
+
854
+ test("renders style object with camelCase keys as kebab-case", async () => {
855
+ const html = await renderToString(h("div", { style: { backgroundColor: "red" } }))
856
+ expect(html).toContain("background-color: red")
857
+ })
858
+ })
859
+
860
+ describe("renderToStream — error handling", () => {
861
+ async function collectStream(stream: ReadableStream<string>): Promise<string> {
862
+ const reader = stream.getReader()
863
+ let result = ""
864
+ while (true) {
865
+ const { done, value } = await reader.read()
866
+ if (done) break
867
+ result += value
868
+ }
869
+ return result
870
+ }
871
+
872
+ test("stream errors when component throws", async () => {
873
+ function Boom(): VNode {
874
+ throw new Error("render error")
875
+ }
876
+
877
+ const stream = renderToStream(h(Boom as ComponentFn, null))
878
+ const reader = stream.getReader()
879
+ await expect(reader.read()).rejects.toThrow("render error")
880
+ })
881
+
882
+ test("stream renders element with skipped prop (event handler)", async () => {
883
+ // Event handlers return null from renderProp — exercises `if (attr)` false branch in streamNode
884
+ const html = await collectStream(
885
+ renderToStream(h("button", { onClick: () => {}, id: "btn" }, "click")),
886
+ )
887
+ expect(html).toContain('<button id="btn">')
888
+ expect(html).not.toContain("onClick")
889
+ })
890
+ })
891
+
892
+ // ─── Edge-case branches ──────────────────────────────────────────────────────
893
+
894
+ describe("edge-case branches", () => {
895
+ test("async component returning null via renderToString", async () => {
896
+ async function NullComp(): Promise<null> {
897
+ return null
898
+ }
899
+ const html = await renderToString(h(NullComp as unknown as ComponentFn, null))
900
+ expect(html).toBe("")
901
+ })
902
+
903
+ test("Suspense in stream without streaming context (renderToString path)", async () => {
904
+ // This tests the !ctx branch in streamSuspenseBoundary
905
+ // renderToString handles Suspense via renderNode, not streamNode, so we test it there
906
+ function Child(): VNode {
907
+ return h("span", null, "resolved")
908
+ }
909
+ const vnode = h(Suspense, {
910
+ fallback: h("span", null, "loading"),
911
+ children: h(Child as ComponentFn, null),
912
+ })
913
+ const html = await renderToString(vnode)
914
+ expect(typeof html).toBe("string")
915
+ })
916
+ })