Skip to content

Commit 8401116

Browse files
committed
test: add tests for useInfiniteScroll hook
1 parent 0ea92ce commit 8401116

1 file changed

Lines changed: 168 additions & 0 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { RefObject } from 'react';
2+
import { render, cleanup, act } from '@testing-library/react';
3+
import { useInfiniteScroll, UseInfiniteScrollOptions } from '../index';
4+
import { MockIntersectionObserver } from './setup/intersectionObserverMock';
5+
6+
function HookWrapper(props: UseInfiniteScrollOptions) {
7+
const { sentinelRef, isLoading } = useInfiniteScroll(props);
8+
return (
9+
<div>
10+
<div
11+
ref={sentinelRef as RefObject<HTMLDivElement>}
12+
data-testid="sentinel"
13+
/>
14+
{isLoading && <div data-testid="loader" />}
15+
</div>
16+
);
17+
}
18+
19+
describe('useInfiniteScroll hook', () => {
20+
beforeEach(() => {
21+
MockIntersectionObserver.instances = [];
22+
});
23+
24+
afterEach(cleanup);
25+
26+
it('calls next when sentinel intersects', () => {
27+
const next = jest.fn();
28+
render(<HookWrapper next={next} hasMore={true} dataLength={10} />);
29+
30+
act(() => {
31+
MockIntersectionObserver.instances[0].triggerIntersect();
32+
});
33+
34+
expect(next).toHaveBeenCalledTimes(1);
35+
});
36+
37+
it('does not create an observer when hasMore is false', () => {
38+
render(<HookWrapper next={jest.fn()} hasMore={false} dataLength={10} />);
39+
expect(MockIntersectionObserver.instances).toHaveLength(0);
40+
});
41+
42+
it('does not call next twice before dataLength changes (load guard)', () => {
43+
const next = jest.fn();
44+
render(<HookWrapper next={next} hasMore={true} dataLength={10} />);
45+
46+
const observer = MockIntersectionObserver.instances[0];
47+
act(() => {
48+
observer.triggerIntersect();
49+
observer.triggerIntersect();
50+
});
51+
52+
expect(next).toHaveBeenCalledTimes(1);
53+
});
54+
55+
it('isLoading is true after sentinel fires and false after dataLength changes', () => {
56+
const next = jest.fn();
57+
const { getByTestId, queryByTestId, rerender } = render(
58+
<HookWrapper next={next} hasMore={true} dataLength={10} />
59+
);
60+
61+
expect(queryByTestId('loader')).toBeNull();
62+
63+
act(() => {
64+
MockIntersectionObserver.instances[0].triggerIntersect();
65+
});
66+
67+
expect(getByTestId('loader')).toBeTruthy();
68+
69+
rerender(<HookWrapper next={next} hasMore={true} dataLength={20} />);
70+
71+
expect(queryByTestId('loader')).toBeNull();
72+
});
73+
74+
it('resets load guard after dataLength changes so next can fire again', () => {
75+
const next = jest.fn();
76+
const { rerender } = render(
77+
<HookWrapper next={next} hasMore={true} dataLength={10} />
78+
);
79+
80+
act(() => {
81+
MockIntersectionObserver.instances[0].triggerIntersect();
82+
});
83+
expect(next).toHaveBeenCalledTimes(1);
84+
85+
rerender(<HookWrapper next={next} hasMore={true} dataLength={20} />);
86+
87+
act(() => {
88+
MockIntersectionObserver.instances[0].triggerIntersect();
89+
});
90+
expect(next).toHaveBeenCalledTimes(2);
91+
});
92+
93+
it('uses scrollableTarget string id as the IO root', () => {
94+
const target = document.createElement('div');
95+
target.id = 'hookScroll';
96+
document.body.appendChild(target);
97+
98+
render(
99+
<HookWrapper
100+
next={jest.fn()}
101+
hasMore={true}
102+
dataLength={5}
103+
scrollableTarget="hookScroll"
104+
/>
105+
);
106+
107+
expect(MockIntersectionObserver.instances[0].options.root).toBe(target);
108+
document.body.removeChild(target);
109+
});
110+
111+
it('accepts an HTMLElement directly as scrollableTarget', () => {
112+
const target = document.createElement('div');
113+
document.body.appendChild(target);
114+
115+
render(
116+
<HookWrapper
117+
next={jest.fn()}
118+
hasMore={true}
119+
dataLength={5}
120+
scrollableTarget={target}
121+
/>
122+
);
123+
124+
expect(MockIntersectionObserver.instances[0].options.root).toBe(target);
125+
document.body.removeChild(target);
126+
});
127+
128+
it('applies top rootMargin in inverse mode', () => {
129+
render(
130+
<HookWrapper
131+
next={jest.fn()}
132+
hasMore={true}
133+
dataLength={5}
134+
inverse={true}
135+
scrollThreshold={0.8}
136+
/>
137+
);
138+
139+
const { rootMargin } = MockIntersectionObserver.instances[0].options;
140+
expect(rootMargin).toBe('20% 0px 0px 0px');
141+
});
142+
143+
it('applies bottom rootMargin in normal (non-inverse) mode', () => {
144+
render(
145+
<HookWrapper
146+
next={jest.fn()}
147+
hasMore={true}
148+
dataLength={5}
149+
scrollThreshold={0.8}
150+
/>
151+
);
152+
153+
const { rootMargin } = MockIntersectionObserver.instances[0].options;
154+
expect(rootMargin).toBe('0px 0px 20% 0px');
155+
});
156+
157+
it('does not throw when IntersectionObserver is unavailable (SSR)', () => {
158+
const g = globalThis as Record<string, unknown>;
159+
const original = g['IntersectionObserver'];
160+
delete g['IntersectionObserver'];
161+
162+
expect(() => {
163+
render(<HookWrapper next={jest.fn()} hasMore={true} dataLength={5} />);
164+
}).not.toThrow();
165+
166+
g['IntersectionObserver'] = original;
167+
});
168+
});

0 commit comments

Comments
 (0)