@usefy/use-infinite-scroll 0.20.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 +144 -0
- package/dist/index.d.mts +137 -0
- package/dist/index.d.ts +137 -0
- package/dist/index.js +94 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +69 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/mirunamu00/usefy/master/assets/logo.png" alt="usefy logo" width="120" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">@usefy/use-infinite-scroll</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>Sentinel-driven infinite loading built on IntersectionObserver — attach one ref, load more automatically</strong>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/@usefy/use-infinite-scroll"><img src="https://img.shields.io/npm/v/@usefy/use-infinite-scroll.svg?style=flat-square&color=007acc" alt="npm version" /></a>
|
|
13
|
+
<a href="https://www.npmjs.com/package/@usefy/use-infinite-scroll"><img src="https://img.shields.io/npm/dm/@usefy/use-infinite-scroll.svg?style=flat-square&color=007acc" alt="npm downloads" /></a>
|
|
14
|
+
<a href="https://bundlephobia.com/package/@usefy/use-infinite-scroll"><img src="https://img.shields.io/bundlephobia/minzip/@usefy/use-infinite-scroll?style=flat-square&color=007acc" alt="bundle size" /></a>
|
|
15
|
+
<a href="https://github.com/mirunamu00/usefy/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/@usefy/use-infinite-scroll.svg?style=flat-square&color=007acc" alt="license" /></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="#installation">Installation</a> •
|
|
20
|
+
<a href="#quick-start">Quick Start</a> •
|
|
21
|
+
<a href="#api">API</a> •
|
|
22
|
+
<a href="#testing">Testing</a> •
|
|
23
|
+
<a href="#license">License</a>
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<a href="https://mirunamu00.github.io/usefy/?path=/docs/hooks-useinfinitescroll--docs" target="_blank" rel="noopener noreferrer">
|
|
28
|
+
<strong>📚 View Storybook Demo</strong>
|
|
29
|
+
</a>
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Overview
|
|
35
|
+
|
|
36
|
+
`useInfiniteScroll` is part of the [@usefy](https://www.npmjs.com/org/usefy) ecosystem — a collection of production-ready, TypeScript-first, SSR-safe React hooks. It turns "load the next page when the user reaches the bottom" into a one-liner: render a small **sentinel** element at the end of your list, attach the returned ref, and your `loadMore` callback fires whenever that sentinel scrolls into view — once per intersection, never while a load is already in flight.
|
|
37
|
+
|
|
38
|
+
Built on top of [`@usefy/use-intersection-observer`](https://www.npmjs.com/package/@usefy/use-intersection-observer), so it inherits its SSR-safe and StrictMode-safe behavior — no scroll listeners, no offset math.
|
|
39
|
+
|
|
40
|
+
## Features
|
|
41
|
+
|
|
42
|
+
- **One ref, that's it** — `const ref = useInfiniteScroll(loadMore, { hasMore, loading })`; attach `ref` to a sentinel
|
|
43
|
+
- **Fires once per intersection** — it does not re-fire while the sentinel stays in view (the sentinel must leave and re-enter)
|
|
44
|
+
- **Respects `hasMore` / `loading` / `enabled`** — stops observing entirely once exhausted or disabled
|
|
45
|
+
- **No double-fire on async loads** — honours the `loading` flag *and* an internal in-flight guard: if `loadMore` returns a promise, it won't fire again until it settles
|
|
46
|
+
- **Latest-callback pattern** — changing `loadMore` (or the flags) never re-subscribes the observer
|
|
47
|
+
- **Prefetch support** — `rootMargin` fires `loadMore` before the sentinel is on screen; custom `root`, `threshold` supported
|
|
48
|
+
- **SSR-safe & StrictMode-safe** — returns an inert no-op ref on the server; no observer leaks
|
|
49
|
+
- **TypeScript-first** — full type inference and exported types
|
|
50
|
+
- **Tiny & tree-shakeable** — one small dependency, published as its own package
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# npm
|
|
56
|
+
npm install @usefy/use-infinite-scroll
|
|
57
|
+
|
|
58
|
+
# yarn
|
|
59
|
+
yarn add @usefy/use-infinite-scroll
|
|
60
|
+
|
|
61
|
+
# pnpm
|
|
62
|
+
pnpm add @usefy/use-infinite-scroll
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Requires React 18 or 19 (`peerDependencies: "react": "^18.0.0 || ^19.0.0"`).
|
|
66
|
+
|
|
67
|
+
## Quick Start
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import { useState } from "react";
|
|
71
|
+
import { useInfiniteScroll } from "@usefy/use-infinite-scroll";
|
|
72
|
+
|
|
73
|
+
function Feed() {
|
|
74
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
75
|
+
const [loading, setLoading] = useState(false);
|
|
76
|
+
const [hasMore, setHasMore] = useState(true);
|
|
77
|
+
|
|
78
|
+
const loadMore = async () => {
|
|
79
|
+
setLoading(true);
|
|
80
|
+
const { data, done } = await fetchNextPage(items.length);
|
|
81
|
+
setItems((prev) => [...prev, ...data]);
|
|
82
|
+
setHasMore(!done);
|
|
83
|
+
setLoading(false);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<ul>
|
|
90
|
+
{items.map((item) => (
|
|
91
|
+
<li key={item.id}>{item.title}</li>
|
|
92
|
+
))}
|
|
93
|
+
{/* Render the sentinel only while there is more to load. */}
|
|
94
|
+
{hasMore && <li ref={sentinelRef} aria-hidden />}
|
|
95
|
+
</ul>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## API
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const sentinelRef = useInfiniteScroll(loadMore, options?);
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Parameters
|
|
107
|
+
|
|
108
|
+
| Parameter | Type | Description |
|
|
109
|
+
| --- | --- | --- |
|
|
110
|
+
| `loadMore` | `() => void \| Promise<void>` | Called to load the next page when the sentinel enters view. May be sync or async — when it returns a promise, the hook treats the load as in-flight and won't fire again until it settles. Changing this reference never re-subscribes the observer. |
|
|
111
|
+
| `options` | `UseInfiniteScrollOptions` | Optional configuration (see below). |
|
|
112
|
+
|
|
113
|
+
### Options — `UseInfiniteScrollOptions`
|
|
114
|
+
|
|
115
|
+
| Option | Type | Default | Description |
|
|
116
|
+
| --- | --- | --- | --- |
|
|
117
|
+
| `hasMore` | `boolean` | `true` | Whether there is more to load. When `false`, the sentinel is **no longer observed** and `loadMore` never fires. Set it `false` after the last page. |
|
|
118
|
+
| `loading` | `boolean` | `false` | Whether a load is in progress. When `true`, an intersection will not trigger `loadMore`. Wire this to your own loading state. |
|
|
119
|
+
| `enabled` | `boolean` | `true` | Master switch. When `false`, the sentinel is not observed and `loadMore` never fires, regardless of `hasMore`/`loading`. |
|
|
120
|
+
| `rootMargin` | `string` | `"0px"` | CSS margin around the root. A positive value like `"300px"` fires `loadMore` *before* the sentinel is on screen (prefetch). |
|
|
121
|
+
| `threshold` | `number \| number[]` | `0` | Intersection ratio(s) that trigger a load. `0` fires as soon as a single pixel is visible. |
|
|
122
|
+
| `root` | `Element \| Document \| null` | `null` | The scroll container used as the observer root. `null` uses the browser viewport; pass a scrollable element to run infinite scroll inside a fixed-height panel. |
|
|
123
|
+
|
|
124
|
+
### Returns — `UseInfiniteScrollRef`
|
|
125
|
+
|
|
126
|
+
A **callback ref** — `(node: Element | null) => void` — to attach to your sentinel element. It has a stable identity across renders, so it is safe to pass directly to a `ref` prop.
|
|
127
|
+
|
|
128
|
+
### Behavior notes
|
|
129
|
+
|
|
130
|
+
- **Once per intersection.** `loadMore` fires when the sentinel *enters* view; it does not re-fire while the sentinel stays visible. If a load doesn't fill the viewport and the sentinel is still visible, the user scrolls (or the sentinel re-enters) to trigger the next page — this matches native infinite-scroll UX and avoids runaway loops.
|
|
131
|
+
- **No double-fire.** Two guards prevent overlapping loads: the `loading` prop you control, and an internal in-flight guard for the async case (a second intersection while the returned promise is pending is ignored). The hook does **not** surface `loadMore` errors — handle them inside `loadMore` (e.g. `try/catch` and reset your `loading` state).
|
|
132
|
+
- **Stops observing when exhausted.** Once `hasMore` is `false` (or `enabled` is `false`), the underlying observer disconnects — no wasted work.
|
|
133
|
+
- **SSR / StrictMode.** On the server (or where `IntersectionObserver` is unavailable) the returned ref is an inert no-op and nothing fires. Under StrictMode's double-mount the observer is set up and torn down cleanly.
|
|
134
|
+
- **Memoize `threshold` / `root`.** Changing `loadMore` and the `hasMore` / `loading` / `enabled` flags never re-subscribes the observer, but `threshold` and `root` are observer configuration — passing a fresh inline array (`threshold={[0, 0.5]}`) or element every render re-subscribes it. Hoist them to a constant or `useMemo`/ref if they are non-primitive.
|
|
135
|
+
|
|
136
|
+
## Testing
|
|
137
|
+
|
|
138
|
+
📊 <a href="https://mirunamu00.github.io/usefy/coverage/use-infinite-scroll/src/index.html" target="_blank" rel="noopener noreferrer"><strong>View Detailed Coverage Report</strong></a> (GitHub Pages) — **19 tests**, 100% statement coverage.
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT © [mirunamu](https://github.com/mirunamu00)
|
|
143
|
+
|
|
144
|
+
This package is part of the [usefy](https://github.com/mirunamu00/usefy) monorepo.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The function invoked to load the next page of data.
|
|
3
|
+
*
|
|
4
|
+
* It may be synchronous (`void`) or asynchronous (`Promise<void>`). When it
|
|
5
|
+
* returns a promise, `useInfiniteScroll` tracks it as in-flight and will not
|
|
6
|
+
* fire again until it settles, even if the sentinel re-enters view in the
|
|
7
|
+
* meantime — an internal guard on top of the `loading` flag.
|
|
8
|
+
*/
|
|
9
|
+
type LoadMoreFn = () => void | Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Options for configuring the {@link useInfiniteScroll} hook.
|
|
12
|
+
*/
|
|
13
|
+
interface UseInfiniteScrollOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Whether there is more data to load. When `false`, the sentinel is no longer
|
|
16
|
+
* observed and `loadMore` will never fire — set it `false` once the last page
|
|
17
|
+
* has been reached.
|
|
18
|
+
* @default true
|
|
19
|
+
*/
|
|
20
|
+
hasMore?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Whether a load is currently in progress. When `true`, an intersection will
|
|
23
|
+
* not trigger `loadMore`, preventing overlapping requests. Wire this to your
|
|
24
|
+
* own loading state.
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
loading?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Master enable/disable switch. When `false`, the sentinel is not observed
|
|
30
|
+
* and `loadMore` never fires, regardless of `hasMore`/`loading`.
|
|
31
|
+
* @default true
|
|
32
|
+
*/
|
|
33
|
+
enabled?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Margin around the root, in CSS margin syntax, used to grow (or shrink) the
|
|
36
|
+
* area that triggers a load. A positive margin like `"200px"` fires
|
|
37
|
+
* `loadMore` *before* the sentinel is actually on screen, prefetching the
|
|
38
|
+
* next page for a seamless scroll.
|
|
39
|
+
* @default "0px"
|
|
40
|
+
*/
|
|
41
|
+
rootMargin?: string;
|
|
42
|
+
/**
|
|
43
|
+
* The intersection ratio(s) at which a load is triggered. `0` fires as soon
|
|
44
|
+
* as a single pixel of the sentinel is visible.
|
|
45
|
+
* @default 0
|
|
46
|
+
*/
|
|
47
|
+
threshold?: number | number[];
|
|
48
|
+
/**
|
|
49
|
+
* The scroll container used as the observer root. `null` (default) uses the
|
|
50
|
+
* browser viewport. Pass a scrollable element (e.g. `containerRef.current`)
|
|
51
|
+
* to run infinite scroll inside a fixed-height panel.
|
|
52
|
+
* @default null
|
|
53
|
+
*/
|
|
54
|
+
root?: Element | Document | null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The value returned by {@link useInfiniteScroll}: a callback ref to attach to
|
|
58
|
+
* the sentinel element (typically an empty `<div>` at the end of the list).
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```tsx
|
|
62
|
+
* const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });
|
|
63
|
+
* return <div ref={sentinelRef} />;
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
type UseInfiniteScrollRef = (node: Element | null) => void;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sentinel-driven infinite scrolling built on the Intersection Observer API.
|
|
70
|
+
*
|
|
71
|
+
* Attach the returned callback ref to a small sentinel element rendered at the
|
|
72
|
+
* end of your list. Whenever that sentinel scrolls into view — and there is
|
|
73
|
+
* more to load, nothing is currently loading, and the hook is enabled — your
|
|
74
|
+
* `loadMore` callback fires exactly **once** per intersection.
|
|
75
|
+
*
|
|
76
|
+
* Built on top of {@link useIntersectionObserver}, so it inherits its SSR-safe
|
|
77
|
+
* (returns an inert no-op ref on the server) and StrictMode-safe behavior.
|
|
78
|
+
*
|
|
79
|
+
* Key guarantees:
|
|
80
|
+
* - **Fires once per intersection.** It does not re-fire while the sentinel
|
|
81
|
+
* stays in view; the sentinel must leave and re-enter to trigger again.
|
|
82
|
+
* - **Latest-callback pattern.** Changing `loadMore` (or the `hasMore` /
|
|
83
|
+
* `loading` / `enabled` flags) never re-subscribes the observer.
|
|
84
|
+
* - **No double-fire while loading.** Respects the `loading` flag *and* an
|
|
85
|
+
* internal in-flight guard: if `loadMore` returns a promise, the hook will
|
|
86
|
+
* not fire again until it settles.
|
|
87
|
+
* - **Stops observing when exhausted.** Once `hasMore` is `false` (or `enabled`
|
|
88
|
+
* is `false`), the observer disconnects entirely.
|
|
89
|
+
*
|
|
90
|
+
* @param loadMore - Called to load the next page. May be sync or async.
|
|
91
|
+
* @param options - Configuration (`hasMore`, `loading`, `enabled`, `rootMargin`,
|
|
92
|
+
* `threshold`, `root`).
|
|
93
|
+
* @returns A callback ref to attach to the sentinel element.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```tsx
|
|
97
|
+
* function Feed() {
|
|
98
|
+
* const [items, setItems] = useState<Item[]>([]);
|
|
99
|
+
* const [loading, setLoading] = useState(false);
|
|
100
|
+
* const [hasMore, setHasMore] = useState(true);
|
|
101
|
+
*
|
|
102
|
+
* const loadMore = async () => {
|
|
103
|
+
* setLoading(true);
|
|
104
|
+
* const { data, done } = await fetchNextPage(items.length);
|
|
105
|
+
* setItems((prev) => [...prev, ...data]);
|
|
106
|
+
* setHasMore(!done);
|
|
107
|
+
* setLoading(false);
|
|
108
|
+
* };
|
|
109
|
+
*
|
|
110
|
+
* const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });
|
|
111
|
+
*
|
|
112
|
+
* return (
|
|
113
|
+
* <ul>
|
|
114
|
+
* {items.map((item) => (
|
|
115
|
+
* <li key={item.id}>{item.title}</li>
|
|
116
|
+
* ))}
|
|
117
|
+
* {hasMore && <li ref={sentinelRef} aria-hidden />}
|
|
118
|
+
* </ul>
|
|
119
|
+
* );
|
|
120
|
+
* }
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* ```tsx
|
|
125
|
+
* // Prefetch 300px early, inside a fixed-height scroll container.
|
|
126
|
+
* const containerRef = useRef<HTMLDivElement>(null);
|
|
127
|
+
* const sentinelRef = useInfiniteScroll(loadMore, {
|
|
128
|
+
* hasMore,
|
|
129
|
+
* loading,
|
|
130
|
+
* root: containerRef.current,
|
|
131
|
+
* rootMargin: "300px",
|
|
132
|
+
* });
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
declare function useInfiniteScroll(loadMore: LoadMoreFn, options?: UseInfiniteScrollOptions): UseInfiniteScrollRef;
|
|
136
|
+
|
|
137
|
+
export { type LoadMoreFn, type UseInfiniteScrollOptions, type UseInfiniteScrollRef, useInfiniteScroll };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The function invoked to load the next page of data.
|
|
3
|
+
*
|
|
4
|
+
* It may be synchronous (`void`) or asynchronous (`Promise<void>`). When it
|
|
5
|
+
* returns a promise, `useInfiniteScroll` tracks it as in-flight and will not
|
|
6
|
+
* fire again until it settles, even if the sentinel re-enters view in the
|
|
7
|
+
* meantime — an internal guard on top of the `loading` flag.
|
|
8
|
+
*/
|
|
9
|
+
type LoadMoreFn = () => void | Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Options for configuring the {@link useInfiniteScroll} hook.
|
|
12
|
+
*/
|
|
13
|
+
interface UseInfiniteScrollOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Whether there is more data to load. When `false`, the sentinel is no longer
|
|
16
|
+
* observed and `loadMore` will never fire — set it `false` once the last page
|
|
17
|
+
* has been reached.
|
|
18
|
+
* @default true
|
|
19
|
+
*/
|
|
20
|
+
hasMore?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Whether a load is currently in progress. When `true`, an intersection will
|
|
23
|
+
* not trigger `loadMore`, preventing overlapping requests. Wire this to your
|
|
24
|
+
* own loading state.
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
loading?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Master enable/disable switch. When `false`, the sentinel is not observed
|
|
30
|
+
* and `loadMore` never fires, regardless of `hasMore`/`loading`.
|
|
31
|
+
* @default true
|
|
32
|
+
*/
|
|
33
|
+
enabled?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Margin around the root, in CSS margin syntax, used to grow (or shrink) the
|
|
36
|
+
* area that triggers a load. A positive margin like `"200px"` fires
|
|
37
|
+
* `loadMore` *before* the sentinel is actually on screen, prefetching the
|
|
38
|
+
* next page for a seamless scroll.
|
|
39
|
+
* @default "0px"
|
|
40
|
+
*/
|
|
41
|
+
rootMargin?: string;
|
|
42
|
+
/**
|
|
43
|
+
* The intersection ratio(s) at which a load is triggered. `0` fires as soon
|
|
44
|
+
* as a single pixel of the sentinel is visible.
|
|
45
|
+
* @default 0
|
|
46
|
+
*/
|
|
47
|
+
threshold?: number | number[];
|
|
48
|
+
/**
|
|
49
|
+
* The scroll container used as the observer root. `null` (default) uses the
|
|
50
|
+
* browser viewport. Pass a scrollable element (e.g. `containerRef.current`)
|
|
51
|
+
* to run infinite scroll inside a fixed-height panel.
|
|
52
|
+
* @default null
|
|
53
|
+
*/
|
|
54
|
+
root?: Element | Document | null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The value returned by {@link useInfiniteScroll}: a callback ref to attach to
|
|
58
|
+
* the sentinel element (typically an empty `<div>` at the end of the list).
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```tsx
|
|
62
|
+
* const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });
|
|
63
|
+
* return <div ref={sentinelRef} />;
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
type UseInfiniteScrollRef = (node: Element | null) => void;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sentinel-driven infinite scrolling built on the Intersection Observer API.
|
|
70
|
+
*
|
|
71
|
+
* Attach the returned callback ref to a small sentinel element rendered at the
|
|
72
|
+
* end of your list. Whenever that sentinel scrolls into view — and there is
|
|
73
|
+
* more to load, nothing is currently loading, and the hook is enabled — your
|
|
74
|
+
* `loadMore` callback fires exactly **once** per intersection.
|
|
75
|
+
*
|
|
76
|
+
* Built on top of {@link useIntersectionObserver}, so it inherits its SSR-safe
|
|
77
|
+
* (returns an inert no-op ref on the server) and StrictMode-safe behavior.
|
|
78
|
+
*
|
|
79
|
+
* Key guarantees:
|
|
80
|
+
* - **Fires once per intersection.** It does not re-fire while the sentinel
|
|
81
|
+
* stays in view; the sentinel must leave and re-enter to trigger again.
|
|
82
|
+
* - **Latest-callback pattern.** Changing `loadMore` (or the `hasMore` /
|
|
83
|
+
* `loading` / `enabled` flags) never re-subscribes the observer.
|
|
84
|
+
* - **No double-fire while loading.** Respects the `loading` flag *and* an
|
|
85
|
+
* internal in-flight guard: if `loadMore` returns a promise, the hook will
|
|
86
|
+
* not fire again until it settles.
|
|
87
|
+
* - **Stops observing when exhausted.** Once `hasMore` is `false` (or `enabled`
|
|
88
|
+
* is `false`), the observer disconnects entirely.
|
|
89
|
+
*
|
|
90
|
+
* @param loadMore - Called to load the next page. May be sync or async.
|
|
91
|
+
* @param options - Configuration (`hasMore`, `loading`, `enabled`, `rootMargin`,
|
|
92
|
+
* `threshold`, `root`).
|
|
93
|
+
* @returns A callback ref to attach to the sentinel element.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```tsx
|
|
97
|
+
* function Feed() {
|
|
98
|
+
* const [items, setItems] = useState<Item[]>([]);
|
|
99
|
+
* const [loading, setLoading] = useState(false);
|
|
100
|
+
* const [hasMore, setHasMore] = useState(true);
|
|
101
|
+
*
|
|
102
|
+
* const loadMore = async () => {
|
|
103
|
+
* setLoading(true);
|
|
104
|
+
* const { data, done } = await fetchNextPage(items.length);
|
|
105
|
+
* setItems((prev) => [...prev, ...data]);
|
|
106
|
+
* setHasMore(!done);
|
|
107
|
+
* setLoading(false);
|
|
108
|
+
* };
|
|
109
|
+
*
|
|
110
|
+
* const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });
|
|
111
|
+
*
|
|
112
|
+
* return (
|
|
113
|
+
* <ul>
|
|
114
|
+
* {items.map((item) => (
|
|
115
|
+
* <li key={item.id}>{item.title}</li>
|
|
116
|
+
* ))}
|
|
117
|
+
* {hasMore && <li ref={sentinelRef} aria-hidden />}
|
|
118
|
+
* </ul>
|
|
119
|
+
* );
|
|
120
|
+
* }
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* ```tsx
|
|
125
|
+
* // Prefetch 300px early, inside a fixed-height scroll container.
|
|
126
|
+
* const containerRef = useRef<HTMLDivElement>(null);
|
|
127
|
+
* const sentinelRef = useInfiniteScroll(loadMore, {
|
|
128
|
+
* hasMore,
|
|
129
|
+
* loading,
|
|
130
|
+
* root: containerRef.current,
|
|
131
|
+
* rootMargin: "300px",
|
|
132
|
+
* });
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
declare function useInfiniteScroll(loadMore: LoadMoreFn, options?: UseInfiniteScrollOptions): UseInfiniteScrollRef;
|
|
136
|
+
|
|
137
|
+
export { type LoadMoreFn, type UseInfiniteScrollOptions, type UseInfiniteScrollRef, useInfiniteScroll };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
useInfiniteScroll: () => useInfiniteScroll
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/useInfiniteScroll.ts
|
|
28
|
+
var import_react = require("react");
|
|
29
|
+
var import_use_intersection_observer = require("@usefy/use-intersection-observer");
|
|
30
|
+
function useInfiniteScroll(loadMore, options = {}) {
|
|
31
|
+
const {
|
|
32
|
+
hasMore = true,
|
|
33
|
+
loading = false,
|
|
34
|
+
enabled = true,
|
|
35
|
+
rootMargin = "0px",
|
|
36
|
+
threshold = 0,
|
|
37
|
+
root = null
|
|
38
|
+
} = options;
|
|
39
|
+
const loadMoreRef = (0, import_react.useRef)(loadMore);
|
|
40
|
+
loadMoreRef.current = loadMore;
|
|
41
|
+
const stateRef = (0, import_react.useRef)({ hasMore, loading, enabled });
|
|
42
|
+
stateRef.current = { hasMore, loading, enabled };
|
|
43
|
+
const inFlightRef = (0, import_react.useRef)(false);
|
|
44
|
+
const prevInViewRef = (0, import_react.useRef)(false);
|
|
45
|
+
const fireLoadMore = (0, import_react.useCallback)(() => {
|
|
46
|
+
const state = stateRef.current;
|
|
47
|
+
if (!state.enabled || !state.hasMore || state.loading || inFlightRef.current) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
inFlightRef.current = true;
|
|
51
|
+
let result;
|
|
52
|
+
try {
|
|
53
|
+
result = loadMoreRef.current();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
inFlightRef.current = false;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
if (result && typeof result.then === "function") {
|
|
59
|
+
const release = () => {
|
|
60
|
+
inFlightRef.current = false;
|
|
61
|
+
};
|
|
62
|
+
Promise.resolve(result).then(release, release);
|
|
63
|
+
} else {
|
|
64
|
+
inFlightRef.current = false;
|
|
65
|
+
}
|
|
66
|
+
}, []);
|
|
67
|
+
const handleChange = (0, import_react.useCallback)(
|
|
68
|
+
(_entry, inView) => {
|
|
69
|
+
if (inView && !prevInViewRef.current) {
|
|
70
|
+
fireLoadMore();
|
|
71
|
+
}
|
|
72
|
+
prevInViewRef.current = inView;
|
|
73
|
+
},
|
|
74
|
+
[fireLoadMore]
|
|
75
|
+
);
|
|
76
|
+
const { ref } = (0, import_use_intersection_observer.useIntersectionObserver)({
|
|
77
|
+
root,
|
|
78
|
+
rootMargin,
|
|
79
|
+
threshold,
|
|
80
|
+
// Disconnect the observer entirely when there is nothing more to load or the
|
|
81
|
+
// hook is disabled — there is no point observing a sentinel we will never
|
|
82
|
+
// act on. `loading` is intentionally *not* folded in here: we keep observing
|
|
83
|
+
// while a load is in flight (the fire guard handles it) so we don't lose the
|
|
84
|
+
// sentinel's position mid-request.
|
|
85
|
+
enabled: enabled && hasMore,
|
|
86
|
+
onChange: handleChange
|
|
87
|
+
});
|
|
88
|
+
return ref;
|
|
89
|
+
}
|
|
90
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
91
|
+
0 && (module.exports = {
|
|
92
|
+
useInfiniteScroll
|
|
93
|
+
});
|
|
94
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/useInfiniteScroll.ts"],"sourcesContent":["export { useInfiniteScroll } from \"./useInfiniteScroll\";\nexport type {\n LoadMoreFn,\n UseInfiniteScrollOptions,\n UseInfiniteScrollRef,\n} from \"./types\";\n","import { useCallback, useRef } from \"react\";\nimport {\n useIntersectionObserver,\n type OnChangeCallback,\n} from \"@usefy/use-intersection-observer\";\nimport type {\n LoadMoreFn,\n UseInfiniteScrollOptions,\n UseInfiniteScrollRef,\n} from \"./types\";\n\n/**\n * Sentinel-driven infinite scrolling built on the Intersection Observer API.\n *\n * Attach the returned callback ref to a small sentinel element rendered at the\n * end of your list. Whenever that sentinel scrolls into view — and there is\n * more to load, nothing is currently loading, and the hook is enabled — your\n * `loadMore` callback fires exactly **once** per intersection.\n *\n * Built on top of {@link useIntersectionObserver}, so it inherits its SSR-safe\n * (returns an inert no-op ref on the server) and StrictMode-safe behavior.\n *\n * Key guarantees:\n * - **Fires once per intersection.** It does not re-fire while the sentinel\n * stays in view; the sentinel must leave and re-enter to trigger again.\n * - **Latest-callback pattern.** Changing `loadMore` (or the `hasMore` /\n * `loading` / `enabled` flags) never re-subscribes the observer.\n * - **No double-fire while loading.** Respects the `loading` flag *and* an\n * internal in-flight guard: if `loadMore` returns a promise, the hook will\n * not fire again until it settles.\n * - **Stops observing when exhausted.** Once `hasMore` is `false` (or `enabled`\n * is `false`), the observer disconnects entirely.\n *\n * @param loadMore - Called to load the next page. May be sync or async.\n * @param options - Configuration (`hasMore`, `loading`, `enabled`, `rootMargin`,\n * `threshold`, `root`).\n * @returns A callback ref to attach to the sentinel element.\n *\n * @example\n * ```tsx\n * function Feed() {\n * const [items, setItems] = useState<Item[]>([]);\n * const [loading, setLoading] = useState(false);\n * const [hasMore, setHasMore] = useState(true);\n *\n * const loadMore = async () => {\n * setLoading(true);\n * const { data, done } = await fetchNextPage(items.length);\n * setItems((prev) => [...prev, ...data]);\n * setHasMore(!done);\n * setLoading(false);\n * };\n *\n * const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });\n *\n * return (\n * <ul>\n * {items.map((item) => (\n * <li key={item.id}>{item.title}</li>\n * ))}\n * {hasMore && <li ref={sentinelRef} aria-hidden />}\n * </ul>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Prefetch 300px early, inside a fixed-height scroll container.\n * const containerRef = useRef<HTMLDivElement>(null);\n * const sentinelRef = useInfiniteScroll(loadMore, {\n * hasMore,\n * loading,\n * root: containerRef.current,\n * rootMargin: \"300px\",\n * });\n * ```\n */\nexport function useInfiniteScroll(\n loadMore: LoadMoreFn,\n options: UseInfiniteScrollOptions = {}\n): UseInfiniteScrollRef {\n const {\n hasMore = true,\n loading = false,\n enabled = true,\n rootMargin = \"0px\",\n threshold = 0,\n root = null,\n } = options;\n\n // Latest-callback pattern: mirror the callback and flags into refs so that a\n // changing `loadMore` (or flags) never re-subscribes the observer.\n const loadMoreRef = useRef<LoadMoreFn>(loadMore);\n loadMoreRef.current = loadMore;\n\n const stateRef = useRef({ hasMore, loading, enabled });\n stateRef.current = { hasMore, loading, enabled };\n\n // Internal in-flight guard for the async case, independent of the `loading`\n // prop: prevents a second fire while a returned promise is still pending.\n const inFlightRef = useRef(false);\n\n // Tracks the sentinel's previous visibility so we fire on the `false -> true`\n // transition only. Without this, a multi-threshold observer (e.g.\n // `threshold: [0, 0.5, 1]`) emits several `inView === true` callbacks as the\n // sentinel scrolls in; a synchronous `loadMore` (one that never sets\n // `loading`) would otherwise fire once per crossing.\n const prevInViewRef = useRef(false);\n\n const fireLoadMore = useCallback(() => {\n const state = stateRef.current;\n if (!state.enabled || !state.hasMore || state.loading || inFlightRef.current) {\n return;\n }\n\n inFlightRef.current = true;\n\n let result: void | Promise<void>;\n try {\n result = loadMoreRef.current();\n } catch (error) {\n // Synchronous throw: release the guard so a later intersection can retry,\n // then rethrow (mirrors the caller invoking loadMore directly).\n inFlightRef.current = false;\n throw error;\n }\n\n if (result && typeof (result as Promise<void>).then === \"function\") {\n const release = () => {\n inFlightRef.current = false;\n };\n // Handle both settle paths so the guard is always released and a rejected\n // promise does not surface as an unhandled rejection. The hook does not\n // itself surface loadMore errors — handle them inside loadMore.\n Promise.resolve(result).then(release, release);\n } else {\n inFlightRef.current = false;\n }\n }, []);\n\n const handleChange = useCallback<OnChangeCallback>(\n (_entry, inView) => {\n // Fire only on the transition into view, so a multi-threshold observer\n // that emits several `inView === true` callbacks per entry triggers a\n // single load.\n if (inView && !prevInViewRef.current) {\n fireLoadMore();\n }\n prevInViewRef.current = inView;\n },\n [fireLoadMore]\n );\n\n const { ref } = useIntersectionObserver({\n root,\n rootMargin,\n threshold,\n // Disconnect the observer entirely when there is nothing more to load or the\n // hook is disabled — there is no point observing a sentinel we will never\n // act on. `loading` is intentionally *not* folded in here: we keep observing\n // while a load is in flight (the fire guard handles it) so we don't lose the\n // sentinel's position mid-request.\n enabled: enabled && hasMore,\n onChange: handleChange,\n });\n\n return ref;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,mBAAoC;AACpC,uCAGO;AA0EA,SAAS,kBACd,UACA,UAAoC,CAAC,GACf;AACtB,QAAM;AAAA,IACJ,UAAU;AAAA,IACV,UAAU;AAAA,IACV,UAAU;AAAA,IACV,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,OAAO;AAAA,EACT,IAAI;AAIJ,QAAM,kBAAc,qBAAmB,QAAQ;AAC/C,cAAY,UAAU;AAEtB,QAAM,eAAW,qBAAO,EAAE,SAAS,SAAS,QAAQ,CAAC;AACrD,WAAS,UAAU,EAAE,SAAS,SAAS,QAAQ;AAI/C,QAAM,kBAAc,qBAAO,KAAK;AAOhC,QAAM,oBAAgB,qBAAO,KAAK;AAElC,QAAM,mBAAe,0BAAY,MAAM;AACrC,UAAM,QAAQ,SAAS;AACvB,QAAI,CAAC,MAAM,WAAW,CAAC,MAAM,WAAW,MAAM,WAAW,YAAY,SAAS;AAC5E;AAAA,IACF;AAEA,gBAAY,UAAU;AAEtB,QAAI;AACJ,QAAI;AACF,eAAS,YAAY,QAAQ;AAAA,IAC/B,SAAS,OAAO;AAGd,kBAAY,UAAU;AACtB,YAAM;AAAA,IACR;AAEA,QAAI,UAAU,OAAQ,OAAyB,SAAS,YAAY;AAClE,YAAM,UAAU,MAAM;AACpB,oBAAY,UAAU;AAAA,MACxB;AAIA,cAAQ,QAAQ,MAAM,EAAE,KAAK,SAAS,OAAO;AAAA,IAC/C,OAAO;AACL,kBAAY,UAAU;AAAA,IACxB;AAAA,EACF,GAAG,CAAC,CAAC;AAEL,QAAM,mBAAe;AAAA,IACnB,CAAC,QAAQ,WAAW;AAIlB,UAAI,UAAU,CAAC,cAAc,SAAS;AACpC,qBAAa;AAAA,MACf;AACA,oBAAc,UAAU;AAAA,IAC1B;AAAA,IACA,CAAC,YAAY;AAAA,EACf;AAEA,QAAM,EAAE,IAAI,QAAI,0DAAwB;AAAA,IACtC;AAAA,IACA;AAAA,IACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMA,SAAS,WAAW;AAAA,IACpB,UAAU;AAAA,EACZ,CAAC;AAED,SAAO;AACT;","names":[]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// src/useInfiniteScroll.ts
|
|
2
|
+
import { useCallback, useRef } from "react";
|
|
3
|
+
import {
|
|
4
|
+
useIntersectionObserver
|
|
5
|
+
} from "@usefy/use-intersection-observer";
|
|
6
|
+
function useInfiniteScroll(loadMore, options = {}) {
|
|
7
|
+
const {
|
|
8
|
+
hasMore = true,
|
|
9
|
+
loading = false,
|
|
10
|
+
enabled = true,
|
|
11
|
+
rootMargin = "0px",
|
|
12
|
+
threshold = 0,
|
|
13
|
+
root = null
|
|
14
|
+
} = options;
|
|
15
|
+
const loadMoreRef = useRef(loadMore);
|
|
16
|
+
loadMoreRef.current = loadMore;
|
|
17
|
+
const stateRef = useRef({ hasMore, loading, enabled });
|
|
18
|
+
stateRef.current = { hasMore, loading, enabled };
|
|
19
|
+
const inFlightRef = useRef(false);
|
|
20
|
+
const prevInViewRef = useRef(false);
|
|
21
|
+
const fireLoadMore = useCallback(() => {
|
|
22
|
+
const state = stateRef.current;
|
|
23
|
+
if (!state.enabled || !state.hasMore || state.loading || inFlightRef.current) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
inFlightRef.current = true;
|
|
27
|
+
let result;
|
|
28
|
+
try {
|
|
29
|
+
result = loadMoreRef.current();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
inFlightRef.current = false;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
if (result && typeof result.then === "function") {
|
|
35
|
+
const release = () => {
|
|
36
|
+
inFlightRef.current = false;
|
|
37
|
+
};
|
|
38
|
+
Promise.resolve(result).then(release, release);
|
|
39
|
+
} else {
|
|
40
|
+
inFlightRef.current = false;
|
|
41
|
+
}
|
|
42
|
+
}, []);
|
|
43
|
+
const handleChange = useCallback(
|
|
44
|
+
(_entry, inView) => {
|
|
45
|
+
if (inView && !prevInViewRef.current) {
|
|
46
|
+
fireLoadMore();
|
|
47
|
+
}
|
|
48
|
+
prevInViewRef.current = inView;
|
|
49
|
+
},
|
|
50
|
+
[fireLoadMore]
|
|
51
|
+
);
|
|
52
|
+
const { ref } = useIntersectionObserver({
|
|
53
|
+
root,
|
|
54
|
+
rootMargin,
|
|
55
|
+
threshold,
|
|
56
|
+
// Disconnect the observer entirely when there is nothing more to load or the
|
|
57
|
+
// hook is disabled — there is no point observing a sentinel we will never
|
|
58
|
+
// act on. `loading` is intentionally *not* folded in here: we keep observing
|
|
59
|
+
// while a load is in flight (the fire guard handles it) so we don't lose the
|
|
60
|
+
// sentinel's position mid-request.
|
|
61
|
+
enabled: enabled && hasMore,
|
|
62
|
+
onChange: handleChange
|
|
63
|
+
});
|
|
64
|
+
return ref;
|
|
65
|
+
}
|
|
66
|
+
export {
|
|
67
|
+
useInfiniteScroll
|
|
68
|
+
};
|
|
69
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/useInfiniteScroll.ts"],"sourcesContent":["import { useCallback, useRef } from \"react\";\nimport {\n useIntersectionObserver,\n type OnChangeCallback,\n} from \"@usefy/use-intersection-observer\";\nimport type {\n LoadMoreFn,\n UseInfiniteScrollOptions,\n UseInfiniteScrollRef,\n} from \"./types\";\n\n/**\n * Sentinel-driven infinite scrolling built on the Intersection Observer API.\n *\n * Attach the returned callback ref to a small sentinel element rendered at the\n * end of your list. Whenever that sentinel scrolls into view — and there is\n * more to load, nothing is currently loading, and the hook is enabled — your\n * `loadMore` callback fires exactly **once** per intersection.\n *\n * Built on top of {@link useIntersectionObserver}, so it inherits its SSR-safe\n * (returns an inert no-op ref on the server) and StrictMode-safe behavior.\n *\n * Key guarantees:\n * - **Fires once per intersection.** It does not re-fire while the sentinel\n * stays in view; the sentinel must leave and re-enter to trigger again.\n * - **Latest-callback pattern.** Changing `loadMore` (or the `hasMore` /\n * `loading` / `enabled` flags) never re-subscribes the observer.\n * - **No double-fire while loading.** Respects the `loading` flag *and* an\n * internal in-flight guard: if `loadMore` returns a promise, the hook will\n * not fire again until it settles.\n * - **Stops observing when exhausted.** Once `hasMore` is `false` (or `enabled`\n * is `false`), the observer disconnects entirely.\n *\n * @param loadMore - Called to load the next page. May be sync or async.\n * @param options - Configuration (`hasMore`, `loading`, `enabled`, `rootMargin`,\n * `threshold`, `root`).\n * @returns A callback ref to attach to the sentinel element.\n *\n * @example\n * ```tsx\n * function Feed() {\n * const [items, setItems] = useState<Item[]>([]);\n * const [loading, setLoading] = useState(false);\n * const [hasMore, setHasMore] = useState(true);\n *\n * const loadMore = async () => {\n * setLoading(true);\n * const { data, done } = await fetchNextPage(items.length);\n * setItems((prev) => [...prev, ...data]);\n * setHasMore(!done);\n * setLoading(false);\n * };\n *\n * const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });\n *\n * return (\n * <ul>\n * {items.map((item) => (\n * <li key={item.id}>{item.title}</li>\n * ))}\n * {hasMore && <li ref={sentinelRef} aria-hidden />}\n * </ul>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Prefetch 300px early, inside a fixed-height scroll container.\n * const containerRef = useRef<HTMLDivElement>(null);\n * const sentinelRef = useInfiniteScroll(loadMore, {\n * hasMore,\n * loading,\n * root: containerRef.current,\n * rootMargin: \"300px\",\n * });\n * ```\n */\nexport function useInfiniteScroll(\n loadMore: LoadMoreFn,\n options: UseInfiniteScrollOptions = {}\n): UseInfiniteScrollRef {\n const {\n hasMore = true,\n loading = false,\n enabled = true,\n rootMargin = \"0px\",\n threshold = 0,\n root = null,\n } = options;\n\n // Latest-callback pattern: mirror the callback and flags into refs so that a\n // changing `loadMore` (or flags) never re-subscribes the observer.\n const loadMoreRef = useRef<LoadMoreFn>(loadMore);\n loadMoreRef.current = loadMore;\n\n const stateRef = useRef({ hasMore, loading, enabled });\n stateRef.current = { hasMore, loading, enabled };\n\n // Internal in-flight guard for the async case, independent of the `loading`\n // prop: prevents a second fire while a returned promise is still pending.\n const inFlightRef = useRef(false);\n\n // Tracks the sentinel's previous visibility so we fire on the `false -> true`\n // transition only. Without this, a multi-threshold observer (e.g.\n // `threshold: [0, 0.5, 1]`) emits several `inView === true` callbacks as the\n // sentinel scrolls in; a synchronous `loadMore` (one that never sets\n // `loading`) would otherwise fire once per crossing.\n const prevInViewRef = useRef(false);\n\n const fireLoadMore = useCallback(() => {\n const state = stateRef.current;\n if (!state.enabled || !state.hasMore || state.loading || inFlightRef.current) {\n return;\n }\n\n inFlightRef.current = true;\n\n let result: void | Promise<void>;\n try {\n result = loadMoreRef.current();\n } catch (error) {\n // Synchronous throw: release the guard so a later intersection can retry,\n // then rethrow (mirrors the caller invoking loadMore directly).\n inFlightRef.current = false;\n throw error;\n }\n\n if (result && typeof (result as Promise<void>).then === \"function\") {\n const release = () => {\n inFlightRef.current = false;\n };\n // Handle both settle paths so the guard is always released and a rejected\n // promise does not surface as an unhandled rejection. The hook does not\n // itself surface loadMore errors — handle them inside loadMore.\n Promise.resolve(result).then(release, release);\n } else {\n inFlightRef.current = false;\n }\n }, []);\n\n const handleChange = useCallback<OnChangeCallback>(\n (_entry, inView) => {\n // Fire only on the transition into view, so a multi-threshold observer\n // that emits several `inView === true` callbacks per entry triggers a\n // single load.\n if (inView && !prevInViewRef.current) {\n fireLoadMore();\n }\n prevInViewRef.current = inView;\n },\n [fireLoadMore]\n );\n\n const { ref } = useIntersectionObserver({\n root,\n rootMargin,\n threshold,\n // Disconnect the observer entirely when there is nothing more to load or the\n // hook is disabled — there is no point observing a sentinel we will never\n // act on. `loading` is intentionally *not* folded in here: we keep observing\n // while a load is in flight (the fire guard handles it) so we don't lose the\n // sentinel's position mid-request.\n enabled: enabled && hasMore,\n onChange: handleChange,\n });\n\n return ref;\n}\n"],"mappings":";AAAA,SAAS,aAAa,cAAc;AACpC;AAAA,EACE;AAAA,OAEK;AA0EA,SAAS,kBACd,UACA,UAAoC,CAAC,GACf;AACtB,QAAM;AAAA,IACJ,UAAU;AAAA,IACV,UAAU;AAAA,IACV,UAAU;AAAA,IACV,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,OAAO;AAAA,EACT,IAAI;AAIJ,QAAM,cAAc,OAAmB,QAAQ;AAC/C,cAAY,UAAU;AAEtB,QAAM,WAAW,OAAO,EAAE,SAAS,SAAS,QAAQ,CAAC;AACrD,WAAS,UAAU,EAAE,SAAS,SAAS,QAAQ;AAI/C,QAAM,cAAc,OAAO,KAAK;AAOhC,QAAM,gBAAgB,OAAO,KAAK;AAElC,QAAM,eAAe,YAAY,MAAM;AACrC,UAAM,QAAQ,SAAS;AACvB,QAAI,CAAC,MAAM,WAAW,CAAC,MAAM,WAAW,MAAM,WAAW,YAAY,SAAS;AAC5E;AAAA,IACF;AAEA,gBAAY,UAAU;AAEtB,QAAI;AACJ,QAAI;AACF,eAAS,YAAY,QAAQ;AAAA,IAC/B,SAAS,OAAO;AAGd,kBAAY,UAAU;AACtB,YAAM;AAAA,IACR;AAEA,QAAI,UAAU,OAAQ,OAAyB,SAAS,YAAY;AAClE,YAAM,UAAU,MAAM;AACpB,oBAAY,UAAU;AAAA,MACxB;AAIA,cAAQ,QAAQ,MAAM,EAAE,KAAK,SAAS,OAAO;AAAA,IAC/C,OAAO;AACL,kBAAY,UAAU;AAAA,IACxB;AAAA,EACF,GAAG,CAAC,CAAC;AAEL,QAAM,eAAe;AAAA,IACnB,CAAC,QAAQ,WAAW;AAIlB,UAAI,UAAU,CAAC,cAAc,SAAS;AACpC,qBAAa;AAAA,MACf;AACA,oBAAc,UAAU;AAAA,IAC1B;AAAA,IACA,CAAC,YAAY;AAAA,EACf;AAEA,QAAM,EAAE,IAAI,IAAI,wBAAwB;AAAA,IACtC;AAAA,IACA;AAAA,IACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMA,SAAS,WAAW;AAAA,IACpB,UAAU;AAAA,EACZ,CAAC;AAED,SAAO;AACT;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@usefy/use-infinite-scroll",
|
|
3
|
+
"version": "0.20.0",
|
|
4
|
+
"description": "A React hook for sentinel-driven infinite scrolling built on IntersectionObserver",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@usefy/use-intersection-observer": "0.20.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
27
|
+
"@testing-library/react": "^16.3.1",
|
|
28
|
+
"@testing-library/user-event": "^14.6.1",
|
|
29
|
+
"@types/react": "^19.0.0",
|
|
30
|
+
"jsdom": "^27.3.0",
|
|
31
|
+
"react": "^19.0.0",
|
|
32
|
+
"rimraf": "^6.0.1",
|
|
33
|
+
"tsup": "^8.0.0",
|
|
34
|
+
"typescript": "^5.0.0",
|
|
35
|
+
"vitest": "^4.0.16"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/mirunamu00/usefy.git",
|
|
43
|
+
"directory": "packages/hooks/use-infinite-scroll"
|
|
44
|
+
},
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"keywords": [
|
|
47
|
+
"react",
|
|
48
|
+
"hooks",
|
|
49
|
+
"infinite-scroll",
|
|
50
|
+
"infinite-loading",
|
|
51
|
+
"intersection-observer",
|
|
52
|
+
"sentinel",
|
|
53
|
+
"load-more",
|
|
54
|
+
"pagination",
|
|
55
|
+
"lazy-loading",
|
|
56
|
+
"useInfiniteScroll"
|
|
57
|
+
],
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsup",
|
|
60
|
+
"dev": "tsup --watch",
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"test:watch": "vitest",
|
|
63
|
+
"typecheck": "tsc --noEmit",
|
|
64
|
+
"clean": "rimraf dist"
|
|
65
|
+
}
|
|
66
|
+
}
|