Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions src/__tests__/useInfiniteScroll.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { RefObject } from 'react';
import { render, cleanup, act } from '@testing-library/react';
import { useInfiniteScroll, UseInfiniteScrollOptions } from '../index';
import { MockIntersectionObserver } from './setup/intersectionObserverMock';

function HookWrapper(props: UseInfiniteScrollOptions) {
const { sentinelRef, isLoading } = useInfiniteScroll(props);
return (
<div>
<div
ref={sentinelRef as RefObject<HTMLDivElement>}
data-testid="sentinel"
/>
{isLoading && <div data-testid="loader" />}
</div>
);
}

describe('useInfiniteScroll hook', () => {
beforeEach(() => {
MockIntersectionObserver.instances = [];
});

afterEach(cleanup);

it('calls next when sentinel intersects', () => {
const next = jest.fn();
render(<HookWrapper next={next} hasMore={true} dataLength={10} />);

act(() => {
MockIntersectionObserver.instances[0].triggerIntersect();
});

expect(next).toHaveBeenCalledTimes(1);
});

it('does not create an observer when hasMore is false', () => {
render(<HookWrapper next={jest.fn()} hasMore={false} dataLength={10} />);
expect(MockIntersectionObserver.instances).toHaveLength(0);
});

it('does not call next twice before dataLength changes (load guard)', () => {
const next = jest.fn();
render(<HookWrapper next={next} hasMore={true} dataLength={10} />);

const observer = MockIntersectionObserver.instances[0];
act(() => {
observer.triggerIntersect();
observer.triggerIntersect();
});

expect(next).toHaveBeenCalledTimes(1);
});

it('isLoading is true after sentinel fires and false after dataLength changes', () => {
const next = jest.fn();
const { getByTestId, queryByTestId, rerender } = render(
<HookWrapper next={next} hasMore={true} dataLength={10} />
);

expect(queryByTestId('loader')).toBeNull();

act(() => {
MockIntersectionObserver.instances[0].triggerIntersect();
});

expect(getByTestId('loader')).toBeTruthy();

rerender(<HookWrapper next={next} hasMore={true} dataLength={20} />);

expect(queryByTestId('loader')).toBeNull();
});

it('resets load guard after dataLength changes so next can fire again', () => {
const next = jest.fn();
const { rerender } = render(
<HookWrapper next={next} hasMore={true} dataLength={10} />
);

act(() => {
MockIntersectionObserver.instances[0].triggerIntersect();
});
expect(next).toHaveBeenCalledTimes(1);

rerender(<HookWrapper next={next} hasMore={true} dataLength={20} />);

act(() => {
MockIntersectionObserver.instances[0].triggerIntersect();
});
expect(next).toHaveBeenCalledTimes(2);
});

it('uses scrollableTarget string id as the IO root', () => {
const target = document.createElement('div');
target.id = 'hookScroll';
document.body.appendChild(target);

render(
<HookWrapper
next={jest.fn()}
hasMore={true}
dataLength={5}
scrollableTarget="hookScroll"
/>
);

expect(MockIntersectionObserver.instances[0].options.root).toBe(target);
document.body.removeChild(target);
});

it('accepts an HTMLElement directly as scrollableTarget', () => {
const target = document.createElement('div');
document.body.appendChild(target);

render(
<HookWrapper
next={jest.fn()}
hasMore={true}
dataLength={5}
scrollableTarget={target}
/>
);

expect(MockIntersectionObserver.instances[0].options.root).toBe(target);
document.body.removeChild(target);
});

it('applies top rootMargin in inverse mode', () => {
render(
<HookWrapper
next={jest.fn()}
hasMore={true}
dataLength={5}
inverse={true}
scrollThreshold={0.8}
/>
);

const { rootMargin } = MockIntersectionObserver.instances[0].options;
expect(rootMargin).toBe('20% 0px 0px 0px');
});

it('applies bottom rootMargin in normal (non-inverse) mode', () => {
render(
<HookWrapper
next={jest.fn()}
hasMore={true}
dataLength={5}
scrollThreshold={0.8}
/>
);

const { rootMargin } = MockIntersectionObserver.instances[0].options;
expect(rootMargin).toBe('0px 0px 20% 0px');
});

it('does not create an observer when sentinel ref is not attached', () => {
function NoRefWrapper(props: UseInfiniteScrollOptions) {
useInfiniteScroll(props);
return <div />;
}
render(<NoRefWrapper next={jest.fn()} hasMore={true} dataLength={5} />);
expect(MockIntersectionObserver.instances).toHaveLength(0);
});

it('does not throw when IntersectionObserver is unavailable (SSR)', () => {
const g = globalThis as Record<string, unknown>;
const original = g['IntersectionObserver'];
delete g['IntersectionObserver'];

expect(() => {
render(<HookWrapper next={jest.fn()} hasMore={true} dataLength={5} />);
}).not.toThrow();

g['IntersectionObserver'] = original;
});
});
88 changes: 87 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,114 @@ import {
} from 'react';
import { buildRootMargin } from './utils/buildRootMargin';

export { useInfiniteScroll } from './useInfiniteScroll';
export type {
UseInfiniteScrollOptions,
UseInfiniteScrollResult,
} from './useInfiniteScroll';

type Fn = () => any;

export interface Props {
/**
* Total number of items currently rendered. Unlocks the next load when it
* changes. Always pass the length of your full accumulated list, not just
* the most recently fetched page.
*/
dataLength: number;
/**
* Called when the user scrolls near the end of the list. Must append new
* items to your list state (not replace them). The component calls this at
* most once per data load, guarded by an IntersectionObserver sentinel.
*/
next: Fn;
/**
* Whether more data exists to load. When false, the observer stops and
* `endMessage` is shown instead of `loader`.
*/
hasMore: boolean;
/**
* The full accumulated list of items to render. Pass every item loaded so
* far — the component is not paginated internally.
*/
children: ReactNode;
/**
* Element shown while the next page is being fetched (while `hasMore` is
* true and `next` has been triggered).
*/
loader: ReactNode;
/**
* How close to the end of the list before `next` fires.
* - Number 0–1: fraction of container height, e.g. `0.8` triggers at 80%
* scrolled (default).
* - Pixel string: absolute offset, e.g. `"200px"` triggers 200 px before
* the end.
* @default 0.8
*/
scrollThreshold?: number | string;
/** Shown below the list once `hasMore` is false. */
endMessage?: ReactNode;
/** Inline styles applied to the inner scroll container. */
style?: CSSProperties;
/**
* Fixed height for the scroll container. When provided, a scrollable box
* of this height is rendered. Omit to scroll the window (document body).
*/
height?: number | string;
/**
* A scrollable parent element that already provides overflow scrollbars.
* Accepts a DOM element reference or the element's string `id`. Pass this
* instead of `height` when the scroll container is owned by the parent.
*
* @example
* // string id
* scrollableTarget="scrollableDiv"
*
* @example
* // ref value
* const ref = useRef(null);
* <div ref={ref}><InfiniteScroll scrollableTarget={ref.current} /></div>
*/
scrollableTarget?: HTMLElement | string | null;
/**
* Set to `true` when `children` is not a plain array (e.g. a single node
* or a fragment). Prevents the component from treating a length of 0 as
* "no items loaded".
*/
hasChildren?: boolean;
/**
* Reverse the scroll direction: the sentinel is placed at the top of the
* list and `next` loads older content upward. Use with
* `flexDirection: 'column-reverse'` on the scroll container for chat or
* messaging UIs.
* @default false
*/
inverse?: boolean;
/**
* Enable pull-down-to-refresh on touch and mouse. Requires
* `refreshFunction` to be provided.
* @default false
*/
pullDownToRefresh?: boolean;
/** Content shown while the user is pulling down. @default <h3>Pull down to refresh</h3> */
pullDownToRefreshContent?: ReactNode;
/** Content shown when the pull threshold is breached. @default <h3>Release to refresh</h3> */
releaseToRefreshContent?: ReactNode;
/**
* Minimum pixels the user must pull before `refreshFunction` fires.
* @default 100
*/
pullDownToRefreshThreshold?: number;
/**
* Called when the pull-to-refresh threshold is breached. Should reload or
* reset the list to fresh data.
*/
refreshFunction?: Fn;
/** Called on every scroll event of the scroll container. */
onScroll?: (e: UIEvent) => any;
dataLength: number;
/** Scroll Y position (in pixels) to restore when the component mounts. */
initialScrollY?: number;
/** CSS class name added to the inner scroll container element. */
className?: string;
}

Expand Down
72 changes: 72 additions & 0 deletions src/stories/UseInfiniteScrollHook.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { useInfiniteScroll } from '../index';

const itemStyle = {
height: 30,
border: '1px solid steelblue',
margin: 6,
padding: 8,
borderRadius: 4,
};

const containerStyle: React.CSSProperties = {
height: 400,
overflow: 'auto',
border: '2px solid steelblue',
borderRadius: 4,
};

export default function UseInfiniteScrollHook() {
const [items, setItems] = useState<number[]>(() =>
Array.from({ length: 20 }, (_, i) => i)
);
const [hasMore, setHasMore] = useState(true);

const { sentinelRef, isLoading } = useInfiniteScroll({
next: () => {
setTimeout(() => {
setItems((prev) => {
const next = Array.from({ length: 20 }, (_, i) => prev.length + i);
if (prev.length + next.length >= 200) setHasMore(false);
return [...prev, ...next];
});
}, 800);
},
hasMore,
dataLength: items.length,
});

return (
<div>
<h1>useInfiniteScroll hook, custom UI</h1>
<p style={{ color: '#666', fontSize: 13 }}>
Fully custom markup. The hook manages the observer; you own the layout.
</p>
<hr />
<div id="hookScrollTarget" style={containerStyle}>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{items.map((n) => (
<li key={n} style={itemStyle}>
item #{n + 1}
</li>
))}
<li
ref={sentinelRef}
aria-hidden="true"
style={{ height: 1, padding: 0 }}
/>
{isLoading && (
<li style={{ textAlign: 'center', padding: 12, color: '#888' }}>
Loading more items...
</li>
)}
{!hasMore && (
<li style={{ textAlign: 'center', padding: 12, color: '#aaa' }}>
All items loaded.
</li>
)}
</ul>
</div>
</div>
);
}
8 changes: 7 additions & 1 deletion src/stories/stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import WindowInf from './WindowInfiniteScrollComponent';
import PullDownToRefreshInfScroll from './PullDownToRefreshInfScroll';
import InfiniteScrollWithHeight from './InfiniteScrollWithHeight';
import ScrollableTargetInfiniteScroll from './ScrollableTargetInfScroll';
import ScrollableTop from './ScrollableTop';
import ScrolleableTop from './ScrolleableTop';
import UseInfiniteScrollHook from './UseInfiniteScrollHook';

const meta: Meta = {
title: 'Components',
Expand Down Expand Up @@ -38,3 +39,8 @@ export const InfiniteScrollTop: Story = {
name: 'InfiniteScrollTop',
render: () => <ScrollableTop />,
};

export const UseInfiniteScrollHookStory: Story = {
name: 'useInfiniteScroll (hook)',
render: () => <UseInfiniteScrollHook />,
};
Loading
Loading