Skip to content

Commit a21ece6

Browse files
committed
feat: implement useLoadMoreSentinel hook for improved infinite scrolling
1 parent bf2bf09 commit a21ece6

2 files changed

Lines changed: 70 additions & 31 deletions

File tree

frontend/features/files/components/sections/sidebar/sidebar-list.tsx

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
DropdownMenuTrigger,
1919
} from "@/components/ui/dropdown-menu";
2020
import { Skeleton } from "@/components/ui/skeleton";
21+
import { useLoadMoreSentinel } from "@/shared/hooks/use-load-more-sentinel";
2122
import { cn } from "@/lib/utils";
2223
import type { FileObjectDTO } from "@/shared/api/file.types";
2324

@@ -221,34 +222,13 @@ export function SidebarList({
221222
const loadMoreRef = React.useRef<HTMLDivElement | null>(null);
222223
const selectedFileIDSet = React.useMemo(() => new Set(selectedFileIDs), [selectedFileIDs]);
223224

224-
React.useEffect(() => {
225-
if (!hasMore || loading || loadingMore) {
226-
return;
227-
}
228-
229-
const sentinel = loadMoreRef.current;
230-
const viewport = scrollAreaRef.current;
231-
if (!sentinel || !viewport) {
232-
return;
233-
}
234-
235-
const observer = new IntersectionObserver(
236-
(entries) => {
237-
const entry = entries[0];
238-
if (entry?.isIntersecting) {
239-
onLoadMore();
240-
}
241-
},
242-
{
243-
root: viewport,
244-
rootMargin: "0px 0px 200px 0px",
245-
threshold: 0.01,
246-
},
247-
);
248-
249-
observer.observe(sentinel);
250-
return () => observer.disconnect();
251-
}, [hasMore, loading, loadingMore, onLoadMore]);
225+
useLoadMoreSentinel({
226+
enabled: hasMore && !loading && !loadingMore,
227+
rootMargin: "0px 0px 200px 0px",
228+
rootRef: scrollAreaRef,
229+
targetRef: loadMoreRef,
230+
onLoadMore,
231+
});
252232

253233
if (!loading && items.length === 0) {
254234
return (

frontend/shared/hooks/use-load-more-sentinel.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,25 @@ import * as React from "react";
55
type UseLoadMoreSentinelOptions = {
66
enabled: boolean;
77
rootMargin?: string;
8+
rootRef?: React.RefObject<HTMLElement | null>;
89
targetRef: React.RefObject<Element | null>;
910
onLoadMore: () => void | Promise<void>;
1011
};
1112

13+
function rootMarginToPixels(rootMargin: string): number {
14+
const values = rootMargin.trim().split(/\s+/).filter(Boolean);
15+
const value = values.length >= 3 ? values[2] : values[0] ?? "";
16+
const match = value.match(/^(-?\d+(?:\.\d+)?)px$/);
17+
if (!match) {
18+
return 120;
19+
}
20+
return Number(match[1]) || 0;
21+
}
22+
1223
export function useLoadMoreSentinel({
1324
enabled,
1425
rootMargin = "120px",
26+
rootRef,
1527
targetRef,
1628
onLoadMore,
1729
}: UseLoadMoreSentinelOptions) {
@@ -31,7 +43,33 @@ export function useLoadMoreSentinel({
3143
return;
3244
}
3345

34-
const root = target.parentElement?.closest<HTMLElement>("[data-sidebar-scroll-root='true']") ?? null;
46+
const root = rootRef?.current ?? target.parentElement?.closest<HTMLElement>("[data-sidebar-scroll-root='true']") ?? null;
47+
const marginPx = rootMarginToPixels(rootMargin);
48+
let animationFrame: number | null = null;
49+
let disposed = false;
50+
51+
const isNearLoadMorePoint = () => {
52+
if (root) {
53+
return root.scrollHeight - root.scrollTop - root.clientHeight <= marginPx;
54+
}
55+
return target.getBoundingClientRect().top <= window.innerHeight + marginPx;
56+
};
57+
58+
const check = () => {
59+
animationFrame = null;
60+
if (disposed || !isNearLoadMorePoint()) {
61+
return;
62+
}
63+
void onLoadMoreRef.current();
64+
};
65+
66+
const scheduleCheck = () => {
67+
if (animationFrame !== null) {
68+
return;
69+
}
70+
animationFrame = window.requestAnimationFrame(check);
71+
};
72+
3573
const observer = new IntersectionObserver(
3674
(entries) => {
3775
const entry = entries[0];
@@ -43,6 +81,27 @@ export function useLoadMoreSentinel({
4381
);
4482

4583
observer.observe(target);
46-
return () => observer.disconnect();
47-
}, [enabled, rootMargin, targetRef]);
84+
const scrollTarget: HTMLElement | Window = root ?? window;
85+
scrollTarget.addEventListener("scroll", scheduleCheck, { passive: true });
86+
window.addEventListener("resize", scheduleCheck);
87+
88+
const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleCheck);
89+
resizeObserver?.observe(target);
90+
if (root) {
91+
resizeObserver?.observe(root);
92+
}
93+
94+
scheduleCheck();
95+
96+
return () => {
97+
disposed = true;
98+
observer.disconnect();
99+
scrollTarget.removeEventListener("scroll", scheduleCheck);
100+
window.removeEventListener("resize", scheduleCheck);
101+
resizeObserver?.disconnect();
102+
if (animationFrame !== null) {
103+
window.cancelAnimationFrame(animationFrame);
104+
}
105+
};
106+
}, [enabled, rootMargin, rootRef, targetRef]);
48107
}

0 commit comments

Comments
 (0)