@asteby/metacore-runtime-react 23.0.0 → 23.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Shared infinite-scroll primitives (used by both DynamicTable and
4
+ // DynamicKanban):
5
+ // 1. dedupeById — pure append that drops ids already present, preserves order,
6
+ // and is identity-stable when there is nothing new to add.
7
+ // 2. useInfiniteScrollSentinel — an IntersectionObserver wrapper. happy-dom
8
+ // has no IntersectionObserver, so we install a controllable fake and drive
9
+ // it: entering view fires onLoadMore, `disabled` suppresses it, and the
10
+ // absence of IntersectionObserver degrades to a no-op instead of throwing.
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12
+ import { cleanup, render } from '@testing-library/react'
13
+ import { useEffect, useState } from 'react'
14
+ import { dedupeById, useInfiniteScrollSentinel } from '../use-infinite-scroll'
15
+
16
+ afterEach(cleanup)
17
+
18
+ describe('dedupeById', () => {
19
+ it('appends only rows whose id is not already present', () => {
20
+ const existing = [{ id: 1, n: 'a' }, { id: 2, n: 'b' }]
21
+ const incoming = [{ id: 2, n: 'b2' }, { id: 3, n: 'c' }]
22
+ const out = dedupeById(existing, incoming)
23
+ expect(out.map((r) => r.id)).toEqual([1, 2, 3])
24
+ // The already-present row keeps the ORIGINAL (existing wins over incoming).
25
+ expect(out[1].n).toBe('b')
26
+ })
27
+
28
+ it('preserves the order of existing then the new arrivals', () => {
29
+ const out = dedupeById(
30
+ [{ id: 'x' }, { id: 'y' }],
31
+ [{ id: 'z' }, { id: 'w' }],
32
+ )
33
+ expect(out.map((r) => r.id)).toEqual(['x', 'y', 'z', 'w'])
34
+ })
35
+
36
+ it('returns the SAME array reference when nothing is added', () => {
37
+ const existing = [{ id: 1 }, { id: 2 }]
38
+ expect(dedupeById(existing, [])).toBe(existing)
39
+ expect(dedupeById(existing, [{ id: 1 }, { id: 2 }])).toBe(existing)
40
+ })
41
+
42
+ it('treats numeric and string ids as the same key', () => {
43
+ const out = dedupeById([{ id: 1 }], [{ id: '1' }, { id: 2 }])
44
+ expect(out.map((r) => r.id)).toEqual([1, 2])
45
+ })
46
+ })
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Controllable IntersectionObserver fake
50
+ // ---------------------------------------------------------------------------
51
+
52
+ type IOEntry = { isIntersecting: boolean }
53
+ let observers: FakeIO[] = []
54
+
55
+ class FakeIO {
56
+ cb: (entries: IOEntry[]) => void
57
+ observed: Element[] = []
58
+ disconnected = false
59
+ constructor(cb: (entries: IOEntry[]) => void) {
60
+ this.cb = cb
61
+ observers.push(this)
62
+ }
63
+ observe(el: Element) {
64
+ this.observed.push(el)
65
+ }
66
+ disconnect() {
67
+ this.disconnected = true
68
+ }
69
+ // Test helper: simulate the sentinel scrolling into / out of view.
70
+ fire(isIntersecting: boolean) {
71
+ this.cb([{ isIntersecting }])
72
+ }
73
+ }
74
+
75
+ function Harness({
76
+ onLoadMore,
77
+ disabled,
78
+ }: {
79
+ onLoadMore: () => void
80
+ disabled?: boolean
81
+ }) {
82
+ const { rootRef, sentinelRef } = useInfiniteScrollSentinel({ onLoadMore, disabled })
83
+ return (
84
+ <div ref={rootRef}>
85
+ <div ref={sentinelRef} data-testid="sentinel" />
86
+ </div>
87
+ )
88
+ }
89
+
90
+ describe('useInfiniteScrollSentinel', () => {
91
+ beforeEach(() => {
92
+ observers = []
93
+ ;(globalThis as any).IntersectionObserver = FakeIO
94
+ })
95
+ afterEach(() => {
96
+ delete (globalThis as any).IntersectionObserver
97
+ })
98
+
99
+ it('fires onLoadMore when the sentinel intersects', () => {
100
+ const onLoadMore = vi.fn()
101
+ render(<Harness onLoadMore={onLoadMore} />)
102
+ expect(observers).toHaveLength(1)
103
+ observers[0].fire(true)
104
+ expect(onLoadMore).toHaveBeenCalledTimes(1)
105
+ })
106
+
107
+ it('does NOT fire when disabled (load in flight / no more pages)', () => {
108
+ const onLoadMore = vi.fn()
109
+ render(<Harness onLoadMore={onLoadMore} disabled />)
110
+ observers[0].fire(true)
111
+ expect(onLoadMore).not.toHaveBeenCalled()
112
+ })
113
+
114
+ it('ignores non-intersecting callbacks (scrolling away)', () => {
115
+ const onLoadMore = vi.fn()
116
+ render(<Harness onLoadMore={onLoadMore} />)
117
+ observers[0].fire(false)
118
+ expect(onLoadMore).not.toHaveBeenCalled()
119
+ })
120
+
121
+ it('reads the LATEST onLoadMore/disabled through a ref (no observer churn)', () => {
122
+ // A parent that flips `disabled` from true -> false without remounting;
123
+ // the observer must not be torn down, and the newest callback wins.
124
+ const calls: string[] = []
125
+ function Parent() {
126
+ const [enabled, setEnabled] = useState(false)
127
+ useEffect(() => {
128
+ setEnabled(true)
129
+ }, [])
130
+ return (
131
+ <Harness onLoadMore={() => calls.push('load')} disabled={!enabled} />
132
+ )
133
+ }
134
+ render(<Parent />)
135
+ // Only ONE observer instance across the disabled->enabled flip.
136
+ expect(observers).toHaveLength(1)
137
+ observers[0].fire(true)
138
+ expect(calls).toEqual(['load'])
139
+ })
140
+
141
+ it('degrades to a no-op when IntersectionObserver is unavailable', () => {
142
+ delete (globalThis as any).IntersectionObserver
143
+ const onLoadMore = vi.fn()
144
+ // Must render without throwing even though there is no IO.
145
+ expect(() => render(<Harness onLoadMore={onLoadMore} />)).not.toThrow()
146
+ })
147
+ })