@pyreon/runtime-dom 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/analysis/keep-alive-entry.js.html +5406 -0
- package/lib/analysis/transition-entry.js.html +5406 -0
- package/lib/index.js +98 -47
- package/lib/index.js.map +1 -1
- package/lib/keep-alive-entry.js +1341 -0
- package/lib/keep-alive-entry.js.map +1 -0
- package/lib/transition-entry.js +167 -0
- package/lib/transition-entry.js.map +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/keep-alive-entry.d.ts +41 -0
- package/lib/types/keep-alive-entry.d.ts.map +1 -0
- package/lib/types/transition-entry.d.ts +59 -0
- package/lib/types/transition-entry.d.ts.map +1 -0
- package/package.json +17 -7
- package/src/hydrate.ts +14 -12
- package/src/index.ts +19 -3
- package/src/keep-alive-entry.ts +3 -0
- package/src/mount.ts +159 -54
- package/src/nodes.ts +61 -11
- package/src/template.ts +13 -2
- package/src/tests/coverage-gaps.test.ts +709 -0
- package/src/tests/lis-prepend.browser.test.ts +99 -0
- package/src/tests/runtime-dom.browser.test.ts +63 -1
- package/src/tests/template.test.ts +64 -0
- package/src/transition-entry.ts +7 -0
|
@@ -2472,3 +2472,712 @@ describe('nodes.ts — keyed list LIS reorder', () => {
|
|
|
2472
2472
|
el.remove()
|
|
2473
2473
|
})
|
|
2474
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
|
+
})
|