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