From e71b27fbd4fd9da6006fa211c72b6ee992569f1a Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 28 Apr 2026 12:57:49 -0500 Subject: [PATCH] Stabilize infinite issue paging Co-Authored-By: Paperclip --- ui/src/pages/Issues.test.tsx | 20 +++++++++++++++++++- ui/src/pages/Issues.tsx | 28 ++++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/Issues.test.tsx b/ui/src/pages/Issues.test.tsx index 25580c6ba90..b959c869516 100644 --- a/ui/src/pages/Issues.test.tsx +++ b/ui/src/pages/Issues.test.tsx @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { buildIssuesSearchUrl, getNextIssuesPageOffset } from "./Issues"; +import type { Issue } from "@paperclipai/shared"; +import { buildIssuesSearchUrl, getNextIssuesPageOffset, mergeIssuePagesStable } from "./Issues"; + +function createIssue(id: string, title: string): Issue { + return { id, title } as Issue; +} describe("buildIssuesSearchUrl", () => { it("preserves trailing spaces in the synced search param", () => { @@ -26,4 +31,17 @@ describe("issues page pagination helpers", () => { expect(getNextIssuesPageOffset(499, 0)).toBeUndefined(); expect(getNextIssuesPageOffset(999, 2000, 1000)).toBeUndefined(); }); + + it("dedupes overlapping pages without moving the original issue position", () => { + const first = createIssue("issue-1", "Original first"); + const second = createIssue("issue-2", "Second"); + const duplicateFirst = createIssue("issue-1", "Duplicate first"); + const third = createIssue("issue-3", "Third"); + + expect(mergeIssuePagesStable([[first, second], [duplicateFirst, third]])).toEqual([ + first, + second, + third, + ]); + }); }); diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index bb7db67d047..2be9a6f0a2d 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useCallback, useState } from "react"; +import { useEffect, useMemo, useCallback, useRef, useState } from "react"; import { useLocation, useSearchParams } from "@/lib/router"; import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; @@ -13,6 +13,7 @@ import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { EmptyState } from "../components/EmptyState"; import { IssuesList } from "../components/IssuesList"; import { CircleDot } from "lucide-react"; +import type { Issue } from "@paperclipai/shared"; const WORKSPACE_FILTER_ISSUE_LIMIT = 1000; const ISSUES_PAGE_SIZE = 500; @@ -25,6 +26,21 @@ export function getNextIssuesPageOffset( return loadedPageSize >= pageSize ? currentOffset + pageSize : undefined; } +export function mergeIssuePagesStable(pages: Issue[][]): Issue[] { + const seenIssueIds = new Set(); + const merged: Issue[] = []; + + for (const page of pages) { + for (const issue of page) { + if (seenIssueIds.has(issue.id)) continue; + seenIssueIds.add(issue.id); + merged.push(issue); + } + } + + return merged; +} + export function buildIssuesSearchUrl(currentHref: string, search: string): string | null { const url = new URL(currentHref); const currentSearch = url.searchParams.get("q") ?? ""; @@ -45,6 +61,7 @@ export function Issues() { const location = useLocation(); const [searchParams] = useSearchParams(); const queryClient = useQueryClient(); + const fetchNextPageInFlightRef = useRef(false); const initialSearch = searchParams.get("q") ?? ""; const [syncedSearch, setSyncedSearch] = useState(initialSearch); @@ -131,12 +148,15 @@ export function Issues() { placeholderData: (previousData) => previousData, }); - const issues = useMemo(() => issuePages?.pages.flat() ?? [], [issuePages]); + const issues = useMemo(() => mergeIssuePagesStable(issuePages?.pages ?? []), [issuePages]); const hasMoreServerIssues = syncedSearch.trim().length === 0 && hasNextPage === true; const loadMoreServerIssues = useCallback(() => { - if (!hasNextPage || isFetchingNextPage) return; - void fetchNextPage(); + if (!hasNextPage || isFetchingNextPage || fetchNextPageInFlightRef.current) return; + fetchNextPageInFlightRef.current = true; + void fetchNextPage({ cancelRefetch: false }).finally(() => { + fetchNextPageInFlightRef.current = false; + }); }, [fetchNextPage, hasNextPage, isFetchingNextPage]); const updateIssue = useMutation({