diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ca11a8c..e832aad 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@stellar/freighter-api": "^2.0.0", "@stellar/stellar-sdk": "^14.5.0", + "@tanstack/react-virtual": "^3.13.26", "lucide-react": "^0.294.0", "react": "^18.2.0", "react-dom": "^18.3.1", @@ -3990,6 +3991,33 @@ "node": ">=20.0.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.26", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz", + "integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.16.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz", + "integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index ec4cafc..3c3b4f7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@stellar/freighter-api": "^2.0.0", "@stellar/stellar-sdk": "^14.5.0", + "@tanstack/react-virtual": "^3.13.26", "lucide-react": "^0.294.0", "react": "^18.2.0", "react-dom": "^18.3.1", diff --git a/frontend/src/components/StreamsTable.test.tsx b/frontend/src/components/StreamsTable.test.tsx index a5df178..08ba4a6 100644 --- a/frontend/src/components/StreamsTable.test.tsx +++ b/frontend/src/components/StreamsTable.test.tsx @@ -1,14 +1,14 @@ import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import "@testing-library/jest-dom"; import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { StreamsTable } from "./StreamsTable"; +import { StreamsTable, STREAMS_TABLE_VIRTUAL_OVERSCAN } from "./StreamsTable"; import { Stream } from "../types/stream"; const noop = vi.fn().mockResolvedValue(undefined); -const mockStreams: Stream[] = [ - { - id: "1", +function createMockStream(id: string, status: Stream["progress"]["status"] = "active"): Stream { + return { + id, sender: "G_SENDER123456789012345678901234567890123456789012345678901", recipient: "G_RECIPIENT123456789012345678901234567890123456789012345", assetCode: "USDC", @@ -17,15 +17,17 @@ const mockStreams: Stream[] = [ startAt: 1670000000, createdAt: 1670000000, progress: { - status: "active", + status, ratePerSecond: 0.01, elapsedSeconds: 100, vestedAmount: 20, remainingAmount: 80, percentComplete: 20, }, - }, -]; + }; +} + +const mockStreams: Stream[] = [createMockStream("1")]; const defaultProps = { streams: mockStreams, @@ -37,6 +39,21 @@ const defaultProps = { onEditStartTime: vi.fn(), }; +function setScrollViewport(element: HTMLElement, height: number) { + Object.defineProperty(element, "clientHeight", { + configurable: true, + value: height, + }); + Object.defineProperty(element, "offsetHeight", { + configurable: true, + value: height, + }); + Object.defineProperty(element, "scrollHeight", { + configurable: true, + value: height * 20, + }); +} + describe("StreamsTable column visibility", () => { beforeEach(() => { localStorage.clear(); @@ -74,3 +91,53 @@ describe("StreamsTable column visibility", () => { expect(screen.getByRole("columnheader", { name: "Asset" })).toBeInTheDocument(); }); }); + +describe("StreamsTable virtual scrolling", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("uses a bounded scroll container for the table body", () => { + render(); + + const scrollContainer = screen.getByTestId("streams-table-scroll"); + expect(scrollContainer).toHaveClass("streams-table-scroll"); + expect(scrollContainer.getAttribute("style")).toContain("max-height"); + }); + + it("renders only visible rows plus overscan for large lists", () => { + const manyStreams = Array.from({ length: 500 }, (_, i) => + createMockStream(String(i + 1).padStart(4, "0")), + ); + + const view = render(); + setScrollViewport(screen.getByTestId("streams-table-scroll"), 400); + view.rerender(); + + const renderedRows = screen.getAllByRole("checkbox", { + name: /^Select stream /, + }); + const expectedMax = + Math.ceil(400 / 52) + STREAMS_TABLE_VIRTUAL_OVERSCAN + 2; + + expect(renderedRows.length).toBeLessThan(500); + expect(renderedRows.length).toBeLessThanOrEqual(expectedMax); + }); + + it("configures virtual overscan to five rows", () => { + expect(STREAMS_TABLE_VIRTUAL_OVERSCAN).toBe(5); + }); + + it("preserves keyboard focus order for rendered row actions", () => { + render(); + + const cancelButton = screen.getByRole("button", { name: "Cancel stream 1" }); + cancelButton.focus(); + expect(document.activeElement).toBe(cancelButton); + }); +}); diff --git a/frontend/src/components/StreamsTable.tsx b/frontend/src/components/StreamsTable.tsx index 52a2d7f..49a0e54 100644 --- a/frontend/src/components/StreamsTable.tsx +++ b/frontend/src/components/StreamsTable.tsx @@ -2,11 +2,13 @@ import { memo, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, type RefObject, } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { Stream } from "../types/stream"; import { getExportCsvUrl, ListStreamsFilters, cancelStream } from "../services/api"; import { CopyableAddress } from "./CopyableAddress"; @@ -33,6 +35,13 @@ interface StreamsTableProps { } const SKELETON_ROW_COUNT = 6; +/** Visible rows outside the viewport kept mounted for smooth scroll. */ +export const STREAMS_TABLE_VIRTUAL_OVERSCAN = 5; +/** Only virtualize once lists are large enough to benefit from windowing. */ +const VIRTUALIZATION_THRESHOLD = 50; +const ESTIMATE_ROW_HEIGHT_PX = 52; +const TABLE_SCROLL_MAX_HEIGHT = "min(70vh, 720px)"; +const TABLE_SCROLL_VIEWPORT_HEIGHT = "480px"; function SkeletonRow({ colCount }: { colCount: number }) { return ( @@ -201,6 +210,94 @@ export function StreamsTable({ return () => document.removeEventListener("mousedown", onDocClick); }, [columnsOpen]); + const [scrollElement, setScrollElement] = useState(null); + const shouldVirtualize = !loading && sortedStreams.length >= VIRTUALIZATION_THRESHOLD; + + const rowVirtualizer = useVirtualizer({ + count: shouldVirtualize ? sortedStreams.length : 0, + getScrollElement: () => scrollElement, + estimateSize: () => ESTIMATE_ROW_HEIGHT_PX, + overscan: STREAMS_TABLE_VIRTUAL_OVERSCAN, + getItemKey: (index) => sortedStreams[index]?.id ?? index, + measureElement: (element) => { + const row = element as HTMLTableRowElement; + let height = row.getBoundingClientRect().height; + const timelineRow = row.nextElementSibling; + if ( + timelineRow instanceof HTMLTableRowElement && + timelineRow.dataset.timelineRow === "true" + ) { + height += timelineRow.getBoundingClientRect().height; + } + return height; + }, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + + const resolvedVirtualRows = useMemo(() => { + if (!shouldVirtualize) return []; + + if (virtualRows.length > 0) return virtualRows; + + const fallbackCount = Math.min( + sortedStreams.length, + Math.ceil(parseInt(TABLE_SCROLL_VIEWPORT_HEIGHT, 10) / ESTIMATE_ROW_HEIGHT_PX) + + STREAMS_TABLE_VIRTUAL_OVERSCAN, + ); + + return Array.from({ length: fallbackCount }, (_, index) => ({ + index, + start: index * ESTIMATE_ROW_HEIGHT_PX, + end: (index + 1) * ESTIMATE_ROW_HEIGHT_PX, + size: ESTIMATE_ROW_HEIGHT_PX, + key: sortedStreams[index].id, + lane: 0, + })); + }, [shouldVirtualize, sortedStreams, virtualRows]); + + useLayoutEffect(() => { + if (!shouldVirtualize || !scrollElement) return; + rowVirtualizer.measure(); + }, [expandedStreamId, shouldVirtualize, scrollElement, visibleOptionalColumns, rowVirtualizer]); + + const renderStreamRow = ( + stream: Stream, + dataIndex: number, + measureRef?: (element: HTMLTableRowElement | null) => void, + ) => ( + + ); + + const paddingTop = + resolvedVirtualRows.length > 0 ? resolvedVirtualRows[0].start : 0; + const paddingBottom = + resolvedVirtualRows.length > 0 + ? rowVirtualizer.getTotalSize() - resolvedVirtualRows[resolvedVirtualRows.length - 1].end + : 0; + return ( <>
@@ -278,9 +375,17 @@ export function StreamsTable({
-
+
- + - {loading - ? Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => ( - - )) - : sortedStreams.map((stream) => ( - - ))} + {loading ? ( + Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => ( + + )) + ) : shouldVirtualize ? ( + <> + {paddingTop > 0 && ( + + + )} + {resolvedVirtualRows.map((virtualRow) => + renderStreamRow( + sortedStreams[virtualRow.index], + virtualRow.index, + rowVirtualizer.measureElement, + ), + )} + {paddingBottom > 0 && ( + + + )} + + ) : ( + sortedStreams.map((stream, index) => renderStreamRow(stream, index)) + )}
@@ -390,6 +501,8 @@ interface StreamRowProps { healthBadges: ReturnType; visibleOptionalColumns: OptionalStreamColumn[]; colSpan: number; + dataIndex: number; + measureRef?: (element: HTMLTableRowElement | null) => void; onToggleTimeline: (id: string) => void; onCheckboxToggle: (id: string) => void; onCancel: (id: string) => Promise; @@ -408,6 +521,8 @@ const StreamRow = memo(function StreamRow({ healthBadges, visibleOptionalColumns, colSpan, + dataIndex, + measureRef, onToggleTimeline, onCheckboxToggle, onCancel, @@ -423,7 +538,7 @@ const StreamRow = memo(function StreamRow({ return ( <> - + {isExpanded && ( - +