@pyreon/runtime-dom 0.24.5 → 0.24.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/package.json +5 -9
  2. package/src/delegate.ts +0 -98
  3. package/src/devtools.ts +0 -339
  4. package/src/env.d.ts +0 -6
  5. package/src/hydrate.ts +0 -450
  6. package/src/hydration-debug.ts +0 -129
  7. package/src/index.ts +0 -83
  8. package/src/keep-alive-entry.ts +0 -3
  9. package/src/keep-alive.ts +0 -83
  10. package/src/manifest.ts +0 -236
  11. package/src/mount.ts +0 -597
  12. package/src/nodes.ts +0 -896
  13. package/src/props.ts +0 -474
  14. package/src/template.ts +0 -523
  15. package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
  16. package/src/tests/callback-ref-unmount.test.ts +0 -52
  17. package/src/tests/compiler-integration.test.tsx +0 -508
  18. package/src/tests/coverage-gaps.test.ts +0 -3183
  19. package/src/tests/coverage.test.ts +0 -1140
  20. package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
  21. package/src/tests/dev-gate-pattern.test.ts +0 -46
  22. package/src/tests/dev-gate-treeshake.test.ts +0 -256
  23. package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
  24. package/src/tests/fanout-repro.test.tsx +0 -219
  25. package/src/tests/hydration-integration.test.tsx +0 -540
  26. package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
  27. package/src/tests/lifecycle-integration.test.tsx +0 -342
  28. package/src/tests/lis-prepend.browser.test.ts +0 -99
  29. package/src/tests/manifest-snapshot.test.ts +0 -85
  30. package/src/tests/mount.test.ts +0 -3529
  31. package/src/tests/native-markers.test.ts +0 -19
  32. package/src/tests/props.test.ts +0 -581
  33. package/src/tests/reactive-props.test.ts +0 -270
  34. package/src/tests/real-world-integration.test.tsx +0 -714
  35. package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
  36. package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
  37. package/src/tests/rs-collapse-h.browser.test.ts +0 -152
  38. package/src/tests/rs-collapse-h.test.ts +0 -237
  39. package/src/tests/rs-collapse.browser.test.ts +0 -128
  40. package/src/tests/runtime-dom.browser.test.ts +0 -409
  41. package/src/tests/setup.ts +0 -3
  42. package/src/tests/show-context.test.ts +0 -270
  43. package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
  44. package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
  45. package/src/tests/style-key-removal.browser.test.ts +0 -54
  46. package/src/tests/style-key-removal.test.ts +0 -88
  47. package/src/tests/template.test.ts +0 -383
  48. package/src/tests/transition-timeout-leak.test.ts +0 -126
  49. package/src/tests/transition.test.ts +0 -568
  50. package/src/tests/verified-correct-probes.test.ts +0 -56
  51. package/src/transition-entry.ts +0 -7
  52. package/src/transition-group.ts +0 -350
  53. package/src/transition.ts +0 -245
@@ -1,1140 +0,0 @@
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
- Portal,
17
- } from '@pyreon/core'
18
- import { signal } from '@pyreon/reactivity'
19
- import { installDevTools, registerComponent, unregisterComponent } from '../devtools'
20
- import {
21
- Transition as _Transition,
22
- TransitionGroup as _TransitionGroup,
23
- _tpl,
24
- hydrateRoot,
25
- mount,
26
- sanitizeHtml,
27
- setSanitizer,
28
- } from '../index'
29
- import { mountChild } from '../mount'
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 returning Promise triggers dev warning', () => {
364
- const el = container()
365
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
366
-
367
- const AsyncComp = (() => Promise.resolve(null)) as unknown as ComponentFn
368
- mount(h(AsyncComp, null), el)
369
-
370
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('returned a Promise'))
371
- warnSpy.mockRestore()
372
- })
373
-
374
- test('void element with children triggers dev warning', () => {
375
- const el = container()
376
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
377
-
378
- mount(h('img', null, 'child text'), el)
379
-
380
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('void element'))
381
- warnSpy.mockRestore()
382
- })
383
-
384
- test('Portal with falsy target warns', () => {
385
- const el = container()
386
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
387
-
388
- mount(h(Portal, { target: null }), el)
389
-
390
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Portal'))
391
- warnSpy.mockRestore()
392
- })
393
-
394
- test('component subtree mount error with propagateError (lines 298-309)', () => {
395
- const el = container()
396
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
397
-
398
- // Component whose subtree throws during mount
399
- const Outer = defineComponent(() => {
400
- // Inner component throws during mount (not setup)
401
- const Inner = defineComponent(() => {
402
- // Return a VNode that will throw when mounted
403
- return h('div', null, (() => {
404
- throw new Error('subtree mount error')
405
- }) as unknown as VNodeChild)
406
- })
407
- return h(Inner, null)
408
- })
409
-
410
- mount(h(Outer, null), el)
411
- errorSpy.mockRestore()
412
- })
413
-
414
- test('mountChildren with >2 children and cleanups (line 387)', () => {
415
- const el = container()
416
- const s1 = signal('a')
417
- const s2 = signal('b')
418
- const s3 = signal('c')
419
-
420
- // 3 reactive children will all have cleanups, hitting the map+cleanup path
421
- const unmount = mount(
422
- h(
423
- 'div',
424
- null,
425
- () => s1(),
426
- () => s2(),
427
- () => s3(),
428
- ),
429
- el,
430
- )
431
-
432
- expect(el.querySelector('div')?.textContent).toContain('a')
433
- expect(el.querySelector('div')?.textContent).toContain('b')
434
- expect(el.querySelector('div')?.textContent).toContain('c')
435
-
436
- unmount()
437
- })
438
-
439
- test('mountElement with ref + propCleanup at _elementDepth > 0', () => {
440
- const el = container()
441
- const ref = createRef<HTMLElement>()
442
- const cls = signal('foo')
443
-
444
- // Nested element with ref AND reactive prop — exercises the combined cleanup path
445
- mount(h('div', null, h('span', { ref, class: () => cls() }, 'inner')), el)
446
-
447
- expect(ref.current).not.toBeNull()
448
- expect(ref.current?.className).toBe('foo')
449
- })
450
-
451
- test('mountElement with propCleanup only at _elementDepth > 0', () => {
452
- const el = container()
453
- const cls = signal('bar')
454
-
455
- // Nested element with reactive prop but no ref
456
- const unmount = mount(h('div', null, h('span', { class: () => cls() }, 'inner')), el)
457
-
458
- expect(el.querySelector('span')?.className).toBe('bar')
459
- cls.set('baz')
460
- expect(el.querySelector('span')?.className).toBe('baz')
461
- unmount()
462
- })
463
-
464
- test('reactive text at _elementDepth > 0 returns just dispose', () => {
465
- const el = container()
466
- const text = signal('nested')
467
-
468
- // Reactive text inside a parent element — should skip DOM removal closure
469
- const unmount = mount(
470
- h('div', null, () => text()),
471
- el,
472
- )
473
-
474
- expect(el.querySelector('div')?.textContent).toBe('nested')
475
- text.set('updated')
476
- expect(el.querySelector('div')?.textContent).toBe('updated')
477
- unmount()
478
- })
479
-
480
- test('NativeItem without cleanup at _elementDepth > 0', () => {
481
- const el = container()
482
- const native = _tpl('<b>native</b>', () => null)
483
-
484
- // Mount NativeItem inside a parent element
485
- mount(h('div', null, native as unknown as VNodeChild), el)
486
- expect(el.querySelector('b')?.textContent).toBe('native')
487
- })
488
-
489
- test('NativeItem with cleanup at _elementDepth > 0', () => {
490
- const el = container()
491
- let _cleaned = false
492
- const native = _tpl('<b>native2</b>', () => () => {
493
- _cleaned = true
494
- })
495
-
496
- mount(h('div', null, native as unknown as VNodeChild), el)
497
- expect(el.querySelector('b')?.textContent).toBe('native2')
498
- })
499
- })
500
-
501
- // ─── transition.ts — uncovered branches (lines 152, 165, 170-175) ────────────
502
-
503
- describe('Transition — uncovered branches', () => {
504
- test('onUnmount cancels pending leave (line 152)', async () => {
505
- const el = container()
506
- const visible = signal(true)
507
-
508
- const unmount = mount(
509
- h(Transition, {
510
- show: visible,
511
- name: 'fade',
512
- children: h('div', { id: 'unmount-leave' }, 'content'),
513
- }),
514
- el,
515
- )
516
-
517
- // Start leave animation
518
- visible.set(false)
519
-
520
- // Unmount before leave completes — should cancel pending leave
521
- unmount()
522
- })
523
-
524
- test('non-object/array child returns rawChild (line 165)', () => {
525
- const el = container()
526
- const visible = signal(true)
527
-
528
- // Pass a non-object, non-array child (string) — hits line 165
529
- mount(
530
- h(Transition, {
531
- show: visible,
532
- children: 'just text' as unknown as VNodeChild,
533
- }),
534
- el,
535
- )
536
- })
537
-
538
- test('array child returns rawChild (line 164)', () => {
539
- const el = container()
540
- const visible = signal(true)
541
-
542
- // Pass an array child — hits the Array.isArray branch
543
- mount(
544
- h(Transition, {
545
- show: visible,
546
- children: [h('span', null, 'a'), h('span', null, 'b')] as unknown as VNodeChild,
547
- }),
548
- el,
549
- )
550
- })
551
-
552
- test('component child warns and returns vnode (lines 170-175)', () => {
553
- const el = container()
554
- const visible = signal(true)
555
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
556
-
557
- const Inner = defineComponent(() => h('span', null, 'comp child'))
558
-
559
- mount(
560
- h(Transition, {
561
- show: visible,
562
- children: h(Inner, null),
563
- }),
564
- el,
565
- )
566
-
567
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Transition child is a component'))
568
- warnSpy.mockRestore()
569
- })
570
-
571
- test('leave with no ref.current sets isMounted false immediately (line 142-144)', async () => {
572
- const el = container()
573
- const visible = signal(true)
574
-
575
- // Use a non-string type (like a component) so ref won't be injected
576
- const Comp = () => h('span', null, 'no-ref')
577
- mount(
578
- h(Transition, {
579
- show: visible,
580
- children: h(Comp, null) as VNodeChild,
581
- }),
582
- el,
583
- )
584
-
585
- // Toggle off — ref.current will be null for component children
586
- visible.set(false)
587
- await new Promise<void>((r) => queueMicrotask(r))
588
- })
589
-
590
- test('onAfterEnter callback fires after enter transition', async () => {
591
- const el = container()
592
- const visible = signal(false)
593
- let afterEnterCalled = false
594
-
595
- mount(
596
- h(Transition, {
597
- show: visible,
598
- name: 'fade',
599
- onAfterEnter: () => {
600
- afterEnterCalled = true
601
- },
602
- children: h('div', { id: 'after-enter-test' }, 'content'),
603
- }),
604
- el,
605
- )
606
-
607
- visible.set(true)
608
- await new Promise<void>((r) => queueMicrotask(r))
609
-
610
- // Trigger rAF
611
- await new Promise<void>((r) => requestAnimationFrame(() => r()))
612
- const target = el.querySelector('#after-enter-test')
613
- if (target) {
614
- target.dispatchEvent(new Event('transitionend'))
615
- }
616
- expect(afterEnterCalled).toBe(true)
617
- })
618
-
619
- test('onAfterLeave callback fires after leave transition', async () => {
620
- const el = container()
621
- const visible = signal(true)
622
- let afterLeaveCalled = false
623
-
624
- mount(
625
- h(Transition, {
626
- show: visible,
627
- name: 'fade',
628
- onAfterLeave: () => {
629
- afterLeaveCalled = true
630
- },
631
- children: h('div', { id: 'after-leave-test' }, 'content'),
632
- }),
633
- el,
634
- )
635
-
636
- const target = el.querySelector('#after-leave-test')
637
- visible.set(false)
638
-
639
- await new Promise<void>((r) => requestAnimationFrame(() => r()))
640
- if (target) {
641
- target.dispatchEvent(new Event('transitionend'))
642
- }
643
- await new Promise<void>((r) => queueMicrotask(r))
644
- expect(afterLeaveCalled).toBe(true)
645
- })
646
-
647
- test('tooltip repositions when near top of viewport', () => {
648
- const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
649
- enableOverlay: () => void
650
- disableOverlay: () => void
651
- }
652
-
653
- const target = document.createElement('div')
654
- // Position near top so tooltip moves below element (rect.top < 35)
655
- target.style.cssText = 'width:100px;height:20px;position:fixed;top:10px;left:10px;'
656
- document.body.appendChild(target)
657
- registerComponent('top-comp', 'TopComp', target, null)
658
-
659
- devtools.enableOverlay()
660
-
661
- const event = new MouseEvent('mousemove', { clientX: 50, clientY: 15, bubbles: true })
662
- document.dispatchEvent(event)
663
-
664
- devtools.disableOverlay()
665
- unregisterComponent('top-comp')
666
- target.remove()
667
- })
668
- })
669
-
670
- // ─── hydrate.ts — uncovered branches (lines 162-183, 338) ────────────────────
671
-
672
- describe('hydrate.ts — uncovered branches', () => {
673
- test('For hydration with SSR markers — full path with afterEnd (lines 162-183)', () => {
674
- const el = container()
675
- // SSR markers with content after the end marker
676
- el.innerHTML = '<!--pyreon-for--><li>a</li><li>b</li><!--/pyreon-for--><p>after</p>'
677
- const items = signal([
678
- { id: 1, label: 'a' },
679
- { id: 2, label: 'b' },
680
- ])
681
- const cleanup = hydrateRoot(
682
- el,
683
- h(
684
- Fragment,
685
- null,
686
- For({
687
- each: items,
688
- by: (r: { id: number }) => r.id,
689
- children: (r: { id: number; label: string }) => h('li', null, r.label),
690
- }),
691
- h('p', null, 'after'),
692
- ),
693
- )
694
- cleanup()
695
- })
696
-
697
- test('component with onUpdate hooks during hydration (line 338)', () => {
698
- const el = container()
699
- el.innerHTML = '<span>update-test</span>'
700
- let _updateCalled = false
701
-
702
- const Comp = defineComponent(() => {
703
- onUpdate(() => {
704
- _updateCalled = true
705
- })
706
- return h('span', null, 'update-test')
707
- })
708
-
709
- const cleanup = hydrateRoot(el, h(Comp, null))
710
- cleanup()
711
- })
712
-
713
- test('component with onUnmount hook during hydration cleanup', () => {
714
- const el = container()
715
- el.innerHTML = '<span>unmount-test</span>'
716
- let unmountCalled = false
717
-
718
- const Comp = defineComponent(() => {
719
- onUnmount(() => {
720
- unmountCalled = true
721
- })
722
- return h('span', null, 'unmount-test')
723
- })
724
-
725
- const cleanup = hydrateRoot(el, h(Comp, null))
726
- cleanup()
727
- expect(unmountCalled).toBe(true)
728
- })
729
-
730
- test('component with mount cleanup during hydration', () => {
731
- const el = container()
732
- el.innerHTML = '<span>mount-cleanup</span>'
733
- let mountCleanupCalled = false
734
-
735
- const Comp = defineComponent(() => {
736
- onMount(() => () => {
737
- mountCleanupCalled = true
738
- })
739
- return h('span', null, 'mount-cleanup')
740
- })
741
-
742
- const cleanup = hydrateRoot(el, h(Comp, null))
743
- cleanup()
744
- expect(mountCleanupCalled).toBe(true)
745
- })
746
-
747
- test('hydrates component with children merge', () => {
748
- const el = container()
749
- el.innerHTML = '<div><b>child</b></div>'
750
-
751
- const Wrapper = defineComponent((props: { children?: VNodeChild }) =>
752
- h('div', null, props.children),
753
- )
754
- const cleanup = hydrateRoot(el, h(Wrapper, null, h('b', null, 'child')))
755
- cleanup()
756
- })
757
-
758
- test('hydrates reactive accessor returning VNode with domNode present', () => {
759
- const el = container()
760
- el.innerHTML = '<div><span>initial</span></div>'
761
- const content = signal<VNodeChild>(h('span', null, 'initial'))
762
- // Reactive accessor returns a VNode — goes through the complex reactive path with marker
763
- const cleanup = hydrateRoot(el, h('div', null, (() => content()) as unknown as VNodeChild))
764
- cleanup()
765
- })
766
- })
767
-
768
- // ─── transition-group.ts — FLIP move animation (lines 209-218) ───────────────
769
-
770
- describe('TransitionGroup — FLIP move animation', () => {
771
- test('FLIP animation fires for moved items', async () => {
772
- const el = container()
773
- const items = signal([
774
- { id: 1, label: 'a' },
775
- { id: 2, label: 'b' },
776
- { id: 3, label: 'c' },
777
- ])
778
-
779
- mount(
780
- h(TransitionGroup, {
781
- tag: 'div',
782
- name: 'list',
783
- items: () => items(),
784
- keyFn: (item: { id: number }) => item.id,
785
- render: (item: { id: number; label: string }) =>
786
- h('span', { class: 'flip-item' }, item.label),
787
- }),
788
- el,
789
- )
790
- await new Promise<void>((r) => queueMicrotask(r))
791
- expect(el.querySelectorAll('span.flip-item').length).toBe(3)
792
-
793
- // Reorder to trigger FLIP
794
- items.set([
795
- { id: 3, label: 'c' },
796
- { id: 1, label: 'a' },
797
- { id: 2, label: 'b' },
798
- ])
799
-
800
- // Wait for the effect and rAF chains
801
- await new Promise<void>((r) => queueMicrotask(r))
802
- await new Promise<void>((r) => requestAnimationFrame(() => r()))
803
- // Second rAF for the inner requestAnimationFrame in FLIP
804
- await new Promise<void>((r) => requestAnimationFrame(() => r()))
805
-
806
- // Fire transitionend to clean up move class
807
- const spans = el.querySelectorAll('span.flip-item')
808
- for (const span of spans) {
809
- span.dispatchEvent(new Event('transitionend'))
810
- }
811
-
812
- // Items should be reordered
813
- const reorderedSpans = el.querySelectorAll('span.flip-item')
814
- expect(reorderedSpans[0]?.textContent).toBe('c')
815
- expect(reorderedSpans[1]?.textContent).toBe('a')
816
- expect(reorderedSpans[2]?.textContent).toBe('b')
817
- })
818
- })
819
-
820
- // ─── nodes.ts — empty mount placeholder paths (lines 433-435, 493-496) ───────
821
-
822
- describe('nodes.ts — placeholder comment paths', () => {
823
- test('mountFor fresh render — component returning null uses placeholder', () => {
824
- const el = container()
825
- const items = signal([{ id: 1 }, { id: 2 }])
826
-
827
- // Component that returns null — mount produces no DOM nodes, so
828
- // the mountFor fresh render path needs a placeholder comment anchor
829
- const NullComp = defineComponent(() => null)
830
-
831
- mount(
832
- h(
833
- 'div',
834
- null,
835
- For({
836
- each: items,
837
- by: (r: { id: number }) => r.id,
838
- children: (r: { id: number }) => h(NullComp, { key: r.id }),
839
- }),
840
- ),
841
- el,
842
- )
843
- })
844
-
845
- test('mountFor replace-all — component returning null uses placeholder', () => {
846
- const el = container()
847
- const items = signal([{ id: 1 }])
848
-
849
- const NullComp = defineComponent(() => null)
850
-
851
- mount(
852
- h(
853
- 'div',
854
- null,
855
- For({
856
- each: items,
857
- by: (r: { id: number }) => r.id,
858
- children: (r: { id: number }) => h(NullComp, { key: r.id }),
859
- }),
860
- ),
861
- el,
862
- )
863
-
864
- // Replace all with new keys
865
- items.set([{ id: 10 }, { id: 11 }])
866
- })
867
-
868
- test('mountFor step 3 — new entries with component returning null (lines 493-496)', () => {
869
- const el = container()
870
- const items = signal([{ id: 1 }, { id: 2 }])
871
-
872
- const NullComp = defineComponent(() => null)
873
-
874
- mount(
875
- h(
876
- 'div',
877
- null,
878
- For({
879
- each: items,
880
- by: (r: { id: number }) => r.id,
881
- children: (r: { id: number }) => h(NullComp, { key: r.id }),
882
- }),
883
- ),
884
- el,
885
- )
886
-
887
- // Add new items — step 3 mount new entries path
888
- items.set([{ id: 1 }, { id: 2 }, { id: 3 }])
889
- })
890
-
891
- test('mountFor with NativeItem having cleanup in replace-all path', () => {
892
- const el = container()
893
- type R = { id: number; label: string }
894
- let cleanupCount = 0
895
-
896
- const items = signal<R[]>([{ id: 1, label: 'old' }])
897
-
898
- mount(
899
- h(
900
- 'div',
901
- null,
902
- For({
903
- each: items,
904
- by: (r) => r.id,
905
- children: (r) => {
906
- const native = _tpl('<b></b>', (root) => {
907
- root.textContent = r.label
908
- return () => {
909
- cleanupCount++
910
- }
911
- })
912
- return native as unknown as ReturnType<typeof h>
913
- },
914
- }),
915
- ),
916
- el,
917
- )
918
-
919
- // Replace all — should call cleanup on old entries
920
- items.set([{ id: 10, label: 'new' }])
921
- expect(cleanupCount).toBe(1)
922
- })
923
- })
924
-
925
- // ─── props.ts — uncovered branches (lines 213, 242, 273-277) ─────────────────
926
-
927
- describe('props.ts — uncovered branches', () => {
928
- test('multiple prop cleanups chain correctly (line 213)', () => {
929
- const el = container()
930
- const cls = signal('a')
931
- const title = signal('t')
932
-
933
- // Two reactive props => two cleanups that chain
934
- const unmount = mount(h('div', { class: () => cls(), title: () => title() }), el)
935
-
936
- const div = el.querySelector('div') as HTMLElement
937
- expect(div.className).toBe('a')
938
- expect(div.title).toBe('t')
939
-
940
- cls.set('b')
941
- title.set('u')
942
- expect(div.className).toBe('b')
943
- expect(div.title).toBe('u')
944
-
945
- unmount()
946
- })
947
-
948
- test('non-function event handler triggers dev warning', () => {
949
- const el = container()
950
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
951
-
952
- mount(h('button', { onClick: 'not a function' }), el)
953
-
954
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('non-function value'))
955
- warnSpy.mockRestore()
956
- })
957
-
958
- test('innerHTML with setHTML method (line 242)', async () => {
959
- const el = container()
960
- const div = document.createElement('div')
961
- el.appendChild(div)
962
-
963
- // Mock setHTML on the element
964
- let setHTMLCalled = false
965
- ;(div as unknown as Record<string, unknown>).setHTML = (html: string) => {
966
- setHTMLCalled = true
967
- div.innerHTML = html
968
- }
969
-
970
- // Use applyProp directly for this test
971
- const { applyProp } = await import('../props')
972
- applyProp(div, 'innerHTML', '<b>via setHTML</b>')
973
- expect(setHTMLCalled).toBe(true)
974
- expect(div.innerHTML).toBe('<b>via setHTML</b>')
975
- })
976
-
977
- test('multiple chained prop cleanups (3+ reactive props)', () => {
978
- const el = container()
979
- const a = signal('a')
980
- const b = signal('b')
981
- const c = signal('c')
982
-
983
- const unmount = mount(
984
- h('div', {
985
- class: () => a(),
986
- title: () => b(),
987
- 'data-x': () => c(),
988
- }),
989
- el,
990
- )
991
-
992
- const div = el.querySelector('div') as HTMLElement
993
- expect(div.className).toBe('a')
994
- expect(div.title).toBe('b')
995
- expect(div.getAttribute('data-x')).toBe('c')
996
-
997
- unmount()
998
- })
999
-
1000
- test('sanitizeHtml with no DOMParser or Sanitizer falls back to tag stripping', () => {
1001
- // This path is hard to test in happy-dom since DOMParser exists,
1002
- // but we can test the custom sanitizer path
1003
- setSanitizer((html) => html.replace(/<[^>]*>/g, ''))
1004
- const result = sanitizeHtml('<b>bold</b><script>bad</script>')
1005
- expect(result).toBe('boldbad')
1006
- setSanitizer(null)
1007
- })
1008
-
1009
- test('dangerouslySetInnerHTML does NOT warn (name itself is the warning, matches React)', () => {
1010
- const el = container()
1011
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
1012
-
1013
- mount(h('div', { dangerouslySetInnerHTML: { __html: '<em>raw</em>' } }), el)
1014
-
1015
- // Previously this warned on every applyProp call, flooding the console
1016
- // on re-renders. The name "dangerouslySetInnerHTML" IS the warning.
1017
- expect(warnSpy).not.toHaveBeenCalled()
1018
- warnSpy.mockRestore()
1019
- })
1020
-
1021
- test('style as null/undefined does nothing', () => {
1022
- const el = container()
1023
- mount(h('div', { style: null as unknown as string }), el)
1024
- // Should not throw
1025
- expect(el.querySelector('div')).not.toBeNull()
1026
- })
1027
- })
1028
-
1029
- // ─── Additional edge cases for mount.ts ──────────────────────────────────────
1030
-
1031
- describe('mount.ts — additional edge cases', () => {
1032
- test('mountElement no reactive work and no ref at depth > 0 returns noop', () => {
1033
- const el = container()
1034
- // Static nested element with no reactive props, no ref — returns noop at _elementDepth > 0
1035
- const unmount = mount(h('div', null, h('span', null, 'static')), el)
1036
- expect(el.querySelector('span')?.textContent).toBe('static')
1037
- unmount()
1038
- })
1039
-
1040
- test('mountChildren 2-child path where one cleanup is noop', () => {
1041
- const el = container()
1042
- // 2 children: one static (noop cleanup) and one with cleanup
1043
- const cls = signal('x')
1044
- mount(h('div', null, h('span', null, 'static'), h('b', { class: () => cls() }, 'reactive')), el)
1045
- expect(el.querySelectorAll('span').length).toBe(1)
1046
- expect(el.querySelector('b')?.className).toBe('x')
1047
- })
1048
-
1049
- test('mountChildren 2-child path where both cleanups are noop', () => {
1050
- const el = container()
1051
- // 2 static children — both noop cleanup
1052
- mount(h('div', null, h('span', null, 'a'), h('b', null, 'b')), el)
1053
- expect(el.querySelector('span')?.textContent).toBe('a')
1054
- expect(el.querySelector('b')?.textContent).toBe('b')
1055
- })
1056
-
1057
- test('mountChildren 2-child path where first cleanup is noop', () => {
1058
- const el = container()
1059
- const cls = signal('x')
1060
- // First child static (noop), second child reactive
1061
- mount(h('div', null, 'text', h('b', { class: () => cls() }, 'reactive')), el)
1062
- })
1063
-
1064
- test('isKeyedArray returns false for empty array', () => {
1065
- const el = container()
1066
- const items = signal<{ id: number }[]>([])
1067
- // Reactive accessor returning empty array — should not use keyed reconciler
1068
- mount(
1069
- h('div', null, () => items().map((it) => h('span', { key: it.id }))),
1070
- el,
1071
- )
1072
- expect(el.querySelector('span')).toBeNull()
1073
- })
1074
-
1075
- test('isKeyedArray returns false for non-keyed vnodes', () => {
1076
- const el = container()
1077
- const items = signal([1, 2, 3])
1078
- // VNodes without keys — should NOT use keyed reconciler
1079
- mount(
1080
- h('div', null, () => items().map((n) => h('span', null, String(n)))),
1081
- el,
1082
- )
1083
- expect(el.querySelectorAll('span').length).toBe(3)
1084
- })
1085
- })
1086
-
1087
- // ─── hydrate.ts — additional branches ────────────────────────────────────────
1088
-
1089
- describe('hydrate.ts — additional branches', () => {
1090
- test('hydrates component returning null', () => {
1091
- const el = container()
1092
- el.innerHTML = ''
1093
- const NullComp = defineComponent(() => null)
1094
- const cleanup = hydrateRoot(el, h(NullComp, null))
1095
- cleanup()
1096
- })
1097
-
1098
- test('hydrates element mismatch — element found but wrong tag', () => {
1099
- const el = container()
1100
- el.innerHTML = '<div>wrong tag</div>'
1101
- // Expect a <p> but find <div>
1102
- const cleanup = hydrateRoot(el, h('p', null, 'right'))
1103
- cleanup()
1104
- })
1105
-
1106
- test('hydrates For without SSR markers but with existing domNode (non-comment)', () => {
1107
- const el = container()
1108
- // Existing element (not a comment) — takes the no-markers path
1109
- el.innerHTML = '<span>not a for marker</span>'
1110
- const items = signal([{ id: 1, label: 'a' }])
1111
- const cleanup = hydrateRoot(
1112
- el,
1113
- For({
1114
- each: items,
1115
- by: (r: { id: number }) => r.id,
1116
- children: (r: { id: number; label: string }) => h('li', null, r.label),
1117
- }),
1118
- )
1119
- cleanup()
1120
- })
1121
-
1122
- test('hydrates PortalSymbol — always remounts', async () => {
1123
- const el = container()
1124
- const target = container()
1125
- el.innerHTML = ''
1126
-
1127
- const { Portal } = await import('@pyreon/core')
1128
- const cleanup = hydrateRoot(el, Portal({ target, children: h('span', null, 'portal') }))
1129
- expect(target.querySelector('span')?.textContent).toBe('portal')
1130
- cleanup()
1131
- })
1132
-
1133
- test('reactive accessor with complex VNode and existing domNode inserts marker before domNode', () => {
1134
- const el = container()
1135
- el.innerHTML = '<span>existing</span>'
1136
- const content = signal<VNodeChild>(h('b', null, 'complex'))
1137
- const cleanup = hydrateRoot(el, (() => content()) as unknown as VNodeChild)
1138
- cleanup()
1139
- })
1140
- })