Fix GitHub Issues/PRs Infinite Scroll Auto-Fetch (#1239)

* auto-claude: subtask-1-1 - Add onViewportRef callback prop to ScrollArea comp

* auto-claude: subtask-2-1 - Add viewport ref state and update IntersectionObserver to use viewport as root

* auto-claude: subtask-3-1 - Add viewport ref state and update IntersectionObserver

- Added useState import for viewportElement state
- Replaced scrollAreaRef with viewportElement state
- Updated IntersectionObserver root from null to viewportElement
- Added viewportElement to useEffect dependencies
- Changed ScrollArea to use onViewportRef callback

This fixes infinite scroll in PRList by using the ScrollArea viewport
as the IntersectionObserver root, matching the pattern used in IssueList.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove node_modules symlink and improve IntersectionObserver consistency

- Remove accidentally committed symlink at apps/frontend/node_modules
- Add explicit .gitignore entry for symlink file (without trailing slash)
- Add onLoadMore to PRList useEffect dependency array for consistency with IssueList
- Add viewportElement guard to both IssueList and PRList to prevent unnecessary observer creation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Test User <test@example.com>
This commit is contained in:
Andy
2026-01-17 20:13:19 +01:00
committed by GitHub
parent 8833feb2b7
commit b74b628b6e
4 changed files with 41 additions and 28 deletions
+1
View File
@@ -108,6 +108,7 @@ dmypy.json
# Node.js (apps/frontend) # Node.js (apps/frontend)
# =========================== # ===========================
node_modules node_modules
apps/frontend/node_modules
.npm .npm
.yarn/ .yarn/
.pnp.* .pnp.*
@@ -1,4 +1,4 @@
import { useRef, useEffect, useCallback } from 'react'; import { useRef, useEffect, useCallback, useState } from 'react';
import { Loader2, AlertCircle } from 'lucide-react'; import { Loader2, AlertCircle } from 'lucide-react';
import { ScrollArea } from '../../ui/scroll-area'; import { ScrollArea } from '../../ui/scroll-area';
import { IssueListItem } from './IssueListItem'; import { IssueListItem } from './IssueListItem';
@@ -19,6 +19,7 @@ export function IssueList({
}: IssueListProps) { }: IssueListProps) {
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const loadMoreTriggerRef = useRef<HTMLDivElement>(null); const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(null);
// Intersection Observer for infinite scroll // Intersection Observer for infinite scroll
const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => { const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => {
@@ -30,10 +31,10 @@ export function IssueList({
useEffect(() => { useEffect(() => {
const trigger = loadMoreTriggerRef.current; const trigger = loadMoreTriggerRef.current;
if (!trigger || !onLoadMore) return; if (!trigger || !onLoadMore || !viewportElement) return;
const observer = new IntersectionObserver(handleIntersection, { const observer = new IntersectionObserver(handleIntersection, {
root: null, root: viewportElement,
rootMargin: '100px', rootMargin: '100px',
threshold: 0 threshold: 0
}); });
@@ -43,7 +44,7 @@ export function IssueList({
return () => { return () => {
observer.disconnect(); observer.disconnect();
}; };
}, [handleIntersection, onLoadMore]); }, [handleIntersection, onLoadMore, viewportElement]);
// Only show blocking error view when no issues are loaded // Only show blocking error view when no issues are loaded
// Load-more errors are shown inline near the load-more trigger // Load-more errors are shown inline near the load-more trigger
@@ -71,7 +72,7 @@ export function IssueList({
} }
return ( return (
<ScrollArea className="flex-1"> <ScrollArea className="flex-1" onViewportRef={setViewportElement}>
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">
{issues.map((issue) => ( {issues.map((issue) => (
<IssueListItem <IssueListItem
@@ -1,4 +1,4 @@
import { useRef, useEffect, useCallback } from 'react'; import { useRef, useEffect, useCallback, useState } from 'react';
import { GitPullRequest, User, Clock, FileDiff, Loader2 } from 'lucide-react'; import { GitPullRequest, User, Clock, FileDiff, Loader2 } from 'lucide-react';
import { ScrollArea } from '../../ui/scroll-area'; import { ScrollArea } from '../../ui/scroll-area';
import { Badge } from '../../ui/badge'; import { Badge } from '../../ui/badge';
@@ -207,8 +207,8 @@ export function PRList({
onLoadMore onLoadMore
}: PRListProps) { }: PRListProps) {
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const scrollAreaRef = useRef<HTMLDivElement>(null);
const loadMoreTriggerRef = useRef<HTMLDivElement>(null); const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(null);
// Intersection Observer for infinite scroll // Intersection Observer for infinite scroll
const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => { const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => {
@@ -220,11 +220,11 @@ export function PRList({
useEffect(() => { useEffect(() => {
const trigger = loadMoreTriggerRef.current; const trigger = loadMoreTriggerRef.current;
if (!trigger) return; if (!trigger || !viewportElement) return;
const observer = new IntersectionObserver(handleIntersection, { const observer = new IntersectionObserver(handleIntersection, {
root: null, // Use viewport as root root: viewportElement,
rootMargin: '100px', // Start loading 100px before reaching the bottom rootMargin: '100px',
threshold: 0 threshold: 0
}); });
@@ -233,7 +233,7 @@ export function PRList({
return () => { return () => {
observer.disconnect(); observer.disconnect();
}; };
}, [handleIntersection]); }, [handleIntersection, onLoadMore, viewportElement]);
if (isLoading && prs.length === 0) { if (isLoading && prs.length === 0) {
return ( return (
@@ -268,7 +268,7 @@ export function PRList({
} }
return ( return (
<ScrollArea className="flex-1" ref={scrollAreaRef}> <ScrollArea className="flex-1" onViewportRef={setViewportElement}>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{prs.map((pr) => { {prs.map((pr) => {
const reviewState = getReviewStateForPR(pr.number); const reviewState = getReviewStateForPR(pr.number);
@@ -5,23 +5,34 @@ import { cn } from '../../lib/utils';
const ScrollArea = React.forwardRef< const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>, React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
viewportClassName?: string; viewportClassName?: string;
} onViewportRef?: (element: HTMLDivElement | null) => void;
>(({ className, children, viewportClassName, ...props }, ref) => ( }
<ScrollAreaPrimitive.Root >(({ className, children, viewportClassName, onViewportRef, ...props }, ref) => {
ref={ref} const viewportRef = React.useCallback(
className={cn('relative overflow-hidden', className)} (element: HTMLDivElement | null) => {
{...props} onViewportRef?.(element);
> },
<ScrollAreaPrimitive.Viewport [onViewportRef]
className={cn('h-full w-full rounded-[inherit]', viewportClassName)} );
return (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
> >
{children} <ScrollAreaPrimitive.Viewport
</ScrollAreaPrimitive.Viewport> ref={viewportRef}
<ScrollBar /> className={cn('h-full w-full rounded-[inherit]', viewportClassName)}
<ScrollAreaPrimitive.Corner /> >
</ScrollAreaPrimitive.Root> {children}
)); </ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
});
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef< const ScrollBar = React.forwardRef<