@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,1123 @@
1
+ /**
2
+ * Targeted tests to increase code coverage above 95% on all metrics.
3
+ * Covers gaps in: devtools.ts, template.ts, mount.ts, transition.ts,
4
+ * hydrate.ts, transition-group.ts, nodes.ts, props.ts
5
+ */
6
+ import type { ComponentFn, VNodeChild } from "@pyreon/core"
7
+ import {
8
+ createRef,
9
+ defineComponent,
10
+ For,
11
+ Fragment,
12
+ h,
13
+ onMount,
14
+ onUnmount,
15
+ onUpdate,
16
+ } from "@pyreon/core"
17
+ import { signal } from "@pyreon/reactivity"
18
+ import { installDevTools, registerComponent, unregisterComponent } from "../devtools"
19
+ import {
20
+ Transition as _Transition,
21
+ TransitionGroup as _TransitionGroup,
22
+ _tpl,
23
+ hydrateRoot,
24
+ mount,
25
+ sanitizeHtml,
26
+ setSanitizer,
27
+ } from "../index"
28
+ import { mountChild } from "../mount"
29
+ import type { Directive } from "../props"
30
+
31
+ const Transition = _Transition as unknown as ComponentFn<Record<string, unknown>>
32
+ const TransitionGroup = _TransitionGroup as unknown as ComponentFn<Record<string, unknown>>
33
+
34
+ function container(): HTMLElement {
35
+ const el = document.createElement("div")
36
+ document.body.appendChild(el)
37
+ return el
38
+ }
39
+
40
+ // ─── template.ts — _tpl() compiler API (lines 72-80) ─────────────────────────
41
+
42
+ describe("_tpl — compiler-facing template API", () => {
43
+ test("creates a NativeItem from HTML string and bind function", () => {
44
+ const el = container()
45
+ const native = _tpl('<div class="box"><span></span></div>', (root) => {
46
+ const span = root.querySelector("span")!
47
+ span.textContent = "hello"
48
+ return null
49
+ })
50
+ expect(native.__isNative).toBe(true)
51
+ expect(native.el).toBeInstanceOf(HTMLElement)
52
+ el.appendChild(native.el)
53
+ expect(el.querySelector(".box span")?.textContent).toBe("hello")
54
+ })
55
+
56
+ test("caches template elements — same HTML string reuses template", () => {
57
+ const html = '<p class="cached"><em></em></p>'
58
+ const n1 = _tpl(html, (root) => {
59
+ root.querySelector("em")!.textContent = "first"
60
+ return null
61
+ })
62
+ const n2 = _tpl(html, (root) => {
63
+ root.querySelector("em")!.textContent = "second"
64
+ return null
65
+ })
66
+ // Both produce valid elements but they are separate clones
67
+ expect(n1.el).not.toBe(n2.el)
68
+ expect((n1.el as HTMLElement).querySelector("em")?.textContent).toBe("first")
69
+ expect((n2.el as HTMLElement).querySelector("em")?.textContent).toBe("second")
70
+ })
71
+
72
+ test("bind function can return a cleanup", () => {
73
+ let cleaned = false
74
+ const native = _tpl("<div></div>", () => {
75
+ return () => {
76
+ cleaned = true
77
+ }
78
+ })
79
+ expect(native.cleanup).not.toBeNull()
80
+ native.cleanup?.()
81
+ expect(cleaned).toBe(true)
82
+ })
83
+
84
+ test("mountChild handles NativeItem from _tpl", () => {
85
+ const el = container()
86
+ const native = _tpl("<span>tpl</span>", () => null)
87
+ const cleanup = mountChild(native as unknown as VNodeChild, el, null)
88
+ expect(el.querySelector("span")?.textContent).toBe("tpl")
89
+ cleanup()
90
+ })
91
+
92
+ test("mountChild handles NativeItem with cleanup from _tpl", () => {
93
+ const el = container()
94
+ let cleaned = false
95
+ const native = _tpl("<span>tpl2</span>", () => () => {
96
+ cleaned = true
97
+ })
98
+ const cleanup = mountChild(native as unknown as VNodeChild, el, null)
99
+ expect(el.querySelector("span")?.textContent).toBe("tpl2")
100
+ cleanup()
101
+ expect(cleaned).toBe(true)
102
+ })
103
+ })
104
+
105
+ // ─── devtools.ts — overlay and $p helpers (lines 155-258, 267-290) ────────────
106
+
107
+ describe("DevTools — overlay and $p console helpers", () => {
108
+ beforeAll(() => {
109
+ installDevTools()
110
+ })
111
+
112
+ test("enableOverlay and disableOverlay toggle overlay state", () => {
113
+ const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
114
+ enableOverlay: () => void
115
+ disableOverlay: () => void
116
+ }
117
+ // Enable overlay
118
+ devtools.enableOverlay()
119
+ expect(document.body.style.cursor).toBe("crosshair")
120
+
121
+ // Enable again — should be noop (already active)
122
+ devtools.enableOverlay()
123
+ expect(document.body.style.cursor).toBe("crosshair")
124
+
125
+ // Disable overlay
126
+ devtools.disableOverlay()
127
+ expect(document.body.style.cursor).toBe("")
128
+
129
+ // Disable again — should be noop (already disabled)
130
+ devtools.disableOverlay()
131
+ expect(document.body.style.cursor).toBe("")
132
+ })
133
+
134
+ test("overlay creates overlay and tooltip elements", () => {
135
+ const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
136
+ enableOverlay: () => void
137
+ disableOverlay: () => void
138
+ }
139
+ devtools.enableOverlay()
140
+ expect(document.getElementById("__pyreon-overlay")).not.toBeNull()
141
+ devtools.disableOverlay()
142
+ })
143
+
144
+ test("overlay mousemove with no registered component hides overlay", () => {
145
+ const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
146
+ enableOverlay: () => void
147
+ disableOverlay: () => void
148
+ }
149
+ devtools.enableOverlay()
150
+
151
+ // Simulate mousemove over an unregistered element
152
+ const target = document.createElement("div")
153
+ document.body.appendChild(target)
154
+ const event = new MouseEvent("mousemove", { clientX: 10, clientY: 10, bubbles: true })
155
+ document.dispatchEvent(event)
156
+
157
+ devtools.disableOverlay()
158
+ target.remove()
159
+ })
160
+
161
+ test("overlay mousemove highlights registered component", () => {
162
+ const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
163
+ enableOverlay: () => void
164
+ disableOverlay: () => void
165
+ }
166
+
167
+ const target = document.createElement("div")
168
+ target.style.cssText = "width:100px;height:100px;position:fixed;top:0;left:0;"
169
+ document.body.appendChild(target)
170
+ registerComponent("overlay-test", "OverlayComp", target, null)
171
+
172
+ devtools.enableOverlay()
173
+
174
+ // Simulate mousemove over the registered element
175
+ const event = new MouseEvent("mousemove", { clientX: 50, clientY: 50, bubbles: true })
176
+ document.dispatchEvent(event)
177
+
178
+ // Simulate same element again — should be noop (same _currentHighlight)
179
+ const event2 = new MouseEvent("mousemove", { clientX: 50, clientY: 50, bubbles: true })
180
+ document.dispatchEvent(event2)
181
+
182
+ devtools.disableOverlay()
183
+ unregisterComponent("overlay-test")
184
+ target.remove()
185
+ })
186
+
187
+ test("overlay click logs component and disables overlay", () => {
188
+ const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
189
+ enableOverlay: () => void
190
+ disableOverlay: () => void
191
+ }
192
+
193
+ const target = document.createElement("div")
194
+ target.style.cssText = "width:100px;height:100px;position:fixed;top:0;left:0;"
195
+ document.body.appendChild(target)
196
+
197
+ // Register parent and child for parent logging branch
198
+ registerComponent("click-parent", "ClickParent", null, null)
199
+ registerComponent("click-test", "ClickComp", target, "click-parent")
200
+
201
+ devtools.enableOverlay()
202
+
203
+ const event = new MouseEvent("click", { clientX: 50, clientY: 50, bubbles: true })
204
+ document.dispatchEvent(event)
205
+
206
+ // In happy-dom elementFromPoint returns null, so the click handler
207
+ // returns early without calling disableOverlay. Manually disable.
208
+ devtools.disableOverlay()
209
+ expect(document.body.style.cursor).toBe("")
210
+
211
+ unregisterComponent("click-test")
212
+ unregisterComponent("click-parent")
213
+ target.remove()
214
+ })
215
+
216
+ test("overlay click on unregistered element — no console log", () => {
217
+ const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
218
+ enableOverlay: () => void
219
+ disableOverlay: () => void
220
+ }
221
+
222
+ devtools.enableOverlay()
223
+
224
+ // Click on area with no component
225
+ const event = new MouseEvent("click", { clientX: 0, clientY: 0, bubbles: true })
226
+ document.dispatchEvent(event)
227
+
228
+ // In happy-dom elementFromPoint returns null, so click handler returns
229
+ // early. Manually disable overlay and verify cursor is restored.
230
+ devtools.disableOverlay()
231
+ expect(document.body.style.cursor).toBe("")
232
+ })
233
+
234
+ test("Escape key disables overlay", () => {
235
+ const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
236
+ enableOverlay: () => void
237
+ disableOverlay: () => void
238
+ }
239
+
240
+ devtools.enableOverlay()
241
+ expect(document.body.style.cursor).toBe("crosshair")
242
+
243
+ const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
244
+ document.dispatchEvent(event)
245
+ expect(document.body.style.cursor).toBe("")
246
+ })
247
+
248
+ test("Ctrl+Shift+P toggles overlay", () => {
249
+ // Enable via Ctrl+Shift+P
250
+ const enableEvent = new KeyboardEvent("keydown", {
251
+ key: "P",
252
+ ctrlKey: true,
253
+ shiftKey: true,
254
+ bubbles: true,
255
+ })
256
+ window.dispatchEvent(enableEvent)
257
+ expect(document.body.style.cursor).toBe("crosshair")
258
+
259
+ // Disable via Ctrl+Shift+P
260
+ const disableEvent = new KeyboardEvent("keydown", {
261
+ key: "P",
262
+ ctrlKey: true,
263
+ shiftKey: true,
264
+ bubbles: true,
265
+ })
266
+ window.dispatchEvent(disableEvent)
267
+ expect(document.body.style.cursor).toBe("")
268
+ })
269
+
270
+ test("overlay with component that has children shows child count", () => {
271
+ const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
272
+ enableOverlay: () => void
273
+ disableOverlay: () => void
274
+ }
275
+
276
+ const target = document.createElement("div")
277
+ target.style.cssText = "width:100px;height:100px;position:fixed;top:50px;left:50px;"
278
+ document.body.appendChild(target)
279
+
280
+ registerComponent("parent-ov", "ParentOv", target, null)
281
+ registerComponent("child-ov-1", "ChildOv1", null, "parent-ov")
282
+
283
+ devtools.enableOverlay()
284
+
285
+ const event = new MouseEvent("mousemove", { clientX: 75, clientY: 75, bubbles: true })
286
+ document.dispatchEvent(event)
287
+
288
+ devtools.disableOverlay()
289
+ unregisterComponent("child-ov-1")
290
+ unregisterComponent("parent-ov")
291
+ target.remove()
292
+ })
293
+
294
+ test("$p console helpers exist and work", () => {
295
+ const $p = (window as unknown as Record<string, unknown>).$p as {
296
+ components: () => unknown[]
297
+ tree: () => unknown[]
298
+ highlight: (id: string) => void
299
+ inspect: () => void
300
+ stats: () => { total: number; roots: number }
301
+ help: () => void
302
+ }
303
+
304
+ expect($p).toBeDefined()
305
+
306
+ // $p.components()
307
+ registerComponent("$p-test", "$pTest", null, null)
308
+ const comps = $p.components()
309
+ expect(comps.length).toBeGreaterThan(0)
310
+
311
+ // $p.tree()
312
+ const tree = $p.tree()
313
+ expect(Array.isArray(tree)).toBe(true)
314
+
315
+ // $p.highlight()
316
+ $p.highlight("$p-test")
317
+
318
+ // $p.inspect() — toggles overlay on
319
+ $p.inspect()
320
+ expect(document.body.style.cursor).toBe("crosshair")
321
+ // $p.inspect() — toggles overlay off
322
+ $p.inspect()
323
+ expect(document.body.style.cursor).toBe("")
324
+
325
+ // $p.stats()
326
+ const stats = $p.stats()
327
+ expect(stats.total).toBeGreaterThan(0)
328
+ expect(typeof stats.roots).toBe("number")
329
+
330
+ // $p.help()
331
+ $p.help()
332
+
333
+ unregisterComponent("$p-test")
334
+ })
335
+
336
+ test("$p.stats shows singular 'root' for 1 root", () => {
337
+ // Clear all components first
338
+ const $p = (window as unknown as Record<string, unknown>).$p as {
339
+ stats: () => { total: number; roots: number }
340
+ }
341
+ registerComponent("sole-root", "SoleRoot", null, null)
342
+ const stats = $p.stats()
343
+ expect(stats.roots).toBeGreaterThanOrEqual(1)
344
+ unregisterComponent("sole-root")
345
+ })
346
+ })
347
+
348
+ // ─── mount.ts — uncovered branches ───────────────────────────────────────────
349
+
350
+ describe("mount.ts — uncovered branches", () => {
351
+ test("component returning invalid value triggers dev warning (line 283)", () => {
352
+ const el = container()
353
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
354
+
355
+ // Component returns an object without 'type' property — triggers invalid return warning
356
+ const BadComp = (() => ({ weird: true })) as unknown as ComponentFn
357
+ mount(h(BadComp, null), el)
358
+
359
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("returned an invalid value"))
360
+ warnSpy.mockRestore()
361
+ })
362
+
363
+ test("component subtree mount error with propagateError (lines 298-309)", () => {
364
+ const el = container()
365
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
366
+
367
+ // Component whose subtree throws during mount
368
+ const Outer = defineComponent(() => {
369
+ // Inner component throws during mount (not setup)
370
+ const Inner = defineComponent(() => {
371
+ // Return a VNode that will throw when mounted
372
+ return h("div", null, (() => {
373
+ throw new Error("subtree mount error")
374
+ }) as unknown as VNodeChild)
375
+ })
376
+ return h(Inner, null)
377
+ })
378
+
379
+ mount(h(Outer, null), el)
380
+ errorSpy.mockRestore()
381
+ })
382
+
383
+ test("mountChildren with >2 children and cleanups (line 387)", () => {
384
+ const el = container()
385
+ const s1 = signal("a")
386
+ const s2 = signal("b")
387
+ const s3 = signal("c")
388
+
389
+ // 3 reactive children will all have cleanups, hitting the map+cleanup path
390
+ const unmount = mount(
391
+ h(
392
+ "div",
393
+ null,
394
+ () => s1(),
395
+ () => s2(),
396
+ () => s3(),
397
+ ),
398
+ el,
399
+ )
400
+
401
+ expect(el.querySelector("div")?.textContent).toContain("a")
402
+ expect(el.querySelector("div")?.textContent).toContain("b")
403
+ expect(el.querySelector("div")?.textContent).toContain("c")
404
+
405
+ unmount()
406
+ })
407
+
408
+ test("mountElement with ref + propCleanup at _elementDepth > 0", () => {
409
+ const el = container()
410
+ const ref = createRef<HTMLElement>()
411
+ const cls = signal("foo")
412
+
413
+ // Nested element with ref AND reactive prop — exercises the combined cleanup path
414
+ mount(h("div", null, h("span", { ref, class: () => cls() }, "inner")), el)
415
+
416
+ expect(ref.current).not.toBeNull()
417
+ expect(ref.current?.className).toBe("foo")
418
+ })
419
+
420
+ test("mountElement with propCleanup only at _elementDepth > 0", () => {
421
+ const el = container()
422
+ const cls = signal("bar")
423
+
424
+ // Nested element with reactive prop but no ref
425
+ const unmount = mount(h("div", null, h("span", { class: () => cls() }, "inner")), el)
426
+
427
+ expect(el.querySelector("span")?.className).toBe("bar")
428
+ cls.set("baz")
429
+ expect(el.querySelector("span")?.className).toBe("baz")
430
+ unmount()
431
+ })
432
+
433
+ test("reactive text at _elementDepth > 0 returns just dispose", () => {
434
+ const el = container()
435
+ const text = signal("nested")
436
+
437
+ // Reactive text inside a parent element — should skip DOM removal closure
438
+ const unmount = mount(
439
+ h("div", null, () => text()),
440
+ el,
441
+ )
442
+
443
+ expect(el.querySelector("div")?.textContent).toBe("nested")
444
+ text.set("updated")
445
+ expect(el.querySelector("div")?.textContent).toBe("updated")
446
+ unmount()
447
+ })
448
+
449
+ test("NativeItem without cleanup at _elementDepth > 0", () => {
450
+ const el = container()
451
+ const native = _tpl("<b>native</b>", () => null)
452
+
453
+ // Mount NativeItem inside a parent element
454
+ mount(h("div", null, native as unknown as VNodeChild), el)
455
+ expect(el.querySelector("b")?.textContent).toBe("native")
456
+ })
457
+
458
+ test("NativeItem with cleanup at _elementDepth > 0", () => {
459
+ const el = container()
460
+ let _cleaned = false
461
+ const native = _tpl("<b>native2</b>", () => () => {
462
+ _cleaned = true
463
+ })
464
+
465
+ mount(h("div", null, native as unknown as VNodeChild), el)
466
+ expect(el.querySelector("b")?.textContent).toBe("native2")
467
+ })
468
+ })
469
+
470
+ // ─── transition.ts — uncovered branches (lines 152, 165, 170-175) ────────────
471
+
472
+ describe("Transition — uncovered branches", () => {
473
+ test("onUnmount cancels pending leave (line 152)", async () => {
474
+ const el = container()
475
+ const visible = signal(true)
476
+
477
+ const unmount = mount(
478
+ h(Transition, {
479
+ show: visible,
480
+ name: "fade",
481
+ children: h("div", { id: "unmount-leave" }, "content"),
482
+ }),
483
+ el,
484
+ )
485
+
486
+ // Start leave animation
487
+ visible.set(false)
488
+
489
+ // Unmount before leave completes — should cancel pending leave
490
+ unmount()
491
+ })
492
+
493
+ test("non-object/array child returns rawChild (line 165)", () => {
494
+ const el = container()
495
+ const visible = signal(true)
496
+
497
+ // Pass a non-object, non-array child (string) — hits line 165
498
+ mount(
499
+ h(Transition, {
500
+ show: visible,
501
+ children: "just text" as unknown as VNodeChild,
502
+ }),
503
+ el,
504
+ )
505
+ })
506
+
507
+ test("array child returns rawChild (line 164)", () => {
508
+ const el = container()
509
+ const visible = signal(true)
510
+
511
+ // Pass an array child — hits the Array.isArray branch
512
+ mount(
513
+ h(Transition, {
514
+ show: visible,
515
+ children: [h("span", null, "a"), h("span", null, "b")] as unknown as VNodeChild,
516
+ }),
517
+ el,
518
+ )
519
+ })
520
+
521
+ test("component child warns and returns vnode (lines 170-175)", () => {
522
+ const el = container()
523
+ const visible = signal(true)
524
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
525
+
526
+ const Inner = defineComponent(() => h("span", null, "comp child"))
527
+
528
+ mount(
529
+ h(Transition, {
530
+ show: visible,
531
+ children: h(Inner, null),
532
+ }),
533
+ el,
534
+ )
535
+
536
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Transition child is a component"))
537
+ warnSpy.mockRestore()
538
+ })
539
+
540
+ test("leave with no ref.current sets isMounted false immediately (line 142-144)", async () => {
541
+ const el = container()
542
+ const visible = signal(true)
543
+
544
+ // Use a non-string type (like a component) so ref won't be injected
545
+ const Comp = () => h("span", null, "no-ref")
546
+ mount(
547
+ h(Transition, {
548
+ show: visible,
549
+ children: h(Comp, null) as VNodeChild,
550
+ }),
551
+ el,
552
+ )
553
+
554
+ // Toggle off — ref.current will be null for component children
555
+ visible.set(false)
556
+ await new Promise<void>((r) => queueMicrotask(r))
557
+ })
558
+
559
+ test("onAfterEnter callback fires after enter transition", async () => {
560
+ const el = container()
561
+ const visible = signal(false)
562
+ let afterEnterCalled = false
563
+
564
+ mount(
565
+ h(Transition, {
566
+ show: visible,
567
+ name: "fade",
568
+ onAfterEnter: () => {
569
+ afterEnterCalled = true
570
+ },
571
+ children: h("div", { id: "after-enter-test" }, "content"),
572
+ }),
573
+ el,
574
+ )
575
+
576
+ visible.set(true)
577
+ await new Promise<void>((r) => queueMicrotask(r))
578
+
579
+ // Trigger rAF
580
+ await new Promise<void>((r) => requestAnimationFrame(() => r()))
581
+ const target = el.querySelector("#after-enter-test")
582
+ if (target) {
583
+ target.dispatchEvent(new Event("transitionend"))
584
+ }
585
+ expect(afterEnterCalled).toBe(true)
586
+ })
587
+
588
+ test("onAfterLeave callback fires after leave transition", async () => {
589
+ const el = container()
590
+ const visible = signal(true)
591
+ let afterLeaveCalled = false
592
+
593
+ mount(
594
+ h(Transition, {
595
+ show: visible,
596
+ name: "fade",
597
+ onAfterLeave: () => {
598
+ afterLeaveCalled = true
599
+ },
600
+ children: h("div", { id: "after-leave-test" }, "content"),
601
+ }),
602
+ el,
603
+ )
604
+
605
+ const target = el.querySelector("#after-leave-test")
606
+ visible.set(false)
607
+
608
+ await new Promise<void>((r) => requestAnimationFrame(() => r()))
609
+ if (target) {
610
+ target.dispatchEvent(new Event("transitionend"))
611
+ }
612
+ await new Promise<void>((r) => queueMicrotask(r))
613
+ expect(afterLeaveCalled).toBe(true)
614
+ })
615
+
616
+ test("tooltip repositions when near top of viewport", () => {
617
+ const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
618
+ enableOverlay: () => void
619
+ disableOverlay: () => void
620
+ }
621
+
622
+ const target = document.createElement("div")
623
+ // Position near top so tooltip moves below element (rect.top < 35)
624
+ target.style.cssText = "width:100px;height:20px;position:fixed;top:10px;left:10px;"
625
+ document.body.appendChild(target)
626
+ registerComponent("top-comp", "TopComp", target, null)
627
+
628
+ devtools.enableOverlay()
629
+
630
+ const event = new MouseEvent("mousemove", { clientX: 50, clientY: 15, bubbles: true })
631
+ document.dispatchEvent(event)
632
+
633
+ devtools.disableOverlay()
634
+ unregisterComponent("top-comp")
635
+ target.remove()
636
+ })
637
+ })
638
+
639
+ // ─── hydrate.ts — uncovered branches (lines 162-183, 338) ────────────────────
640
+
641
+ describe("hydrate.ts — uncovered branches", () => {
642
+ test("For hydration with SSR markers — full path with afterEnd (lines 162-183)", () => {
643
+ const el = container()
644
+ // SSR markers with content after the end marker
645
+ el.innerHTML = "<!--pyreon-for--><li>a</li><li>b</li><!--/pyreon-for--><p>after</p>"
646
+ const items = signal([
647
+ { id: 1, label: "a" },
648
+ { id: 2, label: "b" },
649
+ ])
650
+ const cleanup = hydrateRoot(
651
+ el,
652
+ h(
653
+ Fragment,
654
+ null,
655
+ For({
656
+ each: items,
657
+ by: (r: { id: number }) => r.id,
658
+ children: (r: { id: number; label: string }) => h("li", null, r.label),
659
+ }),
660
+ h("p", null, "after"),
661
+ ),
662
+ )
663
+ cleanup()
664
+ })
665
+
666
+ test("component with onUpdate hooks during hydration (line 338)", () => {
667
+ const el = container()
668
+ el.innerHTML = "<span>update-test</span>"
669
+ let _updateCalled = false
670
+
671
+ const Comp = defineComponent(() => {
672
+ onUpdate(() => {
673
+ _updateCalled = true
674
+ })
675
+ return h("span", null, "update-test")
676
+ })
677
+
678
+ const cleanup = hydrateRoot(el, h(Comp, null))
679
+ cleanup()
680
+ })
681
+
682
+ test("component with onUnmount hook during hydration cleanup", () => {
683
+ const el = container()
684
+ el.innerHTML = "<span>unmount-test</span>"
685
+ let unmountCalled = false
686
+
687
+ const Comp = defineComponent(() => {
688
+ onUnmount(() => {
689
+ unmountCalled = true
690
+ })
691
+ return h("span", null, "unmount-test")
692
+ })
693
+
694
+ const cleanup = hydrateRoot(el, h(Comp, null))
695
+ cleanup()
696
+ expect(unmountCalled).toBe(true)
697
+ })
698
+
699
+ test("component with mount cleanup during hydration", () => {
700
+ const el = container()
701
+ el.innerHTML = "<span>mount-cleanup</span>"
702
+ let mountCleanupCalled = false
703
+
704
+ const Comp = defineComponent(() => {
705
+ onMount(() => () => {
706
+ mountCleanupCalled = true
707
+ })
708
+ return h("span", null, "mount-cleanup")
709
+ })
710
+
711
+ const cleanup = hydrateRoot(el, h(Comp, null))
712
+ cleanup()
713
+ expect(mountCleanupCalled).toBe(true)
714
+ })
715
+
716
+ test("hydrates component with children merge", () => {
717
+ const el = container()
718
+ el.innerHTML = "<div><b>child</b></div>"
719
+
720
+ const Wrapper = defineComponent((props: { children?: VNodeChild }) =>
721
+ h("div", null, props.children),
722
+ )
723
+ const cleanup = hydrateRoot(el, h(Wrapper, null, h("b", null, "child")))
724
+ cleanup()
725
+ })
726
+
727
+ test("hydrates reactive accessor returning VNode with domNode present", () => {
728
+ const el = container()
729
+ el.innerHTML = "<div><span>initial</span></div>"
730
+ const content = signal<VNodeChild>(h("span", null, "initial"))
731
+ // Reactive accessor returns a VNode — goes through the complex reactive path with marker
732
+ const cleanup = hydrateRoot(el, h("div", null, (() => content()) as unknown as VNodeChild))
733
+ cleanup()
734
+ })
735
+ })
736
+
737
+ // ─── transition-group.ts — FLIP move animation (lines 209-218) ───────────────
738
+
739
+ describe("TransitionGroup — FLIP move animation", () => {
740
+ test("FLIP animation fires for moved items", async () => {
741
+ const el = container()
742
+ const items = signal([
743
+ { id: 1, label: "a" },
744
+ { id: 2, label: "b" },
745
+ { id: 3, label: "c" },
746
+ ])
747
+
748
+ mount(
749
+ h(TransitionGroup, {
750
+ tag: "div",
751
+ name: "list",
752
+ items: () => items(),
753
+ keyFn: (item: { id: number }) => item.id,
754
+ render: (item: { id: number; label: string }) =>
755
+ h("span", { class: "flip-item" }, item.label),
756
+ }),
757
+ el,
758
+ )
759
+ await new Promise<void>((r) => queueMicrotask(r))
760
+ expect(el.querySelectorAll("span.flip-item").length).toBe(3)
761
+
762
+ // Reorder to trigger FLIP
763
+ items.set([
764
+ { id: 3, label: "c" },
765
+ { id: 1, label: "a" },
766
+ { id: 2, label: "b" },
767
+ ])
768
+
769
+ // Wait for the effect and rAF chains
770
+ await new Promise<void>((r) => queueMicrotask(r))
771
+ await new Promise<void>((r) => requestAnimationFrame(() => r()))
772
+ // Second rAF for the inner requestAnimationFrame in FLIP
773
+ await new Promise<void>((r) => requestAnimationFrame(() => r()))
774
+
775
+ // Fire transitionend to clean up move class
776
+ const spans = el.querySelectorAll("span.flip-item")
777
+ for (const span of spans) {
778
+ span.dispatchEvent(new Event("transitionend"))
779
+ }
780
+
781
+ // Items should be reordered
782
+ const reorderedSpans = el.querySelectorAll("span.flip-item")
783
+ expect(reorderedSpans[0]?.textContent).toBe("c")
784
+ expect(reorderedSpans[1]?.textContent).toBe("a")
785
+ expect(reorderedSpans[2]?.textContent).toBe("b")
786
+ })
787
+ })
788
+
789
+ // ─── nodes.ts — empty mount placeholder paths (lines 433-435, 493-496) ───────
790
+
791
+ describe("nodes.ts — placeholder comment paths", () => {
792
+ test("mountFor fresh render — component returning null uses placeholder", () => {
793
+ const el = container()
794
+ const items = signal([{ id: 1 }, { id: 2 }])
795
+
796
+ // Component that returns null — mount produces no DOM nodes, so
797
+ // the mountFor fresh render path needs a placeholder comment anchor
798
+ const NullComp = defineComponent(() => null)
799
+
800
+ mount(
801
+ h(
802
+ "div",
803
+ null,
804
+ For({
805
+ each: items,
806
+ by: (r: { id: number }) => r.id,
807
+ children: (r: { id: number }) => h(NullComp, { key: r.id }),
808
+ }),
809
+ ),
810
+ el,
811
+ )
812
+ })
813
+
814
+ test("mountFor replace-all — component returning null uses placeholder", () => {
815
+ const el = container()
816
+ const items = signal([{ id: 1 }])
817
+
818
+ const NullComp = defineComponent(() => null)
819
+
820
+ mount(
821
+ h(
822
+ "div",
823
+ null,
824
+ For({
825
+ each: items,
826
+ by: (r: { id: number }) => r.id,
827
+ children: (r: { id: number }) => h(NullComp, { key: r.id }),
828
+ }),
829
+ ),
830
+ el,
831
+ )
832
+
833
+ // Replace all with new keys
834
+ items.set([{ id: 10 }, { id: 11 }])
835
+ })
836
+
837
+ test("mountFor step 3 — new entries with component returning null (lines 493-496)", () => {
838
+ const el = container()
839
+ const items = signal([{ id: 1 }, { id: 2 }])
840
+
841
+ const NullComp = defineComponent(() => null)
842
+
843
+ mount(
844
+ h(
845
+ "div",
846
+ null,
847
+ For({
848
+ each: items,
849
+ by: (r: { id: number }) => r.id,
850
+ children: (r: { id: number }) => h(NullComp, { key: r.id }),
851
+ }),
852
+ ),
853
+ el,
854
+ )
855
+
856
+ // Add new items — step 3 mount new entries path
857
+ items.set([{ id: 1 }, { id: 2 }, { id: 3 }])
858
+ })
859
+
860
+ test("mountFor with NativeItem having cleanup in replace-all path", () => {
861
+ const el = container()
862
+ type R = { id: number; label: string }
863
+ let cleanupCount = 0
864
+
865
+ const items = signal<R[]>([{ id: 1, label: "old" }])
866
+
867
+ mount(
868
+ h(
869
+ "div",
870
+ null,
871
+ For({
872
+ each: items,
873
+ by: (r) => r.id,
874
+ children: (r) => {
875
+ const native = _tpl("<b></b>", (root) => {
876
+ root.textContent = r.label
877
+ return () => {
878
+ cleanupCount++
879
+ }
880
+ })
881
+ return native as unknown as ReturnType<typeof h>
882
+ },
883
+ }),
884
+ ),
885
+ el,
886
+ )
887
+
888
+ // Replace all — should call cleanup on old entries
889
+ items.set([{ id: 10, label: "new" }])
890
+ expect(cleanupCount).toBe(1)
891
+ })
892
+ })
893
+
894
+ // ─── props.ts — uncovered branches (lines 213, 242, 273-277) ─────────────────
895
+
896
+ describe("props.ts — uncovered branches", () => {
897
+ test("multiple prop cleanups chain correctly (line 213)", () => {
898
+ const el = container()
899
+ const cls = signal("a")
900
+ const title = signal("t")
901
+
902
+ // Two reactive props => two cleanups that chain
903
+ const unmount = mount(h("div", { class: () => cls(), title: () => title() }), el)
904
+
905
+ const div = el.querySelector("div") as HTMLElement
906
+ expect(div.className).toBe("a")
907
+ expect(div.title).toBe("t")
908
+
909
+ cls.set("b")
910
+ title.set("u")
911
+ expect(div.className).toBe("b")
912
+ expect(div.title).toBe("u")
913
+
914
+ unmount()
915
+ })
916
+
917
+ test("n-show prop toggles display (lines 273-277)", () => {
918
+ const el = container()
919
+ const visible = signal(true)
920
+
921
+ mount(h("div", { "n-show": () => visible() }), el)
922
+ const div = el.querySelector("div") as HTMLElement
923
+ expect(div.style.display).toBe("")
924
+
925
+ visible.set(false)
926
+ expect(div.style.display).toBe("none")
927
+
928
+ visible.set(true)
929
+ expect(div.style.display).toBe("")
930
+ })
931
+
932
+ test("innerHTML with setHTML method (line 242)", async () => {
933
+ const el = container()
934
+ const div = document.createElement("div")
935
+ el.appendChild(div)
936
+
937
+ // Mock setHTML on the element
938
+ let setHTMLCalled = false
939
+ ;(div as unknown as Record<string, unknown>).setHTML = (html: string) => {
940
+ setHTMLCalled = true
941
+ div.innerHTML = html
942
+ }
943
+
944
+ // Use applyProp directly for this test
945
+ const { applyProp } = await import("../props")
946
+ applyProp(div, "innerHTML", "<b>via setHTML</b>")
947
+ expect(setHTMLCalled).toBe(true)
948
+ expect(div.innerHTML).toBe("<b>via setHTML</b>")
949
+ })
950
+
951
+ test("multiple chained prop cleanups (3+ reactive props)", () => {
952
+ const el = container()
953
+ const a = signal("a")
954
+ const b = signal("b")
955
+ const c = signal("c")
956
+
957
+ const unmount = mount(
958
+ h("div", {
959
+ class: () => a(),
960
+ title: () => b(),
961
+ "data-x": () => c(),
962
+ }),
963
+ el,
964
+ )
965
+
966
+ const div = el.querySelector("div") as HTMLElement
967
+ expect(div.className).toBe("a")
968
+ expect(div.title).toBe("b")
969
+ expect(div.getAttribute("data-x")).toBe("c")
970
+
971
+ unmount()
972
+ })
973
+
974
+ test("directive without cleanup returns null", () => {
975
+ const el = container()
976
+ let called = false
977
+ const nSimple: Directive = () => {
978
+ called = true
979
+ // No addCleanup call
980
+ }
981
+ mount(h("div", { "n-simple": nSimple }), el)
982
+ expect(called).toBe(true)
983
+ })
984
+
985
+ test("sanitizeHtml with no DOMParser or Sanitizer falls back to tag stripping", () => {
986
+ // This path is hard to test in happy-dom since DOMParser exists,
987
+ // but we can test the custom sanitizer path
988
+ setSanitizer((html) => html.replace(/<[^>]*>/g, ""))
989
+ const result = sanitizeHtml("<b>bold</b><script>bad</script>")
990
+ expect(result).toBe("boldbad")
991
+ setSanitizer(null)
992
+ })
993
+
994
+ test("dangerouslySetInnerHTML warns in dev mode", () => {
995
+ const el = container()
996
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
997
+
998
+ mount(h("div", { dangerouslySetInnerHTML: { __html: "<em>raw</em>" } }), el)
999
+
1000
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("dangerouslySetInnerHTML"))
1001
+ warnSpy.mockRestore()
1002
+ })
1003
+
1004
+ test("style as null/undefined does nothing", () => {
1005
+ const el = container()
1006
+ mount(h("div", { style: null as unknown as string }), el)
1007
+ // Should not throw
1008
+ expect(el.querySelector("div")).not.toBeNull()
1009
+ })
1010
+ })
1011
+
1012
+ // ─── Additional edge cases for mount.ts ──────────────────────────────────────
1013
+
1014
+ describe("mount.ts — additional edge cases", () => {
1015
+ test("mountElement no reactive work and no ref at depth > 0 returns noop", () => {
1016
+ const el = container()
1017
+ // Static nested element with no reactive props, no ref — returns noop at _elementDepth > 0
1018
+ const unmount = mount(h("div", null, h("span", null, "static")), el)
1019
+ expect(el.querySelector("span")?.textContent).toBe("static")
1020
+ unmount()
1021
+ })
1022
+
1023
+ test("mountChildren 2-child path where one cleanup is noop", () => {
1024
+ const el = container()
1025
+ // 2 children: one static (noop cleanup) and one with cleanup
1026
+ const cls = signal("x")
1027
+ mount(h("div", null, h("span", null, "static"), h("b", { class: () => cls() }, "reactive")), el)
1028
+ expect(el.querySelectorAll("span").length).toBe(1)
1029
+ expect(el.querySelector("b")?.className).toBe("x")
1030
+ })
1031
+
1032
+ test("mountChildren 2-child path where both cleanups are noop", () => {
1033
+ const el = container()
1034
+ // 2 static children — both noop cleanup
1035
+ mount(h("div", null, h("span", null, "a"), h("b", null, "b")), el)
1036
+ expect(el.querySelector("span")?.textContent).toBe("a")
1037
+ expect(el.querySelector("b")?.textContent).toBe("b")
1038
+ })
1039
+
1040
+ test("mountChildren 2-child path where first cleanup is noop", () => {
1041
+ const el = container()
1042
+ const cls = signal("x")
1043
+ // First child static (noop), second child reactive
1044
+ mount(h("div", null, "text", h("b", { class: () => cls() }, "reactive")), el)
1045
+ })
1046
+
1047
+ test("isKeyedArray returns false for empty array", () => {
1048
+ const el = container()
1049
+ const items = signal<{ id: number }[]>([])
1050
+ // Reactive accessor returning empty array — should not use keyed reconciler
1051
+ mount(
1052
+ h("div", null, () => items().map((it) => h("span", { key: it.id }))),
1053
+ el,
1054
+ )
1055
+ expect(el.querySelector("span")).toBeNull()
1056
+ })
1057
+
1058
+ test("isKeyedArray returns false for non-keyed vnodes", () => {
1059
+ const el = container()
1060
+ const items = signal([1, 2, 3])
1061
+ // VNodes without keys — should NOT use keyed reconciler
1062
+ mount(
1063
+ h("div", null, () => items().map((n) => h("span", null, String(n)))),
1064
+ el,
1065
+ )
1066
+ expect(el.querySelectorAll("span").length).toBe(3)
1067
+ })
1068
+ })
1069
+
1070
+ // ─── hydrate.ts — additional branches ────────────────────────────────────────
1071
+
1072
+ describe("hydrate.ts — additional branches", () => {
1073
+ test("hydrates component returning null", () => {
1074
+ const el = container()
1075
+ el.innerHTML = ""
1076
+ const NullComp = defineComponent(() => null)
1077
+ const cleanup = hydrateRoot(el, h(NullComp, null))
1078
+ cleanup()
1079
+ })
1080
+
1081
+ test("hydrates element mismatch — element found but wrong tag", () => {
1082
+ const el = container()
1083
+ el.innerHTML = "<div>wrong tag</div>"
1084
+ // Expect a <p> but find <div>
1085
+ const cleanup = hydrateRoot(el, h("p", null, "right"))
1086
+ cleanup()
1087
+ })
1088
+
1089
+ test("hydrates For without SSR markers but with existing domNode (non-comment)", () => {
1090
+ const el = container()
1091
+ // Existing element (not a comment) — takes the no-markers path
1092
+ el.innerHTML = "<span>not a for marker</span>"
1093
+ const items = signal([{ id: 1, label: "a" }])
1094
+ const cleanup = hydrateRoot(
1095
+ el,
1096
+ For({
1097
+ each: items,
1098
+ by: (r: { id: number }) => r.id,
1099
+ children: (r: { id: number; label: string }) => h("li", null, r.label),
1100
+ }),
1101
+ )
1102
+ cleanup()
1103
+ })
1104
+
1105
+ test("hydrates PortalSymbol — always remounts", async () => {
1106
+ const el = container()
1107
+ const target = container()
1108
+ el.innerHTML = ""
1109
+
1110
+ const { Portal } = await import("@pyreon/core")
1111
+ const cleanup = hydrateRoot(el, Portal({ target, children: h("span", null, "portal") }))
1112
+ expect(target.querySelector("span")?.textContent).toBe("portal")
1113
+ cleanup()
1114
+ })
1115
+
1116
+ test("reactive accessor with complex VNode and existing domNode inserts marker before domNode", () => {
1117
+ const el = container()
1118
+ el.innerHTML = "<span>existing</span>"
1119
+ const content = signal<VNodeChild>(h("b", null, "complex"))
1120
+ const cleanup = hydrateRoot(el, (() => content()) as unknown as VNodeChild)
1121
+ cleanup()
1122
+ })
1123
+ })