From 15562bae5a49a3ff50f4dc45f46a7a72c10a517c Mon Sep 17 00:00:00 2001 From: douglance Date: Tue, 28 Jul 2026 14:09:45 -0400 Subject: [PATCH] feat(delegates): server-side pagination + separate count endpoint Move the delegates table from loading the full list + client-side paging to server-driven pagination against the indexer's paginated list endpoint, and fetch the eligible count from the new /api/tally/delegate-count instead of counting a full-list fetch. Sorting (Voting Power / Random / column headers) now drives the indexer's sort params. Exclusion is applied server-side. --- app/api/delegate-count/route.test.ts | 48 +-- app/api/delegate-count/route.ts | 23 +- components/container/DelegateSearch.tsx | 26 +- .../container/delegate/DelegatesTable.tsx | 110 +++--- components/table/ColumnsDelegates.tsx | 14 +- components/table/DelegatesToolbar.tsx | 3 +- components/table/Pagination.tsx | 4 +- hooks/use-delegate-search.ts | 328 +++++++++++------- lib/tally-data/indexer.test.ts | 51 +++ lib/tally-data/indexer.ts | 41 +++ lib/tally-data/types.ts | 31 ++ 11 files changed, 419 insertions(+), 260 deletions(-) diff --git a/app/api/delegate-count/route.test.ts b/app/api/delegate-count/route.test.ts index c2ab81a..f2edfd4 100644 --- a/app/api/delegate-count/route.test.ts +++ b/app/api/delegate-count/route.test.ts @@ -8,27 +8,11 @@ import { import { GET } from "./route"; -function listItem(address: string, votingPower: string) { - return { - address, - votingPower, - lastChangeBlock: 100, - votesCount: votingPower, - delegatorsCount: 1, - isPrioritized: false, - ens: null, - name: null, - picture: null, - knownLabel: null, - displayName: null, - }; -} - -function mockIndexerList(delegates: ReturnType[]) { +function mockIndexerCount(totalCount: number) { return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( new Response( JSON.stringify({ - delegates, + totalCount, totalVotingPower: "0", totalSupply: "0", }), @@ -54,36 +38,30 @@ describe("delegate count route", () => { }); }); - it("asks the indexer for the eligible population with an explicit row cap", async () => { + it("asks the count endpoint with the threshold and exclude list, no row cap", async () => { vi.stubEnv("GOVERNANCE_INDEXER_URL", "https://indexer.example.test/"); - const fetchMock = mockIndexerList([]); + const fetchMock = mockIndexerCount(0); await GET(); const url = new URL(String(fetchMock.mock.calls[0][0])); expect(url.origin + url.pathname).toBe( - "https://indexer.example.test/api/tally/delegates" + "https://indexer.example.test/api/tally/delegate-count" ); expect(url.searchParams.get("minVotingPower")).toBe( DELEGATE_MIN_VOTING_POWER_WEI ); - // Without this the indexer silently truncates at 1,000 rows. - expect(Number(url.searchParams.get("limit"))).toBeGreaterThan(1000); + // Exclusion is applied server-side now; the route forwards the exclude list. + expect(url.searchParams.get("exclude")).toBe( + EXCLUDED_DELEGATE_ADDRESSES.join(",") + ); + // No whole-list read — the count endpoint takes no limit. + expect(url.searchParams.has("limit")).toBe(false); }); - it("counts only delegates above the threshold, excluding the governance address", async () => { + it("relays the server-computed count", async () => { vi.stubEnv("GOVERNANCE_INDEXER_URL", "https://indexer.example.test"); - const above = DELEGATE_MIN_VOTING_POWER_WEI; - const below = ( - BigInt(DELEGATE_MIN_VOTING_POWER_WEI) - BigInt(1) - ).toString(); - mockIndexerList([ - listItem("0x1111111111111111111111111111111111111111", above), - listItem("0x2222222222222222222222222222222222222222", above), - listItem("0x3333333333333333333333333333333333333333", below), - // The indexer includes the exclude address, and it always clears the bar. - listItem(EXCLUDED_DELEGATE_ADDRESSES[0], "5000000000000000000000000000"), - ]); + mockIndexerCount(2); const response = await GET(); diff --git a/app/api/delegate-count/route.ts b/app/api/delegate-count/route.ts index c156e6a..e0b24ae 100644 --- a/app/api/delegate-count/route.ts +++ b/app/api/delegate-count/route.ts @@ -1,10 +1,9 @@ import { - DELEGATE_LIST_MAX_ROWS, DELEGATE_MIN_VOTING_POWER_ARB, DELEGATE_MIN_VOTING_POWER_WEI, - countEligibleDelegates, + EXCLUDED_DELEGATE_ADDRESSES, } from "@/config/delegates"; -import type { TallyDelegateListResult } from "@/lib/tally-data/types"; +import type { TallyDelegateCountResult } from "@/lib/tally-data/types"; export const dynamic = "force-dynamic"; @@ -19,9 +18,9 @@ function getIndexerUrl(): string | null { /** * Counts the delegates that clear the app-wide voting-power threshold. * - * The indexer has no count endpoint, so the only way to get this number is to - * read the whole eligible list (~250 KB today). This route does that read - * server-side and hands the browser a single integer instead. + * The indexer's dedicated /delegate-count endpoint applies the same threshold + * and the governance exclude list server-side (via SQL COUNT), so this route + * just relays that number — no whole-list read. */ export async function GET(): Promise { const indexerUrl = getIndexerUrl(); @@ -34,7 +33,7 @@ export async function GET(): Promise { const search = new URLSearchParams({ minVotingPower: DELEGATE_MIN_VOTING_POWER_WEI, - limit: String(DELEGATE_LIST_MAX_ROWS), + exclude: EXCLUDED_DELEGATE_ADDRESSES.join(","), }); const controller = new AbortController(); @@ -42,7 +41,7 @@ export async function GET(): Promise { try { const upstream = await fetch( - `${indexerUrl}/api/tally/delegates?${search}`, + `${indexerUrl}/api/tally/delegate-count?${search}`, { signal: controller.signal, headers: { accept: "application/json" }, @@ -52,12 +51,12 @@ export async function GET(): Promise { throw new Error(`Indexer request failed: ${upstream.status}`); } - const { delegates } = (await upstream.json()) as TallyDelegateListResult; + const { totalCount } = (await upstream.json()) as TallyDelegateCountResult; const headers = new Headers(); headers.set("content-type", "application/json"); - // The population moves slowly (delegations, not votes), and the upstream - // read is heavy, so cache it hard and refresh in the background. + // The population moves slowly (delegations, not votes), so cache it hard and + // refresh in the background. headers.set( "cache-control", "public, s-maxage=300, stale-while-revalidate=3600" @@ -65,7 +64,7 @@ export async function GET(): Promise { return new Response( JSON.stringify({ - count: countEligibleDelegates(delegates ?? []), + count: totalCount, minVotingPowerArb: DELEGATE_MIN_VOTING_POWER_ARB, minVotingPower: DELEGATE_MIN_VOTING_POWER_WEI, }), diff --git a/components/container/DelegateSearch.tsx b/components/container/DelegateSearch.tsx index ad9cccb..173dbb3 100644 --- a/components/container/DelegateSearch.tsx +++ b/components/container/DelegateSearch.tsx @@ -8,7 +8,6 @@ import RpcStatus from "@/components/container/RpcStatus"; import { DelegateStatsCards, DelegatesTable, - SnapshotBlockNotice, } from "@/components/container/delegate"; import { DELEGATE_MIN_VOTING_POWER_ARB, @@ -78,8 +77,14 @@ export default function DelegateSearch() { totalSupply, error, isLoading, - cacheStats, - snapshotBlock, + pageIndex, + pageSize, + rowCount, + setPagination, + sorting, + setSorting, + sortOrder, + setSortOrder, refreshVisibleDelegates, refreshedAddresses, } = useDelegateSearch({ @@ -128,13 +133,6 @@ export default function DelegateSearch() { /> )} - {snapshotBlock > 0 && cacheStats && ( - - )} - ; + // Server-driven pagination + sorting: the parent owns this state and refetches. + pageIndex: number; + pageSize: number; + rowCount: number; + onPaginationChange: (pagination: PaginationState) => void; + sorting: SortingState; + onSortingChange: (sorting: SortingState) => void; + sortOrder: DelegateSortOrder; + onSortOrderChange: (order: DelegateSortOrder) => void; onSearchChange: (value: string) => void; onMinPowerChange: (value: string) => void; onVisibleRowsChange: (addresses: string[]) => void; @@ -77,6 +82,14 @@ export function DelegatesTable({ rpcHealthy, minPowerFloor, refreshedAddresses, + pageIndex, + pageSize, + rowCount, + onPaginationChange, + sorting, + onSortingChange, + sortOrder, + onSortOrderChange, onSearchChange, onMinPowerChange, onVisibleRowsChange, @@ -84,59 +97,20 @@ export function DelegatesTable({ const [rowSelection, setRowSelection] = useState({}); const [columnVisibility, setColumnVisibility] = useState({}); const [columnFilters, setColumnFilters] = useState([]); - const [sorting, setSorting] = useState([]); - const [sortOrder, setSortOrder] = useState("votingPower"); - const prevSortOrderRef = useRef(sortOrder); - const randomOrderRef = useRef>(new Map()); - const randomOrderKeyRef = useRef(""); const [searchValue, setSearchValue] = useState(""); const [minPowerValue, setMinPowerValue] = useState(String(minPowerFloor)); const [delegateSummaries, setDelegateSummaries] = useState< Map >(new Map()); - const delegateAddressKey = useMemo( - () => - delegates - .map((delegate) => delegate.address.toLowerCase()) - .sort() - .join(","), - [delegates] - ); - - const sortedDelegates = useMemo(() => { - if (sortOrder !== "random") { - prevSortOrderRef.current = sortOrder; - return delegates; - } - - const shouldRefreshRandomOrder = - prevSortOrderRef.current !== "random" || - randomOrderKeyRef.current !== delegateAddressKey; - - if (shouldRefreshRandomOrder) { - randomOrderRef.current = buildShuffleMap( - delegates.map((delegate) => delegate.address.toLowerCase()) - ); - randomOrderKeyRef.current = delegateAddressKey; - } - - prevSortOrderRef.current = sortOrder; - - return sortByOrderMap( - delegates, - (delegate) => delegate.address.toLowerCase(), - randomOrderRef.current - ); - }, [delegateAddressKey, delegates, sortOrder]); const rowDelegateSummaries = useMemo(() => { const summaries = new Map(); - for (const delegate of sortedDelegates) { + for (const delegate of delegates) { const summary = getRowDelegateSummary(delegate); if (summary) summaries.set(delegate.address.toLowerCase(), summary); } return summaries; - }, [sortedDelegates]); + }, [delegates]); const tableDelegateSummaries = useMemo( () => new Map([...delegateSummaries, ...rowDelegateSummaries]), [rowDelegateSummaries, delegateSummaries] @@ -144,33 +118,46 @@ export function DelegatesTable({ // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ - data: sortedDelegates, + data: delegates, columns, state: { - sorting, columnVisibility, rowSelection, columnFilters, + sorting, + pagination: { pageIndex, pageSize }, }, - initialState: { - pagination: { pageIndex: 0, pageSize: 20 }, - }, + // Server-side pagination + sorting: `data` is already the current page, + // `rowCount` is the whole-set total from the count endpoint, and ordering is + // resolved by the indexer from the sorting/sortOrder state below. + manualPagination: true, + manualFiltering: true, + manualSorting: true, + rowCount, enableRowSelection: true, onRowSelectionChange: setRowSelection, - onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, - autoResetPageIndex: false, + onSortingChange: (updater) => { + const next = typeof updater === "function" ? updater(sorting) : updater; + onSortingChange(next); + }, + onPaginationChange: (updater) => { + const next = + typeof updater === "function" + ? updater({ pageIndex, pageSize }) + : updater; + onPaginationChange(next); + }, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), meta: { totalVotingPower, delegateSummaries: tableDelegateSummaries, refreshedAddresses, + rowOffset: pageIndex * pageSize, }, }); @@ -188,7 +175,6 @@ export function DelegatesTable({ // py-5 + h-7 avatar row ≈ 68px; header is 48px (h-12 in TableHead). const ROW_HEIGHT_PX = 68; const HEADER_HEIGHT_PX = 48; - const pageSize = table.getState().pagination.pageSize; const minTableHeight = pageSize * ROW_HEIGHT_PX + HEADER_HEIGHT_PX; useEffect(() => { @@ -238,17 +224,15 @@ export function DelegatesTable({ searchValue={searchValue} minPowerValue={minPowerValue} onSearchChange={(value) => { - table.setPageIndex(0); setSearchValue(value); onSearchChange(value); }} onMinPowerChange={(value) => { - table.setPageIndex(0); setMinPowerValue(value); onMinPowerChange(value); }} sortOrder={sortOrder} - onSortOrderChange={setSortOrder} + onSortOrderChange={onSortOrderChange} />
diff --git a/components/table/ColumnsDelegates.tsx b/components/table/ColumnsDelegates.tsx index fed063e..4b56d80 100644 --- a/components/table/ColumnsDelegates.tsx +++ b/components/table/ColumnsDelegates.tsx @@ -25,6 +25,9 @@ declare module "@tanstack/react-table" { totalVotingPower?: string; delegateSummaries?: Map; refreshedAddresses?: Set; + // Absolute index of the first row on the current page (pageIndex * pageSize), + // so rank stays continuous across server-paginated pages. + rowOffset?: number; } } @@ -35,10 +38,17 @@ export const columns: ColumnDef[] = [ label: "Rank", }, header: "Rank", - cell: ({ row }: { row: Row }) => { + cell: ({ + row, + table, + }: { + row: Row; + table: Table; + }) => { + const rowOffset = table.options.meta?.rowOffset ?? 0; return ( - {row.index + 1} + {rowOffset + row.index + 1} ); }, diff --git a/components/table/DelegatesToolbar.tsx b/components/table/DelegatesToolbar.tsx index 1f4c0ab..4edaa16 100644 --- a/components/table/DelegatesToolbar.tsx +++ b/components/table/DelegatesToolbar.tsx @@ -5,6 +5,7 @@ import { useCallback } from "react"; import { Table } from "@tanstack/react-table"; import { ArrowDownUp } from "lucide-react"; +import type { DelegateSortOrder } from "@/hooks/use-delegate-search"; import { cn } from "@/lib/utils"; import { ToolbarResetButton } from "@components/table/ToolbarResetButton"; import { ToolbarSearch } from "@components/table/ToolbarSearch"; @@ -18,8 +19,6 @@ import { SelectValue, } from "@components/ui/Select"; -export type DelegateSortOrder = "votingPower" | "random"; - interface DelegatesToolbarProps { table: Table; minPowerFloor: number; diff --git a/components/table/Pagination.tsx b/components/table/Pagination.tsx index 7f019e0..3c23aba 100644 --- a/components/table/Pagination.tsx +++ b/components/table/Pagination.tsx @@ -48,7 +48,9 @@ export function DataTablePagination({ const { pageIndex, pageSize } = table.getState().pagination; const currentPage = pageIndex + 1; - const total = table.getFilteredRowModel().rows.length; + // getRowCount() honors a manual `rowCount` (server-paginated total) and falls + // back to the pre-pagination row count for client-side tables — both correct. + const total = table.getRowCount(); const start = total === 0 ? 0 : pageIndex * pageSize + 1; const end = Math.min((pageIndex + 1) * pageSize, total); diff --git a/hooks/use-delegate-search.ts b/hooks/use-delegate-search.ts index d84c618..c7469a1 100644 --- a/hooks/use-delegate-search.ts +++ b/hooks/use-delegate-search.ts @@ -1,6 +1,11 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import type { SortingState } from "@tanstack/react-table"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -10,20 +15,33 @@ import { type DelegateInfo, } from "@gzeoneth/gov-tracker"; -import { countEligibleDelegates } from "@/config/delegates"; +import { EXCLUDED_DELEGATE_ADDRESSES } from "@/config/delegates"; import { delegateVotingPowerKey } from "@/hooks/use-delegate-voting-power"; import { useRpcSettings } from "@/hooks/use-rpc-settings"; import { debug } from "@/lib/debug"; -import { - delegateMatchesSearch, - getDelegateListStats, - loadDelegateList, - type TallyDelegateListItem, - type TallyDelegateListResult, -} from "@/lib/delegate-cache"; import { toError } from "@/lib/error-utils"; import { createRpcProvider } from "@/lib/rpc-utils"; -import type { DelegateCacheStats } from "@/types/delegate"; +import { getTallyDataClient } from "@/lib/tally-data/client"; +import type { + DelegateSortField, + TallyDelegateListItem, +} from "@/lib/tally-data/types"; + +/** Default rows per page for the server-paginated delegate table. */ +export const DELEGATE_PAGE_SIZE = 20; + +/** Toolbar order modes: ranked by voting power, or a seeded shuffle. */ +export type DelegateSortOrder = "votingPower" | "random"; + +// Map a TanStack column id to the indexer's sort field (percentage is derived +// from voting power, so it orders the same). +function columnToSortField(columnId: string): DelegateSortField { + return columnId === "address" ? "address" : "votingPower"; +} + +// Applied server-side (list + count) so pages and the total stay mutually +// consistent; a spread copy because the config export is a readonly tuple. +const EXCLUDE = [...EXCLUDED_DELEGATE_ADDRESSES]; export interface UseDelegateSearchOptions { enabled: boolean; @@ -35,22 +53,48 @@ export interface UseDelegateSearchOptions { export interface UseDelegateSearchResult { delegates: DelegateInfo[]; /** - * Size of the eligible delegate population, independent of the search and - * min-power filters, so a "total delegates" figure does not shrink as the - * user narrows the table. + * Size of the eligible delegate population for the active filters, from the + * dedicated count endpoint — independent of the current page. */ eligibleDelegateCount: number; totalVotingPower: string; totalSupply: string; error: Error | null; isLoading: boolean; - cacheStats?: DelegateCacheStats; - snapshotBlock: number; + pageIndex: number; + pageSize: number; + rowCount: number; + setPagination: (pagination: { pageIndex: number; pageSize: number }) => void; + sorting: SortingState; + setSorting: (sorting: SortingState) => void; + sortOrder: DelegateSortOrder; + setSortOrder: (order: DelegateSortOrder) => void; refreshVisibleDelegates: (addresses: string[]) => Promise; isRefreshingVisible: boolean; refreshedAddresses: Set; } +export function delegatesPageKey( + query: string, + minVotingPower: string, + pageIndex: number, + pageSize: number, + sort: string, + dir: string, + seed: string +) { + return [ + "delegates-page", + { query, minVotingPower, pageIndex, pageSize, sort, dir, seed }, + ] as const; +} + +export function delegatesCountKey(query: string, minVotingPower: string) { + return ["delegates-count", { query, minVotingPower }] as const; +} + +// Retained pure helpers (unit-tested in use-delegate-search.test.ts). No longer +// used by the hook itself now that filtering happens server-side. export function filterDelegates( delegates: DelegateInfo[], options: { @@ -84,109 +128,132 @@ export function sortDelegatesByVotingPower( export function useDelegateSearch({ enabled, customRpcUrl, - minVotingPower, + minVotingPower = "0", addressFilter, }: UseDelegateSearchOptions): UseDelegateSearchResult { const { l2Rpc, isHydrated } = useRpcSettings({ customL2Rpc: customRpcUrl }); const queryClient = useQueryClient(); - const [debouncedAddressFilter, setDebouncedAddressFilter] = useState( - addressFilter ?? "" + const client = useMemo(() => getTallyDataClient(), []); + + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(DELEGATE_PAGE_SIZE); + const [debouncedQuery, setDebouncedQuery] = useState( + (addressFilter ?? "").trim() ); - const [delegates, setDelegates] = useState([]); - const [totalVotingPower, setTotalVotingPower] = useState("0"); - const [totalSupply, setTotalSupply] = useState("0"); - const [snapshotBlock, setSnapshotBlock] = useState(0); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isRefreshingVisible, setIsRefreshingVisible] = useState(false); - const [cacheStats, setCacheStats] = useState(); - const [delegateData, setDelegateData] = - useState(null); + // Sort: a "Voting Power" vs "Random" toolbar mode plus optional column-header + // sorting; both resolve to an indexer order below. `random` carries a seed so + // the shuffle is stable across pages until the user re-selects it. + const [sortOrder, setSortOrderState] = + useState("votingPower"); + const [sorting, setSorting] = useState([]); + const [randomSeed, setRandomSeed] = useState(""); + + const { sort, dir, seed } = useMemo(() => { + if (sortOrder === "random") { + return { + sort: "random" as DelegateSortField, + dir: "desc", + seed: randomSeed, + }; + } + const active = sorting[0]; + if (!active) { + return { + sort: "votingPower" as DelegateSortField, + dir: "desc", + seed: "", + }; + } + return { + sort: columnToSortField(active.id), + dir: active.desc ? "desc" : "asc", + seed: "", + }; + }, [sortOrder, sorting, randomSeed]); + + const setSortOrder = useCallback((order: DelegateSortOrder) => { + if (order === "random") { + // A new seed each time Random is (re)selected → a fresh shuffle. + setRandomSeed(`${Math.floor(Math.random() * 1_000_000_000)}`); + } + setSortOrderState(order); + }, []); + // On-chain voting-power overlay for the visible page: refreshedAddresses gates + // the row out of its skeleton, refreshedPowers supplies the fresh value. const refreshedAddressesRef = useRef>(new Set()); const [refreshedAddresses, setRefreshedAddresses] = useState>( () => new Set() ); + const [refreshedPowers, setRefreshedPowers] = useState>( + () => new Map() + ); + const [isRefreshingVisible, setIsRefreshingVisible] = useState(false); + // Debounce the search box before it drives a server fetch. useEffect(() => { const timeout = window.setTimeout(() => { - setDebouncedAddressFilter(addressFilter ?? ""); + setDebouncedQuery((addressFilter ?? "").trim()); }, 250); - - return () => { - window.clearTimeout(timeout); - }; + return () => window.clearTimeout(timeout); }, [addressFilter]); + // Any filter or sort change resets to the first page. useEffect(() => { - let cancelled = false; - - setIsLoading(true); - setError(null); - - loadDelegateList() - .then((loaded) => { - if (cancelled) return; - - if (loaded) { - // SQLite already returns delegates ordered by rank - // (voting-power desc), so no resort is needed on load. - setDelegateData(loaded); - setTotalVotingPower(loaded.totalVotingPower); - setTotalSupply(loaded.totalSupply); - setSnapshotBlock(0); - setCacheStats(getDelegateListStats(loaded)); - debug.delegates( - "SQLite delegate list loaded: %d delegates", - loaded.delegates.length - ); - } - setIsLoading(false); - }) - .catch((err) => { - if (cancelled) return; - debug.delegates("failed to load SQLite delegate list: %O", err); - setError(toError(err)); - setIsLoading(false); - }); - - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - function filterFromCache() { - if (!delegateData) return; + setPageIndex(0); + }, [debouncedQuery, minVotingPower, sort, dir, seed]); + + const pageQuery = useQuery({ + queryKey: delegatesPageKey( + debouncedQuery, + minVotingPower, + pageIndex, + pageSize, + sort, + dir, + seed + ), + queryFn: () => + client.getDelegatesPage({ + minVotingPower, + query: debouncedQuery || undefined, + exclude: EXCLUDE, + limit: pageSize, + offset: pageIndex * pageSize, + sort, + dir: dir as "asc" | "desc", + seed: seed || undefined, + }), + placeholderData: keepPreviousData, + staleTime: 30_000, + }); - const baseDelegates = filterDelegates(delegateData.delegates, { + const countQuery = useQuery({ + queryKey: delegatesCountKey(debouncedQuery, minVotingPower), + queryFn: () => + client.getDelegateCount({ minVotingPower, - }) as TallyDelegateListItem[]; - - const trimmedFilter = debouncedAddressFilter.trim(); - const filtered = trimmedFilter - ? baseDelegates.filter((delegate) => - delegateMatchesSearch(delegate, trimmedFilter) - ) - : baseDelegates; - - // Preserve SQLite's voting-power-desc order. refreshVisibleDelegates - // sorts within the refreshed subset's original indices; sorting here - // would cause cascading reorders across pages. - setDelegates(filtered); - } + query: debouncedQuery || undefined, + exclude: EXCLUDE, + }), + placeholderData: keepPreviousData, + staleTime: 30_000, + }); - filterFromCache(); - }, [minVotingPower, debouncedAddressFilter, delegateData]); + // The server already orders by voting power desc; overlay any refreshed + // on-chain powers so the visible rows show live values. + const delegates = useMemo(() => { + const rows = (pageQuery.data?.delegates ?? []) as TallyDelegateListItem[]; + const overlaid = rows.map((row) => { + const power = refreshedPowers.get(row.address.toLowerCase()); + return power ? { ...row, votingPower: power } : row; + }); + return overlaid as unknown as DelegateInfo[]; + }, [pageQuery.data, refreshedPowers]); - // Counted off the loaded list rather than `delegates` so the figure survives - // the search box, and re-counted after refreshVisibleDelegates writes fresh - // on-chain power in case a delegate has dropped below the threshold. - const eligibleDelegateCount = useMemo( - () => (delegateData ? countEligibleDelegates(delegateData.delegates) : 0), - [delegateData] - ); + const rawError = pageQuery.error ?? countQuery.error; + const error = rawError ? toError(rawError) : null; const refreshVisibleDelegates = useCallback( async (addresses: string[]) => { @@ -195,11 +262,9 @@ export function useDelegateSearch({ const toRefresh = addresses.filter( (addr) => !refreshedAddressesRef.current.has(addr.toLowerCase()) ); - if (toRefresh.length === 0) return; setIsRefreshingVisible(true); - try { const provider = await createRpcProvider(l2Rpc); const powerMap = await queryDelegateVotingPowers(provider, toRefresh); @@ -222,6 +287,15 @@ export function useDelegateSearch({ } } + if (powerMap.size > 0) { + setRefreshedPowers((current) => { + const next = new Map(current); + for (const [addr, power] of powerMap) { + next.set(addr.toLowerCase(), power); + } + return next; + }); + } if (newlyRefreshed.length > 0) { setRefreshedAddresses((current) => { const next = new Set(current); @@ -229,54 +303,38 @@ export function useDelegateSearch({ return next; }); } - - if (powerMap.size > 0 && delegateData) { - // Update fresh values in place, then sort just the refreshed subset - // within its original indices. This reorders only the rows we just - // fetched (typically the visible page) using fresh on-chain values - // without shifting rows on other pages. - const refreshedIndices: number[] = []; - const refreshedObjects: TallyDelegateListItem[] = []; - const updatedDelegates = delegateData.delegates.map((d, i) => { - const newPower = powerMap.get(d.address.toLowerCase()); - if (!newPower) return d; - - const updated = { ...d, votingPower: newPower }; - refreshedIndices.push(i); - refreshedObjects.push(updated); - return updated; - }); - - const sortedSubset = sortDelegatesByVotingPower(refreshedObjects); - refreshedIndices.forEach((idx, k) => { - updatedDelegates[idx] = sortedSubset[k]; - }); - - setDelegateData({ ...delegateData, delegates: updatedDelegates }); - - const newTotalVotingPower = updatedDelegates - .reduce((sum, d) => sum + BigInt(d.votingPower), BigInt(0)) - .toString(); - setTotalVotingPower(newTotalVotingPower); - } } catch (err) { debug.delegates("error refreshing visible delegates: %O", err); } finally { setIsRefreshingVisible(false); } }, - [enabled, isHydrated, l2Rpc, delegateData, queryClient] + [enabled, isHydrated, l2Rpc, queryClient] + ); + + const setPagination = useCallback( + (pagination: { pageIndex: number; pageSize: number }) => { + setPageIndex(pagination.pageIndex); + setPageSize(pagination.pageSize); + }, + [] ); return { delegates, - eligibleDelegateCount, - totalVotingPower, - totalSupply, + eligibleDelegateCount: countQuery.data?.totalCount ?? 0, + totalVotingPower: countQuery.data?.totalVotingPower ?? "0", + totalSupply: countQuery.data?.totalSupply ?? "0", error, - isLoading, - cacheStats, - snapshotBlock, + isLoading: pageQuery.isLoading || countQuery.isLoading, + pageIndex, + pageSize, + rowCount: countQuery.data?.totalCount ?? 0, + setPagination, + sorting, + setSorting, + sortOrder, + setSortOrder, refreshVisibleDelegates, isRefreshingVisible, refreshedAddresses, diff --git a/lib/tally-data/indexer.test.ts b/lib/tally-data/indexer.test.ts index 15f7b0b..35bfad6 100644 --- a/lib/tally-data/indexer.test.ts +++ b/lib/tally-data/indexer.test.ts @@ -52,6 +52,57 @@ describe("IndexerTallyDataClient", () => { expect(Number(url.searchParams.get("limit"))).toBe(DELEGATE_LIST_MAX_ROWS); }); + it("requests a delegates page with paging + filter params", async () => { + const fetchMock = mockJsonFetch({ + delegates: [], + totalVotingPower: "0", + totalSupply: "0", + }); + + await new IndexerTallyDataClient().getDelegatesPage({ + minVotingPower: "42", + query: "alice", + exclude: ["0xAAA", "0xBBB"], + limit: 20, + offset: 40, + }); + + const url = new URL(String(fetchMock.mock.calls[0][0]), "https://app.test"); + expect(url.pathname).toBe("/api/governance-indexer/api/tally/delegates"); + expect(url.searchParams.get("minVotingPower")).toBe("42"); + expect(url.searchParams.get("query")).toBe("alice"); + expect(url.searchParams.get("exclude")).toBe("0xAAA,0xBBB"); + expect(url.searchParams.get("limit")).toBe("20"); + expect(url.searchParams.get("offset")).toBe("40"); + }); + + it("requests the delegate count endpoint with threshold + exclude", async () => { + const fetchMock = mockJsonFetch({ + totalCount: 5, + totalVotingPower: "0", + totalSupply: "0", + }); + + await expect( + new IndexerTallyDataClient().getDelegateCount({ + minVotingPower: "42", + exclude: ["0xAAA"], + }) + ).resolves.toEqual({ + totalCount: 5, + totalVotingPower: "0", + totalSupply: "0", + }); + + const url = new URL(String(fetchMock.mock.calls[0][0]), "https://app.test"); + expect(url.pathname).toBe( + "/api/governance-indexer/api/tally/delegate-count" + ); + expect(url.searchParams.get("minVotingPower")).toBe("42"); + expect(url.searchParams.get("exclude")).toBe("0xAAA"); + expect(url.searchParams.has("limit")).toBe(false); + }); + it("fetches aggregated vote summaries from the app route, not the proxy", async () => { const entries = [ { diff --git a/lib/tally-data/indexer.ts b/lib/tally-data/indexer.ts index 4a49309..ec799b2 100644 --- a/lib/tally-data/indexer.ts +++ b/lib/tally-data/indexer.ts @@ -5,10 +5,13 @@ import { DELEGATE_MIN_VOTING_POWER_WEI, } from "@/config/delegates"; import type { + DelegateCountParams, + DelegatePageParams, TallyAddressDisplayRecord, TallyCandidateSummary, TallyDataClient, TallyDataStats, + TallyDelegateCountResult, TallyDelegateListResult, TallyDelegateProfile, TallyDelegateSearchResult, @@ -91,6 +94,44 @@ export class IndexerTallyDataClient implements TallyDataClient { ); } + getDelegatesPage({ + minVotingPower, + query, + exclude, + limit, + offset, + sort, + dir, + seed, + }: DelegatePageParams) { + return fetchIndexer( + `api/tally/delegates${queryString({ + minVotingPower, + query: query || undefined, + exclude: exclude?.length ? exclude.join(",") : undefined, + limit, + offset, + sort, + dir, + seed: seed || undefined, + })}` + ); + } + + getDelegateCount({ + minVotingPower, + query, + exclude, + }: DelegateCountParams = {}) { + return fetchIndexer( + `api/tally/delegate-count${queryString({ + minVotingPower, + query: query || undefined, + exclude: exclude?.length ? exclude.join(",") : undefined, + })}` + ); + } + getDelegate(address: string) { return fetchIndexer( `api/tally/delegates/${normalizeAddress(address)}` diff --git a/lib/tally-data/types.ts b/lib/tally-data/types.ts index 7475987..a7ec9f9 100644 --- a/lib/tally-data/types.ts +++ b/lib/tally-data/types.ts @@ -31,6 +31,31 @@ export type TallyDelegateListResult = { totalSupply: string; }; +export type TallyDelegateCountResult = { + totalCount: number; + totalVotingPower: string; + totalSupply: string; +}; + +export type DelegateSortField = "votingPower" | "address" | "random"; + +export type DelegatePageParams = { + minVotingPower?: string; + query?: string; + exclude?: string[]; + limit: number; + offset: number; + sort?: DelegateSortField; + dir?: "asc" | "desc"; + seed?: string; +}; + +export type DelegateCountParams = { + minVotingPower?: string; + query?: string; + exclude?: string[]; +}; + export type TallyElectionCandidate = { address: string; name: string; @@ -124,6 +149,12 @@ export interface TallyDataClient { minVotingPower?: string, limit?: number ): Promise; + getDelegatesPage( + params: DelegatePageParams + ): Promise; + getDelegateCount( + params?: DelegateCountParams + ): Promise; getDelegate(address: string): Promise; getDelegateSummaries( addresses: string[]