@pyreon/runtime-dom 0.24.5 → 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.
Files changed (53) hide show
  1. package/package.json +5 -9
  2. package/src/delegate.ts +0 -98
  3. package/src/devtools.ts +0 -339
  4. package/src/env.d.ts +0 -6
  5. package/src/hydrate.ts +0 -450
  6. package/src/hydration-debug.ts +0 -129
  7. package/src/index.ts +0 -83
  8. package/src/keep-alive-entry.ts +0 -3
  9. package/src/keep-alive.ts +0 -83
  10. package/src/manifest.ts +0 -236
  11. package/src/mount.ts +0 -597
  12. package/src/nodes.ts +0 -896
  13. package/src/props.ts +0 -474
  14. package/src/template.ts +0 -523
  15. package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
  16. package/src/tests/callback-ref-unmount.test.ts +0 -52
  17. package/src/tests/compiler-integration.test.tsx +0 -508
  18. package/src/tests/coverage-gaps.test.ts +0 -3183
  19. package/src/tests/coverage.test.ts +0 -1140
  20. package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
  21. package/src/tests/dev-gate-pattern.test.ts +0 -46
  22. package/src/tests/dev-gate-treeshake.test.ts +0 -256
  23. package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
  24. package/src/tests/fanout-repro.test.tsx +0 -219
  25. package/src/tests/hydration-integration.test.tsx +0 -540
  26. package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
  27. package/src/tests/lifecycle-integration.test.tsx +0 -342
  28. package/src/tests/lis-prepend.browser.test.ts +0 -99
  29. package/src/tests/manifest-snapshot.test.ts +0 -85
  30. package/src/tests/mount.test.ts +0 -3529
  31. package/src/tests/native-markers.test.ts +0 -19
  32. package/src/tests/props.test.ts +0 -581
  33. package/src/tests/reactive-props.test.ts +0 -270
  34. package/src/tests/real-world-integration.test.tsx +0 -714
  35. package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
  36. package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
  37. package/src/tests/rs-collapse-h.browser.test.ts +0 -152
  38. package/src/tests/rs-collapse-h.test.ts +0 -237
  39. package/src/tests/rs-collapse.browser.test.ts +0 -128
  40. package/src/tests/runtime-dom.browser.test.ts +0 -409
  41. package/src/tests/setup.ts +0 -3
  42. package/src/tests/show-context.test.ts +0 -270
  43. package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
  44. package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
  45. package/src/tests/style-key-removal.browser.test.ts +0 -54
  46. package/src/tests/style-key-removal.test.ts +0 -88
  47. package/src/tests/template.test.ts +0 -383
  48. package/src/tests/transition-timeout-leak.test.ts +0 -126
  49. package/src/tests/transition.test.ts +0 -568
  50. package/src/tests/verified-correct-probes.test.ts +0 -56
  51. package/src/transition-entry.ts +0 -7
  52. package/src/transition-group.ts +0 -350
  53. package/src/transition.ts +0 -245
@@ -1,3529 +0,0 @@
1
- import type { ComponentFn, VNodeChild } from '@pyreon/core'
2
- import {
3
- ErrorBoundary as _ErrorBoundary,
4
- createRef,
5
- Dynamic,
6
- defineComponent,
7
- For,
8
- Fragment,
9
- h,
10
- lazy,
11
- Match,
12
- onMount,
13
- onUnmount,
14
- onUpdate,
15
- Portal,
16
- Show,
17
- Suspense as _Suspense,
18
- Switch,
19
- } from '@pyreon/core'
20
- import { cell, signal } from '@pyreon/reactivity'
21
- import { installDevTools, registerComponent, unregisterComponent } from '../devtools'
22
- import {
23
- KeepAlive as _KeepAlive,
24
- Transition as _Transition,
25
- TransitionGroup as _TransitionGroup,
26
- createTemplate,
27
- disableHydrationWarnings,
28
- enableHydrationWarnings,
29
- hydrateRoot,
30
- mount,
31
- sanitizeHtml,
32
- setSanitizer,
33
- } from '../index'
34
- import { mountChild } from '../mount'
35
-
36
- // Cast components that return VNodeChild (not VNode | null) so h() accepts them
37
- const Transition = _Transition as unknown as ComponentFn<Record<string, unknown>>
38
- const TransitionGroup = _TransitionGroup as unknown as ComponentFn<Record<string, unknown>>
39
- const ErrorBoundary = _ErrorBoundary as unknown as ComponentFn<Record<string, unknown>>
40
- const KeepAlive = _KeepAlive as unknown as ComponentFn<Record<string, unknown>>
41
- const Suspense = _Suspense as unknown as ComponentFn<Record<string, unknown>>
42
-
43
- function container(): HTMLElement {
44
- const el = document.createElement('div')
45
- document.body.appendChild(el)
46
- return el
47
- }
48
-
49
- // ─── Static mounting ─────────────────────────────────────────────────────────
50
-
51
- describe('mount — static', () => {
52
- test('mounts a text node', () => {
53
- const el = container()
54
- mount('hello', el)
55
- expect(el.textContent).toBe('hello')
56
- })
57
-
58
- test('mounts a number as text', () => {
59
- const el = container()
60
- mount(42, el)
61
- expect(el.textContent).toBe('42')
62
- })
63
-
64
- test('mounts a simple element', () => {
65
- const el = container()
66
- mount(h('span', null, 'world'), el)
67
- expect(el.innerHTML).toBe('<span>world</span>')
68
- })
69
-
70
- test('mounts nested elements', () => {
71
- const el = container()
72
- mount(h('div', null, h('p', null, 'nested')), el)
73
- expect(el.querySelector('p')?.textContent).toBe('nested')
74
- })
75
-
76
- test('mounts null / undefined / false as nothing', () => {
77
- const el = container()
78
- mount(null, el)
79
- expect(el.innerHTML).toBe('')
80
- mount(undefined, el)
81
- expect(el.innerHTML).toBe('')
82
- mount(false, el)
83
- expect(el.innerHTML).toBe('')
84
- })
85
-
86
- test('mounts a Fragment', () => {
87
- const el = container()
88
- mount(h(Fragment, null, h('span', null, 'a'), h('span', null, 'b')), el)
89
- expect(el.querySelectorAll('span').length).toBe(2)
90
- })
91
- })
92
-
93
- // ─── Props ────────────────────────────────────────────────────────────────────
94
-
95
- describe('mount — props', () => {
96
- test('sets class attribute', () => {
97
- const el = container()
98
- mount(h('div', { class: 'foo bar' }), el)
99
- expect(el.querySelector('div')?.className).toBe('foo bar')
100
- })
101
-
102
- test('sets arbitrary attribute', () => {
103
- const el = container()
104
- mount(h('div', { 'data-id': '123' }), el)
105
- expect(el.querySelector('div')?.getAttribute('data-id')).toBe('123')
106
- })
107
-
108
- test('removes attribute when value is null', () => {
109
- const el = container()
110
- mount(h('div', { 'data-x': null }), el)
111
- expect(el.querySelector('div')?.hasAttribute('data-x')).toBe(false)
112
- })
113
-
114
- test('attaches event listener', () => {
115
- const el = container()
116
- let clicked = false
117
- mount(
118
- h(
119
- 'button',
120
- {
121
- onClick: () => {
122
- clicked = true
123
- },
124
- },
125
- 'click me',
126
- ),
127
- el,
128
- )
129
- el.querySelector('button')?.click()
130
- expect(clicked).toBe(true)
131
- })
132
- })
133
-
134
- // ─── Reactive props & children ────────────────────────────────────────────────
135
-
136
- describe('mount — reactive', () => {
137
- test('reactive text child updates', () => {
138
- const el = container()
139
- const text = signal('hello')
140
- mount(
141
- h('div', null, () => text()),
142
- el,
143
- )
144
- expect(el.querySelector('div')?.textContent).toBe('hello')
145
- text.set('world')
146
- expect(el.querySelector('div')?.textContent).toBe('world')
147
- })
148
-
149
- test('reactive class prop updates', () => {
150
- const el = container()
151
- const cls = signal('a')
152
- mount(h('div', { class: () => cls() }), el)
153
- expect(el.querySelector('div')?.className).toBe('a')
154
- cls.set('b')
155
- expect(el.querySelector('div')?.className).toBe('b')
156
- })
157
- })
158
-
159
- // ─── Components ───────────────────────────────────────────────────────────────
160
-
161
- describe('mount — components', () => {
162
- test('mounts a functional component', () => {
163
- const Greeting = defineComponent(({ name }: { name: string }) =>
164
- h('p', null, `Hello, ${name}!`),
165
- )
166
- const el = container()
167
- mount(h(Greeting, { name: 'Pyreon' }), el)
168
- expect(el.querySelector('p')?.textContent).toBe('Hello, Pyreon!')
169
- })
170
-
171
- test('component with reactive state updates DOM', () => {
172
- const Counter = defineComponent(() => {
173
- const count = signal(0)
174
- return h(
175
- 'div',
176
- null,
177
- h('span', null, () => String(count())),
178
- h('button', { onClick: () => count.update((n) => n + 1) }, '+'),
179
- )
180
- })
181
- const el = container()
182
- mount(h(Counter, {}), el)
183
- expect(el.querySelector('span')?.textContent).toBe('0')
184
- el.querySelector('button')?.click()
185
- expect(el.querySelector('span')?.textContent).toBe('1')
186
- el.querySelector('button')?.click()
187
- expect(el.querySelector('span')?.textContent).toBe('2')
188
- })
189
- })
190
-
191
- // ─── Unmount ──────────────────────────────────────────────────────────────────
192
-
193
- describe('mount — refs', () => {
194
- test('ref.current is set after mount', () => {
195
- const el = container()
196
- const ref = createRef<HTMLButtonElement>()
197
- expect(ref.current).toBeNull()
198
- mount(h('button', { ref }), el)
199
- expect(ref.current).toBeInstanceOf(HTMLButtonElement)
200
- })
201
-
202
- test('ref.current is cleared after unmount', () => {
203
- const el = container()
204
- const ref = createRef<HTMLDivElement>()
205
- const unmount = mount(h('div', { ref }), el)
206
- expect(ref.current).not.toBeNull()
207
- unmount()
208
- expect(ref.current).toBeNull()
209
- })
210
-
211
- test('callback ref is called with element after mount', () => {
212
- const el = container()
213
- let refEl: Element | null = null
214
- mount(
215
- h('div', {
216
- ref: (e: Element) => {
217
- refEl = e
218
- },
219
- }),
220
- el,
221
- )
222
- expect(refEl).toBeInstanceOf(HTMLDivElement)
223
- })
224
-
225
- test('callback ref is invoked with null on unmount', () => {
226
- const el = container()
227
- let refEl: Element | null = null
228
- const unmount = mount(
229
- h('div', {
230
- ref: (e: Element | null) => {
231
- refEl = e
232
- },
233
- }),
234
- el,
235
- )
236
- expect(refEl).toBeInstanceOf(HTMLDivElement)
237
- unmount()
238
- // Fixed: callback refs are now called with null on cleanup
239
- // to match React/Solid/Vue behavior and the RefCallback<T> type.
240
- expect(refEl).toBeNull()
241
- })
242
-
243
- test('ref is not emitted as an HTML attribute', () => {
244
- const el = container()
245
- const ref = createRef<HTMLDivElement>()
246
- mount(h('div', { ref }), el)
247
- expect(el.firstElementChild?.hasAttribute('ref')).toBe(false)
248
- })
249
- })
250
-
251
- describe('mount — unmount', () => {
252
- test('unmount removes mounted nodes', () => {
253
- const el = container()
254
- const unmount = mount(h('div', null, 'bye'), el)
255
- expect(el.innerHTML).not.toBe('')
256
- unmount()
257
- expect(el.innerHTML).toBe('')
258
- })
259
-
260
- test('unmount disposes reactive effects', () => {
261
- const el = container()
262
- const text = signal('initial')
263
- const unmount = mount(
264
- h('p', null, () => text()),
265
- el,
266
- )
267
- unmount()
268
- text.set('updated')
269
- // After unmount, node is gone — no error thrown, no stale update
270
- expect(el.innerHTML).toBe('')
271
- })
272
- })
273
-
274
- // ─── For ──────────────────────────────────────────────────────────────────────
275
-
276
- describe('mount — For', () => {
277
- type Item = { id: number; label: string }
278
-
279
- test('renders initial list', () => {
280
- const el = container()
281
- const items = signal<Item[]>([
282
- { id: 1, label: 'a' },
283
- { id: 2, label: 'b' },
284
- { id: 3, label: 'c' },
285
- ])
286
- mount(
287
- h(
288
- 'ul',
289
- null,
290
- For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
291
- ),
292
- el,
293
- )
294
- expect(el.querySelectorAll('li').length).toBe(3)
295
- expect(el.querySelectorAll('li')[0]?.textContent).toBe('a')
296
- expect(el.querySelectorAll('li')[2]?.textContent).toBe('c')
297
- })
298
-
299
- test('appends new items', () => {
300
- const el = container()
301
- const items = signal<Item[]>([{ id: 1, label: 'a' }])
302
- mount(
303
- h(
304
- 'ul',
305
- null,
306
- For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
307
- ),
308
- el,
309
- )
310
- expect(el.querySelectorAll('li').length).toBe(1)
311
- items.set([
312
- { id: 1, label: 'a' },
313
- { id: 2, label: 'b' },
314
- ])
315
- expect(el.querySelectorAll('li').length).toBe(2)
316
- expect(el.querySelectorAll('li')[1]?.textContent).toBe('b')
317
- })
318
-
319
- test('removes items', () => {
320
- const el = container()
321
- const items = signal<Item[]>([
322
- { id: 1, label: 'a' },
323
- { id: 2, label: 'b' },
324
- ])
325
- mount(
326
- h(
327
- 'ul',
328
- null,
329
- For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
330
- ),
331
- el,
332
- )
333
- items.set([{ id: 1, label: 'a' }])
334
- expect(el.querySelectorAll('li').length).toBe(1)
335
- expect(el.querySelectorAll('li')[0]?.textContent).toBe('a')
336
- })
337
-
338
- test('swaps two items (small-k fast path)', () => {
339
- const el = container()
340
- const items = signal<Item[]>([
341
- { id: 1, label: 'a' },
342
- { id: 2, label: 'b' },
343
- { id: 3, label: 'c' },
344
- ])
345
- mount(
346
- h(
347
- 'ul',
348
- null,
349
- For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
350
- ),
351
- el,
352
- )
353
- items.set([
354
- { id: 1, label: 'a' },
355
- { id: 3, label: 'c' },
356
- { id: 2, label: 'b' },
357
- ])
358
- const lis = el.querySelectorAll('li')
359
- expect(lis[0]?.textContent).toBe('a')
360
- expect(lis[1]?.textContent).toBe('c')
361
- expect(lis[2]?.textContent).toBe('b')
362
- })
363
-
364
- test('replaces all items', () => {
365
- const el = container()
366
- const items = signal<Item[]>([{ id: 1, label: 'old' }])
367
- mount(
368
- h(
369
- 'ul',
370
- null,
371
- For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
372
- ),
373
- el,
374
- )
375
- items.set([{ id: 99, label: 'new' }])
376
- const lis = el.querySelectorAll('li')
377
- expect(lis.length).toBe(1)
378
- expect(lis[0]?.textContent).toBe('new')
379
- })
380
-
381
- test('clears list', () => {
382
- const el = container()
383
- const items = signal<Item[]>([{ id: 1, label: 'x' }])
384
- mount(
385
- h(
386
- 'ul',
387
- null,
388
- For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
389
- ),
390
- el,
391
- )
392
- items.set([])
393
- expect(el.querySelectorAll('li').length).toBe(0)
394
- })
395
-
396
- test('unmount cleans up', () => {
397
- const el = container()
398
- const items = signal<Item[]>([{ id: 1, label: 'x' }])
399
- const unmount = mount(
400
- h(
401
- 'ul',
402
- null,
403
- For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
404
- ),
405
- el,
406
- )
407
- unmount()
408
- expect(el.innerHTML).toBe('')
409
- })
410
- })
411
-
412
- // ─── For + NativeItem (createTemplate path — what the benchmark uses) ─────
413
-
414
- describe('mount — For + NativeItem (createTemplate)', () => {
415
- type RR = { id: number; label: ReturnType<typeof cell<string>> }
416
-
417
- function makeRR(id: number, text: string): RR {
418
- return { id, label: cell(text) }
419
- }
420
-
421
- const rowFactory = createTemplate<RR>('<tr><td>\x00</td><td>\x00</td></tr>', (tr, row) => {
422
- const td1 = tr.firstChild as HTMLElement
423
- const td2 = td1.nextSibling as HTMLElement
424
- const t1 = td1.firstChild as Text
425
- const t2 = td2.firstChild as Text
426
- t1.data = String(row.id)
427
- t2.data = row.label.peek()
428
- row.label.listen(() => {
429
- t2.data = row.label.peek()
430
- })
431
- return null
432
- })
433
-
434
- test('renders initial list with correct text', () => {
435
- const el = container()
436
- const items = signal<RR[]>([makeRR(1, 'a'), makeRR(2, 'b'), makeRR(3, 'c')])
437
- mount(
438
- h(
439
- 'table',
440
- null,
441
- h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
442
- ),
443
- el,
444
- )
445
- const trs = el.querySelectorAll('tr')
446
- expect(trs.length).toBe(3)
447
- expect(trs[0]?.querySelectorAll('td')[1]?.textContent).toBe('a')
448
- expect(trs[2]?.querySelectorAll('td')[1]?.textContent).toBe('c')
449
- })
450
-
451
- test('cell.set() updates text in-place (partial update)', () => {
452
- const el = container()
453
- const rows = [makeRR(1, 'hello'), makeRR(2, 'world')]
454
- const items = signal<RR[]>(rows)
455
- mount(
456
- h(
457
- 'table',
458
- null,
459
- h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
460
- ),
461
- el,
462
- )
463
- // Update label via cell — should change DOM without re-rendering list
464
- const first = rows[0]
465
- if (!first) throw new Error('missing row')
466
- first.label.set('changed')
467
- expect(el.querySelectorAll('tr')[0]?.querySelectorAll('td')[1]?.textContent).toBe('changed')
468
- // Second row untouched
469
- expect(el.querySelectorAll('tr')[1]?.querySelectorAll('td')[1]?.textContent).toBe('world')
470
- })
471
-
472
- test('replace all rows', () => {
473
- const el = container()
474
- const items = signal<RR[]>([makeRR(1, 'old')])
475
- mount(
476
- h(
477
- 'table',
478
- null,
479
- h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
480
- ),
481
- el,
482
- )
483
- items.set([makeRR(10, 'new1'), makeRR(11, 'new2')])
484
- const trs = el.querySelectorAll('tr')
485
- expect(trs.length).toBe(2)
486
- expect(trs[0]?.querySelectorAll('td')[0]?.textContent).toBe('10')
487
- expect(trs[1]?.querySelectorAll('td')[1]?.textContent).toBe('new2')
488
- })
489
-
490
- test('swap rows preserves DOM identity', () => {
491
- const el = container()
492
- const r1 = makeRR(1, 'a')
493
- const r2 = makeRR(2, 'b')
494
- const r3 = makeRR(3, 'c')
495
- const items = signal<RR[]>([r1, r2, r3])
496
- mount(
497
- h(
498
- 'table',
499
- null,
500
- h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
501
- ),
502
- el,
503
- )
504
- const origTr2 = el.querySelectorAll('tr')[1]
505
- const origTr3 = el.querySelectorAll('tr')[2]
506
- // Swap positions 1 and 2
507
- items.set([r1, r3, r2])
508
- const trs = el.querySelectorAll('tr')
509
- expect(trs[1]?.querySelectorAll('td')[1]?.textContent).toBe('c')
510
- expect(trs[2]?.querySelectorAll('td')[1]?.textContent).toBe('b')
511
- // Same DOM nodes reused, just moved
512
- expect(trs[1]).toBe(origTr3)
513
- expect(trs[2]).toBe(origTr2)
514
- })
515
-
516
- test('clear removes all rows', () => {
517
- const el = container()
518
- const items = signal<RR[]>([makeRR(1, 'x'), makeRR(2, 'y')])
519
- mount(
520
- h(
521
- 'table',
522
- null,
523
- h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
524
- ),
525
- el,
526
- )
527
- items.set([])
528
- expect(el.querySelectorAll('tr').length).toBe(0)
529
- })
530
-
531
- test('clear then re-create works', () => {
532
- const el = container()
533
- const items = signal<RR[]>([makeRR(1, 'first')])
534
- mount(
535
- h(
536
- 'table',
537
- null,
538
- h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
539
- ),
540
- el,
541
- )
542
- items.set([])
543
- expect(el.querySelectorAll('tr').length).toBe(0)
544
- items.set([makeRR(5, 'back')])
545
- expect(el.querySelectorAll('tr').length).toBe(1)
546
- expect(el.querySelectorAll('td')[1]?.textContent).toBe('back')
547
- })
548
-
549
- test('append items to existing list', () => {
550
- const el = container()
551
- const r1 = makeRR(1, 'a')
552
- const items = signal<RR[]>([r1])
553
- mount(
554
- h(
555
- 'table',
556
- null,
557
- h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
558
- ),
559
- el,
560
- )
561
- items.set([r1, makeRR(2, 'b'), makeRR(3, 'c')])
562
- expect(el.querySelectorAll('tr').length).toBe(3)
563
- expect(el.querySelectorAll('tr')[2]?.querySelectorAll('td')[1]?.textContent).toBe('c')
564
- })
565
-
566
- test('remove items from middle', () => {
567
- const el = container()
568
- const r1 = makeRR(1, 'a')
569
- const r2 = makeRR(2, 'b')
570
- const r3 = makeRR(3, 'c')
571
- const items = signal<RR[]>([r1, r2, r3])
572
- mount(
573
- h(
574
- 'table',
575
- null,
576
- h('tbody', null, For({ each: items, by: (r) => r.id, children: rowFactory })),
577
- ),
578
- el,
579
- )
580
- items.set([r1, r3])
581
- const trs = el.querySelectorAll('tr')
582
- expect(trs.length).toBe(2)
583
- expect(trs[0]?.querySelectorAll('td')[1]?.textContent).toBe('a')
584
- expect(trs[1]?.querySelectorAll('td')[1]?.textContent).toBe('c')
585
- })
586
- })
587
-
588
- // ─── Portal ───────────────────────────────────────────────────────────────────
589
-
590
- describe('mount — Portal', () => {
591
- test('renders into target instead of parent', () => {
592
- const src = container()
593
- const target = container()
594
- mount(Portal({ target, children: h('span', null, 'portaled') }), src)
595
- // content appears in target, not in src
596
- expect(target.querySelector('span')?.textContent).toBe('portaled')
597
- expect(src.querySelector('span')).toBeNull()
598
- })
599
-
600
- test('unmount removes content from target', () => {
601
- const src = container()
602
- const target = container()
603
- const unmount = mount(Portal({ target, children: h('p', null, 'bye') }), src)
604
- expect(target.querySelector('p')).not.toBeNull()
605
- unmount()
606
- expect(target.querySelector('p')).toBeNull()
607
- })
608
-
609
- test('portal content updates reactively', () => {
610
- const src = container()
611
- const target = container()
612
- const text = signal('hello')
613
- mount(Portal({ target, children: h('span', null, () => text()) }), src)
614
- expect(target.querySelector('span')?.textContent).toBe('hello')
615
- text.set('world')
616
- expect(target.querySelector('span')?.textContent).toBe('world')
617
- })
618
-
619
- test('portal inside component renders into target', () => {
620
- const src = container()
621
- const target = container()
622
- const Modal = () => Portal({ target, children: h('dialog', null, 'modal content') })
623
- mount(h(Modal, null), src)
624
- expect(target.querySelector('dialog')?.textContent).toBe('modal content')
625
- expect(src.querySelector('dialog')).toBeNull()
626
- })
627
-
628
- test('portal re-mount after unmount works correctly', () => {
629
- const src = container()
630
- const target = container()
631
- const unmount1 = mount(Portal({ target, children: h('span', null, 'first') }), src)
632
- expect(target.querySelector('span')?.textContent).toBe('first')
633
- unmount1()
634
- expect(target.querySelector('span')).toBeNull()
635
- // Re-mount into same target
636
- const unmount2 = mount(Portal({ target, children: h('span', null, 'second') }), src)
637
- expect(target.querySelector('span')?.textContent).toBe('second')
638
- unmount2()
639
- expect(target.querySelector('span')).toBeNull()
640
- })
641
-
642
- test('multiple portals into same target', () => {
643
- const src = container()
644
- const target = container()
645
- const unmount1 = mount(Portal({ target, children: h('span', { class: 'a' }, 'A') }), src)
646
- const unmount2 = mount(Portal({ target, children: h('span', { class: 'b' }, 'B') }), src)
647
- expect(target.querySelectorAll('span').length).toBe(2)
648
- expect(target.querySelector('.a')?.textContent).toBe('A')
649
- expect(target.querySelector('.b')?.textContent).toBe('B')
650
- unmount1()
651
- expect(target.querySelectorAll('span').length).toBe(1)
652
- expect(target.querySelector('.b')?.textContent).toBe('B')
653
- unmount2()
654
- expect(target.querySelectorAll('span').length).toBe(0)
655
- })
656
-
657
- test('portal with reactive Show toggle', () => {
658
- const src = container()
659
- const target = container()
660
- const visible = signal(true)
661
- mount(
662
- h('div', null, () =>
663
- visible() ? Portal({ target, children: h('span', null, 'vis') }) : null,
664
- ),
665
- src,
666
- )
667
- expect(target.querySelector('span')?.textContent).toBe('vis')
668
- visible.set(false)
669
- expect(target.querySelector('span')).toBeNull()
670
- visible.set(true)
671
- expect(target.querySelector('span')?.textContent).toBe('vis')
672
- })
673
- })
674
-
675
- // ─── ErrorBoundary ────────────────────────────────────────────────────────────
676
-
677
- describe('ErrorBoundary', () => {
678
- test('renders fallback when child throws', () => {
679
- const el = container()
680
- function Broken(): never {
681
- throw new Error('boom')
682
- }
683
- mount(
684
- h(ErrorBoundary, {
685
- fallback: (err: unknown) => h('p', { id: 'fb' }, String(err)),
686
- children: h(Broken, null),
687
- }),
688
- el,
689
- )
690
- expect(el.querySelector('#fb')?.textContent).toContain('boom')
691
- })
692
-
693
- test('renders children when no error', () => {
694
- const el = container()
695
- function Fine() {
696
- return h('p', { id: 'ok' }, 'works')
697
- }
698
- mount(
699
- h(ErrorBoundary, {
700
- fallback: () => h('p', null, 'error'),
701
- children: h(Fine, null),
702
- }),
703
- el,
704
- )
705
- expect(el.querySelector('#ok')?.textContent).toBe('works')
706
- })
707
-
708
- test('reset() clears error and re-renders children', () => {
709
- const el = container()
710
- let shouldThrow = true
711
-
712
- function MaybeThrow() {
713
- if (shouldThrow) throw new Error('recoverable')
714
- return h('p', { id: 'recovered' }, 'back')
715
- }
716
-
717
- mount(
718
- h(ErrorBoundary, {
719
- fallback: (_err: unknown, reset: () => void) =>
720
- h(
721
- 'button',
722
- {
723
- id: 'retry',
724
- onClick: () => {
725
- shouldThrow = false
726
- reset()
727
- },
728
- },
729
- 'retry',
730
- ),
731
- children: h(MaybeThrow, null),
732
- }),
733
- el,
734
- )
735
-
736
- // Fallback rendered
737
- expect(el.querySelector('#retry')).not.toBeNull()
738
- expect(el.querySelector('#recovered')).toBeNull()
739
-
740
- // Click retry — reset() fires, shouldThrow is false, children re-render
741
- ;(el.querySelector('#retry') as HTMLButtonElement).click()
742
-
743
- expect(el.querySelector('#recovered')?.textContent).toBe('back')
744
- expect(el.querySelector('#retry')).toBeNull()
745
- })
746
-
747
- test('reset() with signal-driven children', () => {
748
- const el = container()
749
- const broken = signal(true)
750
-
751
- function Reactive() {
752
- if (broken()) throw new Error('signal error')
753
- return h('p', { id: 'signal-ok' }, 'fixed')
754
- }
755
-
756
- mount(
757
- h(ErrorBoundary, {
758
- fallback: (_err: unknown, reset: () => void) =>
759
- h(
760
- 'button',
761
- {
762
- id: 'fix',
763
- onClick: () => {
764
- broken.set(false)
765
- reset()
766
- },
767
- },
768
- 'fix',
769
- ),
770
- children: h(Reactive, null),
771
- }),
772
- el,
773
- )
774
-
775
- expect(el.querySelector('#fix')).not.toBeNull()
776
- ;(el.querySelector('#fix') as HTMLButtonElement).click()
777
- expect(el.querySelector('#signal-ok')?.textContent).toBe('fixed')
778
- })
779
-
780
- // ── lazy() + Suspense + ErrorBoundary integration ──
781
- //
782
- // The `lazy(loader)` wrapper throws synchronously when its loader's
783
- // promise rejects (`error()` returns truthy → `throw err`).
784
- //
785
- // Pyreon components run ONCE — reactivity comes from reading signals
786
- // inside reactive scopes. `lazy()`'s wrapper reads its `error` /
787
- // `loaded` signals inline, so the surrounding context must be a
788
- // reactive scope for signal changes to trigger re-render.
789
- //
790
- // `Suspense` wraps its children in `h(Fragment, null, () => ...)` —
791
- // an explicit reactive accessor that calls `__loading()`. THAT
792
- // accessor's reactive scope is what tracks lazy's signals: when the
793
- // loader rejects, the accessor re-runs, the lazy child re-mounts,
794
- // the wrapper throws, mountComponent catches, dispatches to the
795
- // nearest `<ErrorBoundary>` on the boundary stack.
796
- //
797
- // Without Suspense, lazy()'s post-mount errors don't surface (no
798
- // reactive scope to drive re-render). This is consistent with the
799
- // framework's component-runs-once contract — but worth pinning
800
- // down with explicit tests.
801
-
802
- test('lazy() loader rejection surfaces to ErrorBoundary via Suspense', async () => {
803
- const el = container()
804
- const Comp = lazy<Record<string, never>>(() =>
805
- Promise.reject(new Error('module load failed')),
806
- )
807
-
808
- mount(
809
- h(ErrorBoundary, {
810
- fallback: (err: unknown) =>
811
- h('p', { id: 'lazy-fb' }, `Caught: ${(err as Error).message}`),
812
- children: h(
813
- Suspense,
814
- { fallback: h('p', { id: 'spinner' }, 'loading...') },
815
- h(Comp, {}),
816
- ),
817
- }),
818
- el,
819
- )
820
-
821
- // Initial render: lazy is still loading → Suspense shows spinner,
822
- // boundary fallback NOT triggered yet.
823
- expect(el.querySelector('#spinner')).not.toBeNull()
824
- expect(el.querySelector('#lazy-fb')).toBeNull()
825
-
826
- // Wait for promise rejection to flush.
827
- await new Promise((r) => setTimeout(r, 0))
828
- // Reactive flush.
829
- await Promise.resolve()
830
-
831
- // After load fails: Suspense's reactive accessor re-runs → child
832
- // wrapper throws → caught by mountComponent → dispatched to
833
- // ErrorBoundary → fallback rendered.
834
- expect(el.querySelector('#lazy-fb')?.textContent).toContain('module load failed')
835
- expect(el.querySelector('#spinner')).toBeNull()
836
- })
837
-
838
- test('lazy() resolves successfully renders content without firing fallback', async () => {
839
- const el = container()
840
- const Inner: ComponentFn<Record<string, never>> = () =>
841
- h('p', { id: 'loaded' }, 'content')
842
- const Comp = lazy<Record<string, never>>(() => Promise.resolve({ default: Inner }))
843
-
844
- let fallbackInvocations = 0
845
- mount(
846
- h(ErrorBoundary, {
847
- fallback: () => {
848
- fallbackInvocations++
849
- return h('p', { id: 'should-not-appear' }, 'error')
850
- },
851
- children: h(
852
- Suspense,
853
- { fallback: h('p', { id: 'spinner' }, 'loading...') },
854
- h(Comp, {}),
855
- ),
856
- }),
857
- el,
858
- )
859
-
860
- await new Promise((r) => setTimeout(r, 0))
861
- await Promise.resolve()
862
-
863
- expect(el.querySelector('#loaded')?.textContent).toBe('content')
864
- expect(el.querySelector('#should-not-appear')).toBeNull()
865
- expect(fallbackInvocations).toBe(0)
866
- })
867
- })
868
-
869
- // ─── Transition component ─────────────────────────────────────────────────────
870
-
871
- describe('Transition', () => {
872
- test('mounts child when show starts true', () => {
873
- const el = container()
874
- const visible = signal(true)
875
- mount(h(Transition, { show: visible, children: h('div', { id: 'target' }, 'hi') }), el)
876
- expect(el.querySelector('#target')).not.toBeNull()
877
- })
878
-
879
- test('does not mount child when show starts false', () => {
880
- const el = container()
881
- const visible = signal(false)
882
- mount(h(Transition, { show: visible, children: h('div', { id: 'target' }, 'hi') }), el)
883
- expect(el.querySelector('#target')).toBeNull()
884
- })
885
-
886
- test('mounts child reactively when show becomes true', () => {
887
- const el = container()
888
- const visible = signal(false)
889
- mount(h(Transition, { show: visible, children: h('div', { id: 'target' }, 'hi') }), el)
890
- expect(el.querySelector('#target')).toBeNull()
891
- visible.set(true)
892
- expect(el.querySelector('#target')).not.toBeNull()
893
- })
894
-
895
- test('calls onBeforeEnter when entering', async () => {
896
- const el = container()
897
- const visible = signal(false)
898
- let called = false
899
- mount(
900
- h(Transition, {
901
- show: visible,
902
- onBeforeEnter: () => {
903
- called = true
904
- },
905
- children: h('div', { id: 't' }),
906
- }),
907
- el,
908
- )
909
- visible.set(true)
910
- // onBeforeEnter fires inside queueMicrotask — wait one microtask tick
911
- await new Promise<void>((r) => queueMicrotask(r))
912
- expect(called).toBe(true)
913
- })
914
- })
915
-
916
- // ─── Show component ───────────────────────────────────────────────────────────
917
-
918
- describe('Show', () => {
919
- test('renders children when when() is truthy', () => {
920
- const el = container()
921
- mount(h(Show, { when: () => true }, h('span', { id: 's' }, 'yes')), el)
922
- expect(el.querySelector('#s')).not.toBeNull()
923
- })
924
-
925
- test('renders fallback when when() is falsy', () => {
926
- const el = container()
927
- mount(
928
- h(
929
- Show,
930
- { when: () => false, fallback: h('span', { id: 'fb' }, 'no') },
931
- h('span', { id: 's' }, 'yes'),
932
- ),
933
- el,
934
- )
935
- expect(el.querySelector('#s')).toBeNull()
936
- expect(el.querySelector('#fb')).not.toBeNull()
937
- })
938
-
939
- test('reactively toggles on signal change', () => {
940
- const el = container()
941
- const show = signal(false)
942
- mount(h(Show, { when: show }, h('div', { id: 't' }, 'visible')), el)
943
- expect(el.querySelector('#t')).toBeNull()
944
- show.set(true)
945
- expect(el.querySelector('#t')).not.toBeNull()
946
- show.set(false)
947
- expect(el.querySelector('#t')).toBeNull()
948
- })
949
-
950
- test('renders nothing when falsy and no fallback', () => {
951
- const el = container()
952
- mount(h(Show, { when: () => false }, h('div', null, 'hi')), el)
953
- expect(el.textContent).toBe('')
954
- })
955
- })
956
-
957
- // ─── Switch / Match components ────────────────────────────────────────────────
958
-
959
- describe('Switch / Match', () => {
960
- test('renders first matching branch', () => {
961
- const el = container()
962
- const route = signal('home')
963
- mount(
964
- h(
965
- Switch,
966
- { fallback: h('span', { id: 'notfound' }) },
967
- h(Match, { when: () => route() === 'home' }, h('span', { id: 'home' })),
968
- h(Match, { when: () => route() === 'about' }, h('span', { id: 'about' })),
969
- ),
970
- el,
971
- )
972
- expect(el.querySelector('#home')).not.toBeNull()
973
- expect(el.querySelector('#about')).toBeNull()
974
- expect(el.querySelector('#notfound')).toBeNull()
975
- })
976
-
977
- test('renders fallback when no match', () => {
978
- const el = container()
979
- const route = signal('other')
980
- mount(
981
- h(
982
- Switch,
983
- { fallback: h('span', { id: 'notfound' }) },
984
- h(Match, { when: () => route() === 'home' }, h('span', { id: 'home' })),
985
- ),
986
- el,
987
- )
988
- expect(el.querySelector('#notfound')).not.toBeNull()
989
- expect(el.querySelector('#home')).toBeNull()
990
- })
991
-
992
- test('switches branch reactively', () => {
993
- const el = container()
994
- const route = signal('home')
995
- mount(
996
- h(
997
- Switch,
998
- { fallback: h('span', { id: 'notfound' }) },
999
- h(Match, { when: () => route() === 'home' }, h('span', { id: 'home' })),
1000
- h(Match, { when: () => route() === 'about' }, h('span', { id: 'about' })),
1001
- ),
1002
- el,
1003
- )
1004
- expect(el.querySelector('#home')).not.toBeNull()
1005
- route.set('about')
1006
- expect(el.querySelector('#home')).toBeNull()
1007
- expect(el.querySelector('#about')).not.toBeNull()
1008
- route.set('other')
1009
- expect(el.querySelector('#notfound')).not.toBeNull()
1010
- })
1011
- })
1012
-
1013
- // ─── Props (extended coverage) ───────────────────────────────────────────────
1014
-
1015
- describe('mount — props (extended)', () => {
1016
- test('style as string sets cssText', () => {
1017
- const el = container()
1018
- mount(h('div', { style: 'color: red; font-size: 14px' }), el)
1019
- const div = el.querySelector('div') as HTMLElement
1020
- expect(div.style.color).toBe('red')
1021
- expect(div.style.fontSize).toBe('14px')
1022
- })
1023
-
1024
- test('style as object sets individual properties', () => {
1025
- const el = container()
1026
- mount(h('div', { style: { color: 'blue', marginTop: '10px' } }), el)
1027
- const div = el.querySelector('div') as HTMLElement
1028
- expect(div.style.color).toBe('blue')
1029
- expect(div.style.marginTop).toBe('10px')
1030
- })
1031
-
1032
- test('style object auto-appends px to numeric values', () => {
1033
- const el = container()
1034
- mount(h('div', { style: { height: 100, marginTop: 20, opacity: 0.5, zIndex: 10 } }), el)
1035
- const div = el.querySelector('div') as HTMLElement
1036
- expect(div.style.height).toBe('100px')
1037
- expect(div.style.marginTop).toBe('20px')
1038
- expect(div.style.opacity).toBe('0.5')
1039
- expect(div.style.zIndex).toBe('10')
1040
- })
1041
-
1042
- test('style object handles CSS custom properties', () => {
1043
- const el = container()
1044
- mount(h('div', { style: { '--my-color': 'red' } }), el)
1045
- const div = el.querySelector('div') as HTMLElement
1046
- expect(div.style.getPropertyValue('--my-color')).toBe('red')
1047
- })
1048
-
1049
- test('className sets class attribute', () => {
1050
- const el = container()
1051
- mount(h('div', { className: 'my-class' }), el)
1052
- expect(el.querySelector('div')?.getAttribute('class')).toBe('my-class')
1053
- })
1054
-
1055
- test('class null sets empty class', () => {
1056
- const el = container()
1057
- mount(h('div', { class: null }), el)
1058
- expect(el.querySelector('div')?.getAttribute('class')).toBe('')
1059
- })
1060
-
1061
- test('boolean attribute true sets empty attr', () => {
1062
- const el = container()
1063
- mount(h('input', { disabled: true }), el)
1064
- const input = el.querySelector('input') as HTMLInputElement
1065
- expect(input.disabled).toBe(true)
1066
- })
1067
-
1068
- test('boolean attribute false removes attr', () => {
1069
- const el = container()
1070
- mount(h('input', { disabled: false }), el)
1071
- const input = el.querySelector('input') as HTMLInputElement
1072
- expect(input.disabled).toBe(false)
1073
- })
1074
-
1075
- test('event handler receives event object', () => {
1076
- const el = container()
1077
- let receivedEvent: Event | null = null
1078
- mount(
1079
- h(
1080
- 'button',
1081
- {
1082
- onClick: (e: Event) => {
1083
- receivedEvent = e
1084
- },
1085
- },
1086
- 'click',
1087
- ),
1088
- el,
1089
- )
1090
- el.querySelector('button')?.click()
1091
- expect(receivedEvent).not.toBeNull()
1092
- expect(receivedEvent).toBeInstanceOf(Event)
1093
- })
1094
-
1095
- test('multiple event handlers on same element', () => {
1096
- const el = container()
1097
- let mouseDown = false
1098
- let mouseUp = false
1099
- mount(
1100
- h(
1101
- 'div',
1102
- {
1103
- onMousedown: () => {
1104
- mouseDown = true
1105
- },
1106
- onMouseup: () => {
1107
- mouseUp = true
1108
- },
1109
- },
1110
- 'target',
1111
- ),
1112
- el,
1113
- )
1114
- const div = el.querySelector('div') as HTMLElement
1115
- div.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
1116
- div.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
1117
- expect(mouseDown).toBe(true)
1118
- expect(mouseUp).toBe(true)
1119
- })
1120
-
1121
- test('event handler cleanup on unmount', () => {
1122
- const el = container()
1123
- let count = 0
1124
- const unmount = mount(
1125
- h(
1126
- 'button',
1127
- {
1128
- onClick: () => {
1129
- count++
1130
- },
1131
- },
1132
- 'click',
1133
- ),
1134
- el,
1135
- )
1136
- el.querySelector('button')?.click()
1137
- expect(count).toBe(1)
1138
- unmount()
1139
- // Button removed from DOM so click won't reach it
1140
- expect(count).toBe(1)
1141
- })
1142
-
1143
- test('sanitizes javascript: in href', () => {
1144
- const el = container()
1145
- mount(h('a', { href: 'javascript:alert(1)' }), el)
1146
- const a = el.querySelector('a') as HTMLAnchorElement
1147
- // Should not have the dangerous href set
1148
- expect(a.getAttribute('href')).not.toBe('javascript:alert(1)')
1149
- })
1150
-
1151
- test('sanitizes data: in src', () => {
1152
- const el = container()
1153
- mount(h('img', { src: 'data:text/html,<script>alert(1)</script>' }), el)
1154
- const img = el.querySelector('img') as HTMLImageElement
1155
- expect(img.getAttribute('src')).not.toBe('data:text/html,<script>alert(1)</script>')
1156
- })
1157
-
1158
- test('allows safe href values', () => {
1159
- const el = container()
1160
- mount(h('a', { href: 'https://example.com' }), el)
1161
- const a = el.querySelector('a') as HTMLAnchorElement
1162
- expect(a.href).toContain('https://example.com')
1163
- })
1164
-
1165
- test('innerHTML sets content', () => {
1166
- const el = container()
1167
- mount(h('div', { innerHTML: '<b>bold</b>' }), el)
1168
- const div = el.querySelector('div') as HTMLElement
1169
- expect(div.innerHTML).toBe('<b>bold</b>')
1170
- })
1171
-
1172
- test('dangerouslySetInnerHTML sets __html content', () => {
1173
- const el = container()
1174
- mount(h('div', { dangerouslySetInnerHTML: { __html: '<em>raw</em>' } }), el)
1175
- const div = el.querySelector('div') as HTMLElement
1176
- expect(div.innerHTML).toBe('<em>raw</em>')
1177
- })
1178
-
1179
- test('reactive style updates', () => {
1180
- const el = container()
1181
- const color = signal('red')
1182
- mount(h('div', { style: () => `color: ${color()}` }), el)
1183
- const div = el.querySelector('div') as HTMLElement
1184
- expect(div.style.color).toBe('red')
1185
- color.set('blue')
1186
- expect(div.style.color).toBe('blue')
1187
- })
1188
-
1189
- test('DOM property (value) set via prop', () => {
1190
- const el = container()
1191
- mount(h('input', { value: 'hello' }), el)
1192
- const input = el.querySelector('input') as HTMLInputElement
1193
- expect(input.value).toBe('hello')
1194
- })
1195
-
1196
- test('data-* attributes set correctly', () => {
1197
- const el = container()
1198
- mount(h('div', { 'data-testid': 'foo', 'data-count': '42' }), el)
1199
- const div = el.querySelector('div') as HTMLElement
1200
- expect(div.getAttribute('data-testid')).toBe('foo')
1201
- expect(div.getAttribute('data-count')).toBe('42')
1202
- })
1203
- })
1204
-
1205
- // ─── Keyed list (nodes.ts) — additional reorder patterns ────────────────────
1206
-
1207
- describe('mount — For keyed list reorder patterns', () => {
1208
- type Item = { id: number; label: string }
1209
-
1210
- function mountList(el: HTMLElement, items: ReturnType<typeof signal<Item[]>>) {
1211
- mount(
1212
- h(
1213
- 'ul',
1214
- null,
1215
- For({
1216
- each: items,
1217
- by: (r) => r.id,
1218
- children: (r) => h('li', { key: r.id }, r.label),
1219
- }),
1220
- ),
1221
- el,
1222
- )
1223
- }
1224
-
1225
- test('reverse order', () => {
1226
- const el = container()
1227
- const items = signal<Item[]>([
1228
- { id: 1, label: 'a' },
1229
- { id: 2, label: 'b' },
1230
- { id: 3, label: 'c' },
1231
- { id: 4, label: 'd' },
1232
- { id: 5, label: 'e' },
1233
- ])
1234
- mountList(el, items)
1235
- items.set([
1236
- { id: 5, label: 'e' },
1237
- { id: 4, label: 'd' },
1238
- { id: 3, label: 'c' },
1239
- { id: 2, label: 'b' },
1240
- { id: 1, label: 'a' },
1241
- ])
1242
- const lis = el.querySelectorAll('li')
1243
- expect(lis.length).toBe(5)
1244
- expect(lis[0]?.textContent).toBe('e')
1245
- expect(lis[1]?.textContent).toBe('d')
1246
- expect(lis[2]?.textContent).toBe('c')
1247
- expect(lis[3]?.textContent).toBe('b')
1248
- expect(lis[4]?.textContent).toBe('a')
1249
- })
1250
-
1251
- test('move single item to front', () => {
1252
- const el = container()
1253
- const items = signal<Item[]>([
1254
- { id: 1, label: 'a' },
1255
- { id: 2, label: 'b' },
1256
- { id: 3, label: 'c' },
1257
- ])
1258
- mountList(el, items)
1259
- items.set([
1260
- { id: 3, label: 'c' },
1261
- { id: 1, label: 'a' },
1262
- { id: 2, label: 'b' },
1263
- ])
1264
- const lis = el.querySelectorAll('li')
1265
- expect(lis[0]?.textContent).toBe('c')
1266
- expect(lis[1]?.textContent).toBe('a')
1267
- expect(lis[2]?.textContent).toBe('b')
1268
- })
1269
-
1270
- test('prepend items', () => {
1271
- const el = container()
1272
- const items = signal<Item[]>([
1273
- { id: 3, label: 'c' },
1274
- { id: 4, label: 'd' },
1275
- ])
1276
- mountList(el, items)
1277
- items.set([
1278
- { id: 1, label: 'a' },
1279
- { id: 2, label: 'b' },
1280
- { id: 3, label: 'c' },
1281
- { id: 4, label: 'd' },
1282
- ])
1283
- const lis = el.querySelectorAll('li')
1284
- expect(lis.length).toBe(4)
1285
- expect(lis[0]?.textContent).toBe('a')
1286
- expect(lis[1]?.textContent).toBe('b')
1287
- expect(lis[2]?.textContent).toBe('c')
1288
- expect(lis[3]?.textContent).toBe('d')
1289
- })
1290
-
1291
- test('interleave new items', () => {
1292
- const el = container()
1293
- const items = signal<Item[]>([
1294
- { id: 1, label: 'a' },
1295
- { id: 3, label: 'c' },
1296
- { id: 5, label: 'e' },
1297
- ])
1298
- mountList(el, items)
1299
- items.set([
1300
- { id: 1, label: 'a' },
1301
- { id: 2, label: 'b' },
1302
- { id: 3, label: 'c' },
1303
- { id: 4, label: 'd' },
1304
- { id: 5, label: 'e' },
1305
- ])
1306
- const lis = el.querySelectorAll('li')
1307
- expect(lis.length).toBe(5)
1308
- expect(lis[0]?.textContent).toBe('a')
1309
- expect(lis[1]?.textContent).toBe('b')
1310
- expect(lis[2]?.textContent).toBe('c')
1311
- expect(lis[3]?.textContent).toBe('d')
1312
- expect(lis[4]?.textContent).toBe('e')
1313
- })
1314
-
1315
- test('large reorder triggers LIS fallback (>8 diffs)', () => {
1316
- const el = container()
1317
- const initial = Array.from({ length: 20 }, (_, i) => ({
1318
- id: i + 1,
1319
- label: String.fromCharCode(97 + i),
1320
- }))
1321
- const items = signal<Item[]>(initial)
1322
- mountList(el, items)
1323
- // Shuffle: reverse first 15 items to force >8 diffs
1324
- const shuffled = [...initial]
1325
- shuffled.splice(0, 15, ...shuffled.slice(0, 15).reverse())
1326
- items.set(shuffled)
1327
- const lis = el.querySelectorAll('li')
1328
- expect(lis.length).toBe(20)
1329
- for (let i = 0; i < 20; i++) {
1330
- expect(lis[i]?.textContent).toBe(shuffled[i]?.label)
1331
- }
1332
- })
1333
-
1334
- test('remove from front and back simultaneously', () => {
1335
- const el = container()
1336
- const items = signal<Item[]>([
1337
- { id: 1, label: 'a' },
1338
- { id: 2, label: 'b' },
1339
- { id: 3, label: 'c' },
1340
- { id: 4, label: 'd' },
1341
- { id: 5, label: 'e' },
1342
- ])
1343
- mountList(el, items)
1344
- items.set([
1345
- { id: 2, label: 'b' },
1346
- { id: 3, label: 'c' },
1347
- { id: 4, label: 'd' },
1348
- ])
1349
- const lis = el.querySelectorAll('li')
1350
- expect(lis.length).toBe(3)
1351
- expect(lis[0]?.textContent).toBe('b')
1352
- expect(lis[2]?.textContent).toBe('d')
1353
- })
1354
-
1355
- test('swap first and last', () => {
1356
- const el = container()
1357
- const items = signal<Item[]>([
1358
- { id: 1, label: 'a' },
1359
- { id: 2, label: 'b' },
1360
- { id: 3, label: 'c' },
1361
- ])
1362
- mountList(el, items)
1363
- items.set([
1364
- { id: 3, label: 'c' },
1365
- { id: 2, label: 'b' },
1366
- { id: 1, label: 'a' },
1367
- ])
1368
- const lis = el.querySelectorAll('li')
1369
- expect(lis[0]?.textContent).toBe('c')
1370
- expect(lis[1]?.textContent).toBe('b')
1371
- expect(lis[2]?.textContent).toBe('a')
1372
- })
1373
-
1374
- test('multiple rapid updates', () => {
1375
- const el = container()
1376
- const items = signal<Item[]>([{ id: 1, label: 'a' }])
1377
- mountList(el, items)
1378
- items.set([
1379
- { id: 1, label: 'a' },
1380
- { id: 2, label: 'b' },
1381
- ])
1382
- items.set([
1383
- { id: 2, label: 'b' },
1384
- { id: 3, label: 'c' },
1385
- ])
1386
- items.set([{ id: 4, label: 'd' }])
1387
- const lis = el.querySelectorAll('li')
1388
- expect(lis.length).toBe(1)
1389
- expect(lis[0]?.textContent).toBe('d')
1390
- })
1391
- })
1392
-
1393
- // ─── Transition (extended coverage) ──────────────────────────────────────────
1394
-
1395
- describe('Transition — extended', () => {
1396
- test('custom class names', () => {
1397
- const el = container()
1398
- const visible = signal(false)
1399
- mount(
1400
- h(Transition, {
1401
- show: visible,
1402
- enterFrom: 'my-enter-from',
1403
- enterActive: 'my-enter-active',
1404
- enterTo: 'my-enter-to',
1405
- children: h('div', { id: 'custom' }, 'content'),
1406
- }),
1407
- el,
1408
- )
1409
- expect(el.querySelector('#custom')).toBeNull()
1410
- visible.set(true)
1411
- expect(el.querySelector('#custom')).not.toBeNull()
1412
- })
1413
-
1414
- test('leave hides element after animation', async () => {
1415
- const el = container()
1416
- const visible = signal(true)
1417
- mount(
1418
- h(Transition, {
1419
- show: visible,
1420
- children: h('div', { id: 'leave-test' }, 'content'),
1421
- }),
1422
- el,
1423
- )
1424
- expect(el.querySelector('#leave-test')).not.toBeNull()
1425
- visible.set(false)
1426
- // After rAF + transitionend, the element should be removed
1427
- // In happy-dom, we simulate the transitionend
1428
- const target = el.querySelector('#leave-test')
1429
- if (target) {
1430
- // Wait for the requestAnimationFrame callback
1431
- await new Promise<void>((r) => requestAnimationFrame(() => r()))
1432
- target.dispatchEvent(new Event('transitionend'))
1433
- }
1434
- // isMounted should now be false
1435
- await new Promise<void>((r) => queueMicrotask(r))
1436
- expect(el.querySelector('#leave-test')).toBeNull()
1437
- })
1438
-
1439
- test('appear triggers enter animation on initial mount', async () => {
1440
- const el = container()
1441
- const visible = signal(true)
1442
- let beforeEnterCalled = false
1443
- mount(
1444
- h(Transition, {
1445
- show: visible,
1446
- appear: true,
1447
- onBeforeEnter: () => {
1448
- beforeEnterCalled = true
1449
- },
1450
- children: h('div', { id: 'appear-test' }, 'content'),
1451
- }),
1452
- el,
1453
- )
1454
- await new Promise<void>((r) => queueMicrotask(r))
1455
- expect(beforeEnterCalled).toBe(true)
1456
- })
1457
-
1458
- test('calls onBeforeLeave when leaving', async () => {
1459
- const el = container()
1460
- const visible = signal(true)
1461
- let beforeLeaveCalled = false
1462
- mount(
1463
- h(Transition, {
1464
- show: visible,
1465
- onBeforeLeave: () => {
1466
- beforeLeaveCalled = true
1467
- },
1468
- children: h('div', { id: 'leave-cb' }, 'content'),
1469
- }),
1470
- el,
1471
- )
1472
- visible.set(false)
1473
- await new Promise<void>((r) => queueMicrotask(r))
1474
- expect(beforeLeaveCalled).toBe(true)
1475
- })
1476
-
1477
- test('re-entering during leave cancels leave', async () => {
1478
- const el = container()
1479
- const visible = signal(true)
1480
- mount(
1481
- h(Transition, {
1482
- show: visible,
1483
- name: 'fade',
1484
- children: h('div', { id: 'reenter' }, 'content'),
1485
- }),
1486
- el,
1487
- )
1488
- // Start leaving
1489
- visible.set(false)
1490
- // Before the leave animation finishes, re-enter
1491
- visible.set(true)
1492
- await new Promise<void>((r) => queueMicrotask(r))
1493
- expect(el.querySelector('#reenter')).not.toBeNull()
1494
- })
1495
-
1496
- test('transition with name prefix', () => {
1497
- const el = container()
1498
- const visible = signal(true)
1499
- mount(
1500
- h(Transition, {
1501
- show: visible,
1502
- name: 'slide',
1503
- children: h('div', { id: 'named' }, 'content'),
1504
- }),
1505
- el,
1506
- )
1507
- expect(el.querySelector('#named')).not.toBeNull()
1508
- })
1509
- })
1510
-
1511
- // ─── Hydration ───────────────────────────────────────────────────────────────
1512
-
1513
- describe('hydrateRoot', () => {
1514
- test('hydrates basic element', async () => {
1515
- const el = container()
1516
- el.innerHTML = '<div><span>hello</span></div>'
1517
- const cleanup = hydrateRoot(el, h('div', null, h('span', null, 'hello')))
1518
- expect(el.querySelector('span')?.textContent).toBe('hello')
1519
- cleanup()
1520
- })
1521
-
1522
- test('hydrates and attaches event handler', async () => {
1523
- const el = container()
1524
- el.innerHTML = '<button>click me</button>'
1525
- let clicked = false
1526
- hydrateRoot(
1527
- el,
1528
- h(
1529
- 'button',
1530
- {
1531
- onClick: () => {
1532
- clicked = true
1533
- },
1534
- },
1535
- 'click me',
1536
- ),
1537
- )
1538
- el.querySelector('button')?.click()
1539
- expect(clicked).toBe(true)
1540
- })
1541
-
1542
- test('hydrates text content', async () => {
1543
- const el = container()
1544
- el.innerHTML = '<p>some text</p>'
1545
- const cleanup = hydrateRoot(el, h('p', null, 'some text'))
1546
- expect(el.querySelector('p')?.textContent).toBe('some text')
1547
- cleanup()
1548
- })
1549
-
1550
- test('hydrates reactive text', async () => {
1551
- const el = container()
1552
- el.innerHTML = '<div>initial</div>'
1553
- const text = signal('initial')
1554
- hydrateRoot(
1555
- el,
1556
- h('div', null, () => text()),
1557
- )
1558
- expect(el.querySelector('div')?.textContent).toBe('initial')
1559
- text.set('updated')
1560
- expect(el.querySelector('div')?.textContent).toBe('updated')
1561
- })
1562
-
1563
- test('hydrates nested elements', async () => {
1564
- const el = container()
1565
- el.innerHTML = '<div><p><span>deep</span></p></div>'
1566
- const cleanup = hydrateRoot(el, h('div', null, h('p', null, h('span', null, 'deep'))))
1567
- expect(el.querySelector('span')?.textContent).toBe('deep')
1568
- cleanup()
1569
- })
1570
-
1571
- test('hydrates component', async () => {
1572
- const el = container()
1573
- el.innerHTML = '<p>Hello, World!</p>'
1574
- const Greeting = defineComponent(() => h('p', null, 'Hello, World!'))
1575
- const cleanup = hydrateRoot(el, h(Greeting, null))
1576
- expect(el.querySelector('p')?.textContent).toBe('Hello, World!')
1577
- cleanup()
1578
- })
1579
- })
1580
-
1581
- // ─── Mount edge cases ────────────────────────────────────────────────────────
1582
-
1583
- describe('mount — edge cases', () => {
1584
- test('null children in fragment', () => {
1585
- const el = container()
1586
- mount(h(Fragment, null, null, 'text', null), el)
1587
- expect(el.textContent).toBe('text')
1588
- })
1589
-
1590
- test('deeply nested fragments', () => {
1591
- const el = container()
1592
- mount(h(Fragment, null, h(Fragment, null, h(Fragment, null, h('span', null, 'deep')))), el)
1593
- expect(el.querySelector('span')?.textContent).toBe('deep')
1594
- })
1595
-
1596
- test('component returning null', () => {
1597
- const el = container()
1598
- const NullComp = defineComponent(() => null)
1599
- mount(h(NullComp, null), el)
1600
- expect(el.innerHTML).toBe('')
1601
- })
1602
-
1603
- test('component returning fragment with mixed children', () => {
1604
- const el = container()
1605
- const Mixed = defineComponent(() => h(Fragment, null, 'text', h('b', null, 'bold'), null, 42))
1606
- mount(h(Mixed, null), el)
1607
- expect(el.textContent).toContain('text')
1608
- expect(el.querySelector('b')?.textContent).toBe('bold')
1609
- expect(el.textContent).toContain('42')
1610
- })
1611
-
1612
- test('mounting array of children', () => {
1613
- const el = container()
1614
- mount(h('div', null, h('span', null, 'a'), h('span', null, 'b'), h('span', null, 'c')), el)
1615
- expect(el.querySelectorAll('span').length).toBe(3)
1616
- })
1617
-
1618
- test('reactive child toggling between null and element', () => {
1619
- const el = container()
1620
- const show = signal(false)
1621
- mount(
1622
- h('div', null, () => (show() ? h('span', { id: 'toggle' }, 'yes') : null)),
1623
- el,
1624
- )
1625
- expect(el.querySelector('#toggle')).toBeNull()
1626
- show.set(true)
1627
- expect(el.querySelector('#toggle')).not.toBeNull()
1628
- show.set(false)
1629
- expect(el.querySelector('#toggle')).toBeNull()
1630
- })
1631
-
1632
- test('reactive child returning component renders content', () => {
1633
- const el = container()
1634
- const Dashboard = () => h('div', { id: 'dashboard' }, 'Dashboard content')
1635
- const Store = () => h('div', { id: 'store' }, 'Store content')
1636
- const activeTab = signal('dashboard')
1637
- const tabs = [
1638
- { id: 'dashboard', component: Dashboard },
1639
- { id: 'store', component: Store },
1640
- ]
1641
-
1642
- mount(
1643
- h('div', null, () => {
1644
- const tab = tabs.find((t) => t.id === activeTab())
1645
- if (!tab) return null
1646
- const Component = tab.component
1647
- return h(Component, null)
1648
- }),
1649
- el,
1650
- )
1651
-
1652
- expect(el.querySelector('#dashboard')).not.toBeNull()
1653
- expect(el.querySelector('#dashboard')?.textContent).toBe('Dashboard content')
1654
-
1655
- activeTab.set('store')
1656
- expect(el.querySelector('#store')).not.toBeNull()
1657
- expect(el.querySelector('#store')?.textContent).toBe('Store content')
1658
- expect(el.querySelector('#dashboard')).toBeNull()
1659
- })
1660
-
1661
- test('reactive child returning component with internal signals', () => {
1662
- const el = container()
1663
- const Dashboard = () => {
1664
- const count = signal(0)
1665
- return h('div', { id: 'dashboard' }, () => `Count: ${count()}`)
1666
- }
1667
- const Settings = () => {
1668
- return h('div', { id: 'settings' }, h('span', null, 'Settings page'))
1669
- }
1670
- const activeTab = signal<string>('dashboard')
1671
-
1672
- mount(
1673
- h('div', null, () => {
1674
- const tab = activeTab()
1675
- if (tab === 'dashboard') return h(Dashboard, null)
1676
- if (tab === 'settings') return h(Settings, null)
1677
- return null
1678
- }),
1679
- el,
1680
- )
1681
-
1682
- expect(el.querySelector('#dashboard')).not.toBeNull()
1683
- expect(el.querySelector('#dashboard')?.textContent).toBe('Count: 0')
1684
-
1685
- activeTab.set('settings')
1686
- expect(el.querySelector('#settings')).not.toBeNull()
1687
- expect(el.querySelector('#settings')?.textContent).toBe('Settings page')
1688
- expect(el.querySelector('#dashboard')).toBeNull()
1689
-
1690
- activeTab.set('none')
1691
- expect(el.querySelector('#settings')).toBeNull()
1692
- expect(el.querySelector('#dashboard')).toBeNull()
1693
- })
1694
-
1695
- test('reactive Dynamic component switching', () => {
1696
- const el = container()
1697
- const Dashboard = () => h('div', { id: 'dashboard' }, 'Dashboard')
1698
- const Settings = () => h('div', { id: 'settings' }, 'Settings')
1699
- const activeTab = signal<string>('dashboard')
1700
- const components: Record<string, ComponentFn> = { dashboard: Dashboard, settings: Settings }
1701
-
1702
- mount(
1703
- h('div', null, () => h(Dynamic, { component: components[activeTab()] })),
1704
- el,
1705
- )
1706
-
1707
- expect(el.querySelector('#dashboard')?.textContent).toBe('Dashboard')
1708
-
1709
- activeTab.set('settings')
1710
- expect(el.querySelector('#settings')?.textContent).toBe('Settings')
1711
- expect(el.querySelector('#dashboard')).toBeNull()
1712
- })
1713
-
1714
- test('boolean false renders nothing', () => {
1715
- const el = container()
1716
- mount(h('div', null, false), el)
1717
- expect(el.querySelector('div')?.textContent).toBe('')
1718
- })
1719
-
1720
- test('number 0 renders as text', () => {
1721
- const el = container()
1722
- mount(h('div', null, 0), el)
1723
- expect(el.querySelector('div')?.textContent).toBe('0')
1724
- })
1725
-
1726
- test('empty string renders as text node', () => {
1727
- const el = container()
1728
- mount(h('div', null, ''), el)
1729
- expect(el.querySelector('div')?.textContent).toBe('')
1730
- })
1731
-
1732
- test('component with children prop', () => {
1733
- const el = container()
1734
- const Wrapper = defineComponent((props: { children?: VNodeChild }) => {
1735
- return h('div', { id: 'wrapper' }, props.children)
1736
- })
1737
- mount(h(Wrapper, null, h('span', null, 'child')), el)
1738
- expect(el.querySelector('#wrapper span')?.textContent).toBe('child')
1739
- })
1740
- })
1741
-
1742
- // ─── KeepAlive ───────────────────────────────────────────────────────────────
1743
-
1744
- describe('KeepAlive', () => {
1745
- test('mounts children and preserves them when toggled', async () => {
1746
- const el = container()
1747
- const active = signal(true)
1748
- mount(h(KeepAlive, { active }, h('div', { id: 'kept' }, 'alive')), el)
1749
- // KeepAlive mounts in onMount which fires sync in this framework
1750
- await new Promise<void>((r) => queueMicrotask(r))
1751
- expect(el.querySelector('#kept')).not.toBeNull()
1752
- active.set(false)
1753
- // Content should still exist in DOM but container hidden
1754
- expect(el.querySelector('#kept')).not.toBeNull()
1755
- })
1756
-
1757
- test('cleanup disposes effect and child cleanup', async () => {
1758
- const el = container()
1759
- const active = signal(true)
1760
- const unmount = mount(h(KeepAlive, { active }, h('div', { id: 'ka-cleanup' }, 'content')), el)
1761
- await new Promise<void>((r) => queueMicrotask(r))
1762
- expect(el.querySelector('#ka-cleanup')).not.toBeNull()
1763
- unmount()
1764
- // After unmount, the KeepAlive container is gone
1765
- expect(el.innerHTML).toBe('')
1766
- })
1767
-
1768
- test('active defaults to true when not provided', async () => {
1769
- const el = container()
1770
- mount(h(KeepAlive, {}, h('div', { id: 'ka-default' }, 'visible')), el)
1771
- await new Promise<void>((r) => queueMicrotask(r))
1772
- expect(el.querySelector('#ka-default')).not.toBeNull()
1773
- // Container should be visible (display not set to none)
1774
- const wrapper = el.querySelector('div[style]') as HTMLElement | null
1775
- // If wrapper exists, display should not be none
1776
- if (wrapper) expect(wrapper.style.display).not.toBe('none')
1777
- })
1778
-
1779
- test('toggles display:none when active changes', async () => {
1780
- const el = container()
1781
- const active = signal(true)
1782
- mount(h(KeepAlive, { active }, h('span', { id: 'ka-toggle' }, 'x')), el)
1783
- await new Promise<void>((r) => queueMicrotask(r))
1784
- // Find the container div that KeepAlive creates
1785
- const containers = el.querySelectorAll('div')
1786
- const keepAliveContainer = Array.from(containers).find((d) => d.querySelector('#ka-toggle')) as
1787
- | HTMLElement
1788
- | undefined
1789
- if (keepAliveContainer) {
1790
- expect(keepAliveContainer.style.display).not.toBe('none')
1791
- active.set(false)
1792
- expect(keepAliveContainer.style.display).toBe('none')
1793
- active.set(true)
1794
- expect(keepAliveContainer.style.display).toBe('')
1795
- }
1796
- })
1797
- })
1798
-
1799
- // ─── Hydration (extended coverage) ───────────────────────────────────────────
1800
-
1801
- describe('hydrateRoot — extended', () => {
1802
- test('hydrates Fragment children', async () => {
1803
- const el = container()
1804
- el.innerHTML = '<span>a</span><span>b</span>'
1805
- const cleanup = hydrateRoot(el, h(Fragment, null, h('span', null, 'a'), h('span', null, 'b')))
1806
- const spans = el.querySelectorAll('span')
1807
- expect(spans.length).toBe(2)
1808
- expect(spans[0]?.textContent).toBe('a')
1809
- expect(spans[1]?.textContent).toBe('b')
1810
- cleanup()
1811
- })
1812
-
1813
- test('hydrates array children', async () => {
1814
- const el = container()
1815
- el.innerHTML = '<div><span>x</span><span>y</span></div>'
1816
- const cleanup = hydrateRoot(el, h('div', null, h('span', null, 'x'), h('span', null, 'y')))
1817
- expect(el.querySelectorAll('span').length).toBe(2)
1818
- cleanup()
1819
- })
1820
-
1821
- test('hydrates null/false child — returns noop', async () => {
1822
- const el = container()
1823
- el.innerHTML = '<div></div>'
1824
- const cleanup = hydrateRoot(el, h('div', null, null, false))
1825
- expect(el.querySelector('div')).not.toBeNull()
1826
- cleanup()
1827
- })
1828
-
1829
- test('hydrates reactive accessor returning null initially', async () => {
1830
- const el = container()
1831
- el.innerHTML = '<div></div>'
1832
- const show = signal<string | null>(null)
1833
- const cleanup = hydrateRoot(
1834
- el,
1835
- h('div', null, () => show()),
1836
- )
1837
- // Initially null — a comment marker is inserted
1838
- show.set('hello')
1839
- // After update, the text should appear
1840
- expect(el.textContent).toContain('hello')
1841
- cleanup()
1842
- })
1843
-
1844
- test('hydrates reactive text that mismatches DOM node type', async () => {
1845
- const el = container()
1846
- el.innerHTML = '<div><span>wrong</span></div>'
1847
- const text = signal('hello')
1848
- // Reactive text expects a TextNode but finds a SPAN — should fall back
1849
- const cleanup = hydrateRoot(
1850
- el,
1851
- h('div', null, () => text()),
1852
- )
1853
- cleanup()
1854
- })
1855
-
1856
- test('hydrates reactive VNode (complex initial value)', async () => {
1857
- const el = container()
1858
- el.innerHTML = '<div><p>old</p></div>'
1859
- const content = signal<VNodeChild>(h('p', null, 'old'))
1860
- const cleanup = hydrateRoot(el, h('div', null, (() => content()) as unknown as VNodeChild))
1861
- cleanup()
1862
- })
1863
-
1864
- test('hydrates static text node', async () => {
1865
- const el = container()
1866
- el.innerHTML = 'just text'
1867
- const cleanup = hydrateRoot(el, 'just text')
1868
- expect(el.textContent).toContain('just text')
1869
- cleanup()
1870
- })
1871
-
1872
- test('hydrates number as text', async () => {
1873
- const el = container()
1874
- el.innerHTML = '42'
1875
- const cleanup = hydrateRoot(el, 42)
1876
- expect(el.textContent).toContain('42')
1877
- cleanup()
1878
- })
1879
-
1880
- test('hydration tag mismatch falls back to mount', async () => {
1881
- const el = container()
1882
- el.innerHTML = '<div>wrong</div>'
1883
- // Expect span but find div — should fall back
1884
- const cleanup = hydrateRoot(el, h('span', null, 'right'))
1885
- // The span should have been mounted via fallback
1886
- cleanup()
1887
- })
1888
-
1889
- test('hydrates element with ref', async () => {
1890
- const el = container()
1891
- el.innerHTML = '<button>click</button>'
1892
- const ref = createRef<HTMLButtonElement>()
1893
- const cleanup = hydrateRoot(el, h('button', { ref }, 'click'))
1894
- expect(ref.current).not.toBeNull()
1895
- expect(ref.current?.tagName).toBe('BUTTON')
1896
- cleanup()
1897
- expect(ref.current).toBeNull()
1898
- })
1899
-
1900
- test('hydrates Portal — always remounts', async () => {
1901
- const el = container()
1902
- const target = container()
1903
- el.innerHTML = ''
1904
- const cleanup = hydrateRoot(el, Portal({ target, children: h('span', null, 'portaled') }))
1905
- expect(target.querySelector('span')?.textContent).toBe('portaled')
1906
- cleanup()
1907
- })
1908
-
1909
- test('hydrates component with children prop', async () => {
1910
- const el = container()
1911
- el.innerHTML = '<div><p>child content</p></div>'
1912
- const Wrapper = defineComponent((props: { children?: VNodeChild }) =>
1913
- h('div', null, props.children),
1914
- )
1915
- const cleanup = hydrateRoot(el, h(Wrapper, null, h('p', null, 'child content')))
1916
- expect(el.querySelector('p')?.textContent).toBe('child content')
1917
- cleanup()
1918
- })
1919
-
1920
- test('hydrates component that throws — error handled gracefully', async () => {
1921
- const el = container()
1922
- el.innerHTML = '<p>content</p>'
1923
- const Broken = defineComponent((): never => {
1924
- throw new Error('hydration boom')
1925
- })
1926
- // Should not throw — error is caught internally
1927
- const cleanup = hydrateRoot(el, h(Broken, null))
1928
- cleanup()
1929
- })
1930
-
1931
- test('hydrates with For — fresh mount fallback (no markers)', async () => {
1932
- const el = container()
1933
- el.innerHTML = '<ul></ul>'
1934
- const items = signal([{ id: 1, label: 'a' }])
1935
- const cleanup = hydrateRoot(
1936
- el,
1937
- h(
1938
- 'ul',
1939
- null,
1940
- For({
1941
- each: items,
1942
- by: (r: { id: number }) => r.id,
1943
- children: (r: { id: number; label: string }) => h('li', null, r.label),
1944
- }),
1945
- ),
1946
- )
1947
- cleanup()
1948
- })
1949
-
1950
- test('hydrates with For — SSR markers present', async () => {
1951
- const el = container()
1952
- el.innerHTML = '<!--pyreon-for--><li>a</li><!--/pyreon-for-->'
1953
- const items = signal([{ id: 1, label: 'a' }])
1954
- const cleanup = hydrateRoot(
1955
- el,
1956
- For({
1957
- each: items,
1958
- by: (r: { id: number }) => r.id,
1959
- children: (r: { id: number; label: string }) => h('li', null, r.label),
1960
- }),
1961
- )
1962
- cleanup()
1963
- })
1964
-
1965
- test('hydration skips comment and whitespace text nodes', async () => {
1966
- const el = container()
1967
- // Simulate SSR output with comments and whitespace
1968
- el.innerHTML = '<!-- comment --> <p>real</p>'
1969
- const cleanup = hydrateRoot(el, h('p', null, 'real'))
1970
- expect(el.querySelector('p')?.textContent).toBe('real')
1971
- cleanup()
1972
- })
1973
-
1974
- test('hydrates with missing DOM node (null domNode)', async () => {
1975
- const el = container()
1976
- el.innerHTML = ''
1977
- // VNode expects content but DOM is empty — should fall back
1978
- const cleanup = hydrateRoot(el, h('div', null, 'content'))
1979
- cleanup()
1980
- })
1981
-
1982
- test('hydrates reactive accessor returning VNode with no domNode', async () => {
1983
- const el = container()
1984
- el.innerHTML = ''
1985
- const content = signal<VNodeChild>(h('p', null, 'dynamic'))
1986
- const cleanup = hydrateRoot(el, (() => content()) as unknown as VNodeChild)
1987
- cleanup()
1988
- })
1989
-
1990
- test('hydrates component with onMount hooks', async () => {
1991
- const el = container()
1992
- el.innerHTML = '<span>mounted</span>'
1993
- let mountCalled = false
1994
- const Comp = defineComponent(() => {
1995
- onMount(() => {
1996
- mountCalled = true
1997
- })
1998
- return h('span', null, 'mounted')
1999
- })
2000
- const cleanup = hydrateRoot(el, h(Comp, null))
2001
- expect(mountCalled).toBe(true)
2002
- cleanup()
2003
- })
2004
-
2005
- test('hydrates text mismatch for static string — falls back', async () => {
2006
- const el = container()
2007
- // Put an element where text is expected
2008
- el.innerHTML = '<span>not text</span>'
2009
- const cleanup = hydrateRoot(el, 'plain text')
2010
- cleanup()
2011
- })
2012
- })
2013
-
2014
- // ─── mountFor — additional edge cases ────────────────────────────────────────
2015
-
2016
- describe('mountFor — edge cases', () => {
2017
- type Item = { id: number; label: string }
2018
-
2019
- function mountForList(el: HTMLElement, items: ReturnType<typeof signal<Item[]>>) {
2020
- mount(
2021
- h(
2022
- 'ul',
2023
- null,
2024
- For({
2025
- each: items,
2026
- by: (r) => r.id,
2027
- children: (r) => h('li', { key: r.id }, r.label),
2028
- }),
2029
- ),
2030
- el,
2031
- )
2032
- }
2033
-
2034
- test('empty initial → add items (fresh render path)', () => {
2035
- const el = container()
2036
- const items = signal<Item[]>([])
2037
- mountForList(el, items)
2038
- expect(el.querySelectorAll('li').length).toBe(0)
2039
- items.set([
2040
- { id: 1, label: 'a' },
2041
- { id: 2, label: 'b' },
2042
- ])
2043
- expect(el.querySelectorAll('li').length).toBe(2)
2044
- expect(el.querySelectorAll('li')[0]?.textContent).toBe('a')
2045
- })
2046
-
2047
- test('clear then add uses fresh render path', () => {
2048
- const el = container()
2049
- const items = signal<Item[]>([{ id: 1, label: 'x' }])
2050
- mountForList(el, items)
2051
- items.set([])
2052
- expect(el.querySelectorAll('li').length).toBe(0)
2053
- items.set([
2054
- { id: 2, label: 'y' },
2055
- { id: 3, label: 'z' },
2056
- ])
2057
- expect(el.querySelectorAll('li').length).toBe(2)
2058
- expect(el.querySelectorAll('li')[0]?.textContent).toBe('y')
2059
- })
2060
-
2061
- test('clear path with parent-swap optimization', () => {
2062
- // When the For's markers are the first and last children of a parent,
2063
- // the clear path uses parent-swap for O(1) clear.
2064
- const el = container()
2065
- const items = signal<Item[]>([
2066
- { id: 1, label: 'a' },
2067
- { id: 2, label: 'b' },
2068
- { id: 3, label: 'c' },
2069
- ])
2070
- // Mount directly in the ul so markers are first/last children
2071
- mount(
2072
- h(
2073
- 'ul',
2074
- null,
2075
- For({
2076
- each: items,
2077
- by: (r) => r.id,
2078
- children: (r) => h('li', { key: r.id }, r.label),
2079
- }),
2080
- ),
2081
- el,
2082
- )
2083
- expect(el.querySelectorAll('li').length).toBe(3)
2084
- items.set([])
2085
- expect(el.querySelectorAll('li').length).toBe(0)
2086
- })
2087
-
2088
- test('clear path without parent-swap (markers not first/last)', () => {
2089
- const el = container()
2090
- const items = signal<Item[]>([{ id: 1, label: 'a' }])
2091
- // Mount with extra siblings so markers are not first/last
2092
- mount(
2093
- h(
2094
- 'div',
2095
- null,
2096
- h('span', null, 'before'),
2097
- For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
2098
- h('span', null, 'after'),
2099
- ),
2100
- el,
2101
- )
2102
- expect(el.querySelectorAll('li').length).toBe(1)
2103
- items.set([])
2104
- expect(el.querySelectorAll('li').length).toBe(0)
2105
- // The before/after spans should still be present
2106
- expect(el.querySelectorAll('span').length).toBe(2)
2107
- })
2108
-
2109
- test('replace-all with parent-swap optimization', () => {
2110
- const el = container()
2111
- const items = signal<Item[]>([
2112
- { id: 1, label: 'old1' },
2113
- { id: 2, label: 'old2' },
2114
- ])
2115
- mount(
2116
- h(
2117
- 'ul',
2118
- null,
2119
- For({
2120
- each: items,
2121
- by: (r) => r.id,
2122
- children: (r) => h('li', { key: r.id }, r.label),
2123
- }),
2124
- ),
2125
- el,
2126
- )
2127
- // Replace with completely new keys
2128
- items.set([
2129
- { id: 10, label: 'new1' },
2130
- { id: 11, label: 'new2' },
2131
- ])
2132
- expect(el.querySelectorAll('li').length).toBe(2)
2133
- expect(el.querySelectorAll('li')[0]?.textContent).toBe('new1')
2134
- })
2135
-
2136
- test('replace-all without parent-swap (extra siblings)', () => {
2137
- const el = container()
2138
- const items = signal<Item[]>([{ id: 1, label: 'old' }])
2139
- mount(
2140
- h(
2141
- 'div',
2142
- null,
2143
- h('span', null, 'before'),
2144
- For({ each: items, by: (r) => r.id, children: (r) => h('li', { key: r.id }, r.label) }),
2145
- h('span', null, 'after'),
2146
- ),
2147
- el,
2148
- )
2149
- items.set([{ id: 10, label: 'new' }])
2150
- expect(el.querySelectorAll('li').length).toBe(1)
2151
- expect(el.querySelectorAll('li')[0]?.textContent).toBe('new')
2152
- expect(el.querySelectorAll('span').length).toBe(2)
2153
- })
2154
-
2155
- test('remove stale entries', () => {
2156
- const el = container()
2157
- const items = signal<Item[]>([
2158
- { id: 1, label: 'a' },
2159
- { id: 2, label: 'b' },
2160
- { id: 3, label: 'c' },
2161
- ])
2162
- mountForList(el, items)
2163
- // Remove middle item — hits stale entry removal path
2164
- items.set([
2165
- { id: 1, label: 'a' },
2166
- { id: 3, label: 'c' },
2167
- ])
2168
- expect(el.querySelectorAll('li').length).toBe(2)
2169
- expect(el.querySelectorAll('li')[0]?.textContent).toBe('a')
2170
- expect(el.querySelectorAll('li')[1]?.textContent).toBe('c')
2171
- })
2172
-
2173
- test('LIS fallback for complex reorder (>8 diffs, same length)', () => {
2174
- const el = container()
2175
- // Create 15 items, then reverse all — forces > SMALL_K diffs and LIS path
2176
- const initial = Array.from({ length: 15 }, (_, i) => ({
2177
- id: i + 1,
2178
- label: String.fromCharCode(97 + i),
2179
- }))
2180
- const items = signal<Item[]>(initial)
2181
- mountForList(el, items)
2182
- // Reverse all items: 15 diffs > SMALL_K (8)
2183
- items.set([...initial].reverse())
2184
- const lis = el.querySelectorAll('li')
2185
- expect(lis.length).toBe(15)
2186
- expect(lis[0]?.textContent).toBe('o') // last letter reversed
2187
- expect(lis[14]?.textContent).toBe('a')
2188
- })
2189
-
2190
- test('LIS fallback for reorder with different length', () => {
2191
- const el = container()
2192
- const initial = Array.from({ length: 10 }, (_, i) => ({
2193
- id: i + 1,
2194
- label: String.fromCharCode(97 + i),
2195
- }))
2196
- const items = signal<Item[]>(initial)
2197
- mountForList(el, items)
2198
- // Reverse and add one — different length triggers LIS
2199
- const reversed = [...initial].reverse()
2200
- reversed.push({ id: 99, label: 'z' })
2201
- items.set(reversed)
2202
- const lis = el.querySelectorAll('li')
2203
- expect(lis.length).toBe(11)
2204
- expect(lis[0]?.textContent).toBe('j')
2205
- expect(lis[10]?.textContent).toBe('z')
2206
- })
2207
-
2208
- test('small-k reorder path (<=8 diffs, same length)', () => {
2209
- const el = container()
2210
- const items = signal<Item[]>([
2211
- { id: 1, label: 'a' },
2212
- { id: 2, label: 'b' },
2213
- { id: 3, label: 'c' },
2214
- { id: 4, label: 'd' },
2215
- ])
2216
- mountForList(el, items)
2217
- // Swap positions 1 and 2 — only 2 diffs < SMALL_K
2218
- items.set([
2219
- { id: 1, label: 'a' },
2220
- { id: 3, label: 'c' },
2221
- { id: 2, label: 'b' },
2222
- { id: 4, label: 'd' },
2223
- ])
2224
- const lis = el.querySelectorAll('li')
2225
- expect(lis[1]?.textContent).toBe('c')
2226
- expect(lis[2]?.textContent).toBe('b')
2227
- })
2228
-
2229
- test('add and remove items simultaneously', () => {
2230
- const el = container()
2231
- const items = signal<Item[]>([
2232
- { id: 1, label: 'a' },
2233
- { id: 2, label: 'b' },
2234
- { id: 3, label: 'c' },
2235
- ])
2236
- mountForList(el, items)
2237
- // Remove 2, add 4 and 5
2238
- items.set([
2239
- { id: 1, label: 'a' },
2240
- { id: 4, label: 'd' },
2241
- { id: 3, label: 'c' },
2242
- { id: 5, label: 'e' },
2243
- ])
2244
- const lis = el.querySelectorAll('li')
2245
- expect(lis.length).toBe(4)
2246
- expect(lis[0]?.textContent).toBe('a')
2247
- // Verify all expected items are present
2248
- const texts = Array.from(lis).map((li) => li.textContent)
2249
- expect(texts).toContain('a')
2250
- expect(texts).toContain('c')
2251
- expect(texts).toContain('d')
2252
- expect(texts).toContain('e')
2253
- })
2254
-
2255
- test('unmount For cleanup disposes all entries', () => {
2256
- const el = container()
2257
- const items = signal<Item[]>([
2258
- { id: 1, label: 'a' },
2259
- { id: 2, label: 'b' },
2260
- ])
2261
- const unmount = mount(
2262
- h(
2263
- 'ul',
2264
- null,
2265
- For({
2266
- each: items,
2267
- by: (r) => r.id,
2268
- children: (r) => h('li', { key: r.id }, r.label),
2269
- }),
2270
- ),
2271
- el,
2272
- )
2273
- expect(el.querySelectorAll('li').length).toBe(2)
2274
- unmount()
2275
- expect(el.innerHTML).toBe('')
2276
- })
2277
- })
2278
-
2279
- // ─── mountKeyedList — additional coverage ────────────────────────────────────
2280
-
2281
- describe('mountKeyedList — via reactive keyed array', () => {
2282
- test('reactive accessor returning keyed VNode array uses mountKeyedList', () => {
2283
- const el = container()
2284
- const items = signal([
2285
- { id: 1, text: 'a' },
2286
- { id: 2, text: 'b' },
2287
- ])
2288
- mount(
2289
- h('ul', null, () => items().map((it) => h('li', { key: it.id }, it.text))),
2290
- el,
2291
- )
2292
- expect(el.querySelectorAll('li').length).toBe(2)
2293
- expect(el.querySelectorAll('li')[0]?.textContent).toBe('a')
2294
- })
2295
-
2296
- test('mountKeyedList handles clear (empty array)', () => {
2297
- const el = container()
2298
- const items = signal([
2299
- { id: 1, text: 'a' },
2300
- { id: 2, text: 'b' },
2301
- ])
2302
- mount(
2303
- h('ul', null, () => items().map((it) => h('li', { key: it.id }, it.text))),
2304
- el,
2305
- )
2306
- items.set([])
2307
- expect(el.querySelectorAll('li').length).toBe(0)
2308
- })
2309
-
2310
- test('mountKeyedList handles reorder', () => {
2311
- const el = container()
2312
- const items = signal([
2313
- { id: 1, text: 'a' },
2314
- { id: 2, text: 'b' },
2315
- { id: 3, text: 'c' },
2316
- ])
2317
- mount(
2318
- h('ul', null, () => items().map((it) => h('li', { key: it.id }, it.text))),
2319
- el,
2320
- )
2321
- items.set([
2322
- { id: 3, text: 'c' },
2323
- { id: 1, text: 'a' },
2324
- { id: 2, text: 'b' },
2325
- ])
2326
- const lis = el.querySelectorAll('li')
2327
- expect(lis[0]?.textContent).toBe('c')
2328
- expect(lis[1]?.textContent).toBe('a')
2329
- expect(lis[2]?.textContent).toBe('b')
2330
- })
2331
-
2332
- test('mountKeyedList removes stale entries', () => {
2333
- const el = container()
2334
- const items = signal([
2335
- { id: 1, text: 'a' },
2336
- { id: 2, text: 'b' },
2337
- { id: 3, text: 'c' },
2338
- ])
2339
- mount(
2340
- h('ul', null, () => items().map((it) => h('li', { key: it.id }, it.text))),
2341
- el,
2342
- )
2343
- items.set([{ id: 2, text: 'b' }])
2344
- expect(el.querySelectorAll('li').length).toBe(1)
2345
- expect(el.querySelectorAll('li')[0]?.textContent).toBe('b')
2346
- })
2347
-
2348
- test('mountKeyedList adds new entries', () => {
2349
- const el = container()
2350
- const items = signal([{ id: 1, text: 'a' }])
2351
- mount(
2352
- h('ul', null, () => items().map((it) => h('li', { key: it.id }, it.text))),
2353
- el,
2354
- )
2355
- items.set([
2356
- { id: 1, text: 'a' },
2357
- { id: 2, text: 'b' },
2358
- { id: 3, text: 'c' },
2359
- ])
2360
- expect(el.querySelectorAll('li').length).toBe(3)
2361
- })
2362
-
2363
- test('mountKeyedList cleanup disposes all entries', () => {
2364
- const el = container()
2365
- const items = signal([
2366
- { id: 1, text: 'a' },
2367
- { id: 2, text: 'b' },
2368
- ])
2369
- const unmount = mount(
2370
- h('ul', null, () => items().map((it) => h('li', { key: it.id }, it.text))),
2371
- el,
2372
- )
2373
- unmount()
2374
- expect(el.innerHTML).toBe('')
2375
- })
2376
- })
2377
-
2378
- // ─── mountReactive — additional coverage ─────────────────────────────────────
2379
-
2380
- describe('mountReactive — edge cases', () => {
2381
- test('reactive accessor returning null then VNode', () => {
2382
- const el = container()
2383
- const show = signal(false)
2384
- mount(
2385
- h('div', null, () => (show() ? h('span', null, 'yes') : null)),
2386
- el,
2387
- )
2388
- expect(el.querySelector('span')).toBeNull()
2389
- show.set(true)
2390
- expect(el.querySelector('span')?.textContent).toBe('yes')
2391
- })
2392
-
2393
- test('reactive accessor returning false', () => {
2394
- const el = container()
2395
- const show = signal(false)
2396
- mount(
2397
- h('div', null, () => (show() ? 'visible' : false)),
2398
- el,
2399
- )
2400
- expect(el.querySelector('div')?.textContent).toBe('')
2401
- show.set(true)
2402
- expect(el.querySelector('div')?.textContent).toBe('visible')
2403
- })
2404
-
2405
- test('reactive text fast path — null/undefined fallback', () => {
2406
- const el = container()
2407
- const text = signal<string | null>('hello')
2408
- mount(
2409
- h('div', null, () => text()),
2410
- el,
2411
- )
2412
- expect(el.querySelector('div')?.textContent).toBe('hello')
2413
- text.set(null)
2414
- expect(el.querySelector('div')?.textContent).toBe('')
2415
- })
2416
-
2417
- test('reactive boolean text fast path', () => {
2418
- const el = container()
2419
- const val = signal(true)
2420
- mount(
2421
- h('div', null, () => val()),
2422
- el,
2423
- )
2424
- expect(el.querySelector('div')?.textContent).toBe('true')
2425
- val.set(false)
2426
- expect(el.querySelector('div')?.textContent).toBe('')
2427
- })
2428
-
2429
- test('mountReactive cleanup when anchor has no parent', () => {
2430
- const el = container()
2431
- const show = signal(true)
2432
- const unmount = mount(
2433
- h('div', null, () => (show() ? h('span', null, 'content') : null)),
2434
- el,
2435
- )
2436
- unmount()
2437
- // Should not throw even though marker may be detached
2438
- expect(el.innerHTML).toBe('')
2439
- })
2440
- })
2441
-
2442
- // ─── mount.ts — component branches ──────────────────────────────────────────
2443
-
2444
- describe('mount — component branches', () => {
2445
- test('component returning Fragment', () => {
2446
- const el = container()
2447
- const FragComp = defineComponent(() =>
2448
- h(Fragment, null, h('span', null, 'a'), h('span', null, 'b')),
2449
- )
2450
- mount(h(FragComp, null), el)
2451
- expect(el.querySelectorAll('span').length).toBe(2)
2452
- })
2453
-
2454
- test('component with onMount returning cleanup', async () => {
2455
- const el = container()
2456
- let cleaned = false
2457
- const Comp = defineComponent(() => {
2458
- onMount(() => () => {
2459
- cleaned = true
2460
- })
2461
- return h('div', null, 'with-cleanup')
2462
- })
2463
- const unmount = mount(h(Comp, null), el)
2464
- expect(cleaned).toBe(false)
2465
- unmount()
2466
- expect(cleaned).toBe(true)
2467
- })
2468
-
2469
- test('component with onUnmount hook', async () => {
2470
- const el = container()
2471
- let unmounted = false
2472
- const Comp = defineComponent(() => {
2473
- onUnmount(() => {
2474
- unmounted = true
2475
- })
2476
- return h('div', null, 'unmount-test')
2477
- })
2478
- const unmount = mount(h(Comp, null), el)
2479
- expect(unmounted).toBe(false)
2480
- unmount()
2481
- expect(unmounted).toBe(true)
2482
- })
2483
-
2484
- test('component with onUpdate hook', async () => {
2485
- const el = container()
2486
- const Comp = defineComponent(() => {
2487
- const count = signal(0)
2488
- onUpdate(() => {
2489
- /* update tracked */
2490
- })
2491
- return h(
2492
- 'div',
2493
- null,
2494
- h('span', null, () => String(count())),
2495
- h('button', { onClick: () => count.update((n: number) => n + 1) }, '+'),
2496
- )
2497
- })
2498
- mount(h(Comp, null), el)
2499
- // Click to trigger update
2500
- el.querySelector('button')?.click()
2501
- // onUpdate fires via microtask
2502
- })
2503
-
2504
- test('component children merge into props.children', () => {
2505
- const el = container()
2506
- const Parent = defineComponent((props: { children?: VNodeChild }) =>
2507
- h('div', { id: 'parent' }, props.children),
2508
- )
2509
- mount(h(Parent, null, h('span', null, 'child1'), h('span', null, 'child2')), el)
2510
- const spans = el.querySelectorAll('#parent span')
2511
- expect(spans.length).toBe(2)
2512
- })
2513
-
2514
- test('component with single child merges as singular children prop', () => {
2515
- const el = container()
2516
- const Parent = defineComponent((props: { children?: VNodeChild }) =>
2517
- h('div', { id: 'single' }, props.children),
2518
- )
2519
- mount(h(Parent, null, h('b', null, 'only')), el)
2520
- expect(el.querySelector('#single b')?.textContent).toBe('only')
2521
- })
2522
-
2523
- test('component props.children already set — no merge', () => {
2524
- const el = container()
2525
- const Parent = defineComponent((props: { children?: VNodeChild }) =>
2526
- h('div', { id: 'no-merge' }, props.children),
2527
- )
2528
- mount(h(Parent, { children: h('em', null, 'explicit') }, h('b', null, 'ignored')), el)
2529
- expect(el.querySelector('#no-merge em')?.textContent).toBe('explicit')
2530
- })
2531
-
2532
- test('anonymous component name fallback', () => {
2533
- const el = container()
2534
- // Use an anonymous arrow function
2535
- const comp = (() => h('span', null, 'anon')) as unknown as ReturnType<typeof defineComponent>
2536
- mount(h(comp, null), el)
2537
- expect(el.querySelector('span')?.textContent).toBe('anon')
2538
- })
2539
- })
2540
-
2541
- // ─── props.ts — additional coverage ──────────────────────────────────────────
2542
-
2543
- describe('props — additional coverage', () => {
2544
- test('reactive prop via function', () => {
2545
- const el = container()
2546
- const title = signal('hello')
2547
- mount(h('div', { title: () => title() }), el)
2548
- const div = el.querySelector('div') as HTMLElement
2549
- expect(div.title).toBe('hello')
2550
- title.set('world')
2551
- expect(div.title).toBe('world')
2552
- })
2553
-
2554
- test('null value removes attribute', () => {
2555
- const el = container()
2556
- mount(h('div', { 'data-x': 'initial' }), el)
2557
- const div = el.querySelector('div') as HTMLElement
2558
- expect(div.getAttribute('data-x')).toBe('initial')
2559
- })
2560
-
2561
- test('key and ref props are skipped in applyProps', () => {
2562
- const el = container()
2563
- const ref = createRef<HTMLDivElement>()
2564
- mount(h('div', { key: 'k', ref, 'data-test': 'yes' }), el)
2565
- const div = el.querySelector('div') as HTMLElement
2566
- // key should not be an attribute
2567
- expect(div.hasAttribute('key')).toBe(false)
2568
- // ref should not be an attribute
2569
- expect(div.hasAttribute('ref')).toBe(false)
2570
- // data-test should be set
2571
- expect(div.getAttribute('data-test')).toBe('yes')
2572
- })
2573
-
2574
- test('sanitizes javascript: in action attribute', () => {
2575
- const el = container()
2576
- mount(h('form', { action: 'javascript:void(0)' }), el)
2577
- const form = el.querySelector('form') as HTMLFormElement
2578
- expect(form.getAttribute('action')).not.toBe('javascript:void(0)')
2579
- })
2580
-
2581
- test('sanitizes data: in formaction', () => {
2582
- const el = container()
2583
- mount(h('button', { formaction: 'data:text/html,<script>alert(1)</script>' }), el)
2584
- const btn = el.querySelector('button') as HTMLButtonElement
2585
- expect(btn.getAttribute('formaction')).not.toBe('data:text/html,<script>alert(1)</script>')
2586
- })
2587
-
2588
- test('sanitizes javascript: with leading whitespace', () => {
2589
- const el = container()
2590
- mount(h('a', { href: ' javascript:alert(1)' }), el)
2591
- const a = el.querySelector('a') as HTMLAnchorElement
2592
- expect(a.getAttribute('href')).not.toBe(' javascript:alert(1)')
2593
- })
2594
-
2595
- test('sanitizeHtml preserves safe tags', async () => {
2596
- const result = sanitizeHtml('<b>bold</b><em>italic</em>')
2597
- expect(result).toContain('<b>bold</b>')
2598
- expect(result).toContain('<em>italic</em>')
2599
- })
2600
-
2601
- test('sanitizeHtml strips script tags', async () => {
2602
- const result = sanitizeHtml('<div>safe</div><script>alert(1)</script>')
2603
- expect(result).toContain('safe')
2604
- expect(result).not.toContain('<script>')
2605
- })
2606
-
2607
- test('sanitizeHtml strips event handler attributes', async () => {
2608
- const result = sanitizeHtml('<div onclick="alert(1)">hello</div>')
2609
- expect(result).toContain('hello')
2610
- expect(result).not.toContain('onclick')
2611
- })
2612
-
2613
- test('sanitizeHtml strips javascript: URLs', async () => {
2614
- const result = sanitizeHtml('<a href="javascript:alert(1)">link</a>')
2615
- expect(result).not.toContain('javascript:')
2616
- })
2617
-
2618
- test('setSanitizer overrides built-in sanitizer', async () => {
2619
- // Set custom sanitizer that uppercases everything
2620
- setSanitizer((html: string) => html.toUpperCase())
2621
- expect(sanitizeHtml('<b>hello</b>')).toBe('<B>HELLO</B>')
2622
- // Reset to built-in
2623
- setSanitizer(null)
2624
- // Built-in should work again
2625
- const result = sanitizeHtml('<b>safe</b><script>bad</script>')
2626
- expect(result).toContain('safe')
2627
- expect(result).not.toContain('<script>')
2628
- })
2629
-
2630
- test('sanitizeHtml strips iframe and object tags', async () => {
2631
- const result = sanitizeHtml(
2632
- '<div>ok</div><iframe src="evil"></iframe><object data="x"></object>',
2633
- )
2634
- expect(result).toContain('ok')
2635
- expect(result).not.toContain('<iframe')
2636
- expect(result).not.toContain('<object')
2637
- })
2638
-
2639
- test('sanitizeHtml handles nested unsafe elements', async () => {
2640
- const result = sanitizeHtml('<div><script><script>alert(1)</script></script></div>')
2641
- expect(result).not.toContain('<script')
2642
- })
2643
-
2644
- test('DOM property for known properties like value', () => {
2645
- const el = container()
2646
- mount(h('input', { value: 'test', type: 'text' }), el)
2647
- const input = el.querySelector('input') as HTMLInputElement
2648
- expect(input.value).toBe('test')
2649
- })
2650
-
2651
- test('setAttribute fallback for unknown attributes', () => {
2652
- const el = container()
2653
- mount(h('div', { 'aria-label': 'test label' }), el)
2654
- const div = el.querySelector('div') as HTMLElement
2655
- expect(div.getAttribute('aria-label')).toBe('test label')
2656
- })
2657
- })
2658
-
2659
- // ─── DevTools ────────────────────────────────────────────────────────────────
2660
-
2661
- describe('DevTools', () => {
2662
- test('installDevTools sets __PYREON_DEVTOOLS__ on window', async () => {
2663
- installDevTools()
2664
- const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as Record<
2665
- string,
2666
- unknown
2667
- >
2668
- expect(devtools).not.toBeNull()
2669
- expect(devtools.version).toBe('0.1.0')
2670
- })
2671
-
2672
- test('registerComponent and getAllComponents', async () => {
2673
- const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
2674
- getAllComponents: () => {
2675
- id: string
2676
- name: string
2677
- parentId: string | null
2678
- childIds: string[]
2679
- }[]
2680
- getComponentTree: () => { id: string; name: string; parentId: string | null }[]
2681
- highlight: (id: string) => void
2682
- onComponentMount: (cb: (entry: { id: string }) => void) => () => void
2683
- onComponentUnmount: (cb: (id: string) => void) => () => void
2684
- }
2685
-
2686
- registerComponent('test-1', 'TestComp', null, null)
2687
- const all = devtools.getAllComponents()
2688
- const found = all.find((c: { id: string }) => c.id === 'test-1')
2689
- expect(found).not.toBeUndefined()
2690
- expect(found?.name).toBe('TestComp')
2691
-
2692
- // Cleanup
2693
- unregisterComponent('test-1')
2694
- })
2695
-
2696
- test('registerComponent with parentId creates parent-child relationship', async () => {
2697
- const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
2698
- getAllComponents: () => {
2699
- id: string
2700
- name: string
2701
- parentId: string | null
2702
- childIds: string[]
2703
- }[]
2704
- }
2705
-
2706
- registerComponent('parent-1', 'Parent', null, null)
2707
- registerComponent('child-1', 'Child', null, 'parent-1')
2708
-
2709
- const parent = devtools.getAllComponents().find((c: { id: string }) => c.id === 'parent-1')
2710
- expect(parent?.childIds).toContain('child-1')
2711
-
2712
- unregisterComponent('child-1')
2713
- const parentAfter = devtools.getAllComponents().find((c: { id: string }) => c.id === 'parent-1')
2714
- expect(parentAfter?.childIds).not.toContain('child-1')
2715
-
2716
- unregisterComponent('parent-1')
2717
- })
2718
-
2719
- test('getComponentTree returns only root components', async () => {
2720
- const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
2721
- getComponentTree: () => { id: string; parentId: string | null }[]
2722
- }
2723
-
2724
- registerComponent('root-1', 'Root', null, null)
2725
- registerComponent('sub-1', 'Sub', null, 'root-1')
2726
-
2727
- const tree = devtools.getComponentTree()
2728
- const rootInTree = tree.find((c: { id: string }) => c.id === 'root-1')
2729
- const subInTree = tree.find((c: { id: string }) => c.id === 'sub-1')
2730
- expect(rootInTree).not.toBeUndefined()
2731
- expect(subInTree).toBeUndefined() // sub is not root
2732
-
2733
- unregisterComponent('sub-1')
2734
- unregisterComponent('root-1')
2735
- })
2736
-
2737
- test('highlight adds and removes outline', async () => {
2738
- const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
2739
- highlight: (id: string) => void
2740
- }
2741
- const el = document.createElement('div')
2742
- document.body.appendChild(el)
2743
- registerComponent('hl-1', 'Highlight', el, null)
2744
- devtools.highlight('hl-1')
2745
- expect(el.style.outline).toContain('#00b4d8')
2746
-
2747
- // Highlight non-existent — should not throw
2748
- devtools.highlight('non-existent')
2749
-
2750
- unregisterComponent('hl-1')
2751
- el.remove()
2752
- })
2753
-
2754
- test('onComponentMount and onComponentUnmount listeners', async () => {
2755
- const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
2756
- onComponentMount: (cb: (entry: { id: string; name: string }) => void) => () => void
2757
- onComponentUnmount: (cb: (id: string) => void) => () => void
2758
- }
2759
-
2760
- const mountedIds: string[] = []
2761
- const unmountedIds: string[] = []
2762
- const unsubMount = devtools.onComponentMount((entry) => mountedIds.push(entry.id))
2763
- const unsubUnmount = devtools.onComponentUnmount((id) => unmountedIds.push(id))
2764
-
2765
- registerComponent('listen-1', 'ListenComp', null, null)
2766
- expect(mountedIds).toContain('listen-1')
2767
-
2768
- unregisterComponent('listen-1')
2769
- expect(unmountedIds).toContain('listen-1')
2770
-
2771
- // Unsub and verify listeners are removed
2772
- unsubMount()
2773
- unsubUnmount()
2774
- registerComponent('listen-2', 'ListenComp2', null, null)
2775
- expect(mountedIds).not.toContain('listen-2')
2776
- unregisterComponent('listen-2')
2777
- expect(unmountedIds).not.toContain('listen-2')
2778
- })
2779
-
2780
- test('unregisterComponent is noop for unknown id', async () => {
2781
- // Should not throw
2782
- unregisterComponent('does-not-exist')
2783
- })
2784
-
2785
- test('highlight with no el is noop', async () => {
2786
- const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
2787
- highlight: (id: string) => void
2788
- }
2789
- registerComponent('no-el', 'NoEl', null, null)
2790
- // Should not throw
2791
- devtools.highlight('no-el')
2792
- unregisterComponent('no-el')
2793
- })
2794
- })
2795
-
2796
- // ─── TransitionGroup ─────────────────────────────────────────────────────────
2797
-
2798
- describe('TransitionGroup', () => {
2799
- test('renders container element with specified tag', async () => {
2800
- const el = container()
2801
- const items = signal([{ id: 1 }, { id: 2 }])
2802
- mount(
2803
- h(TransitionGroup, {
2804
- tag: 'ul',
2805
- name: 'list',
2806
- items,
2807
- keyFn: (item: { id: number }) => item.id,
2808
- render: (item: { id: number }) => h('li', null, String(item.id)),
2809
- }),
2810
- el,
2811
- )
2812
- await new Promise<void>((r) => queueMicrotask(r))
2813
- expect(el.querySelector('ul')).not.toBeNull()
2814
- })
2815
-
2816
- test('renders initial items', async () => {
2817
- const el = container()
2818
- const items = signal([{ id: 1 }, { id: 2 }, { id: 3 }])
2819
- mount(
2820
- h(TransitionGroup, {
2821
- tag: 'div',
2822
- items,
2823
- keyFn: (item: { id: number }) => item.id,
2824
- render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
2825
- }),
2826
- el,
2827
- )
2828
- await new Promise<void>((r) => queueMicrotask(r))
2829
- const spans = el.querySelectorAll('span.item')
2830
- expect(spans.length).toBe(3)
2831
- expect(spans[0]?.textContent).toBe('1')
2832
- expect(spans[2]?.textContent).toBe('3')
2833
- })
2834
-
2835
- test('adding items triggers enter animation', async () => {
2836
- const el = container()
2837
- const items = signal([{ id: 1 }])
2838
- mount(
2839
- h(TransitionGroup, {
2840
- tag: 'div',
2841
- name: 'fade',
2842
- items,
2843
- keyFn: (item: { id: number }) => item.id,
2844
- render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
2845
- }),
2846
- el,
2847
- )
2848
- await new Promise<void>((r) => queueMicrotask(r))
2849
- expect(el.querySelectorAll('span.item').length).toBe(1)
2850
-
2851
- // Add a new item
2852
- items.set([{ id: 1 }, { id: 2 }])
2853
- // Wait for the microtask (enter animation scheduled via queueMicrotask)
2854
- await new Promise<void>((r) => queueMicrotask(r))
2855
- await new Promise<void>((r) => queueMicrotask(r))
2856
- expect(el.querySelectorAll('span.item').length).toBe(2)
2857
- })
2858
-
2859
- test('removing items keeps element during leave animation', async () => {
2860
- const el = container()
2861
- const items = signal([{ id: 1 }, { id: 2 }])
2862
- mount(
2863
- h(TransitionGroup, {
2864
- tag: 'div',
2865
- name: 'fade',
2866
- items,
2867
- keyFn: (item: { id: number }) => item.id,
2868
- render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
2869
- }),
2870
- el,
2871
- )
2872
- await new Promise<void>((r) => queueMicrotask(r))
2873
- expect(el.querySelectorAll('span.item').length).toBe(2)
2874
-
2875
- // Remove item 2
2876
- items.set([{ id: 1 }])
2877
- // Element should still be in DOM during leave animation
2878
- expect(el.querySelectorAll('span.item').length).toBeGreaterThanOrEqual(1)
2879
- })
2880
-
2881
- test('default tag is div and default name is pyreon', async () => {
2882
- const el = container()
2883
- const items = signal([{ id: 1 }])
2884
- mount(
2885
- h(TransitionGroup, {
2886
- items,
2887
- keyFn: (item: { id: number }) => item.id,
2888
- render: (item: { id: number }) => h('span', null, String(item.id)),
2889
- }),
2890
- el,
2891
- )
2892
- await new Promise<void>((r) => queueMicrotask(r))
2893
- // Default tag is div
2894
- expect(el.querySelector('div')).not.toBeNull()
2895
- })
2896
-
2897
- test('appear option triggers enter on initial mount', async () => {
2898
- const el = container()
2899
- let enterCalled = false
2900
- const items = signal([{ id: 1 }])
2901
- mount(
2902
- h(TransitionGroup, {
2903
- tag: 'div',
2904
- name: 'test',
2905
- appear: true,
2906
- items,
2907
- keyFn: (item: { id: number }) => item.id,
2908
- render: (item: { id: number }) => h('span', { class: 'appear-item' }, String(item.id)),
2909
- onBeforeEnter: () => {
2910
- enterCalled = true
2911
- },
2912
- }),
2913
- el,
2914
- )
2915
- await new Promise<void>((r) => queueMicrotask(r))
2916
- await new Promise<void>((r) => queueMicrotask(r))
2917
- expect(enterCalled).toBe(true)
2918
- })
2919
-
2920
- test('custom class name overrides', async () => {
2921
- const el = container()
2922
- const items = signal([{ id: 1 }])
2923
- mount(
2924
- h(TransitionGroup, {
2925
- tag: 'div',
2926
- enterFrom: 'my-enter-from',
2927
- enterActive: 'my-enter-active',
2928
- enterTo: 'my-enter-to',
2929
- leaveFrom: 'my-leave-from',
2930
- leaveActive: 'my-leave-active',
2931
- leaveTo: 'my-leave-to',
2932
- moveClass: 'my-move',
2933
- items,
2934
- keyFn: (item: { id: number }) => item.id,
2935
- render: (item: { id: number }) => h('span', null, String(item.id)),
2936
- }),
2937
- el,
2938
- )
2939
- await new Promise<void>((r) => queueMicrotask(r))
2940
- expect(el.querySelectorAll('span').length).toBe(1)
2941
- })
2942
-
2943
- test('leave callback with no ref.current removes entry immediately', async () => {
2944
- const el = container()
2945
- const items = signal([{ id: 1 }, { id: 2 }])
2946
- mount(
2947
- h(TransitionGroup, {
2948
- tag: 'div',
2949
- items,
2950
- keyFn: (item: { id: number }) => item.id,
2951
- // Return a non-element VNode (component VNode) so ref won't be injected
2952
- render: (item: { id: number }) => {
2953
- const Comp = () => h('span', null, String(item.id))
2954
- return h(Comp, null) as unknown as ReturnType<typeof h>
2955
- },
2956
- }),
2957
- el,
2958
- )
2959
- await new Promise<void>((r) => queueMicrotask(r))
2960
- // Remove an item — since ref.current will be null for component VNodes,
2961
- // the entry is cleaned up immediately
2962
- items.set([{ id: 1 }])
2963
- await new Promise<void>((r) => queueMicrotask(r))
2964
- })
2965
-
2966
- test('reorder triggers move animation setup', async () => {
2967
- const el = container()
2968
- const items = signal([{ id: 1 }, { id: 2 }, { id: 3 }])
2969
- mount(
2970
- h(TransitionGroup, {
2971
- tag: 'div',
2972
- name: 'list',
2973
- items,
2974
- keyFn: (item: { id: number }) => item.id,
2975
- render: (item: { id: number }) => h('span', { class: 'reorder-item' }, String(item.id)),
2976
- }),
2977
- el,
2978
- )
2979
- await new Promise<void>((r) => queueMicrotask(r))
2980
- expect(el.querySelectorAll('span.reorder-item').length).toBe(3)
2981
-
2982
- // Reorder items
2983
- items.set([{ id: 3 }, { id: 1 }, { id: 2 }])
2984
- await new Promise<void>((r) => queueMicrotask(r))
2985
-
2986
- // Items should be reordered
2987
- const spans = el.querySelectorAll('span.reorder-item')
2988
- expect(spans[0]?.textContent).toBe('3')
2989
- expect(spans[1]?.textContent).toBe('1')
2990
- expect(spans[2]?.textContent).toBe('2')
2991
- })
2992
-
2993
- test('onAfterEnter callback fires after enter transition', async () => {
2994
- const el = container()
2995
- let afterEnterCalled = false
2996
- const items = signal<{ id: number }[]>([])
2997
- mount(
2998
- h(TransitionGroup, {
2999
- tag: 'div',
3000
- name: 'fade',
3001
- items,
3002
- keyFn: (item: { id: number }) => item.id,
3003
- render: (item: { id: number }) => h('span', null, String(item.id)),
3004
- onAfterEnter: () => {
3005
- afterEnterCalled = true
3006
- },
3007
- }),
3008
- el,
3009
- )
3010
- await new Promise<void>((r) => queueMicrotask(r))
3011
-
3012
- // Add item (not first run, so enter animation triggers)
3013
- items.set([{ id: 1 }])
3014
- await new Promise<void>((r) => queueMicrotask(r))
3015
- await new Promise<void>((r) => queueMicrotask(r))
3016
-
3017
- // Trigger rAF and transitionend
3018
- await new Promise<void>((r) => requestAnimationFrame(() => r()))
3019
- const span = el.querySelector('span')
3020
- if (span) {
3021
- span.dispatchEvent(new Event('transitionend'))
3022
- }
3023
- expect(afterEnterCalled).toBe(true)
3024
- })
3025
-
3026
- test('onBeforeLeave and onAfterLeave callbacks fire', async () => {
3027
- const el = container()
3028
- let beforeLeaveCalled = false
3029
- const items = signal([{ id: 1 }])
3030
- mount(
3031
- h(TransitionGroup, {
3032
- tag: 'div',
3033
- name: 'fade',
3034
- items,
3035
- keyFn: (item: { id: number }) => item.id,
3036
- render: (item: { id: number }) => h('span', { class: 'leave-item' }, String(item.id)),
3037
- onBeforeLeave: () => {
3038
- beforeLeaveCalled = true
3039
- },
3040
- onAfterLeave: () => {
3041
- /* after leave tracked */
3042
- },
3043
- }),
3044
- el,
3045
- )
3046
- await new Promise<void>((r) => queueMicrotask(r))
3047
-
3048
- // Remove item
3049
- items.set([])
3050
- expect(beforeLeaveCalled).toBe(true)
3051
-
3052
- // Trigger leave animation completion
3053
- await new Promise<void>((r) => requestAnimationFrame(() => r()))
3054
- const span = el.querySelector('span.leave-item')
3055
- if (span) {
3056
- span.dispatchEvent(new Event('transitionend'))
3057
- }
3058
- // afterLeave fires inside rAF callback, might need another tick
3059
- await new Promise<void>((r) => requestAnimationFrame(() => r()))
3060
- if (span) {
3061
- span.dispatchEvent(new Event('transitionend'))
3062
- }
3063
- })
3064
- })
3065
-
3066
- // ─── Hydration debug ────────────────────────────────────────────────────────
3067
-
3068
- describe('hydration warnings', () => {
3069
- test('enableHydrationWarnings and disableHydrationWarnings', async () => {
3070
- // Should not throw
3071
- enableHydrationWarnings()
3072
- disableHydrationWarnings()
3073
- enableHydrationWarnings() // re-enable for other tests
3074
- })
3075
- })
3076
-
3077
- // ─── Additional hydrate.ts branch coverage ───────────────────────────────────
3078
-
3079
- describe('hydrateRoot — branch coverage', () => {
3080
- test('hydrates raw array child (non-Fragment array path)', async () => {
3081
- const el = container()
3082
- el.innerHTML = '<span>a</span><span>b</span>'
3083
- // Pass an array directly — hits the Array.isArray branch in hydrateChild
3084
- const cleanup = hydrateRoot(el, [h('span', null, 'a'), h('span', null, 'b')])
3085
- expect(el.querySelectorAll('span').length).toBe(2)
3086
- cleanup()
3087
- })
3088
-
3089
- test('hydrates For with SSR markers (start/end comment pair)', async () => {
3090
- const el = container()
3091
- el.innerHTML = '<div><!--pyreon-for--><li>item1</li><li>item2</li><!--/pyreon-for--></div>'
3092
- const items = signal([
3093
- { id: 1, label: 'item1' },
3094
- { id: 2, label: 'item2' },
3095
- ])
3096
- const cleanup = hydrateRoot(
3097
- el,
3098
- h(
3099
- 'div',
3100
- null,
3101
- For({
3102
- each: items,
3103
- by: (r: { id: number }) => r.id,
3104
- children: (r: { id: number; label: string }) => h('li', null, r.label),
3105
- }),
3106
- ),
3107
- )
3108
- cleanup()
3109
- })
3110
-
3111
- test('hydrates reactive accessor returning null with no domNode', async () => {
3112
- const el = container()
3113
- el.innerHTML = '<div></div>'
3114
- const show = signal<VNodeChild>(null)
3115
- // The div has no children, so domNode will be null inside
3116
- const cleanup = hydrateRoot(el, h('div', null, (() => show()) as unknown as VNodeChild))
3117
- show.set('hello')
3118
- cleanup()
3119
- })
3120
-
3121
- test('hydrates reactive VNode accessor with marker when no domNode', async () => {
3122
- const el = container()
3123
- el.innerHTML = '<div></div>'
3124
- const content = signal<VNodeChild>(h('span', null, 'initial'))
3125
- const cleanup = hydrateRoot(el, h('div', null, (() => content()) as unknown as VNodeChild))
3126
- cleanup()
3127
- })
3128
-
3129
- test('hydrates unknown symbol vnode type — returns noop', async () => {
3130
- const el = container()
3131
- el.innerHTML = '<div></div>'
3132
- const weirdVNode = { type: Symbol('weird'), props: {}, children: [], key: null }
3133
- const cleanup = hydrateRoot(el, h('div', null, weirdVNode as VNodeChild))
3134
- cleanup()
3135
- })
3136
-
3137
- test('hydration of text that matches existing text node — cleanup removes it', async () => {
3138
- const el = container()
3139
- el.innerHTML = 'hello'
3140
- const cleanup = hydrateRoot(el, 'hello')
3141
- expect(el.textContent).toBe('hello')
3142
- cleanup()
3143
- })
3144
-
3145
- test('For with no SSR markers and domNode present', async () => {
3146
- const el = container()
3147
- el.innerHTML = '<div><span>existing</span></div>'
3148
- const items = signal([{ id: 1, label: 'a' }])
3149
- const cleanup = hydrateRoot(
3150
- el,
3151
- h(
3152
- 'div',
3153
- null,
3154
- For({
3155
- each: items,
3156
- by: (r: { id: number }) => r.id,
3157
- children: (r: { id: number; label: string }) => h('li', null, r.label),
3158
- }),
3159
- ),
3160
- )
3161
- cleanup()
3162
- })
3163
-
3164
- test('For with no SSR markers and no domNode', async () => {
3165
- const el = container()
3166
- el.innerHTML = '<div></div>'
3167
- const items = signal([{ id: 1, label: 'a' }])
3168
- const cleanup = hydrateRoot(
3169
- el,
3170
- h(
3171
- 'div',
3172
- null,
3173
- For({
3174
- each: items,
3175
- by: (r: { id: number }) => r.id,
3176
- children: (r: { id: number; label: string }) => h('li', null, r.label),
3177
- }),
3178
- ),
3179
- )
3180
- cleanup()
3181
- })
3182
-
3183
- test('reactive accessor returning null when domNode exists', async () => {
3184
- const el = container()
3185
- // Put a real DOM node that will be domNode, but accessor returns null
3186
- el.innerHTML = '<div><span>existing</span></div>'
3187
- const show = signal<VNodeChild>(null)
3188
- // The span is the domNode, but accessor returns null — hits line 91-92
3189
- const cleanup = hydrateRoot(
3190
- el,
3191
- h('div', null, (() => show()) as unknown as VNodeChild, h('span', null, 'existing')),
3192
- )
3193
- cleanup()
3194
- })
3195
- })
3196
-
3197
- // ─── mount.ts — error handling branches ──────────────────────────────────────
3198
-
3199
- describe('mount — error handling branches', () => {
3200
- test('mountChild with raw array', async () => {
3201
- const el = container()
3202
- // Pass an array directly to mountChild (line 72)
3203
- const cleanup = mountChild([h('span', null, 'x'), h('span', null, 'y')], el, null)
3204
- expect(el.querySelectorAll('span').length).toBe(2)
3205
- cleanup()
3206
- })
3207
-
3208
- test('component subtree throw is caught', () => {
3209
- const el = container()
3210
- // A component whose render output itself causes an error during mount
3211
- const BadRender = defineComponent(() => {
3212
- // Return a VNode that includes a broken component child
3213
- const Throws = defineComponent((): never => {
3214
- throw new Error('subtree error')
3215
- })
3216
- return h(Throws, null)
3217
- })
3218
- // Should not throw — error is caught in mountComponent's subtree try/catch
3219
- mount(h(BadRender, null), el)
3220
- })
3221
-
3222
- test('onMount hook that throws is caught', async () => {
3223
- const el = container()
3224
- const Comp = defineComponent(() => {
3225
- onMount(() => {
3226
- throw new Error('onMount error')
3227
- })
3228
- return h('div', null, 'content')
3229
- })
3230
- // Should not throw
3231
- mount(h(Comp, null), el)
3232
- expect(el.querySelector('div')?.textContent).toBe('content')
3233
- })
3234
-
3235
- test('onUnmount hook that throws is caught', async () => {
3236
- const el = container()
3237
- const Comp = defineComponent(() => {
3238
- onUnmount(() => {
3239
- throw new Error('onUnmount error')
3240
- })
3241
- return h('div', null, 'content')
3242
- })
3243
- const unmount = mount(h(Comp, null), el)
3244
- // Should not throw
3245
- unmount()
3246
- })
3247
- })
3248
-
3249
- // ─── TransitionGroup — unmount cleanup ───────────────────────────────────────
3250
-
3251
- describe('TransitionGroup — cleanup', () => {
3252
- test('unmount disposes effect and cleans up entries', async () => {
3253
- const el = container()
3254
- const items = signal([{ id: 1 }, { id: 2 }])
3255
- const unmount = mount(
3256
- h(TransitionGroup, {
3257
- tag: 'div',
3258
- items,
3259
- keyFn: (item: { id: number }) => item.id,
3260
- render: (item: { id: number }) => h('span', null, String(item.id)),
3261
- }),
3262
- el,
3263
- )
3264
- await new Promise<void>((r) => queueMicrotask(r))
3265
- expect(el.querySelectorAll('span').length).toBe(2)
3266
- unmount()
3267
- expect(el.innerHTML).toBe('')
3268
- })
3269
- })
3270
-
3271
- // ─── TransitionGroup — leak regression tests ─────────────────────────────────
3272
- // Regression for the two fixes:
3273
- // 1. No safety timeout on applyLeave meant an item whose transition never
3274
- // fired stayed in the `entries` Map forever (`entries.delete(key)` was
3275
- // gated on the `done` callback firing).
3276
- // 2. Unmount during in-flight transition left the 5s safety timer running,
3277
- // firing `onAfterEnter` / `onAfterLeave` on detached elements.
3278
-
3279
- describe('TransitionGroup — leak regressions', () => {
3280
- beforeEach(() => {
3281
- vi.useFakeTimers()
3282
- })
3283
- afterEach(() => {
3284
- vi.useRealTimers()
3285
- })
3286
-
3287
- test('onAfterLeave fires via 5s safety timeout when transitionend never fires', async () => {
3288
- const el = container()
3289
- const items = signal([{ id: 1 }, { id: 2 }])
3290
- const onAfterLeave = vi.fn()
3291
- mount(
3292
- h(TransitionGroup, {
3293
- tag: 'div',
3294
- name: 'fade',
3295
- items,
3296
- keyFn: (item: { id: number }) => item.id,
3297
- render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
3298
- onAfterLeave,
3299
- }),
3300
- el,
3301
- )
3302
- await vi.advanceTimersByTimeAsync(20)
3303
- items.set([{ id: 1 }])
3304
- await vi.advanceTimersByTimeAsync(20)
3305
- // transitionend never fires — before the fix this would leak forever.
3306
- expect(onAfterLeave).not.toHaveBeenCalled()
3307
- await vi.advanceTimersByTimeAsync(5100)
3308
- expect(onAfterLeave).toHaveBeenCalledTimes(1)
3309
- })
3310
-
3311
- test('onAfterEnter does NOT fire after container unmount during in-flight enter', async () => {
3312
- const el = container()
3313
- const items = signal<{ id: number }[]>([])
3314
- const onAfterEnter = vi.fn()
3315
- const dispose = mount(
3316
- h(TransitionGroup, {
3317
- tag: 'div',
3318
- name: 'fade',
3319
- items,
3320
- keyFn: (item: { id: number }) => item.id,
3321
- render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
3322
- onAfterEnter,
3323
- }),
3324
- el,
3325
- )
3326
- await vi.advanceTimersByTimeAsync(20)
3327
- items.set([{ id: 1 }])
3328
- await vi.advanceTimersByTimeAsync(20)
3329
- // Mid-transition — unmount. The 5s safety timer must NOT fire the
3330
- // callback on a detached element.
3331
- dispose()
3332
- await vi.advanceTimersByTimeAsync(6000)
3333
- expect(onAfterEnter).not.toHaveBeenCalled()
3334
- })
3335
-
3336
- test('onAfterLeave does NOT fire after container unmount during in-flight leave', async () => {
3337
- const el = container()
3338
- const items = signal([{ id: 1 }])
3339
- const onAfterLeave = vi.fn()
3340
- const dispose = mount(
3341
- h(TransitionGroup, {
3342
- tag: 'div',
3343
- name: 'fade',
3344
- items,
3345
- keyFn: (item: { id: number }) => item.id,
3346
- render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
3347
- onAfterLeave,
3348
- }),
3349
- el,
3350
- )
3351
- await vi.advanceTimersByTimeAsync(20)
3352
- items.set([])
3353
- await vi.advanceTimersByTimeAsync(20)
3354
- dispose()
3355
- await vi.advanceTimersByTimeAsync(6000)
3356
- expect(onAfterLeave).not.toHaveBeenCalled()
3357
- })
3358
- })
3359
-
3360
- // ─── Error paths (no ErrorBoundary) ──────────────────────────────────────────
3361
-
3362
- describe('mount — error paths', () => {
3363
- test('component that throws during setup fires console.error', () => {
3364
- const el = container()
3365
- const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
3366
-
3367
- const Broken: ComponentFn = () => {
3368
- throw new Error('setup boom')
3369
- }
3370
- mount(h(Broken, null), el)
3371
-
3372
- expect(spy).toHaveBeenCalledWith(
3373
- expect.stringContaining('threw during setup'),
3374
- expect.any(Error),
3375
- )
3376
- spy.mockRestore()
3377
- })
3378
-
3379
- test('component that throws during render fires console.error', () => {
3380
- const el = container()
3381
- const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
3382
-
3383
- const BrokenChild: ComponentFn = () => {
3384
- throw new Error('render boom')
3385
- }
3386
- // Parent returns a child that throws when mounted (render phase)
3387
- const Parent: ComponentFn = () => h(BrokenChild, null)
3388
- mount(h(Parent, null), el)
3389
-
3390
- expect(spy).toHaveBeenCalledWith(expect.stringContaining('threw during'), expect.any(Error))
3391
- spy.mockRestore()
3392
- })
3393
- })
3394
-
3395
- // ─── SVG namespace ──────────────────────────────────────────────────────────
3396
-
3397
- describe('mount — SVG namespace', () => {
3398
- const SVG_NS = 'http://www.w3.org/2000/svg'
3399
-
3400
- test('svg element gets SVG namespace', () => {
3401
- const el = container()
3402
- mount(h('svg', null), el)
3403
- const svg = el.firstElementChild!
3404
- expect(svg.namespaceURI).toBe(SVG_NS)
3405
- })
3406
-
3407
- test('nested element inside svg inherits SVG namespace', () => {
3408
- const el = container()
3409
- // Use null props to avoid happy-dom SVG property setter issues
3410
- mount(h('svg', null, h('circle', null)), el)
3411
- const svg = el.firstElementChild!
3412
- const circle = svg.firstElementChild!
3413
- expect(svg.namespaceURI).toBe(SVG_NS)
3414
- expect(circle.namespaceURI).toBe(SVG_NS)
3415
- expect(circle.tagName.toLowerCase()).toBe('circle')
3416
- })
3417
-
3418
- test('deeply nested SVG elements inherit namespace', () => {
3419
- const el = container()
3420
- mount(
3421
- h('svg', null, h('g', null, h('rect', null))),
3422
- el,
3423
- )
3424
- const svg = el.firstElementChild!
3425
- const g = svg.firstElementChild!
3426
- const rect = g.firstElementChild!
3427
- expect(g.namespaceURI).toBe(SVG_NS)
3428
- expect(rect.namespaceURI).toBe(SVG_NS)
3429
- })
3430
-
3431
- test('SVG marker mounts cleanly with read-only animated-length attributes', () => {
3432
- // Regression test for the bug fixed via the SVG namespace
3433
- // special case in setStaticProp: SVGMarkerElement properties
3434
- // like markerWidth, markerHeight, refX, refY are read-only
3435
- // SVGAnimatedLength getters. Before the fix, the runtime tried
3436
- // `el[key] = value` and crashed with `Cannot set property
3437
- // markerWidth of [object Object] which has only a getter`.
3438
- // Same for SVGRectElement.x/y/width/height and many others.
3439
- //
3440
- // The fix: SVG and MathML elements always go through
3441
- // setAttribute() instead of property assignment. This test
3442
- // mounts a marker with all the offending attributes and asserts
3443
- // they were applied via setAttribute.
3444
- const el = container()
3445
- mount(
3446
- h(
3447
- 'svg',
3448
- null,
3449
- h(
3450
- 'defs',
3451
- null,
3452
- h(
3453
- 'marker',
3454
- {
3455
- id: 'arrowhead',
3456
- markerWidth: '10',
3457
- markerHeight: '7',
3458
- refX: '10',
3459
- refY: '3.5',
3460
- orient: 'auto',
3461
- },
3462
- h('polygon', { points: '0 0, 10 3.5, 0 7', fill: '#999' }),
3463
- ),
3464
- ),
3465
- ),
3466
- el,
3467
- )
3468
-
3469
- const marker = el.querySelector('marker#arrowhead')
3470
- expect(marker).not.toBeNull()
3471
- expect(marker?.getAttribute('markerWidth')).toBe('10')
3472
- expect(marker?.getAttribute('markerHeight')).toBe('7')
3473
- expect(marker?.getAttribute('refX')).toBe('10')
3474
- expect(marker?.getAttribute('refY')).toBe('3.5')
3475
- expect(marker?.getAttribute('orient')).toBe('auto')
3476
- })
3477
-
3478
- test('SVG rect mounts cleanly with read-only x/y/width/height attributes', () => {
3479
- // Same regression class as the marker test above. SVGRectElement
3480
- // exposes x, y, width, height as read-only SVGAnimatedLength.
3481
- const el = container()
3482
- mount(
3483
- h(
3484
- 'svg',
3485
- null,
3486
- h('rect', { x: '5', y: '10', width: '100', height: '50', fill: 'red' }),
3487
- ),
3488
- el,
3489
- )
3490
-
3491
- const rect = el.querySelector('rect')
3492
- expect(rect).not.toBeNull()
3493
- expect(rect?.getAttribute('x')).toBe('5')
3494
- expect(rect?.getAttribute('y')).toBe('10')
3495
- expect(rect?.getAttribute('width')).toBe('100')
3496
- expect(rect?.getAttribute('height')).toBe('50')
3497
- expect(rect?.getAttribute('fill')).toBe('red')
3498
- })
3499
-
3500
- test('elements outside svg do not get SVG namespace', () => {
3501
- const el = container()
3502
- mount(
3503
- h(Fragment, null, h('svg', null, h('circle', null)), h('div', null, 'text')),
3504
- el,
3505
- )
3506
- const svg = el.querySelector('svg')!
3507
- const circle = svg.firstElementChild!
3508
- const div = el.querySelector('div')!
3509
- expect(svg.namespaceURI).toBe(SVG_NS)
3510
- expect(circle.namespaceURI).toBe(SVG_NS)
3511
- expect(div.namespaceURI).not.toBe(SVG_NS)
3512
- })
3513
-
3514
- test('svg with multiple children all get SVG namespace', () => {
3515
- const el = container()
3516
- mount(
3517
- h('svg', null,
3518
- h('circle', null),
3519
- h('rect', null),
3520
- h('path', null),
3521
- ),
3522
- el,
3523
- )
3524
- const svg = el.firstElementChild!
3525
- for (const child of Array.from(svg.children)) {
3526
- expect(child.namespaceURI).toBe(SVG_NS)
3527
- }
3528
- })
3529
- })