@pyreon/runtime-server 0.24.4 → 0.24.6

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.
@@ -1,1365 +0,0 @@
1
- import type { ComponentFn, VNode } from '@pyreon/core'
2
- import {
3
- createContext,
4
- For,
5
- Fragment,
6
- h,
7
- onUnmount,
8
- provide,
9
- pushContext,
10
- Suspense,
11
- useContext,
12
- } from '@pyreon/core'
13
- import { signal } from '@pyreon/reactivity'
14
- import {
15
- configureStoreIsolation,
16
- renderToStream,
17
- renderToString,
18
- runWithRequestContext,
19
- } from '../index'
20
-
21
- async function collectStream(stream: ReadableStream<string>): Promise<string> {
22
- const reader = stream.getReader()
23
- const chunks: string[] = []
24
- while (true) {
25
- const { done, value } = await reader.read()
26
- if (done) break
27
- chunks.push(value)
28
- }
29
- return chunks.join('')
30
- }
31
-
32
- // ─── renderToString ───────────────────────────────────────────────────────────
33
-
34
- describe('renderToString — elements', () => {
35
- test('renders a simple element', async () => {
36
- expect(await renderToString(h('div', null))).toBe('<div></div>')
37
- })
38
-
39
- test('renders void element self-closing', async () => {
40
- expect(await renderToString(h('br', null))).toBe('<br />')
41
- })
42
-
43
- test('renders static text child', async () => {
44
- expect(await renderToString(h('p', null, 'hello'))).toBe('<p>hello</p>')
45
- })
46
-
47
- test('escapes text content', async () => {
48
- expect(await renderToString(h('p', null, '<script>'))).toBe('<p>&lt;script&gt;</p>')
49
- })
50
-
51
- test('renders nested elements', async () => {
52
- const vnode = h('ul', null, h('li', null, 'a'), h('li', null, 'b'))
53
- expect(await renderToString(vnode)).toBe('<ul><li>a</li><li>b</li></ul>')
54
- })
55
-
56
- test('renders null as empty string', async () => {
57
- expect(await renderToString(null)).toBe('')
58
- })
59
- })
60
-
61
- describe('renderToString — props', () => {
62
- test('renders static string prop', async () => {
63
- expect(await renderToString(h('div', { class: 'box' }))).toBe(`<div class="box"></div>`)
64
- })
65
-
66
- test('renders boolean prop (true → attribute name only)', async () => {
67
- const html = await renderToString(h('input', { disabled: true }))
68
- expect(html).toContain('disabled')
69
- })
70
-
71
- test('omits false prop', async () => {
72
- const html = await renderToString(h('input', { disabled: false }))
73
- expect(html).not.toContain('disabled')
74
- })
75
-
76
- test('omits event handlers', async () => {
77
- const html = await renderToString(h('button', { onClick: () => {} }))
78
- expect(html).not.toContain('onClick')
79
- expect(html).not.toContain('on-click')
80
- })
81
-
82
- test('omits key and ref', async () => {
83
- const html = await renderToString(h('div', { key: 'k', ref: null }))
84
- expect(html).not.toContain('key')
85
- expect(html).not.toContain('ref')
86
- })
87
-
88
- test('renders style object', async () => {
89
- const html = await renderToString(h('div', { style: { color: 'red', fontSize: '16px' } }))
90
- expect(html).toContain('color: red')
91
- expect(html).toContain('font-size: 16px')
92
- })
93
- })
94
-
95
- describe('renderToString — reactive props (signal snapshots)', () => {
96
- test('snapshots a reactive prop getter', async () => {
97
- const count = signal(42)
98
- const html = await renderToString(h('span', { 'data-count': () => count() }))
99
- expect(html).toBe(`<span data-count="42"></span>`)
100
- })
101
-
102
- test('snapshots a reactive child getter', async () => {
103
- const name = signal('world')
104
- const html = await renderToString(h('p', null, () => name()))
105
- expect(html).toBe('<p>world</p>')
106
- })
107
- })
108
-
109
- // Regression: `innerHTML` and `dangerouslySetInnerHTML` were rendered as
110
- // literal HTML attributes (`<span innerHTML="...">`) in the open tag instead
111
- // of as element INNER content. That produced wasted bytes, a hydration
112
- // mismatch, AND (with the client-side innerHTML bug) the literal closure
113
- // text was visible before hydration replaced it with the real SVG.
114
- describe('renderToString — innerHTML / dangerouslySetInnerHTML inner-content rendering', () => {
115
- test('innerHTML renders as inner content, not as an attribute', async () => {
116
- const html = await renderToString(h('span', { innerHTML: '<em>x</em>' }))
117
- expect(html).toBe('<span><em>x</em></span>')
118
- expect(html).not.toContain('innerHTML=')
119
- })
120
-
121
- test('dangerouslySetInnerHTML renders as inner content, not as an attribute', async () => {
122
- const html = await renderToString(h('span', { dangerouslySetInnerHTML: { __html: '<em>x</em>' } }))
123
- expect(html).toBe('<span><em>x</em></span>')
124
- expect(html).not.toContain('dangerouslySetInnerHTML=')
125
- })
126
-
127
- test('reactive innerHTML accessor is called at render time', async () => {
128
- const icon = signal('<svg>moon</svg>')
129
- const html = await renderToString(h('span', { innerHTML: () => icon() }))
130
- expect(html).toBe('<span><svg>moon</svg></span>')
131
- // The literal closure text must NOT appear.
132
- expect(html).not.toContain('=>')
133
- })
134
-
135
- test('reactive dangerouslySetInnerHTML accessor is called at render time', async () => {
136
- const inner = signal('<em>x</em>')
137
- const html = await renderToString(
138
- h('div', { dangerouslySetInnerHTML: () => ({ __html: inner() }) }),
139
- )
140
- expect(html).toBe('<div><em>x</em></div>')
141
- expect(html).not.toContain('=>')
142
- })
143
-
144
- test('empty/null innerHTML falls back to children', async () => {
145
- const html = await renderToString(h('span', { innerHTML: '' }, h('em', null, 'child')))
146
- expect(html).toBe('<span><em>child</em></span>')
147
- })
148
- })
149
-
150
- describe('renderToString — Fragment', () => {
151
- test('renders Fragment children without wrapper', async () => {
152
- const vnode = h(Fragment, null, h('span', null, 'a'), h('span', null, 'b'))
153
- expect(await renderToString(vnode)).toBe('<span>a</span><span>b</span>')
154
- })
155
- })
156
-
157
- describe('renderToString — components', () => {
158
- test('renders a component', async () => {
159
- const Greeting = (props: { name: string }) => h('p', null, `Hello, ${props.name}!`)
160
- const html = await renderToString(h(Greeting, { name: 'Pyreon' }))
161
- expect(html).toBe('<p>Hello, Pyreon!</p>')
162
- })
163
-
164
- test('renders a component returning null', async () => {
165
- const Empty = () => null
166
- const html = await renderToString(h(Empty, null))
167
- expect(html).toBe('')
168
- })
169
- })
170
-
171
- // ─── renderToStream ───────────────────────────────────────────────────────────
172
-
173
- describe('renderToStream', () => {
174
- async function collect(stream: ReadableStream<string>): Promise<string> {
175
- const reader = stream.getReader()
176
- let result = ''
177
- while (true) {
178
- const { done, value } = await reader.read()
179
- if (done) break
180
- result += value
181
- }
182
- return result
183
- }
184
-
185
- test('streams a simple element', async () => {
186
- const html = await collect(renderToStream(h('div', null, 'hi')))
187
- expect(html).toBe('<div>hi</div>')
188
- })
189
-
190
- test('streams null as empty', async () => {
191
- expect(await collect(renderToStream(null))).toBe('')
192
- })
193
-
194
- test('streams chunks progressively — opening tag before children', async () => {
195
- // An async child that resolves after a delay.
196
- // The parent opening tag must be enqueued BEFORE the child resolves.
197
- const chunks: string[] = []
198
- async function SlowChild() {
199
- await new Promise<void>((r) => setTimeout(r, 5))
200
- return h('span', null, 'done')
201
- }
202
- const stream = renderToStream(h('div', null, h(SlowChild as unknown as ComponentFn, null)))
203
- const reader = stream.getReader()
204
- while (true) {
205
- const { done, value } = await reader.read()
206
- if (done) break
207
- chunks.push(value)
208
- }
209
- // First chunk must be the opening tag, not the full string
210
- expect(chunks[0]).toBe('<div>')
211
- expect(chunks.join('')).toBe('<div><span>done</span></div>')
212
- })
213
-
214
- test('streams async component output', async () => {
215
- async function Async() {
216
- await new Promise<void>((r) => setTimeout(r, 1))
217
- return h('p', null, 'async')
218
- }
219
- const html = await collect(renderToStream(h(Async as unknown as ComponentFn, null)))
220
- expect(html).toBe('<p>async</p>')
221
- })
222
- })
223
-
224
- // ─── Concurrent SSR isolation ────────────────────────────────────────────────
225
-
226
- describe('concurrent SSR — context isolation', () => {
227
- test('two concurrent renderToString calls do not share context', async () => {
228
- const Ctx = createContext('default')
229
-
230
- // Each HeadInjector runs inside its own ALS scope.
231
- // If context were shared, the second render would see the first's value.
232
- function makeInjector(value: string): ComponentFn {
233
- return function Injector() {
234
- pushContext(new Map([[Ctx.id, value]]))
235
- return h('span', null, () => useContext(Ctx))
236
- }
237
- }
238
-
239
- // Stagger start slightly so they interleave
240
- const [html1, html2] = await Promise.all([
241
- renderToString(h(makeInjector('request-A'), null)),
242
- renderToString(h(makeInjector('request-B'), null)),
243
- ])
244
-
245
- expect(html1).toBe('<span>request-A</span>')
246
- expect(html2).toBe('<span>request-B</span>')
247
- })
248
-
249
- test('concurrent renders with async components stay isolated', async () => {
250
- const Ctx = createContext('none')
251
-
252
- function makeApp(value: string): ComponentFn {
253
- return async function App() {
254
- // Inject context, then yield (simulates async data loading)
255
- pushContext(new Map([[Ctx.id, value]]))
256
- await new Promise<void>((r) => setTimeout(r, 5))
257
- return h('div', null, () => useContext(Ctx))
258
- } as unknown as ComponentFn
259
- }
260
-
261
- const [html1, html2] = await Promise.all([
262
- renderToString(h(makeApp('R1'), null)),
263
- renderToString(h(makeApp('R2'), null)),
264
- ])
265
-
266
- expect(html1).toBe('<div>R1</div>')
267
- expect(html2).toBe('<div>R2</div>')
268
- })
269
- })
270
-
271
- // ─── Streaming Suspense SSR ───────────────────────────────────────────────────
272
-
273
- describe('renderToStream — Suspense boundaries', () => {
274
- test('streams fallback immediately, then resolved content with swap', async () => {
275
- async function Slow(): Promise<ReturnType<typeof h>> {
276
- await new Promise<void>((r) => setTimeout(r, 10))
277
- return h('p', { id: 'resolved' }, 'loaded')
278
- }
279
-
280
- const vnode = h(Suspense, {
281
- fallback: h('p', { id: 'fallback' }, 'loading...'),
282
- children: h(Slow as unknown as unknown as ComponentFn, null),
283
- })
284
-
285
- const html = await collectStream(renderToStream(vnode))
286
-
287
- // Fallback placeholder was emitted
288
- expect(html).toContain('id="pyreon-s-0"')
289
- expect(html).toContain('loading...')
290
- // Resolved content emitted in template + swap
291
- expect(html).toContain('id="pyreon-t-0"')
292
- expect(html).toContain('loaded')
293
- expect(html).toContain('__NS')
294
- })
295
-
296
- test('renderToStream emits chunks progressively (placeholder before resolution)', async () => {
297
- const chunkOrder: string[] = []
298
-
299
- async function SlowContent(): Promise<ReturnType<typeof h>> {
300
- await new Promise<void>((r) => setTimeout(r, 10))
301
- return h('span', null, 'done')
302
- }
303
-
304
- const vnode = h(
305
- 'div',
306
- null,
307
- h(Suspense, {
308
- fallback: h('span', { id: 'fb' }, 'wait'),
309
- children: h(SlowContent as unknown as unknown as ComponentFn, null),
310
- }),
311
- h('p', null, 'after'),
312
- )
313
-
314
- const reader = renderToStream(vnode).getReader()
315
- while (true) {
316
- const { done, value } = await reader.read()
317
- if (done) break
318
- chunkOrder.push(value)
319
- }
320
-
321
- const full = chunkOrder.join('')
322
- // Placeholder chunk must appear before the resolved template chunk
323
- const placeholderIdx = full.indexOf('pyreon-s-0')
324
- const templateIdx = full.indexOf('pyreon-t-0')
325
- expect(placeholderIdx).toBeGreaterThanOrEqual(0)
326
- expect(templateIdx).toBeGreaterThan(placeholderIdx)
327
- // "after" sibling is in the HTML (not blocked by Suspense)
328
- expect(full).toContain('<p>after</p>')
329
- })
330
-
331
- test('renderToString renders Suspense children synchronously (no streaming)', async () => {
332
- async function Data(): Promise<ReturnType<typeof h>> {
333
- return h('span', null, 'ssr-data')
334
- }
335
-
336
- const vnode = h(Suspense, {
337
- fallback: h('span', null, 'fb'),
338
- children: h(Data as unknown as unknown as ComponentFn, null),
339
- })
340
-
341
- const html = await renderToString(vnode)
342
- // renderToString should render fallback via Suspense's sync path
343
- // (Suspense on server with non-lazy child just shows the fallback or children)
344
- expect(typeof html).toBe('string')
345
- expect(html.length).toBeGreaterThan(0)
346
- })
347
- })
348
-
349
- // ─── Configurable Suspense timeout (renderToStream options.suspenseTimeoutMs) ─
350
-
351
- describe('renderToStream — suspenseTimeoutMs config', () => {
352
- async function collect(stream: ReadableStream<string>): Promise<string> {
353
- const reader = stream.getReader()
354
- const chunks: string[] = []
355
- while (true) {
356
- const { done, value } = await reader.read()
357
- if (done) break
358
- chunks.push(value)
359
- }
360
- return chunks.join('')
361
- }
362
-
363
- test('explicit short timeout drops post-resolve content for slow boundary', async () => {
364
- // Boundary takes 100ms to resolve; timeout fires at 20ms → fallback
365
- // stays visible, no `__NS(` swap call lands. The hard-coded 30_000
366
- // default would let it through; this asserts the config knob is
367
- // actually honored end-to-end.
368
- async function Slow() {
369
- await new Promise<void>((r) => setTimeout(r, 100))
370
- return h('div', null, 'resolved-too-late')
371
- }
372
- const vnode = h(Suspense, {
373
- fallback: h('span', null, 'still-loading'),
374
- children: h(Slow as unknown as ComponentFn, null),
375
- })
376
- const html = await collect(renderToStream(vnode, { suspenseTimeoutMs: 20 }))
377
- // Fallback was emitted before timeout fired
378
- expect(html).toContain('still-loading')
379
- // Resolved content was dropped (timed out)
380
- expect(html).not.toContain('resolved-too-late')
381
- expect(html).not.toMatch(/__NS\(\s*["']pyreon-s-/)
382
- })
383
-
384
- test('default 30s timeout preserves pre-config behavior (fast boundary completes)', async () => {
385
- // Boundary completes in 10ms — well within the 30_000 default.
386
- // Asserts the default path still works (no regression from
387
- // adding the option).
388
- async function Fast() {
389
- await new Promise<void>((r) => setTimeout(r, 10))
390
- return h('div', null, 'arrived')
391
- }
392
- const vnode = h(Suspense, {
393
- fallback: h('span', null, 'briefly-loading'),
394
- children: h(Fast as unknown as ComponentFn, null),
395
- })
396
- // No suspenseTimeoutMs → falls back to 30_000 default.
397
- const html = await collect(renderToStream(vnode))
398
- expect(html).toContain('briefly-loading') // fallback was emitted
399
- expect(html).toContain('arrived') // resolved content swapped in
400
- expect(html).toMatch(/__NS\(\s*["']pyreon-s-0/) // swap call landed
401
- })
402
-
403
- test('invalid timeout values fall back to default (0, NaN, negative)', async () => {
404
- // Each of these should be treated as "use the default 30_000"
405
- // rather than "fire immediately" (a 0ms timeout that fired
406
- // synchronously would drop EVERY boundary, breaking apps that
407
- // pass an invalid value through a config layer).
408
- async function Fast() {
409
- await new Promise<void>((r) => setTimeout(r, 10))
410
- return h('div', null, 'arrived')
411
- }
412
- const vnode = h(Suspense, {
413
- fallback: h('span', null, 'briefly-loading'),
414
- children: h(Fast as unknown as ComponentFn, null),
415
- })
416
-
417
- for (const bad of [0, -1, Number.NaN]) {
418
- const html = await collect(
419
- renderToStream(vnode, { suspenseTimeoutMs: bad }),
420
- )
421
- expect(html, `value ${bad} should fall back to default and let the boundary resolve`).toContain('arrived')
422
- }
423
- })
424
-
425
- test('Infinity disables the timeout — boundary resolves regardless of duration', async () => {
426
- // Apps that legitimately need long async work (exports, reports,
427
- // scheduled SSR jobs) can opt out of the timeout entirely. The
428
- // race is skipped — only the AbortSignal can stop a hung boundary.
429
- async function Fast() {
430
- await new Promise<void>((r) => setTimeout(r, 10))
431
- return h('div', null, 'arrived')
432
- }
433
- const vnode = h(Suspense, {
434
- fallback: h('span', null, 'briefly-loading'),
435
- children: h(Fast as unknown as ComponentFn, null),
436
- })
437
- const html = await collect(
438
- renderToStream(vnode, { suspenseTimeoutMs: Infinity }),
439
- )
440
- expect(html).toContain('briefly-loading')
441
- expect(html).toContain('arrived')
442
- expect(html).toMatch(/__NS\(\s*["']pyreon-s-0/)
443
- })
444
- })
445
-
446
- // ─── Concurrent SSR — context isolation ───────────────────────────────────────
447
-
448
- describe('concurrent SSR — context isolation', () => {
449
- test("50 concurrent requests with async components don't bleed context", async () => {
450
- const ReqIdCtx = createContext('none')
451
-
452
- // Async component that reads context after a variable delay — simulates DB/fetch latency
453
- async function AsyncReader(props: { delay: number }): Promise<VNode> {
454
- await new Promise<void>((r) => setTimeout(r, props.delay))
455
- const id = useContext(ReqIdCtx)
456
- return h('span', null, id)
457
- }
458
-
459
- // Wrapper component that injects a per-request context value then renders AsyncReader
460
- function RequestWrapper(props: { reqId: string; delay: number }): VNode {
461
- pushContext(new Map([[ReqIdCtx.id, props.reqId]]))
462
- return h(AsyncReader as unknown as unknown as ComponentFn, { delay: props.delay })
463
- }
464
-
465
- const N = 50
466
- const results = await Promise.all(
467
- Array.from({ length: N }, (_, i) =>
468
- renderToString(
469
- h(RequestWrapper, { reqId: `req-${i}`, delay: Math.floor(Math.random() * 20) }),
470
- ),
471
- ),
472
- )
473
-
474
- // Every render must see its own context value, never another request's
475
- results.forEach((html, i) => {
476
- expect(html).toContain(`req-${i}`)
477
- })
478
- })
479
-
480
- test('context is isolated even when all requests resolve in reverse order', async () => {
481
- const ReqIdCtx = createContext('none')
482
-
483
- async function SlowReader(props: { delay: number }): Promise<VNode> {
484
- await new Promise<void>((r) => setTimeout(r, props.delay))
485
- return h('span', null, useContext(ReqIdCtx))
486
- }
487
-
488
- const N = 10
489
- const results = await Promise.all(
490
- Array.from({ length: N }, (_, i) => {
491
- // Higher index = shorter delay → resolves first in reverse order
492
- const delay = (N - i) * 5
493
- return renderToString(
494
- h(
495
- ((props: { reqId: string; delay: number }): VNode => {
496
- pushContext(new Map([[ReqIdCtx.id, props.reqId]]))
497
- return h(SlowReader as unknown as unknown as ComponentFn, { delay: props.delay })
498
- }) as unknown as ComponentFn,
499
- { reqId: `id-${i}`, delay },
500
- ),
501
- )
502
- }),
503
- )
504
-
505
- results.forEach((html, i) => {
506
- expect(html).toBe(`<span>id-${i}</span>`)
507
- })
508
- })
509
- })
510
-
511
- // ─── configureStoreIsolation ────────────────────────────────────────────────
512
-
513
- describe('configureStoreIsolation', () => {
514
- test('activates store isolation — withStoreContext wraps in ALS', async () => {
515
- let providerCalled = false
516
- const mockSetProvider = (fn: () => Map<string, unknown>) => {
517
- providerCalled = true
518
- // The provider should return a Map
519
- const result = fn()
520
- expect(result).toBeInstanceOf(Map)
521
- }
522
- configureStoreIsolation(mockSetProvider)
523
- expect(providerCalled).toBe(true)
524
-
525
- // After activation, renderToString should work (it uses withStoreContext internally)
526
- const html = await renderToString(h('div', null, 'store-iso'))
527
- expect(html).toBe('<div>store-iso</div>')
528
- })
529
- })
530
-
531
- // ─── runWithRequestContext ───────────────────────────────────────────────────
532
-
533
- describe('runWithRequestContext', () => {
534
- test('provides isolated context for async operations', async () => {
535
- const Ctx = createContext('default')
536
- const result = await runWithRequestContext(async () => {
537
- pushContext(new Map([[Ctx.id, 'request-val']]))
538
- return useContext(Ctx)
539
- })
540
- expect(result).toBe('request-val')
541
- })
542
-
543
- test('two concurrent runWithRequestContext calls are isolated', async () => {
544
- const Ctx = createContext('none')
545
- const [r1, r2] = await Promise.all([
546
- runWithRequestContext(async () => {
547
- pushContext(new Map([[Ctx.id, 'ctx-A']]))
548
- await new Promise<void>((r) => setTimeout(r, 5))
549
- return useContext(Ctx)
550
- }),
551
- runWithRequestContext(async () => {
552
- pushContext(new Map([[Ctx.id, 'ctx-B']]))
553
- await new Promise<void>((r) => setTimeout(r, 5))
554
- return useContext(Ctx)
555
- }),
556
- ])
557
- expect(r1).toBe('ctx-A')
558
- expect(r2).toBe('ctx-B')
559
- })
560
- })
561
-
562
- // ─── renderToString — uncovered branches ─────────────────────────────────────
563
-
564
- describe('renderToString — For component', () => {
565
- test('renders For with hydration markers', async () => {
566
- const items = signal(['a', 'b', 'c'])
567
- const vnode = For({
568
- each: items,
569
- by: (item: unknown) => item as string,
570
- children: (item: unknown) => h('li', null, item as string),
571
- })
572
- const html = await renderToString(vnode)
573
- expect(html).toContain('<!--pyreon-for-->')
574
- expect(html).toContain('<!--/pyreon-for-->')
575
- expect(html).toContain('<li>a</li>')
576
- expect(html).toContain('<li>b</li>')
577
- expect(html).toContain('<li>c</li>')
578
- })
579
- })
580
-
581
- describe('renderToString — array children', () => {
582
- test('renders array of VNodes', async () => {
583
- const arr = [h('span', null, 'x'), h('span', null, 'y')]
584
- // Components can return arrays
585
- const Comp: ComponentFn = () => arr as unknown as VNode
586
- const html = await renderToString(h(Comp, null))
587
- expect(html).toContain('<span>x</span>')
588
- expect(html).toContain('<span>y</span>')
589
- })
590
- })
591
-
592
- describe('renderToString — class and style edge cases', () => {
593
- test('renders class as array', async () => {
594
- const html = await renderToString(h('div', { class: ['foo', null, 'bar', false, 'baz'] }))
595
- expect(html).toContain('class="foo bar baz"')
596
- })
597
-
598
- test('renders class as object (truthy/falsy)', async () => {
599
- const html = await renderToString(h('div', { class: { active: true, hidden: false, bold: 1 } }))
600
- expect(html).toContain('active')
601
- expect(html).toContain('bold')
602
- expect(html).not.toContain('hidden')
603
- })
604
-
605
- test('renders empty class as no attribute', async () => {
606
- const html = await renderToString(h('div', { class: '' }))
607
- expect(html).toBe('<div></div>')
608
- })
609
-
610
- test('renders numeric class value as string', async () => {
611
- // cx() converts numbers to strings — class="42" is valid HTML
612
- const html = await renderToString(h('div', { class: 42 }))
613
- expect(html).toBe('<div class="42"></div>')
614
- })
615
-
616
- test('renders class from array', async () => {
617
- const html = await renderToString(h('div', { class: ['foo', false, 'bar'] }))
618
- expect(html).toBe('<div class="foo bar"></div>')
619
- })
620
-
621
- test('renders class from object', async () => {
622
- const html = await renderToString(
623
- h('div', { class: { active: true, hidden: false, bold: true } }),
624
- )
625
- expect(html).toBe('<div class="active bold"></div>')
626
- })
627
-
628
- test('renders class from nested array/object', async () => {
629
- const html = await renderToString(h('div', { class: ['base', { active: true }, ['nested']] }))
630
- expect(html).toBe('<div class="base active nested"></div>')
631
- })
632
-
633
- test('renders style as string', async () => {
634
- const html = await renderToString(h('div', { style: 'color: red' }))
635
- expect(html).toContain('style="color: red"')
636
- })
637
-
638
- test('renders empty style object as no attribute', async () => {
639
- // normalizeStyle with non-object/non-string falls through to return ""
640
- const html = await renderToString(h('div', { style: 42 }))
641
- expect(html).toBe('<div></div>')
642
- })
643
-
644
- test('renders className → class and htmlFor → for', async () => {
645
- const html = await renderToString(h('label', { className: 'lbl', htmlFor: 'inp' }))
646
- expect(html).toContain('class="lbl"')
647
- expect(html).toContain('for="inp"')
648
- })
649
-
650
- test('renders camelCase props as kebab-case attributes', async () => {
651
- const html = await renderToString(h('div', { dataTestId: 'val' }))
652
- expect(html).toContain('data-test-id="val"')
653
- })
654
- })
655
-
656
- describe('renderToString — URL injection blocking', () => {
657
- test('blocks javascript: in href', async () => {
658
- const html = await renderToString(h('a', { href: 'javascript:alert(1)' }))
659
- expect(html).not.toContain('javascript')
660
- expect(html).toBe('<a></a>')
661
- })
662
-
663
- test('blocks data: in src', async () => {
664
- const html = await renderToString(h('img', { src: 'data:text/html,<h1>hi</h1>' }))
665
- expect(html).not.toContain('data:')
666
- })
667
- })
668
-
669
- describe('renderToString — null/undefined/boolean prop values', () => {
670
- test('omits null and undefined props', async () => {
671
- const html = await renderToString(h('div', { 'data-a': null, 'data-b': undefined }))
672
- expect(html).toBe('<div></div>')
673
- })
674
-
675
- test('renders number children', async () => {
676
- const html = await renderToString(h('span', null, 42))
677
- expect(html).toBe('<span>42</span>')
678
- })
679
-
680
- test('renders boolean true as text in children', async () => {
681
- const html = await renderToString(h('span', null, true))
682
- expect(html).toBe('<span>true</span>')
683
- })
684
-
685
- test('omits false children', async () => {
686
- const html = await renderToString(h('span', null, false))
687
- expect(html).toBe('<span></span>')
688
- })
689
- })
690
-
691
- describe('renderToString — component with children via h()', () => {
692
- test('mergeChildrenIntoProps passes children to component', async () => {
693
- const Wrapper: ComponentFn = (props: Record<string, unknown>) => {
694
- return h('div', null, props.children as VNode)
695
- }
696
- const html = await renderToString(h(Wrapper, null, h('span', null, 'child')))
697
- expect(html).toBe('<div><span>child</span></div>')
698
- })
699
-
700
- test('mergeChildrenIntoProps passes multiple children as array', async () => {
701
- const Wrapper: ComponentFn = (props: Record<string, unknown>) => {
702
- const kids = props.children as VNode[]
703
- return h('div', null, ...kids)
704
- }
705
- const html = await renderToString(h(Wrapper, null, h('a', null, '1'), h('b', null, '2')))
706
- expect(html).toBe('<div><a>1</a><b>2</b></div>')
707
- })
708
-
709
- test('does not override explicit children prop', async () => {
710
- const Wrapper: ComponentFn = (props: Record<string, unknown>) => {
711
- return h('div', null, props.children as VNode)
712
- }
713
- const html = await renderToString(h(Wrapper, { children: h('em', null, 'explicit') }))
714
- expect(html).toBe('<div><em>explicit</em></div>')
715
- })
716
- })
717
-
718
- // ─── renderToStream — uncovered branches ─────────────────────────────────────
719
-
720
- describe('renderToStream — additional coverage', () => {
721
- async function collect(stream: ReadableStream<string>): Promise<string> {
722
- const reader = stream.getReader()
723
- let result = ''
724
- while (true) {
725
- const { done, value } = await reader.read()
726
- if (done) break
727
- result += value
728
- }
729
- return result
730
- }
731
-
732
- test('streams Fragment children', async () => {
733
- const html = await collect(
734
- renderToStream(h(Fragment, null, h('a', null, '1'), h('b', null, '2'))),
735
- )
736
- expect(html).toBe('<a>1</a><b>2</b>')
737
- })
738
-
739
- test('streams For component with markers', async () => {
740
- const items = signal(['x', 'y'])
741
- const vnode = For({
742
- each: items,
743
- by: (item: unknown) => item as string,
744
- children: (item: unknown) => h('li', null, item as string),
745
- })
746
- const html = await collect(renderToStream(vnode))
747
- expect(html).toContain('<!--pyreon-for-->')
748
- expect(html).toContain('<li>x</li>')
749
- expect(html).toContain('<li>y</li>')
750
- expect(html).toContain('<!--/pyreon-for-->')
751
- })
752
-
753
- test('streams reactive getter children', async () => {
754
- const name = signal('streamed')
755
- const html = await collect(renderToStream(h('p', null, () => name())))
756
- expect(html).toContain('streamed')
757
- })
758
-
759
- test('streams number and boolean children', async () => {
760
- const html = await collect(renderToStream(h('span', null, 99)))
761
- expect(html).toContain('99')
762
- })
763
-
764
- test('streams array children', async () => {
765
- const Comp: ComponentFn = () => [h('a', null, '1'), h('b', null, '2')] as unknown as VNode
766
- const html = await collect(renderToStream(h(Comp, null)))
767
- expect(html).toContain('<a>1</a>')
768
- expect(html).toContain('<b>2</b>')
769
- })
770
-
771
- test('streams void elements', async () => {
772
- const html = await collect(renderToStream(h('img', { src: '/pic.png' })))
773
- expect(html).toContain('<img')
774
- expect(html).toContain('/>')
775
- })
776
-
777
- test('streams component returning null', async () => {
778
- const Empty: ComponentFn = () => null
779
- const html = await collect(renderToStream(h(Empty, null)))
780
- expect(html).toBe('')
781
- })
782
-
783
- test('streams false/null children as empty', async () => {
784
- const html = await collect(renderToStream(h('div', null, false, null)))
785
- expect(html).toBe('<div></div>')
786
- })
787
-
788
- test('streams string children directly', async () => {
789
- const html = await collect(renderToStream(h('p', null, 'text & <tag>')))
790
- expect(html).toContain('text &amp; &lt;tag&gt;')
791
- })
792
-
793
- test('multiple Suspense boundaries get incrementing IDs', async () => {
794
- async function Slow1(): Promise<VNode> {
795
- await new Promise<void>((r) => setTimeout(r, 5))
796
- return h('span', null, 's1')
797
- }
798
- async function Slow2(): Promise<VNode> {
799
- await new Promise<void>((r) => setTimeout(r, 5))
800
- return h('span', null, 's2')
801
- }
802
- const vnode = h(
803
- 'div',
804
- null,
805
- h(Suspense, {
806
- fallback: h('p', null, 'fb1'),
807
- children: h(Slow1 as unknown as unknown as ComponentFn, null),
808
- }),
809
- h(Suspense, {
810
- fallback: h('p', null, 'fb2'),
811
- children: h(Slow2 as unknown as unknown as ComponentFn, null),
812
- }),
813
- )
814
- const html = await collect(renderToStream(vnode))
815
- expect(html).toContain('pyreon-s-0')
816
- expect(html).toContain('pyreon-s-1')
817
- expect(html).toContain('pyreon-t-0')
818
- expect(html).toContain('pyreon-t-1')
819
- // The swap script should only be emitted once
820
- const scriptMatches = html.match(/function __NS/g)
821
- expect(scriptMatches).toHaveLength(1)
822
- })
823
- })
824
-
825
- // ─── Concurrent SSR isolation ─────────────────────────────────────────────────
826
-
827
- describe('concurrent SSR isolation', () => {
828
- test('50 concurrent renders produce correct isolated output', async () => {
829
- function Page(props: { id: number }) {
830
- return h('div', { 'data-id': props.id }, `page-${props.id}`)
831
- }
832
-
833
- const renders = Array.from({ length: 50 }, (_, i) =>
834
- runWithRequestContext(() =>
835
- renderToString(h(Page as unknown as unknown as ComponentFn, { id: i })),
836
- ),
837
- )
838
- const results = await Promise.all(renders)
839
-
840
- for (let i = 0; i < 50; i++) {
841
- const html = results[i]
842
- expect(html).toContain(`data-id="${i}"`)
843
- expect(html).toContain(`page-${i}`)
844
- }
845
- })
846
-
847
- test('concurrent renders with different props do not leak state', async () => {
848
- function UserPage(props: { name: string }) {
849
- return h('div', null, `user:${props.name}`)
850
- }
851
-
852
- // Launch 40 concurrent renders with alternating data
853
- const renders = Array.from({ length: 40 }, (_, i) =>
854
- runWithRequestContext(() => {
855
- const name = i % 2 === 0 ? `alice-${i}` : `bob-${i}`
856
- return renderToString(h(UserPage as unknown as unknown as ComponentFn, { name }))
857
- }),
858
- )
859
- const results = await Promise.all(renders)
860
-
861
- for (let i = 0; i < 40; i++) {
862
- const expected = i % 2 === 0 ? `user:alice-${i}` : `user:bob-${i}`
863
- expect(results[i]).toContain(expected)
864
- }
865
- })
866
-
867
- test('concurrent renders with async components stay isolated', async () => {
868
- async function SlowPage(props: { label: string }): Promise<VNode> {
869
- await new Promise<void>((r) => setTimeout(r, Math.random() * 10))
870
- return h('span', null, props.label)
871
- }
872
-
873
- const renders = Array.from({ length: 30 }, (_, i) =>
874
- runWithRequestContext(() =>
875
- renderToString(h(SlowPage as unknown as unknown as ComponentFn, { label: `item-${i}` })),
876
- ),
877
- )
878
- const results = await Promise.all(renders)
879
-
880
- for (let i = 0; i < 30; i++) {
881
- expect(results[i]).toContain(`item-${i}`)
882
- }
883
- })
884
- })
885
-
886
- // ─── Additional coverage — edge cases ─────────────────────────────────────────
887
-
888
- describe('renderToString — escapeHtml edge cases', () => {
889
- test('escapes single quotes in attribute values', async () => {
890
- const html = await renderToString(h('div', { title: "it's here" }))
891
- expect(html).toContain('it&#39;s here')
892
- })
893
-
894
- test('escapes ampersand in text content', async () => {
895
- const html = await renderToString(h('p', null, 'A & B'))
896
- expect(html).toBe('<p>A &amp; B</p>')
897
- })
898
-
899
- test('escapes double quotes in attribute values', async () => {
900
- const html = await renderToString(h('div', { title: 'say "hello"' }))
901
- expect(html).toContain('say &quot;hello&quot;')
902
- })
903
- })
904
-
905
- describe('renderToStream — boolean and edge-case children', () => {
906
- async function collect(stream: ReadableStream<string>): Promise<string> {
907
- const reader = stream.getReader()
908
- let result = ''
909
- while (true) {
910
- const { done, value } = await reader.read()
911
- if (done) break
912
- result += value
913
- }
914
- return result
915
- }
916
-
917
- test("streams boolean true child as 'true'", async () => {
918
- const html = await collect(renderToStream(h('span', null, true)))
919
- expect(html).toBe('<span>true</span>')
920
- })
921
-
922
- test('streams boolean false child as empty', async () => {
923
- const html = await collect(renderToStream(h('span', null, false)))
924
- expect(html).toBe('<span></span>')
925
- })
926
-
927
- test('streams props with reactive getter', async () => {
928
- const cls = signal('active')
929
- const html = await collect(renderToStream(h('div', { class: () => cls() })))
930
- expect(html).toContain('class="active"')
931
- })
932
-
933
- test('streams element with multiple props', async () => {
934
- const html = await collect(
935
- renderToStream(h('input', { type: 'text', placeholder: 'enter', disabled: true })),
936
- )
937
- expect(html).toContain('type="text"')
938
- expect(html).toContain('placeholder="enter"')
939
- expect(html).toContain('disabled')
940
- })
941
- })
942
-
943
- describe('renderToStream — Suspense edge cases', () => {
944
- async function collect(stream: ReadableStream<string>): Promise<string> {
945
- const reader = stream.getReader()
946
- let result = ''
947
- while (true) {
948
- const { done, value } = await reader.read()
949
- if (done) break
950
- result += value
951
- }
952
- return result
953
- }
954
-
955
- test('Suspense boundary with no fallback prop', async () => {
956
- async function Content(): Promise<VNode> {
957
- await new Promise<void>((r) => setTimeout(r, 5))
958
- return h('span', null, 'content')
959
- }
960
-
961
- const vnode = h(Suspense, {
962
- fallback: h('span', null, ''),
963
- children: h(Content as unknown as ComponentFn, null),
964
- })
965
- const html = await collect(renderToStream(vnode))
966
- expect(html).toContain('content')
967
- })
968
-
969
- test('Suspense boundary with no children prop', async () => {
970
- const vnode = h(Suspense, {
971
- fallback: h('span', null, 'loading'),
972
- })
973
- const html = await collect(renderToStream(vnode))
974
- expect(html).toContain('loading')
975
- })
976
- })
977
-
978
- describe('renderToString — prop rendering edge cases', () => {
979
- test('renders true boolean prop as attribute name only (escaped)', async () => {
980
- const html = await renderToString(h('input', { disabled: true }))
981
- expect(html).toContain('disabled')
982
- // Should not contain ="true"
983
- expect(html).not.toContain('disabled="true"')
984
- })
985
-
986
- test('omits props with null value', async () => {
987
- const html = await renderToString(h('div', { 'data-x': null }))
988
- expect(html).toBe('<div></div>')
989
- })
990
-
991
- test('omits props with undefined value', async () => {
992
- const html = await renderToString(h('div', { 'data-x': undefined }))
993
- expect(html).toBe('<div></div>')
994
- })
995
-
996
- test('blocks javascript: URI in action attribute', async () => {
997
- const html = await renderToString(h('form', { action: 'javascript:void(0)' }))
998
- expect(html).not.toContain('javascript')
999
- })
1000
-
1001
- test('blocks data: URI in poster attribute', async () => {
1002
- const html = await renderToString(h('video', { poster: 'data:image/png;base64,abc' }))
1003
- expect(html).not.toContain('data:')
1004
- })
1005
-
1006
- test('allows safe URLs in href', async () => {
1007
- const html = await renderToString(h('a', { href: 'https://example.com' }))
1008
- expect(html).toContain('href="https://example.com"')
1009
- })
1010
-
1011
- test('renders style object with camelCase keys as kebab-case', async () => {
1012
- const html = await renderToString(h('div', { style: { backgroundColor: 'red' } }))
1013
- expect(html).toContain('background-color: red')
1014
- })
1015
-
1016
- test('renders style object with auto-px for numeric values', async () => {
1017
- const html = await renderToString(
1018
- h('div', { style: { height: 100, marginTop: 20, opacity: 0.5, zIndex: 10 } }),
1019
- )
1020
- expect(html).toContain('height: 100px')
1021
- expect(html).toContain('margin-top: 20px')
1022
- expect(html).toContain('opacity: 0.5')
1023
- expect(html).toContain('z-index: 10')
1024
- })
1025
- })
1026
-
1027
- describe('renderToStream — error handling', () => {
1028
- async function collectStream(stream: ReadableStream<string>): Promise<string> {
1029
- const reader = stream.getReader()
1030
- let result = ''
1031
- while (true) {
1032
- const { done, value } = await reader.read()
1033
- if (done) break
1034
- result += value
1035
- }
1036
- return result
1037
- }
1038
-
1039
- test('stream emits error comment when component throws outside Suspense', async () => {
1040
- function Boom(): VNode {
1041
- throw new Error('render error')
1042
- }
1043
-
1044
- const html = await collectStream(renderToStream(h(Boom as ComponentFn, null)))
1045
- expect(html).toContain('<!--pyreon-error-->')
1046
- })
1047
-
1048
- test('stream renders element with skipped prop (event handler)', async () => {
1049
- // Event handlers return null from renderProp — exercises `if (attr)` false branch in streamNode
1050
- const html = await collectStream(
1051
- renderToStream(h('button', { onClick: () => {}, id: 'btn' }, 'click')),
1052
- )
1053
- expect(html).toContain('<button id="btn">')
1054
- expect(html).not.toContain('onClick')
1055
- })
1056
- })
1057
-
1058
- // ─── Edge-case branches ──────────────────────────────────────────────────────
1059
-
1060
- describe('edge-case branches', () => {
1061
- test('async component returning null via renderToString', async () => {
1062
- async function NullComp(): Promise<null> {
1063
- return null
1064
- }
1065
- const html = await renderToString(h(NullComp as unknown as ComponentFn, null))
1066
- expect(html).toBe('')
1067
- })
1068
-
1069
- test('Suspense in stream without streaming context (renderToString path)', async () => {
1070
- // This tests the !ctx branch in streamSuspenseBoundary
1071
- // renderToString handles Suspense via renderNode, not streamNode, so we test it there
1072
- function Child(): VNode {
1073
- return h('span', null, 'resolved')
1074
- }
1075
- const vnode = h(Suspense, {
1076
- fallback: h('span', null, 'loading'),
1077
- children: h(Child as ComponentFn, null),
1078
- })
1079
- const html = await renderToString(vnode)
1080
- expect(typeof html).toBe('string')
1081
- })
1082
- })
1083
-
1084
- // ─── renderToString — Suspense with async components ─────────────────────────
1085
-
1086
- describe('renderToString — Suspense async paths', () => {
1087
- test('renderToString with Suspense waits for async component and renders inline', async () => {
1088
- async function AsyncData(): Promise<ReturnType<typeof h>> {
1089
- await new Promise<void>((r) => setTimeout(r, 5))
1090
- return h('p', { id: 'content' }, 'loaded data')
1091
- }
1092
-
1093
- const vnode = h(Suspense, {
1094
- fallback: h('span', null, 'loading...'),
1095
- children: h(AsyncData as unknown as ComponentFn, null),
1096
- })
1097
-
1098
- const html = await renderToString(vnode)
1099
- // The async content should be present (renderToString awaits async components)
1100
- expect(html).toContain('loaded data')
1101
- })
1102
-
1103
- test('renderToString with Suspense where async component throws propagates error', async () => {
1104
- async function FailingComponent(): Promise<ReturnType<typeof h>> {
1105
- await new Promise<void>((r) => setTimeout(r, 1))
1106
- throw new Error('SSR component failure')
1107
- }
1108
-
1109
- const vnode = h(Suspense, {
1110
- fallback: h('span', null, 'fallback'),
1111
- children: h(FailingComponent as unknown as ComponentFn, null),
1112
- })
1113
-
1114
- // renderToString does not catch per-boundary — the error propagates
1115
- await expect(renderToString(vnode)).rejects.toThrow('SSR component failure')
1116
- })
1117
- })
1118
-
1119
- describe('renderToStream — Suspense error fallback', () => {
1120
- test('renderToStream keeps fallback visible when async component throws', async () => {
1121
- async function ThrowingChild(): Promise<ReturnType<typeof h>> {
1122
- await new Promise<void>((r) => setTimeout(r, 5))
1123
- throw new Error('stream component error')
1124
- }
1125
-
1126
- const vnode = h(Suspense, {
1127
- fallback: h('span', { id: 'fb' }, 'fallback content'),
1128
- children: h(ThrowingChild as unknown as ComponentFn, null),
1129
- })
1130
-
1131
- const html = await collectStream(renderToStream(vnode))
1132
- // Fallback should be present
1133
- expect(html).toContain('fallback content')
1134
- // No swap script invocation should be emitted (error was caught, no template + swap)
1135
- expect(html).not.toContain('pyreon-t-')
1136
- expect(html).not.toContain('__NS("pyreon-s-')
1137
- })
1138
- })
1139
-
1140
- // ─── Suspense XSS — </template> escaping ─────────────────────────────────────
1141
-
1142
- describe('renderToStream — Suspense XSS escape', () => {
1143
- test('escapes </template> inside Suspense async content', async () => {
1144
- async function XSSChild(): Promise<ReturnType<typeof h>> {
1145
- await new Promise<void>((r) => setTimeout(r, 5))
1146
- return h('div', { dangerouslySetInnerHTML: { __html: '</template><script>alert(1)</script>' } })
1147
- }
1148
-
1149
- const vnode = h(Suspense, {
1150
- fallback: h('span', null, 'loading'),
1151
- children: h(XSSChild as unknown as ComponentFn, null),
1152
- })
1153
-
1154
- const html = await collectStream(renderToStream(vnode))
1155
- // The raw </template> should be escaped to <\/template in the buffered content
1156
- expect(html).not.toContain('</template><script>alert')
1157
- // The escaped version should be present instead
1158
- expect(html).toContain('<\\/template')
1159
- })
1160
- })
1161
-
1162
- // ─── For SSR — key markers ───────────────────────────────────────────────────
1163
-
1164
- describe('renderToString — For key markers', () => {
1165
- test('emits key markers for each item', async () => {
1166
- const items = [
1167
- { id: 1, name: 'a' },
1168
- { id: 2, name: 'b' },
1169
- ]
1170
- const vnode = h(For, {
1171
- each: () => items,
1172
- by: (r: { id: number }) => r.id,
1173
- children: (item: { id: number; name: string }) => h('li', null, item.name),
1174
- })
1175
- const html = await renderToString(vnode)
1176
- expect(html).toContain('<!--k:1-->')
1177
- expect(html).toContain('<!--k:2-->')
1178
- expect(html).toContain('<!--pyreon-for-->')
1179
- expect(html).toContain('<!--/pyreon-for-->')
1180
- expect(html).toContain('<li>a</li>')
1181
- expect(html).toContain('<li>b</li>')
1182
- })
1183
-
1184
- test('For markers appear in correct order', async () => {
1185
- const items = [
1186
- { id: 10, name: 'first' },
1187
- { id: 20, name: 'second' },
1188
- { id: 30, name: 'third' },
1189
- ]
1190
- const vnode = h(For, {
1191
- each: () => items,
1192
- by: (r: { id: number }) => r.id,
1193
- children: (item: { id: number; name: string }) => h('span', null, item.name),
1194
- })
1195
- const html = await renderToString(vnode)
1196
- const idx10 = html.indexOf('<!--k:10-->')
1197
- const idx20 = html.indexOf('<!--k:20-->')
1198
- const idx30 = html.indexOf('<!--k:30-->')
1199
- expect(idx10).toBeLessThan(idx20)
1200
- expect(idx20).toBeLessThan(idx30)
1201
- })
1202
-
1203
- test('For with empty array emits boundary markers only', async () => {
1204
- const vnode = h(For, {
1205
- each: () => [],
1206
- by: (r: unknown) => r,
1207
- children: () => h('li', null, 'nope'),
1208
- })
1209
- const html = await renderToString(vnode)
1210
- expect(html).toBe('<!--pyreon-for--><!--/pyreon-for-->')
1211
- })
1212
- })
1213
-
1214
- // ─── For SSR — key markers in stream ─────────────────────────────────────────
1215
-
1216
- // ─── Bug 4: SSR provide() context cleanup across siblings ───────────────────
1217
- //
1218
- // Regression: pre-fix, `renderComponent` invoked `runWithHooks(...)` to render
1219
- // each component but DESTRUCTURED only the vnode — never invoked the
1220
- // component's unmount hooks. `provide(context, value)` registers
1221
- // `onUnmount(popContext)` to clean up its pushed context frame on unmount.
1222
- // Without unmount-hook invocation during SSR, every `provide()` call left
1223
- // its context frame on the global stack permanently. Subsequent siblings
1224
- // saw the leaked context value instead of the outer provider's value.
1225
- //
1226
- // Real-world manifestation (bokisch.com): a `<PyreonUI inversed>` inside an
1227
- // `<Intro>` section flipped mode to dark and pushed it as context. After
1228
- // Intro rendered, every subsequent section (`<Quote>`, `<Companies>`, etc.)
1229
- // saw the inverted dark mode → all sections rendered in dark even though
1230
- // the page was in light mode → wrong colors everywhere.
1231
-
1232
- describe('SSR — provide() context cleanup across siblings (Bug 4)', () => {
1233
- test('sibling AFTER a provide() call sees the OUTER context value, not the leak', async () => {
1234
- const Ctx = createContext('outer')
1235
-
1236
- // Component that pushes a NEW context value via `provide()`.
1237
- const Inner: ComponentFn = () => {
1238
- provide(Ctx, 'inner')
1239
- return h('span', { 'data-testid': 'inner' }, () => useContext(Ctx))
1240
- }
1241
-
1242
- // Sibling rendered AFTER Inner — should see 'outer', not 'inner'.
1243
- const Sibling: ComponentFn = () => {
1244
- return h('span', { 'data-testid': 'sibling' }, () => useContext(Ctx))
1245
- }
1246
-
1247
- // Outer wrapper: <div><Inner /><Sibling /></div>. Pre-fix, Sibling
1248
- // sees 'inner' because Inner's provide() was never popped.
1249
- const App: ComponentFn = () => {
1250
- return h(Fragment, null, h(Inner, null), h(Sibling, null))
1251
- }
1252
-
1253
- const html = await renderToString(h(App, null))
1254
-
1255
- expect(html).toContain('data-testid="inner">inner<')
1256
- expect(html).toContain('data-testid="sibling">outer<') // not "inner"
1257
- })
1258
-
1259
- test('multiple sequential provide() calls each clean up their own frame', async () => {
1260
- const Ctx = createContext('default')
1261
-
1262
- const First: ComponentFn = () => {
1263
- provide(Ctx, 'first')
1264
- return h('span', { 'data-testid': 'first' }, () => useContext(Ctx))
1265
- }
1266
- const Second: ComponentFn = () => {
1267
- provide(Ctx, 'second')
1268
- return h('span', { 'data-testid': 'second' }, () => useContext(Ctx))
1269
- }
1270
- const Third: ComponentFn = () => {
1271
- return h('span', { 'data-testid': 'third' }, () => useContext(Ctx))
1272
- }
1273
-
1274
- const html = await renderToString(
1275
- h(Fragment, null, h(First, null), h(Second, null), h(Third, null)),
1276
- )
1277
-
1278
- expect(html).toContain('data-testid="first">first<')
1279
- expect(html).toContain('data-testid="second">second<')
1280
- // Third sees 'default' — no leakage from First or Second.
1281
- expect(html).toContain('data-testid="third">default<')
1282
- })
1283
-
1284
- test('nested provide() — child sees parent provide, sibling outside sees outer', async () => {
1285
- const Ctx = createContext('outer')
1286
-
1287
- const InnerChild: ComponentFn = () =>
1288
- h('span', { 'data-testid': 'inner-child' }, () => useContext(Ctx))
1289
-
1290
- const Provider: ComponentFn = () => {
1291
- provide(Ctx, 'provider-value')
1292
- return h('div', null, h(InnerChild, null))
1293
- }
1294
-
1295
- const Sibling: ComponentFn = () =>
1296
- h('span', { 'data-testid': 'sibling' }, () => useContext(Ctx))
1297
-
1298
- const html = await renderToString(
1299
- h(Fragment, null, h(Provider, null), h(Sibling, null)),
1300
- )
1301
-
1302
- // Inner child sees the provider's value (correct).
1303
- expect(html).toContain('data-testid="inner-child">provider-value<')
1304
- // Sibling outside the provider sees outer (must NOT see leaked
1305
- // provider-value).
1306
- expect(html).toContain('data-testid="sibling">outer<')
1307
- })
1308
-
1309
- // Bug 4 follow-up: the FIRST attempt at the fix (running runUnmountHooks
1310
- // during SSR) overshot — it fired ALL user-registered onUnmount hooks,
1311
- // not just `provide()`'s `popContext`. That broke @pyreon/head, where
1312
- // `useHead({ title })` registers `onUnmount(() => removeFromHeadStore())`
1313
- // to clean up on CSR unmount; running it during SSR cleared the head
1314
- // store BEFORE `renderWithHead` extracted it → 38 head tests failed,
1315
- // every SSR'd page lost its <title>/<meta>/<link> tags.
1316
- //
1317
- // Architectural rule: SSR has no real unmount phase (the response
1318
- // ships, the process moves on). User-registered `onUnmount` hooks are
1319
- // for the CSR lifecycle. The ONE SSR-visible side effect of `provide()`
1320
- // is its context frame, and we clean that up structurally (snapshot +
1321
- // trim the stack) without firing user hooks.
1322
- test('user-registered onUnmount hooks DO NOT fire during SSR (head store contract)', async () => {
1323
- // Simulate `useHead({ title })`-style registration: register an entry
1324
- // in a per-render store, register `onUnmount` to clean it up. Real
1325
- // `@pyreon/head` does this via a context'd HeadStore; we use a
1326
- // module-local for the test.
1327
- const store: { title?: string } = {}
1328
-
1329
- const TitleRegister: ComponentFn = () => {
1330
- store.title = 'Hello SSR'
1331
- onUnmount(() => {
1332
- delete store.title
1333
- })
1334
- return null
1335
- }
1336
-
1337
- const App: ComponentFn = () =>
1338
- h('html', null, h('head', null, h(TitleRegister, null)), h('body', null))
1339
-
1340
- await renderToString(h(App, null))
1341
-
1342
- // After SSR completes, the head-style store entry MUST still be
1343
- // present — `renderWithHead` and similar post-render extractors
1344
- // need it. If `runUnmountHooks` were called here, `store.title`
1345
- // would be undefined.
1346
- expect(store.title).toBe('Hello SSR')
1347
- })
1348
- })
1349
-
1350
- describe('renderToStream — For key markers', () => {
1351
- test('emits key markers for each item in stream', async () => {
1352
- const items = [
1353
- { id: 1, name: 'x' },
1354
- { id: 2, name: 'y' },
1355
- ]
1356
- const vnode = h(For, {
1357
- each: () => items,
1358
- by: (r: { id: number }) => r.id,
1359
- children: (item: { id: number; name: string }) => h('li', null, item.name),
1360
- })
1361
- const html = await collectStream(renderToStream(vnode))
1362
- expect(html).toContain('<!--k:1-->')
1363
- expect(html).toContain('<!--k:2-->')
1364
- })
1365
- })