@pyreon/runtime-dom 0.1.0

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