Stabilize infinite issue paging

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 12:57:49 -05:00
1 parent 889cf660ac
commit e71b27fbd4
2 files changed
+43 -5

No files matched your search

+19 -1
View File
@@ -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,
]);
});
});
+24 -4
View File
@@ -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<string>();
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({