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