diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 741d8ce302c..104d12da2dd 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -942,7 +942,8 @@ async function listIssueProductivityReviewMap( isNull(issues.hiddenAt), notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES), ), - ); + ) + .orderBy(desc(issues.createdAt), desc(issues.id)); reviewRows.push(...rows); } @@ -985,6 +986,7 @@ async function listIssueProductivityReviewMap( for (const row of reviewRows) { if (!row.sourceIssueId) continue; + if (map.has(row.sourceIssueId)) continue; const detail = triggerByReviewIssueId.get(row.reviewIssueId); map.set(row.sourceIssueId, { reviewIssueId: row.reviewIssueId, diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index c49abeea63c..00587c38352 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -908,6 +908,57 @@ describe("IssuesList", () => { }); }); + it("waits for the desktop main scroll container before rendering more local rows", async () => { + const manyIssues = Array.from({ length: 420 }, (_, index) => + createIssue({ + id: `issue-${index + 1}`, + identifier: `PAP-${index + 1}`, + title: `Issue ${index + 1}`, + }), + ); + const main = document.createElement("main"); + main.id = "main-content"; + main.style.overflowY = "auto"; + document.body.appendChild(main); + main.appendChild(container); + Object.defineProperty(main, "clientHeight", { configurable: true, value: 600 }); + Object.defineProperty(main, "scrollHeight", { configurable: true, value: 2000 }); + Object.defineProperty(main, "scrollTop", { configurable: true, writable: true, value: 0 }); + setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 600 }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100); + }); + + await flush(); + await flush(); + expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100); + + act(() => { + main.scrollTop = 1500; + main.dispatchEvent(new Event("scroll")); + }); + + await waitForAssertion(() => { + expect(container.querySelectorAll('[data-testid="issue-row"]').length).toBeGreaterThan(100); + }); + + act(() => { + root.unmount(); + }); + }); + it("requests more server issues after scrolling past the rendered rows", async () => { const visibleIssues = Array.from({ length: 100 }, (_, index) => createIssue({ diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 48df8ae992b..d2345412094 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -73,6 +73,22 @@ const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200; const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100; const ISSUE_ROW_RENDER_BATCH_SIZE = 150; const ISSUE_SCROLL_LOAD_THRESHOLD_PX = 320; + +function findIssuesScrollContainer(element: HTMLElement | null): HTMLElement | null { + if (!element || typeof window === "undefined") return null; + let current = element.parentElement; + while (current && current !== document.body && current !== document.documentElement) { + const overflowY = window.getComputedStyle(current).overflowY; + if ( + (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") + && current.scrollHeight > current.clientHeight + ) { + return current; + } + current = current.parentElement; + } + return null; +} const boardIssueStatuses = ISSUE_STATUSES; const issueStatusLabels: Record = { backlog: "Backlog", @@ -486,6 +502,7 @@ export function IssuesList({ onSearchChange, onUpdateIssue, }: IssuesListProps) { + const rootRef = useRef(null); const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialogActions(); const { data: session } = useQuery({ @@ -1016,27 +1033,31 @@ export function IssuesList({ useEffect(() => { if (!canLoadMoreIssues) return; let animationFrameId: number | null = null; + const scrollContainer = findIssuesScrollContainer(rootRef.current); + const scrollTarget: Window | HTMLElement = scrollContainer ?? window; const checkScrollPosition = () => { if (animationFrameId !== null) return; animationFrameId = window.requestAnimationFrame(() => { animationFrameId = null; - const documentElement = document.documentElement; - if (documentElement.scrollHeight === 0) return; - const scrollBottom = window.scrollY + window.innerHeight; - const threshold = documentElement.scrollHeight - ISSUE_SCROLL_LOAD_THRESHOLD_PX; + const scrollHeight = scrollContainer?.scrollHeight ?? document.documentElement.scrollHeight; + if (scrollHeight === 0) return; + const scrollBottom = scrollContainer + ? scrollContainer.scrollTop + scrollContainer.clientHeight + : window.scrollY + window.innerHeight; + const threshold = scrollHeight - ISSUE_SCROLL_LOAD_THRESHOLD_PX; if (scrollBottom >= threshold) { loadMoreIssueRows(); } }); }; - window.addEventListener("scroll", checkScrollPosition, { passive: true }); + scrollTarget.addEventListener("scroll", checkScrollPosition, { passive: true }); window.addEventListener("resize", checkScrollPosition); checkScrollPosition(); return () => { - window.removeEventListener("scroll", checkScrollPosition); + scrollTarget.removeEventListener("scroll", checkScrollPosition); window.removeEventListener("resize", checkScrollPosition); if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId); }; @@ -1094,7 +1115,7 @@ export function IssuesList({ let remainingRowsToRender = viewState.viewMode === "list" ? renderedIssueRowLimit : Number.POSITIVE_INFINITY; return ( -
+
{progressSummary ? ( ) : null}