@pyreon/head 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,1313 +0,0 @@
1
- import { h } from '@pyreon/core'
2
- import { signal } from '@pyreon/reactivity'
3
- import { mount } from '@pyreon/runtime-dom'
4
- import type { HeadContextValue } from '../index'
5
- import { createHeadContext, HeadProvider, useHead } from '../index'
6
- import { renderWithHead } from '../ssr'
7
-
8
- // ─── SSR tests ────────────────────────────────────────────────────────────────
9
-
10
- describe('renderWithHead — SSR', () => {
11
- test('extracts <title> from useHead', async () => {
12
- function Page() {
13
- useHead({ title: 'Hello Pyreon' })
14
- return h('main', null, 'content')
15
- }
16
- const { html, head } = await renderWithHead(h(Page, null))
17
- expect(html).toContain('<main>')
18
- expect(head).toContain('<title>Hello Pyreon</title>')
19
- })
20
-
21
- test('extracts <meta> tags from useHead', async () => {
22
- function Page() {
23
- useHead({
24
- meta: [
25
- { name: 'description', content: 'A great page' },
26
- { property: 'og:title', content: 'Hello' },
27
- ],
28
- })
29
- return h('div', null)
30
- }
31
- const { head } = await renderWithHead(h(Page, null))
32
- expect(head).toContain('name="description"')
33
- expect(head).toContain('content="A great page"')
34
- expect(head).toContain('property="og:title"')
35
- })
36
-
37
- test('extracts <link> tags from useHead', async () => {
38
- function Page() {
39
- useHead({ link: [{ rel: 'canonical', href: 'https://example.com/page' }] })
40
- return h('div', null)
41
- }
42
- const { head } = await renderWithHead(h(Page, null))
43
- expect(head).toContain('rel="canonical"')
44
- expect(head).toContain('href="https://example.com/page"')
45
- })
46
-
47
- test('deduplication: innermost title wins', async () => {
48
- function Inner() {
49
- useHead({ title: 'Inner Title' })
50
- return h('span', null)
51
- }
52
- function Outer() {
53
- useHead({ title: 'Outer Title' })
54
- return h('div', null, h(Inner, null))
55
- }
56
- const { head } = await renderWithHead(h(Outer, null))
57
- // Only one <title> — Inner wins (last-added wins per key)
58
- const titleMatches = head.match(/<title>/g)
59
- expect(titleMatches).toHaveLength(1)
60
- expect(head).toContain('<title>Inner Title</title>')
61
- })
62
-
63
- test('escapes HTML entities in title', async () => {
64
- function Page() {
65
- useHead({ title: 'A & B <script>' })
66
- return h('div', null)
67
- }
68
- const { head } = await renderWithHead(h(Page, null))
69
- expect(head).toContain('A &amp; B &lt;script&gt;')
70
- expect(head).not.toContain('<script>')
71
- })
72
-
73
- test('works with async component', async () => {
74
- async function AsyncPage() {
75
- await new Promise((r) => setTimeout(r, 1))
76
- useHead({ title: 'Async Page' })
77
- return h('div', null)
78
- }
79
- const { head } = await renderWithHead(h(AsyncPage as never, null))
80
- expect(head).toContain('<title>Async Page</title>')
81
- })
82
-
83
- test('renders <style> tags', async () => {
84
- function Page() {
85
- useHead({ style: [{ children: 'body { color: red }' }] })
86
- return h('div', null)
87
- }
88
- const { head } = await renderWithHead(h(Page, null))
89
- expect(head).toContain('<style>body { color: red }</style>')
90
- })
91
-
92
- test('renders <noscript> tags', async () => {
93
- function Page() {
94
- useHead({ noscript: [{ children: '<p>Please enable JavaScript</p>' }] })
95
- return h('div', null)
96
- }
97
- const { head } = await renderWithHead(h(Page, null))
98
- expect(head).toContain('<noscript><p>Please enable JavaScript</p></noscript>')
99
- })
100
-
101
- test('renders JSON-LD script tag', async () => {
102
- function Page() {
103
- useHead({ jsonLd: { '@type': 'WebPage', name: 'Test' } })
104
- return h('div', null)
105
- }
106
- const { head } = await renderWithHead(h(Page, null))
107
- expect(head).toContain('type="application/ld+json"')
108
- expect(head).toContain('"@type":"WebPage"')
109
- expect(head).toContain('"name":"Test"')
110
- })
111
-
112
- test('script content is not HTML-escaped', async () => {
113
- function Page() {
114
- useHead({ script: [{ children: 'var x = 1 < 2 && 3 > 1' }] })
115
- return h('div', null)
116
- }
117
- const { head } = await renderWithHead(h(Page, null))
118
- expect(head).toContain('var x = 1 < 2 && 3 > 1')
119
- expect(head).not.toContain('&lt;')
120
- })
121
-
122
- test('titleTemplate with %s placeholder', async () => {
123
- function Layout() {
124
- useHead({ titleTemplate: '%s | My App' })
125
- return h('div', null, h(Page, null))
126
- }
127
- function Page() {
128
- useHead({ title: 'About' })
129
- return h('span', null)
130
- }
131
- const { head } = await renderWithHead(h(Layout, null))
132
- expect(head).toContain('<title>About | My App</title>')
133
- })
134
-
135
- test('titleTemplate with function', async () => {
136
- function Layout() {
137
- useHead({ titleTemplate: (t: string) => (t ? `${t} — Site` : 'Site') })
138
- return h('div', null, h(Page, null))
139
- }
140
- function Page() {
141
- useHead({ title: 'Home' })
142
- return h('span', null)
143
- }
144
- const { head } = await renderWithHead(h(Layout, null))
145
- expect(head).toContain('<title>Home — Site</title>')
146
- })
147
-
148
- test('returns htmlAttrs and bodyAttrs', async () => {
149
- function Page() {
150
- useHead({ htmlAttrs: { lang: 'en', dir: 'ltr' }, bodyAttrs: { class: 'dark' } })
151
- return h('div', null)
152
- }
153
- const result = await renderWithHead(h(Page, null))
154
- expect(result.htmlAttrs).toEqual({ lang: 'en', dir: 'ltr' })
155
- expect(result.bodyAttrs).toEqual({ class: 'dark' })
156
- })
157
-
158
- test('ssr.ts: serializeTag for non-void, non-raw tag with children (line 76)', async () => {
159
- // A <noscript> is raw, but let's use a tag that goes through esc() path
160
- // Actually noscript IS raw. We need a non-void, non-raw tag with children.
161
- // The only non-void non-raw tags possible: title (handled separately).
162
- // Actually looking at the code, any tag not in VOID_TAGS and not script/style/noscript
163
- // goes through esc(). But HeadTag only allows specific tags. Let's use base with children
164
- // Actually base is void. Let's just check that a regular tag with empty children works.
165
- // The serializeTag function handles: title (line 59-66), void tags (line 72),
166
- // raw tags (line 74-76), and everything else. But HeadTag limits tags.
167
- // Actually — "base" is in VOID_TAGS. The only non-void non-raw non-title tags would
168
- // be ones not in the union, but that's type-constrained. Let's look more carefully...
169
- // Wait: meta and link are void. script/style/noscript are raw. title is special.
170
- // So line 76's `esc(content)` branch is actually for tags that are not void, not title,
171
- // and not raw. But with the current HeadTag type, no such tag exists!
172
- // Actually, looking again: line 76 is `const body = isRaw ? content.replace(...) : esc(content)`
173
- // The esc branch fires when isRaw is false AND tag is not void AND tag is not title.
174
- // With the HeadTag union type, there's no such tag... So this is dead code.
175
- // Let me focus on line 60 (title with no children = undefined) and line 76 (noscript raw path).
176
- // Line 60: tag.children ?? "" when children is undefined
177
- function _Page() {
178
- // title with undefined children — manually add to context
179
- return h('div', null)
180
- }
181
- // Test title with no children set (tag.children is undefined → fallback to "")
182
- const ctx2 = createHeadContext()
183
- ctx2.add(Symbol(), { tags: [{ tag: 'title', key: 'title' }] }) // no children prop
184
- const tags = ctx2.resolve()
185
- expect(tags[0]?.children).toBeUndefined()
186
- // Now test through renderWithHead — the title tag should render with empty content
187
- function PageNoTitle() {
188
- useHead({ title: '' })
189
- return h('div', null)
190
- }
191
- const result = await renderWithHead(h(PageNoTitle, null))
192
- expect(result.head).toContain('<title></title>')
193
- })
194
-
195
- test('ssr.ts: serializeTag renders noscript with closing-tag escaping (line 76)', async () => {
196
- function Page() {
197
- useHead({
198
- noscript: [{ children: 'test </noscript><script>alert(1)</script>' }],
199
- })
200
- return h('div', null)
201
- }
202
- const { head } = await renderWithHead(h(Page, null))
203
- // The raw path should escape </noscript> to <\/noscript>
204
- expect(head).toContain('<\\/noscript>')
205
- })
206
-
207
- test('ssr.ts: serializeTag for title with undefined children (line 60)', async () => {
208
- // Directly test by adding a title tag with no children to context
209
- const ctx2 = createHeadContext()
210
- ctx2.add(Symbol(), { tags: [{ tag: 'title', key: 'title' }] })
211
- // Use renderWithHead with a component that adds title tag without children
212
- function _Page() {
213
- return h('div', null)
214
- }
215
- // We need to test serializeTag with title where children is undefined
216
- // Since we can't call serializeTag directly, let's use renderWithHead
217
- // with a titleTemplate and no title to exercise both template branches
218
- function PageWithTemplate() {
219
- useHead({ titleTemplate: '%s | Site' })
220
- return h('div', null, h(Inner, null))
221
- }
222
- function Inner() {
223
- useHead({ title: '' }) // empty string title with template
224
- return h('span', null)
225
- }
226
- const { head } = await renderWithHead(h(PageWithTemplate, null))
227
- expect(head).toContain('<title> | Site</title>')
228
- })
229
-
230
- test('ssr.ts: script tag with closing tag escaping (line 76)', async () => {
231
- function Page() {
232
- useHead({
233
- script: [{ children: "var x = '</script><img onerror=alert(1)>'" }],
234
- })
235
- return h('div', null)
236
- }
237
- const { head } = await renderWithHead(h(Page, null))
238
- expect(head).toContain('<\\/script>')
239
- })
240
-
241
- test('use-head.ts: reactive function input on SSR (line 88)', async () => {
242
- // The SSR path for function input evaluates once synchronously
243
- function Page() {
244
- useHead(() => ({ title: 'SSR Reactive' }))
245
- return h('div', null)
246
- }
247
- const { head } = await renderWithHead(h(Page, null))
248
- expect(head).toContain('<title>SSR Reactive</title>')
249
- })
250
-
251
- test('multiple link tags with same rel but different href are kept', async () => {
252
- function Page() {
253
- useHead({
254
- link: [
255
- { rel: 'stylesheet', href: '/a.css' },
256
- { rel: 'stylesheet', href: '/b.css' },
257
- ],
258
- })
259
- return h('div', null)
260
- }
261
- const { head } = await renderWithHead(h(Page, null))
262
- expect(head).toContain('href="/a.css"')
263
- expect(head).toContain('href="/b.css"')
264
- })
265
- })
266
-
267
- // ─── CSR tests ────────────────────────────────────────────────────────────────
268
-
269
- describe('useHead — CSR', () => {
270
- let container: HTMLElement
271
- let ctx: HeadContextValue
272
-
273
- beforeEach(() => {
274
- container = document.createElement('div')
275
- document.body.appendChild(container)
276
- ctx = createHeadContext()
277
- // Clean up any pyreon-injected head tags from prior tests
278
- for (const el of document.head.querySelectorAll('[data-pyreon-head]')) el.remove()
279
- document.title = ''
280
- })
281
-
282
- test('syncs document.title on mount', () => {
283
- function Page() {
284
- useHead({ title: 'CSR Title' })
285
- return h('div', null)
286
- }
287
- // When using h() directly (not JSX), component children must be in props.children
288
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
289
- expect(document.title).toBe('CSR Title')
290
- })
291
-
292
- test('syncs <meta> tags on mount', () => {
293
- function Page() {
294
- useHead({ meta: [{ name: 'description', content: 'CSR desc' }] })
295
- return h('div', null)
296
- }
297
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
298
- const meta = document.head.querySelector('meta[name="description"]')
299
- expect(meta?.getAttribute('content')).toBe('CSR desc')
300
- })
301
-
302
- test('removes meta tags on unmount', () => {
303
- function Page() {
304
- useHead({ meta: [{ name: 'keywords', content: 'pyreon' }] })
305
- return h('div', null)
306
- }
307
- const cleanup = mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
308
- expect(document.head.querySelector('meta[name="keywords"]')).not.toBeNull()
309
- cleanup()
310
- expect(document.head.querySelector('meta[name="keywords"]')).toBeNull()
311
- })
312
-
313
- test('reactive useHead updates title when signal changes', () => {
314
- const title = signal('Initial')
315
- function Page() {
316
- useHead(() => ({ title: title() }))
317
- return h('div', null)
318
- }
319
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
320
- expect(document.title).toBe('Initial')
321
- title.set('Updated')
322
- expect(document.title).toBe('Updated')
323
- })
324
-
325
- test('syncs <style> tags on mount', () => {
326
- function Page() {
327
- useHead({ style: [{ children: 'body { margin: 0 }' }] })
328
- return h('div', null)
329
- }
330
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
331
- const style = document.head.querySelector('style[data-pyreon-head]')
332
- expect(style).not.toBeNull()
333
- expect(style?.textContent).toBe('body { margin: 0 }')
334
- })
335
-
336
- test('syncs JSON-LD script on mount', () => {
337
- function Page() {
338
- useHead({ jsonLd: { '@type': 'WebPage', name: 'Test' } })
339
- return h('div', null)
340
- }
341
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
342
- const script = document.head.querySelector('script[type="application/ld+json"]')
343
- expect(script).not.toBeNull()
344
- expect(script?.textContent).toContain('"@type":"WebPage"')
345
- })
346
-
347
- test('titleTemplate applies to document.title', () => {
348
- function Layout() {
349
- useHead({ titleTemplate: '%s | My App' })
350
- return h('div', null, h(Page, null))
351
- }
352
- function Page() {
353
- useHead({ title: 'Dashboard' })
354
- return h('span', null)
355
- }
356
- mount(h(HeadProvider, { context: ctx, children: h(Layout, null) }), container)
357
- expect(document.title).toBe('Dashboard | My App')
358
- })
359
-
360
- test('htmlAttrs sets attributes on <html>', () => {
361
- function Page() {
362
- useHead({ htmlAttrs: { lang: 'fr' } })
363
- return h('div', null)
364
- }
365
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
366
- expect(document.documentElement.getAttribute('lang')).toBe('fr')
367
- })
368
-
369
- test('bodyAttrs sets attributes on <body>', () => {
370
- function Page() {
371
- useHead({ bodyAttrs: { class: 'dark-mode' } })
372
- return h('div', null)
373
- }
374
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
375
- expect(document.body.getAttribute('class')).toBe('dark-mode')
376
- })
377
-
378
- test('incremental sync updates attributes in place', () => {
379
- const desc = signal('initial')
380
- function Page() {
381
- useHead(() => ({ meta: [{ name: 'description', content: desc() }] }))
382
- return h('div', null)
383
- }
384
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
385
- const meta1 = document.head.querySelector('meta[name="description"]')
386
- expect(meta1?.getAttribute('content')).toBe('initial')
387
- desc.set('updated')
388
- const meta2 = document.head.querySelector('meta[name="description"]')
389
- // Same element should be reused (incremental sync)
390
- expect(meta2).toBe(meta1)
391
- expect(meta2?.getAttribute('content')).toBe('updated')
392
- })
393
-
394
- test('titleTemplate function applies to document.title in CSR', () => {
395
- function Layout() {
396
- useHead({ titleTemplate: (t: string) => (t ? `${t} - App` : 'App') })
397
- return h('div', null, h(Page, null))
398
- }
399
- function Page() {
400
- useHead({ title: 'CSR Page' })
401
- return h('span', null)
402
- }
403
- mount(h(HeadProvider, { context: ctx, children: h(Layout, null) }), container)
404
- expect(document.title).toBe('CSR Page - App')
405
- })
406
-
407
- test('removes stale elements when tags change', () => {
408
- const show = signal(true)
409
- function Page() {
410
- useHead(() => {
411
- const tags: { name: string; content: string }[] = []
412
- if (show()) tags.push({ name: 'keywords', content: 'pyreon' })
413
- return { meta: tags }
414
- })
415
- return h('div', null)
416
- }
417
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
418
- expect(document.head.querySelector('meta[name="keywords"]')).not.toBeNull()
419
- show.set(false)
420
- expect(document.head.querySelector('meta[name="keywords"]')).toBeNull()
421
- })
422
-
423
- test('patchAttrs removes old attributes no longer in props', () => {
424
- const attrs = signal<Record<string, string>>({ name: 'test', content: 'value' })
425
- function Page() {
426
- useHead(() => ({ meta: [attrs()] }))
427
- return h('div', null)
428
- }
429
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
430
- const el = document.head.querySelector('meta[name="test"]')
431
- expect(el?.getAttribute('content')).toBe('value')
432
- // Change attrs to remove 'content'
433
- attrs.set({ name: 'test' })
434
- const el2 = document.head.querySelector('meta[name="test"]')
435
- expect(el2?.getAttribute('content')).toBeNull()
436
- })
437
-
438
- test('syncElementAttrs removes previously managed attrs', () => {
439
- const show = signal(true)
440
- function Page() {
441
- useHead(() => (show() ? { htmlAttrs: { lang: 'en', dir: 'ltr' } } : { htmlAttrs: {} }))
442
- return h('div', null)
443
- }
444
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
445
- expect(document.documentElement.getAttribute('lang')).toBe('en')
446
- expect(document.documentElement.getAttribute('dir')).toBe('ltr')
447
- show.set(false)
448
- // Previously managed attrs should be removed
449
- expect(document.documentElement.getAttribute('lang')).toBeNull()
450
- expect(document.documentElement.getAttribute('dir')).toBeNull()
451
- })
452
-
453
- test('link tag key deduplication by rel when no href', async () => {
454
- function Page() {
455
- useHead({
456
- link: [{ rel: 'icon' }],
457
- })
458
- return h('div', null)
459
- }
460
- const { head } = await renderWithHead(h(Page, null))
461
- expect(head).toContain('rel=')
462
- })
463
-
464
- test('link tag key uses index when no href or rel', async () => {
465
- function Page() {
466
- useHead({
467
- link: [{ crossorigin: 'anonymous' }],
468
- })
469
- return h('div', null)
470
- }
471
- const { head } = await renderWithHead(h(Page, null))
472
- expect(head).toContain('<link')
473
- })
474
-
475
- test('meta tag key uses property when name is absent', async () => {
476
- function Page() {
477
- useHead({
478
- meta: [{ property: 'og:title', content: 'OG' }],
479
- })
480
- return h('div', null)
481
- }
482
- const { head } = await renderWithHead(h(Page, null))
483
- expect(head).toContain('property="og:title"')
484
- })
485
-
486
- test('meta tag key falls back to index when no name or property', async () => {
487
- function Page() {
488
- useHead({
489
- meta: [{ charset: 'utf-8' }],
490
- })
491
- return h('div', null)
492
- }
493
- const { head } = await renderWithHead(h(Page, null))
494
- expect(head).toContain('charset="utf-8"')
495
- })
496
-
497
- test('script tag with src uses src as key', async () => {
498
- function Page() {
499
- useHead({
500
- script: [{ src: '/app.js' }],
501
- })
502
- return h('div', null)
503
- }
504
- const { head } = await renderWithHead(h(Page, null))
505
- expect(head).toContain('src="/app.js"')
506
- })
507
-
508
- test('script tag without src uses index as key', async () => {
509
- function Page() {
510
- useHead({
511
- script: [{ children: "console.log('hi')" }],
512
- })
513
- return h('div', null)
514
- }
515
- const { head } = await renderWithHead(h(Page, null))
516
- expect(head).toContain("console.log('hi')")
517
- })
518
-
519
- test('base tag renders in SSR', async () => {
520
- function Page() {
521
- useHead({ base: { href: '/' } })
522
- return h('div', null)
523
- }
524
- const { head } = await renderWithHead(h(Page, null))
525
- expect(head).toContain('<base')
526
- expect(head).toContain('href="/"')
527
- })
528
-
529
- test('noscript raw content escaping in SSR', async () => {
530
- function Page() {
531
- useHead({
532
- noscript: [{ children: '<p>Enable JS</p>' }],
533
- })
534
- return h('div', null)
535
- }
536
- const { head } = await renderWithHead(h(Page, null))
537
- // noscript is a raw tag, content preserved
538
- expect(head).toContain('<p>Enable JS</p>')
539
- })
540
-
541
- test('style content escaping in SSR prevents tag injection', async () => {
542
- function Page() {
543
- useHead({
544
- style: [{ children: 'body { color: red } </style><script>' }],
545
- })
546
- return h('div', null)
547
- }
548
- const { head } = await renderWithHead(h(Page, null))
549
- // Closing tag should be escaped
550
- expect(head).toContain('<\\/style>')
551
- })
552
-
553
- test('unkeyed tags are all preserved in resolve', () => {
554
- const id1 = Symbol()
555
- const id2 = Symbol()
556
- ctx.add(id1, { tags: [{ tag: 'meta', props: { name: 'a', content: '1' } }] })
557
- ctx.add(id2, { tags: [{ tag: 'meta', props: { name: 'b', content: '2' } }] })
558
- const tags = ctx.resolve()
559
- expect(tags).toHaveLength(2)
560
- ctx.remove(id1)
561
- ctx.remove(id2)
562
- })
563
-
564
- test('title tag without children renders empty', async () => {
565
- function Page() {
566
- useHead({ title: '' })
567
- return h('div', null)
568
- }
569
- const { head } = await renderWithHead(h(Page, null))
570
- expect(head).toContain('<title></title>')
571
- })
572
-
573
- test('useHead with no context is a no-op', () => {
574
- // Calling useHead outside of any HeadProvider should not throw
575
- expect(() => {
576
- useHead({ title: 'No Provider' })
577
- }).not.toThrow()
578
- })
579
-
580
- test('CSR sync creates new elements for unkeyed tags', () => {
581
- function Page() {
582
- useHead({ meta: [{ name: 'viewport', content: 'width=device-width' }] })
583
- return h('div', null)
584
- }
585
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
586
- const meta = document.head.querySelector('meta[name="viewport"]')
587
- expect(meta).not.toBeNull()
588
- })
589
-
590
- test('CSR patchAttrs sets new attribute values', () => {
591
- const val = signal('initial')
592
- function Page() {
593
- useHead(() => ({
594
- meta: [{ name: 'test-patch', content: val() }],
595
- }))
596
- return h('div', null)
597
- }
598
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
599
- const el = document.head.querySelector('meta[name="test-patch"]')
600
- expect(el?.getAttribute('content')).toBe('initial')
601
- val.set('changed')
602
- expect(el?.getAttribute('content')).toBe('changed')
603
- })
604
-
605
- test('dom.ts: syncDom creates new meta element when none exists', () => {
606
- function Page() {
607
- useHead({ meta: [{ name: 'keywords', content: 'test,coverage' }] })
608
- return h('div', null)
609
- }
610
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
611
- const meta = document.head.querySelector('meta[name="keywords"]')
612
- expect(meta).not.toBeNull()
613
- expect(meta?.getAttribute('content')).toBe('test,coverage')
614
- })
615
-
616
- test('dom.ts: syncDom replaces element when tag name differs from found (line 41-49)', () => {
617
- // Pre-create a keyed element with a different tag name
618
- const existing = document.createElement('link')
619
- existing.setAttribute('data-pyreon-head', 'style-0')
620
- document.head.appendChild(existing)
621
-
622
- function Page() {
623
- useHead({ style: [{ children: '.x { color: red }' }] })
624
- return h('div', null)
625
- }
626
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
627
- // The old link should be removed (stale), new style element created
628
- const style = document.head.querySelector('style[data-pyreon-head]')
629
- expect(style).not.toBeNull()
630
- expect(style?.textContent).toBe('.x { color: red }')
631
- })
632
-
633
- test('dom.ts: syncDom handles tag with empty key (line 34)', () => {
634
- // A tag with no key should not use byKey lookup (empty key → undefined)
635
- function Page() {
636
- useHead({
637
- meta: [{ charset: 'utf-8' }], // no name or property → key is "meta-0"
638
- })
639
- return h('div', null)
640
- }
641
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
642
- const meta = document.head.querySelector('meta[charset="utf-8"]')
643
- expect(meta).not.toBeNull()
644
- })
645
-
646
- test('dom.ts: syncDom patches textContent when content changes (line 40)', () => {
647
- const content = signal('initial content')
648
- function Page() {
649
- useHead(() => ({
650
- style: [{ children: content() }],
651
- }))
652
- return h('div', null)
653
- }
654
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
655
- const style = document.head.querySelector('style[data-pyreon-head]')
656
- expect(style?.textContent).toBe('initial content')
657
- content.set('updated content')
658
- expect(style?.textContent).toBe('updated content')
659
- })
660
-
661
- test('dom.ts: syncElementAttrs removes managed-attrs tracker when all attrs removed (line 99-100)', () => {
662
- const show = signal(true)
663
- function Page() {
664
- useHead(() => (show() ? { bodyAttrs: { 'data-theme': 'dark' } } : { bodyAttrs: {} }))
665
- return h('div', null)
666
- }
667
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
668
- expect(document.body.getAttribute('data-theme')).toBe('dark')
669
- expect(document.body.getAttribute('data-pyreon-head-attrs')).toBe('data-theme')
670
- show.set(false)
671
- expect(document.body.getAttribute('data-theme')).toBeNull()
672
- // The managed-attrs tracker should also be removed
673
- expect(document.body.getAttribute('data-pyreon-head-attrs')).toBeNull()
674
- })
675
-
676
- test('dom.ts: syncElementAttrs updates managed attrs tracking (lines 92-98)', () => {
677
- const val = signal('en')
678
- function Page() {
679
- useHead(() => ({ htmlAttrs: { lang: val(), 'data-x': 'y' } }))
680
- return h('div', null)
681
- }
682
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
683
- const managed = document.documentElement.getAttribute('data-pyreon-head-attrs')
684
- expect(managed).toContain('lang')
685
- expect(managed).toContain('data-x')
686
- // Update to only one attr to verify partial removal
687
- val.set('fr')
688
- expect(document.documentElement.getAttribute('lang')).toBe('fr')
689
- })
690
-
691
- test('provider.tsx: HeadProvider handles function children (line 32)', () => {
692
- function Page() {
693
- useHead({ title: 'Func Children' })
694
- return h('div', null, 'hello')
695
- }
696
- // Pass children as a function (thunk)
697
- const childFn = () => h(Page, null)
698
- mount(h(HeadProvider, { context: ctx, children: childFn }), container)
699
- expect(document.title).toBe('Func Children')
700
- })
701
-
702
- test('dom.ts: patchAttrs removes old attrs not in new props (line 67)', () => {
703
- const val = signal<Record<string, string>>({
704
- name: 'desc',
705
- content: 'old',
706
- 'data-extra': 'yes',
707
- })
708
- function Page() {
709
- useHead(() => ({ meta: [val()] }))
710
- return h('div', null)
711
- }
712
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
713
- const el = document.head.querySelector('meta[name="desc"]')
714
- expect(el?.getAttribute('data-extra')).toBe('yes')
715
- // Update to remove data-extra attr
716
- val.set({ name: 'desc', content: 'new' })
717
- expect(el?.getAttribute('data-extra')).toBeNull()
718
- expect(el?.getAttribute('content')).toBe('new')
719
- })
720
-
721
- test('dom.ts: syncDom skips textContent update when content already matches (line 40 false)', () => {
722
- const trigger = signal(0)
723
- function Page() {
724
- useHead(() => {
725
- trigger() // subscribe so effect re-runs
726
- return { style: [{ children: 'unchanged' }] }
727
- })
728
- return h('div', null)
729
- }
730
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
731
- const style = document.head.querySelector('style[data-pyreon-head]')
732
- expect(style?.textContent).toBe('unchanged')
733
- // Re-trigger syncDom with same content — exercises the "content matches" branch
734
- trigger.set(1)
735
- expect(style?.textContent).toBe('unchanged')
736
- })
737
-
738
- test('dom.ts: syncDom handles tag.children when content changes (line 39-40)', () => {
739
- const s = signal('body1')
740
- function Page() {
741
- useHead(() => ({ style: [{ children: s() }] }))
742
- return h('div', null)
743
- }
744
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
745
- const style = document.head.querySelector('style[data-pyreon-head]')
746
- expect(style?.textContent).toBe('body1')
747
- s.set('body2')
748
- expect(style?.textContent).toBe('body2')
749
- })
750
- })
751
-
752
- // ─── createHeadContext — context stacking & caching ──────────────────────────
753
-
754
- describe('createHeadContext', () => {
755
- test('resolve returns empty array when no entries', () => {
756
- const ctx = createHeadContext()
757
- expect(ctx.resolve()).toEqual([])
758
- })
759
-
760
- test('resolve caches result until dirty', () => {
761
- const ctx = createHeadContext()
762
- const id = Symbol()
763
- ctx.add(id, { tags: [{ tag: 'meta', key: 'a', props: { name: 'a' } }] })
764
- const first = ctx.resolve()
765
- const second = ctx.resolve()
766
- expect(first).toBe(second) // same array reference — cached
767
- })
768
-
769
- test('resolve invalidates cache after add', () => {
770
- const ctx = createHeadContext()
771
- const id1 = Symbol()
772
- ctx.add(id1, { tags: [{ tag: 'meta', key: 'a', props: { name: 'a' } }] })
773
- const first = ctx.resolve()
774
- const id2 = Symbol()
775
- ctx.add(id2, { tags: [{ tag: 'meta', key: 'b', props: { name: 'b' } }] })
776
- const second = ctx.resolve()
777
- expect(first).not.toBe(second) // different reference — rebuilt
778
- expect(second).toHaveLength(2)
779
- })
780
-
781
- test('resolve invalidates cache after remove', () => {
782
- const ctx = createHeadContext()
783
- const id = Symbol()
784
- ctx.add(id, { tags: [{ tag: 'meta', key: 'a', props: { name: 'a' } }] })
785
- const first = ctx.resolve()
786
- ctx.remove(id)
787
- const second = ctx.resolve()
788
- expect(second).toHaveLength(0)
789
- expect(first).not.toBe(second)
790
- })
791
-
792
- test('keyed tags deduplicate — last added wins', () => {
793
- const ctx = createHeadContext()
794
- const id1 = Symbol()
795
- const id2 = Symbol()
796
- ctx.add(id1, { tags: [{ tag: 'title', key: 'title', children: 'First' }] })
797
- ctx.add(id2, { tags: [{ tag: 'title', key: 'title', children: 'Second' }] })
798
- const tags = ctx.resolve()
799
- expect(tags).toHaveLength(1)
800
- expect(tags[0]?.children).toBe('Second')
801
- })
802
-
803
- test('resolveTitleTemplate returns undefined when none set', () => {
804
- const ctx = createHeadContext()
805
- expect(ctx.resolveTitleTemplate()).toBeUndefined()
806
- })
807
-
808
- test('resolveTitleTemplate returns last added template', () => {
809
- const ctx = createHeadContext()
810
- const id1 = Symbol()
811
- const id2 = Symbol()
812
- ctx.add(id1, { tags: [], titleTemplate: '%s | Site A' })
813
- ctx.add(id2, { tags: [], titleTemplate: '%s | Site B' })
814
- expect(ctx.resolveTitleTemplate()).toBe('%s | Site B')
815
- })
816
-
817
- test('resolveHtmlAttrs merges from multiple entries', () => {
818
- const ctx = createHeadContext()
819
- const id1 = Symbol()
820
- const id2 = Symbol()
821
- ctx.add(id1, { tags: [], htmlAttrs: { lang: 'en' } })
822
- ctx.add(id2, { tags: [], htmlAttrs: { dir: 'ltr' } })
823
- expect(ctx.resolveHtmlAttrs()).toEqual({ lang: 'en', dir: 'ltr' })
824
- })
825
-
826
- test('resolveHtmlAttrs later entries override earlier', () => {
827
- const ctx = createHeadContext()
828
- const id1 = Symbol()
829
- const id2 = Symbol()
830
- ctx.add(id1, { tags: [], htmlAttrs: { lang: 'en' } })
831
- ctx.add(id2, { tags: [], htmlAttrs: { lang: 'fr' } })
832
- expect(ctx.resolveHtmlAttrs()).toEqual({ lang: 'fr' })
833
- })
834
-
835
- test('resolveBodyAttrs merges from multiple entries', () => {
836
- const ctx = createHeadContext()
837
- const id1 = Symbol()
838
- const id2 = Symbol()
839
- ctx.add(id1, { tags: [], bodyAttrs: { class: 'dark' } })
840
- ctx.add(id2, { tags: [], bodyAttrs: { 'data-page': 'home' } })
841
- expect(ctx.resolveBodyAttrs()).toEqual({ class: 'dark', 'data-page': 'home' })
842
- })
843
-
844
- test('remove non-existent id does not throw', () => {
845
- const ctx = createHeadContext()
846
- expect(() => ctx.remove(Symbol())).not.toThrow()
847
- })
848
- })
849
-
850
- // ─── HeadProvider — context stacking ─────────────────────────────────────────
851
-
852
- describe('HeadProvider — context stacking', () => {
853
- let container: HTMLElement
854
-
855
- beforeEach(() => {
856
- container = document.createElement('div')
857
- document.body.appendChild(container)
858
- for (const el of document.head.querySelectorAll('[data-pyreon-head]')) el.remove()
859
- document.title = ''
860
- })
861
-
862
- test('auto-creates context when no context prop', () => {
863
- function Page() {
864
- useHead({ title: 'Auto Context' })
865
- return h('div', null)
866
- }
867
- // HeadProvider without context prop
868
- mount(h(HeadProvider, { children: h(Page, null) }), container)
869
- expect(document.title).toBe('Auto Context')
870
- })
871
-
872
- test('nested HeadProviders — inner context receives inner useHead calls', () => {
873
- const outerCtx = createHeadContext()
874
- const innerCtx = createHeadContext()
875
-
876
- function Outer() {
877
- useHead({ title: 'Outer' })
878
- return h('div', null, h(HeadProvider, { context: innerCtx, children: h(Inner, null) }))
879
- }
880
- function Inner() {
881
- useHead({ title: 'Inner' })
882
- return h('span', null)
883
- }
884
- mount(h(HeadProvider, { context: outerCtx, children: h(Outer, null) }), container)
885
- // Outer context has "Outer" title, inner context has "Inner" title
886
- // The outer syncDom runs and sets title to "Outer"
887
- // Both contexts sync independently
888
- const outerTags = outerCtx.resolve()
889
- expect(outerTags.some((t) => t.children === 'Outer')).toBe(true)
890
- // The inner context should have Inner's title registered
891
- const innerTags = innerCtx.resolve()
892
- expect(innerTags.some((t) => t.children === 'Inner')).toBe(true)
893
- })
894
- })
895
-
896
- // ─── useHead with reactive signals — CSR ────────────────────────────────────
897
-
898
- describe('useHead — reactive signal-driven values', () => {
899
- let container: HTMLElement
900
- let ctx: HeadContextValue
901
-
902
- beforeEach(() => {
903
- container = document.createElement('div')
904
- document.body.appendChild(container)
905
- ctx = createHeadContext()
906
- for (const el of document.head.querySelectorAll('[data-pyreon-head]')) el.remove()
907
- document.title = ''
908
- })
909
-
910
- test('reactive meta tags update when signal changes', () => {
911
- const description = signal('Initial description')
912
- function Page() {
913
- useHead(() => ({
914
- meta: [{ name: 'description', content: description() }],
915
- }))
916
- return h('div', null)
917
- }
918
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
919
- expect(document.head.querySelector('meta[name="description"]')?.getAttribute('content')).toBe(
920
- 'Initial description',
921
- )
922
- description.set('Updated description')
923
- expect(document.head.querySelector('meta[name="description"]')?.getAttribute('content')).toBe(
924
- 'Updated description',
925
- )
926
- })
927
-
928
- test('reactive link tags update when signal changes', () => {
929
- const href = signal('/page-v1')
930
- function Page() {
931
- useHead(() => ({
932
- link: [{ rel: 'canonical', href: href() }],
933
- }))
934
- return h('div', null)
935
- }
936
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
937
- const link = document.head.querySelector('link[rel="canonical"]')
938
- expect(link?.getAttribute('href')).toBe('/page-v1')
939
- href.set('/page-v2')
940
- // A new link element is created because the key changes (includes href)
941
- const newLink = document.head.querySelector('link[data-pyreon-head]')
942
- expect(newLink).not.toBeNull()
943
- })
944
-
945
- test('reactive bodyAttrs update when signal changes', () => {
946
- const theme = signal('light')
947
- function Page() {
948
- useHead(() => ({ bodyAttrs: { 'data-theme': theme() } }))
949
- return h('div', null)
950
- }
951
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
952
- expect(document.body.getAttribute('data-theme')).toBe('light')
953
- theme.set('dark')
954
- expect(document.body.getAttribute('data-theme')).toBe('dark')
955
- })
956
-
957
- test('reactive htmlAttrs update when signal changes', () => {
958
- const lang = signal('en')
959
- function Page() {
960
- useHead(() => ({ htmlAttrs: { lang: lang() } }))
961
- return h('div', null)
962
- }
963
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
964
- expect(document.documentElement.getAttribute('lang')).toBe('en')
965
- lang.set('de')
966
- expect(document.documentElement.getAttribute('lang')).toBe('de')
967
- })
968
-
969
- test('reactive jsonLd updates when signal changes', () => {
970
- const pageName = signal('Home')
971
- function Page() {
972
- useHead(() => ({ jsonLd: { '@type': 'WebPage', name: pageName() } }))
973
- return h('div', null)
974
- }
975
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
976
- const script = document.head.querySelector('script[type="application/ld+json"]')
977
- expect(script?.textContent).toContain('"name":"Home"')
978
- pageName.set('About')
979
- const updated = document.head.querySelector('script[type="application/ld+json"]')
980
- expect(updated?.textContent).toContain('"name":"About"')
981
- })
982
-
983
- test('reactive titleTemplate with signal-driven title', () => {
984
- const pageTitle = signal('Home')
985
- function Layout() {
986
- useHead({ titleTemplate: '%s | MySite' })
987
- return h('div', null, h(Page, null))
988
- }
989
- function Page() {
990
- useHead(() => ({ title: pageTitle() }))
991
- return h('span', null)
992
- }
993
- mount(h(HeadProvider, { context: ctx, children: h(Layout, null) }), container)
994
- expect(document.title).toBe('Home | MySite')
995
- pageTitle.set('About')
996
- expect(document.title).toBe('About | MySite')
997
- })
998
- })
999
-
1000
- // ─── renderWithHead — SSR subpath import ─────────────────────────────────────
1001
-
1002
- describe('renderWithHead — @pyreon/head/ssr subpath', () => {
1003
- test('renderWithHead is importable from ssr module', async () => {
1004
- const mod = await import('../ssr')
1005
- expect(typeof mod.renderWithHead).toBe('function')
1006
- })
1007
-
1008
- test('renderWithHead with multiple useHead calls merges tags', async () => {
1009
- function Layout() {
1010
- useHead({ titleTemplate: '%s | App', htmlAttrs: { lang: 'en' } })
1011
- return h('div', null, h(Page, null))
1012
- }
1013
- function Page() {
1014
- useHead({
1015
- title: 'Dashboard',
1016
- meta: [{ name: 'description', content: 'Dashboard page' }],
1017
- bodyAttrs: { class: 'dashboard' },
1018
- })
1019
- return h('span', null)
1020
- }
1021
- const result = await renderWithHead(h(Layout, null))
1022
- expect(result.head).toContain('<title>Dashboard | App</title>')
1023
- expect(result.head).toContain('name="description"')
1024
- expect(result.htmlAttrs).toEqual({ lang: 'en' })
1025
- expect(result.bodyAttrs).toEqual({ class: 'dashboard' })
1026
- })
1027
-
1028
- test('renderWithHead with empty head returns empty string', async () => {
1029
- function Page() {
1030
- return h('div', null, 'content')
1031
- }
1032
- const result = await renderWithHead(h(Page, null))
1033
- expect(result.head).toBe('')
1034
- expect(result.html).toContain('content')
1035
- })
1036
-
1037
- test('renderWithHead serializes HTML comment openers in script content', async () => {
1038
- function Page() {
1039
- useHead({ script: [{ children: 'if (x <!-- y) {}' }] })
1040
- return h('div', null)
1041
- }
1042
- const { head } = await renderWithHead(h(Page, null))
1043
- expect(head).toContain('<\\!--')
1044
- expect(head).not.toContain('<!--')
1045
- })
1046
- })
1047
-
1048
- // ─── SSR — additional branch coverage ────────────────────────────────────────
1049
-
1050
- describe('renderWithHead — SSR additional branches', () => {
1051
- test('ssr.ts: title without children (line 60)', async () => {
1052
- function Page() {
1053
- useHead({} as any) // no title field at all
1054
- return h('div', null)
1055
- }
1056
- const { head } = await renderWithHead(h(Page, null))
1057
- expect(head).toBe('') // no tags
1058
- })
1059
-
1060
- test('ssr.ts: non-raw tag with children (line 76)', async () => {
1061
- function Page() {
1062
- useHead({
1063
- noscript: [{ children: '<p>Enable JavaScript</p>' }],
1064
- })
1065
- return h('div', null)
1066
- }
1067
- const { head } = await renderWithHead(h(Page, null))
1068
- expect(head).toContain('<noscript>')
1069
- expect(head).toContain('Enable JavaScript')
1070
- })
1071
-
1072
- test('ssr.ts: function titleTemplate in SSR', async () => {
1073
- function Page() {
1074
- useHead({
1075
- title: 'Page Title',
1076
- titleTemplate: (t: string) => `${t} | MySite`,
1077
- })
1078
- return h('div', null)
1079
- }
1080
- const { head } = await renderWithHead(h(Page, null))
1081
- expect(head).toContain('Page Title | MySite')
1082
- })
1083
-
1084
- test('ssr.ts: string titleTemplate in SSR', async () => {
1085
- function Page() {
1086
- useHead({
1087
- title: 'Page',
1088
- titleTemplate: '%s - App',
1089
- })
1090
- return h('div', null)
1091
- }
1092
- const { head } = await renderWithHead(h(Page, null))
1093
- expect(head).toContain('Page - App')
1094
- })
1095
-
1096
- test('use-head.ts: reactive input in SSR evaluates once (line 86-88)', async () => {
1097
- function Page() {
1098
- useHead(() => ({
1099
- title: 'SSR Reactive',
1100
- meta: [{ name: 'desc', content: 'from function' }],
1101
- }))
1102
- return h('div', null)
1103
- }
1104
- const { head } = await renderWithHead(h(Page, null))
1105
- expect(head).toContain('SSR Reactive')
1106
- })
1107
-
1108
- test('use-head.ts: link key without href falls back to rel (line 20)', async () => {
1109
- function Page() {
1110
- useHead({
1111
- link: [
1112
- { rel: 'preconnect' }, // no href → key uses rel
1113
- { rel: 'dns-prefetch' }, // another no-href
1114
- ],
1115
- })
1116
- return h('div', null)
1117
- }
1118
- const { head } = await renderWithHead(h(Page, null))
1119
- expect(head).toContain('preconnect')
1120
- expect(head).toContain('dns-prefetch')
1121
- })
1122
-
1123
- test('use-head.ts: link key without href or rel falls back to index (line 20)', async () => {
1124
- function Page() {
1125
- useHead({
1126
- link: [{ type: 'text/css' }], // no href, no rel → key is "link-0"
1127
- })
1128
- return h('div', null)
1129
- }
1130
- const { head } = await renderWithHead(h(Page, null))
1131
- expect(head).toContain('text/css')
1132
- })
1133
-
1134
- test('use-head.ts: script with children in SSR (line 30)', async () => {
1135
- function Page() {
1136
- useHead({
1137
- script: [{ children: "console.log('hi')" }],
1138
- })
1139
- return h('div', null)
1140
- }
1141
- const { head } = await renderWithHead(h(Page, null))
1142
- expect(head).toContain('console.log')
1143
- })
1144
-
1145
- test('ssr.ts: htmlAttrs and bodyAttrs in result', async () => {
1146
- function Page() {
1147
- useHead({
1148
- htmlAttrs: { lang: 'en', dir: 'ltr' },
1149
- bodyAttrs: { class: 'dark' },
1150
- })
1151
- return h('div', null)
1152
- }
1153
- const { htmlAttrs, bodyAttrs } = await renderWithHead(h(Page, null))
1154
- expect(htmlAttrs.lang).toBe('en')
1155
- expect(bodyAttrs.class).toBe('dark')
1156
- })
1157
- })
1158
-
1159
- // ─── SSR paths via document mocking ──────────────────────────────────────────
1160
-
1161
- describe('useHead — SSR paths (document undefined)', () => {
1162
- const origDoc = globalThis.document
1163
-
1164
- beforeEach(() => {
1165
- delete (globalThis as Record<string, unknown>).document
1166
- })
1167
-
1168
- afterEach(() => {
1169
- globalThis.document = origDoc
1170
- })
1171
-
1172
- test('syncDom is no-op when document is undefined (dom.ts line 13)', async () => {
1173
- const { syncDom } = await import('../dom')
1174
- const ctx2 = createHeadContext()
1175
- ctx2.add(Symbol(), {
1176
- tags: [{ tag: 'meta', key: 'test', props: { name: 'x', content: 'y' } }],
1177
- })
1178
- // Should not throw — early return because document is undefined
1179
- syncDom(ctx2)
1180
- })
1181
-
1182
- test('useHead static input registers synchronously in SSR (use-head.ts line 88-92)', async () => {
1183
- // In SSR (no document), static input doesn't trigger syncDom
1184
- const { useHead: _uh } = await import('../use-head')
1185
- // This just verifies the code path doesn't error when document is undefined
1186
- // The actual registration happens via the context
1187
- expect(typeof document).toBe('undefined')
1188
- })
1189
- })
1190
-
1191
- // ─── speculationRules (E12) ──────────────────────────────────────────────────
1192
-
1193
- describe('useHead — speculationRules', () => {
1194
- test('SSR: emits a single <script type="speculationrules"> with valid JSON body', async () => {
1195
- function Page() {
1196
- useHead({
1197
- speculationRules: {
1198
- prerender: [{ source: 'list', urls: ['/about', '/posts'], eagerness: 'moderate' }],
1199
- },
1200
- })
1201
- return h('div', null)
1202
- }
1203
- const { head } = await renderWithHead(h(Page, null))
1204
- expect(head).toContain('type="speculationrules"')
1205
- // Body must be valid JSON parseable back to the exact rules.
1206
- const m = head.match(/<script type="speculationrules">([\s\S]*?)<\/script>/)
1207
- expect(m).not.toBeNull()
1208
- const parsed = JSON.parse((m as RegExpMatchArray)[1] as string)
1209
- expect(parsed).toEqual({
1210
- prerender: [{ source: 'list', urls: ['/about', '/posts'], eagerness: 'moderate' }],
1211
- })
1212
- // Exactly one block — no accidental duplicate.
1213
- expect(head.match(/type="speculationrules"/g)).toHaveLength(1)
1214
- })
1215
-
1216
- test('CSR: syncs the speculationrules script into document.head on mount', () => {
1217
- const ctx = createHeadContext()
1218
- const container = document.createElement('div')
1219
- function Page() {
1220
- useHead({ speculationRules: { prefetch: [{ source: 'list', urls: ['/next'] }] } })
1221
- return h('div', null)
1222
- }
1223
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
1224
- const script = document.head.querySelector('script[type="speculationrules"]')
1225
- expect(script).not.toBeNull()
1226
- expect(JSON.parse(script?.textContent ?? '{}')).toEqual({
1227
- prefetch: [{ source: 'list', urls: ['/next'] }],
1228
- })
1229
- })
1230
-
1231
- test('deduped by key — innermost component wins, never two blocks', async () => {
1232
- function Inner() {
1233
- useHead({ speculationRules: { prerender: [{ source: 'list', urls: ['/inner'] }] } })
1234
- return h('div', null)
1235
- }
1236
- function Outer() {
1237
- useHead({ speculationRules: { prerender: [{ source: 'list', urls: ['/outer'] }] } })
1238
- return h('div', null, h(Inner, null))
1239
- }
1240
- const { head } = await renderWithHead(h(Outer, null))
1241
- expect(head.match(/type="speculationrules"/g)).toHaveLength(1)
1242
- expect(head).toContain('/inner')
1243
- expect(head).not.toContain('/outer')
1244
- })
1245
-
1246
- test('reactive: regenerates when the source signal changes', () => {
1247
- const ctx = createHeadContext()
1248
- const container = document.createElement('div')
1249
- const target = signal('/a')
1250
- function Page() {
1251
- useHead(() => ({
1252
- speculationRules: { prerender: [{ source: 'list', urls: [target()] }] },
1253
- }))
1254
- return h('div', null)
1255
- }
1256
- mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
1257
- expect(document.head.querySelector('script[type="speculationrules"]')?.textContent).toContain(
1258
- '/a',
1259
- )
1260
- target.set('/b')
1261
- expect(document.head.querySelector('script[type="speculationrules"]')?.textContent).toContain(
1262
- '/b',
1263
- )
1264
- })
1265
-
1266
- test('document-source predicate rules round-trip', async () => {
1267
- function Page() {
1268
- useHead({
1269
- speculationRules: {
1270
- prefetch: [
1271
- {
1272
- source: 'document',
1273
- where: { selector_matches: 'a.router-link' },
1274
- eagerness: 'conservative',
1275
- },
1276
- ],
1277
- },
1278
- })
1279
- return h('div', null)
1280
- }
1281
- const { head } = await renderWithHead(h(Page, null))
1282
- const m = head.match(/<script type="speculationrules">([\s\S]*?)<\/script>/)
1283
- expect(JSON.parse((m as RegExpMatchArray)[1] as string).prefetch[0].where).toEqual({
1284
- selector_matches: 'a.router-link',
1285
- })
1286
- })
1287
-
1288
- test('opt-in: no script emitted when speculationRules is absent', async () => {
1289
- function Page() {
1290
- useHead({ title: 'No Rules' })
1291
- return h('div', null)
1292
- }
1293
- const { head } = await renderWithHead(h(Page, null))
1294
- expect(head).not.toContain('speculationrules')
1295
- })
1296
-
1297
- test('XSS-safe: a URL containing </script> is escaped, body stays valid JSON', async () => {
1298
- function Page() {
1299
- useHead({
1300
- speculationRules: { prerender: [{ source: 'list', urls: ['/x</script><b>pwn'] }] },
1301
- })
1302
- return h('div', null)
1303
- }
1304
- const { head } = await renderWithHead(h(Page, null))
1305
- // The raw closing tag must NOT appear unescaped inside the block.
1306
- const block = head.match(/<script type="speculationrules">([\s\S]*?)<\/script>/)
1307
- expect(block).not.toBeNull()
1308
- expect((block as RegExpMatchArray)[1]).not.toContain('</script>')
1309
- // And the un-escaped JSON still parses back to the original URL.
1310
- const unescaped = (block as RegExpMatchArray)[1]!.replace(/<\\\//g, '</')
1311
- expect(JSON.parse(unescaped).prerender[0].urls[0]).toBe('/x</script><b>pwn')
1312
- })
1313
- })