From b364bb3a8f664360370fb0209cd9422446229b19 Mon Sep 17 00:00:00 2001 From: Samaro1 Date: Mon, 17 Aug 2026 15:34:21 +0100 Subject: [PATCH 1/6] feat(transactions): virtualize list and add cursor-based pagination Replace the plain transaction list with a lightweight windowed virtualizer so accounts with thousands of payments render only the visible rows, keeping DOM size and memory bounded. Convert pagination to a cursor-based scheme (Horizon paging tokens) so deep-page navigation never skips or repeats rows when new transactions arrive between requests. - VirtualizedList: dependency-free windowed list, roving-tabindex safe, with aria-posinset/aria-setsize per row and a polite live region - api: encodeCursor/decodeCursor, readCursorFromQuery/updateCursorInUrl, dedupeById, mergeRecordPages, prependNewestUnique, cursorSlice - transactions page: URL keeps ?cursor= anchor, echo-guarded sync - eslint: set next.rootDir so lint-staged resolves /pages from frontend/ - 17 acceptance tests covering skip/duplicate safety, window mounting, ARIA, search across pages, and 10k/50k-row performance - translations for the new "Newest" control across all locales --- frontend/.eslintrc.json | 42 +- .../__tests__/transactionPagination.test.ts | 389 +++++++++ frontend/components/TransactionList.tsx | 735 ++++++++++-------- frontend/components/VirtualizedList.tsx | 257 ++++++ frontend/lib/api.ts | 219 +++++- frontend/pages/transactions.tsx | 125 ++- frontend/public/locales/ar/common.json | 1 + frontend/public/locales/en/common.json | 1 + frontend/public/locales/es/common.json | 1 + frontend/public/locales/fr/common.json | 1 + frontend/public/locales/he/common.json | 1 + frontend/public/locales/ja/common.json | 1 + frontend/public/locales/pt/common.json | 1 + 13 files changed, 1400 insertions(+), 374 deletions(-) create mode 100644 frontend/__tests__/transactionPagination.test.ts create mode 100644 frontend/components/VirtualizedList.tsx diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 3e0663e3..b1bd8bf2 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,28 +1,38 @@ { - "extends": [ - "next/core-web-vitals", - "plugin:@typescript-eslint/recommended", - "prettier" - ], + "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"], + "settings": { + "next": { + "rootDir": "frontend" + } + }, "parser": "@typescript-eslint/parser", "plugins": ["@typescript-eslint"], "rules": { "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-unused-vars": ["warn", { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_" - }], + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ], "@typescript-eslint/prefer-optional-chain": "off", "@typescript-eslint/no-non-null-assertion": "warn", - "no-console": ["warn", { - "allow": ["warn", "error"] - }], + "no-console": [ + "warn", + { + "allow": ["warn", "error"] + } + ], "react-hooks/exhaustive-deps": "warn", - "import/order": ["warn", { - "groups": ["builtin", "external", "internal", "parent", "sibling", "index"], - "alphabetize": { "order": "asc" } - }] + "import/order": [ + "warn", + { + "groups": ["builtin", "external", "internal", "parent", "sibling", "index"], + "alphabetize": { "order": "asc" } + } + ] }, "overrides": [ { diff --git a/frontend/__tests__/transactionPagination.test.ts b/frontend/__tests__/transactionPagination.test.ts new file mode 100644 index 00000000..caee0d6f --- /dev/null +++ b/frontend/__tests__/transactionPagination.test.ts @@ -0,0 +1,389 @@ +/** + * __tests__/transactionPagination.test.ts + * Performance + correctness tests for the virtualized, cursor-paginated + * transaction list: + * + * 1. Cursor pagination must never skip or duplicate rows, even when new + * transactions arrive at the head of the dataset between page requests. + * 2. The list must only mount the visible window (virtualization), verified + * against synthetic datasets of >= 10k rows. + * 3. Filters/search keep working across paginated pages. + * 4. Window computations and renders stay cheap for large datasets. + */ + +import { render, within } from "@testing-library/react"; +import { createElement } from "react"; +import VirtualizedList, { + getVisibleRange, + computeScrollOffset, +} from "@/components/VirtualizedList"; +import { + encodeCursor, + decodeCursor, + readCursorFromQuery, + updateCursorInUrl, + dedupeById, + mergeRecordPages, + prependNewestUnique, + cursorSlice, + CURSOR_QUERY_KEY, +} from "@/lib/api"; +import { PaymentRecord } from "@/lib/stellar"; +import { searchPayments, parseSearchQuery } from "@/lib/transactionSearch"; + +// ─── Synthetic dataset helpers ─────────────────────────────────────────────── + +export type Synthetic = PaymentRecord & { + pagingToken: string; + /** Legacy field still read by transactionSearch at runtime. */ + hash: string; +}; + +/** Build a newest-first dataset of `count` payments with sequential ids. */ +function buildDataset(count: number, idOffset = 0) { + const records: Synthetic[] = []; + for (let i = 0; i < count; i++) { + // Most recent first, so `i` grows as the history gets older. + records.push({ + id: `op-${idOffset + i}`, + type: i % 3 === 0 ? "sent" : "received", + amount: `${(i % 400) + 0.5}`, + asset: "XLM:GBSTRUSD", + from: `GACCOUNTFROM00000000000000000000000000000000000000${i.toString(36)}`, + to: `GACCOUNTTO000000000000000000000000000000000000000000${i.toString(36)}`, + memo: i % 10 === 0 ? `payroll-${i}` : `memo-${i}`, + hash: `hash${i}`.slice(0, 64), + createdAt: new Date(2026, 0, 1, 0, 0, Math.max(1, count - i)).toISOString(), + transactionHash: `abc123def456ghi789${i.toString(36)}${i}`, + pagingToken: `1000000-${count - i}`, + }); + } + return records; +} + +/** Extract a `cursor=` param from a URL string (helper for assertions). */ +function readCursorFromUrl(url: string): string { + const match = url.match(new RegExp(`[?&]${CURSOR_QUERY_KEY}=([^&]+)`)); + return match ? decodeURIComponent(match[1]) : ""; +} + +// ─── Cursor pagination correctness ─────────────────────────────────────────── + +describe("cursor pagination — skip & duplicate safety", () => { + it("paginates a full dataset with no skipped or duplicated rows", () => { + const dataset = buildDataset(100); + const limit = 10; + const seenIds: string[] = []; + let cursor: string | undefined; + let pages = 0; + + for (;;) { + const page = cursorSlice(dataset, limit, cursor); + if (page.records.length === 0) break; + seenIds.push(...page.records.map((r) => r.id)); + pages += 1; + if (page.nextCursor == null) break; + cursor = page.nextCursor; + } + + expect(pages).toBe(10); + expect(new Set(seenIds).size).toBe(100); + expect(seenIds).toEqual(dataset.map((r) => r.id)); + }); + + it("does not skip or duplicate older rows when new transactions arrive mid-pagination", () => { + const dataset = buildDataset(100); + const limit = 10; + + const page1 = cursorSlice(dataset, limit); + expect(page1.records).toHaveLength(10); + const cursorAfterPage1 = page1.nextCursor; + + const page2 = cursorSlice(dataset, limit, cursorAfterPage1); + expect(page2.records.map((r) => r.id)).toEqual([ + "op-10", + "op-11", + "op-12", + "op-13", + "op-14", + "op-15", + "op-16", + "op-17", + "op-18", + "op-19", + ]); + + // Five brand-new payments land at the head of the history. + const withNewData = [...buildDataset(5), ...dataset]; + const page3 = cursorSlice(withNewData, limit, page2.nextCursor); + + // Oldest boundary records still advance one clean page — no overlap with + // page2 (no duplicates) and nothing older skipped. + expect(page3.records.map((r) => r.id)).toEqual([ + "op-20", + "op-21", + "op-22", + "op-23", + "op-24", + "op-25", + "op-26", + "op-27", + "op-28", + "op-29", + ]); + expect(page3.nextCursor).toBe(dataset[29].pagingToken); + }); + + it("mergeRecordPages deduplicates overlapping pages from a shifting backend", () => { + const dataset = buildDataset(20); + const page1 = cursorSlice(dataset, 10).records; + // Simulate Horizon returning a page whose head overlaps page1's tail. + const overlapping = [dataset[9], ...dataset.slice(10, 18)]; + const merged = mergeRecordPages(page1, overlapping); + expect(new Set(merged.map((r) => r.id)).size).toBe(merged.length); + expect(merged.length).toBe(18); + // Order is stable: everything loaded keeps its first-seen position. + expect(merged[0].id).toBe(page1[0].id); + expect(merged[merged.length - 1].id).toBe(overlapping[overlapping.length - 1].id); + }); + + it("prependNewestUnique folds fresh records in without duplicating loaded ones", () => { + const existing = cursorSlice(buildDataset(50), 20).records; + const fresh = buildDataset(5, 100); // brand-new ids that must not collide + const merged = prependNewestUnique(existing, fresh); + expect(merged.slice(0, 5).map((r) => r.id)).toEqual(fresh.map((r) => r.id)); + expect(new Set(merged.map((r) => r.id)).size).toBe(merged.length); + // Everything previously loaded is still present exactly once. + expect(merged.length).toBe(5 + existing.length); + }); + + it("reacts gracefully when the cursor no longer exists (records trimmed)", () => { + const dataset = buildDataset(30); + const stale = cursorSlice(dataset, 10, "1000000-999999"); + expect(stale.records).toHaveLength(0); + expect(stale.nextCursor).toBeUndefined(); + expect(stale.hasMore).toBe(false); + }); + + it("cursor encoders round-trip and stay URL-safe", () => { + const raw = "1234567-890:ABCDEF special/characters=+"; + const encoded = encodeCursor(raw); + expect(encoded).not.toBe(raw); + expect(encoded).not.toMatch(/[+/=]/); // base64url alphabet only + expect(decodeCursor(encoded)).toBe(raw); + + const url = updateCursorInUrl("/transactions?direction=sent&limit=20", raw); + expect(readCursorFromUrl(url)).toBe(encoded); + expect(readCursorFromQuery(new URLSearchParams(new URL(url, "http://x").search))).toBe(raw); + expect(readCursorFromQuery(url.split("?")[1] ?? "")).toBe(raw); + }); + + it("updateCursorInUrl removes the cursor without dropping other params", () => { + const url = "/transactions?cursor=cGF5bG9hZP0&direction=sent"; + expect(updateCursorInUrl(url, undefined)).toBe("/transactions?direction=sent"); + expect(updateCursorInUrl("/transactions", undefined)).toBe("/transactions"); + }); + + it("readCursorFromQuery understands string, URLSearchParams and object sources", () => { + const raw = "tob"; + const encoded = encodeCursor(raw); + expect(readCursorFromQuery(`cursor=${encoded}&x=1`)).toBe(raw); + expect(readCursorFromQuery(new URLSearchParams(`cursor=${encoded}`))).toBe(raw); + expect(readCursorFromQuery({ [CURSOR_QUERY_KEY]: encoded })).toBe(raw); + expect(readCursorFromQuery({ [CURSOR_QUERY_KEY]: [encoded] })).toBe(raw); + expect(readCursorFromQuery({ foo: "bar" })).toBeUndefined(); + expect(readCursorFromQuery(null)).toBeUndefined(); + }); + + it("dedupeById is stable and preserves first-seen order", () => { + const rows = [ + { id: "a", v: 1 }, + { id: "b", v: 2 }, + { id: "a", v: 3 }, + { id: "c", v: 4 }, + ]; + expect(dedupeById(rows).map((r) => [r.id, r.v])).toEqual([ + ["a", 1], + ["b", 2], + ["c", 4], + ]); + }); +}); + +// ─── Virtualization: only the visible window is mounted ────────────────────── + +describe("virtualized list windowing", () => { + const stride = 76 + 8; // itemHeight + rowGap + + it("mounts only a small window for a 10k-row dataset", () => { + const dataset = buildDataset(10_000); + + const range = getVisibleRange({ + total: dataset.length, + scrollTop: 0, + viewportHeight: 800, + itemStride: stride, + overscan: 6, + }); + expect(range.startIndex).toBe(0); + // Real visible rows (800 / 84 ≈ 10) plus overscan — nowhere near 10k. + expect(range.endIndex).toBeLessThan(50); + + const deep = getVisibleRange({ + total: dataset.length, + scrollTop: 8_000 * stride, + viewportHeight: 800, + itemStride: stride, + overscan: 6, + }); + expect(deep.startIndex).toBeGreaterThanOrEqual(7_990); + expect(deep.startIndex).toBeLessThanOrEqual(8_000); + expect(deep.endIndex - deep.startIndex).toBeLessThan(50); + }); + + it("renders 10k rows with a bounded DOM size and correct ARIA", () => { + const dataset = buildDataset(10_000); + const start = performance.now(); + const { container } = render( + createElement(VirtualizedList as never, { + items: dataset as PaymentRecord[], + itemHeight: 76, + rowGap: 8, + listLabel: "Payment history", + itemKey: (tx: PaymentRecord) => tx.id, + renderItem: (tx: PaymentRecord) => createElement("div", null, tx.id), + }), + ); + const renderTime = performance.now() - start; + + const list = within(container as HTMLElement).getByRole("list"); + expect(list).toHaveAttribute("aria-label", "Payment history"); + + const mounted = container.querySelectorAll("[data-virtual-row]"); + expect(mounted.length).toBeGreaterThan(0); + expect(mounted.length).toBeLessThan(100); // window + overscan only + + // Every mounted row advertises its true position in the full dataset. + mounted.forEach((row) => { + expect(row).toHaveAttribute("aria-setsize", "10000"); + expect(Number(row.getAttribute("aria-posinset"))).toBeGreaterThan(0); + }); + + // Polite live region announces the visible range of the full list. + const live = container.querySelector("[data-virtualized-live]"); + expect(live).toHaveAttribute("role", "status"); + expect(live).toHaveAttribute("aria-live", "polite"); + expect(live?.textContent).toContain("of 10000"); + + // Rendering 10k rows must stay cheap. + expect(renderTime).toBeLessThan(3000); + }); + + it("keeps the focused row mounted so keyboard nav never targets a stale row", () => { + const dataset = buildDataset(10_000); + const { container } = render( + createElement(VirtualizedList as never, { + items: dataset as PaymentRecord[], + itemHeight: 76, + rowGap: 8, + listLabel: "Payment history", + itemKey: (tx: PaymentRecord) => tx.id, + renderItem: (tx: PaymentRecord) => createElement("div", null, tx.id), + focusedIndex: 9_000, + }), + ); + const mounted = container.querySelectorAll("[data-virtual-row]"); + expect(mounted.length).toBeLessThan(100); + const positions = Array.from(mounted).map((row) => Number(row.getAttribute("aria-posinset"))); + expect(positions).toContain(9_000); + }); + + it("viewport scroll offset computation matches browser geometry", () => { + // List top scrolled 200px above the viewport top → offset is 200. + expect(computeScrollOffset({ viewportScrollY: 500, containerRectTop: -200 })).toBe(200); + // List still below the fold → offset clamps to 0. + expect(computeScrollOffset({ viewportScrollY: 100, containerRectTop: 400 })).toBe(0); + }); +}); + +// ─── Filters & search across paginated pages ───────────────────────────────── + +describe("filters and search across paginated pages", () => { + it("search still finds a transaction that lives on a later page", () => { + const dataset = buildDataset(500); + const page1 = cursorSlice(dataset, 20).records; + const page2 = cursorSlice(dataset, 20, "1000000-480").records; + const merged = mergeRecordPages(page1, page2); + + // Force a unique candidate later in history so the match only exists once + // both pages have been loaded. + const target = { ...dataset[50], memo: "waldo-42" }; + const mergedWithTarget = mergeRecordPages(merged, [target]); + + const results = searchPayments(mergedWithTarget, "waldo-42"); + expect(results).toHaveLength(1); + expect(results[0].payment.id).toBe(target.id); + expect(parseSearchQuery("waldo-42").text).toBe("waldo-42"); + }); + + it("operator-based filtering applies across merged pages", () => { + const dataset = buildDataset(300); + const pages = [cursorSlice(dataset, 20), cursorSlice(dataset, 20, "1000000-280")]; + const records = pages.reduce( + (acc, page) => mergeRecordPages(acc, page.records), + [] as PaymentRecord[], + ); + + // dataset[30] only appears on the second page (records 21..40), so a + // match proves operator filters apply across merged cursor pages. + const target = dataset[30]; + const results = searchPayments(records, `amount:${target.amount}`); + expect(results).toHaveLength(1); + expect(results[0].payment.id).toBe(target.id); + results.forEach((r) => { + expect(parseFloat(r.payment.amount)).toBe(parseFloat(target.amount)); + }); + }); +}); + +// ─── Performance assertions for large datasets ─────────────────────────────── + +describe("performance on large synthetic datasets", () => { + it("computes windows instantly for 50k+ rows", () => { + const dataset = buildDataset(50_000); + const start = performance.now(); + let last = { startIndex: 0, endIndex: 0 }; + // Simulate a scroll through the whole history. + for (let i = 0; i < 500; i++) { + last = getVisibleRange({ + total: dataset.length, + scrollTop: i * 5 * 84, + viewportHeight: 800, + itemStride: 84, + overscan: 6, + }); + } + const elapsed = performance.now() - start; + expect(elapsed).toBeLessThan(500); + expect(last.endIndex).toBeLessThanOrEqual(dataset.length); + }); + + it("is resilient with an empty dataset and degenerate inputs", () => { + expect( + getVisibleRange({ + total: 0, + scrollTop: 0, + viewportHeight: 800, + itemStride: 84, + overscan: 6, + }), + ).toEqual({ startIndex: 0, endIndex: 0 }); + expect(cursorSlice([], 20)).toEqual({ + records: [], + nextCursor: undefined, + hasMore: false, + }); + // Limit 0 degrades to a minimum of 1 rather than throwing. + expect(cursorSlice(buildDataset(5), 0).records).toHaveLength(1); + }); +}); diff --git a/frontend/components/TransactionList.tsx b/frontend/components/TransactionList.tsx index 17ad66b8..d661e7c1 100644 --- a/frontend/components/TransactionList.tsx +++ b/frontend/components/TransactionList.tsx @@ -8,6 +8,9 @@ import { motion, AnimatePresence } from "framer-motion"; import { useRouter } from "next/router"; import { useState, useEffect, useCallback, useRef, useReducer } from "react"; import { useTranslation } from "react-i18next"; +import HighlightedTransactionRow from "./HighlightedTransactionRow"; +import TransactionSearchBar from "./TransactionSearchBar"; +import VirtualizedList from "./VirtualizedList"; import { withErrorBoundary } from "@/components/ErrorBoundary"; import { HistoryIcon, @@ -15,9 +18,9 @@ import { ArrowDownIcon, RefreshIcon, ExternalLinkIcon, - PrinterIcon, } from "@/components/icons"; import { useContacts } from "@/hooks/useContacts"; +import { CursorPageInfo, mergeRecordPages } from "@/lib/api"; import { logger } from "@/lib/logger"; import { getPaymentHistory, @@ -28,8 +31,15 @@ import { } from "@/lib/stellar"; import { SearchResult } from "@/lib/transactionSearch"; import { formatAsset, timeAgo, copyToClipboard } from "@/utils/format"; -import HighlightedTransactionRow from "./HighlightedTransactionRow"; -import TransactionSearchBar from "./TransactionSearchBar"; + +/** + * Upper bound on how many payment records are held in memory at once. + * Virtualization keeps the DOM tiny; this bounds the array itself so + * accounts with massive histories stay memory-light. + */ +const MAX_PAGINATED_PAYMENTS = 5000; +const VIRTUALIZED_ROW_HEIGHT = 76; +const VIRTUALIZED_ROW_GAP = 8; /** Shape of the CustomEvent detail for pending transaction lifecycle events. */ interface PendingTxEventDetail { @@ -62,6 +72,10 @@ interface TransactionListProps { limit?: number; compact?: boolean; filters?: TransactionFilters; + /** Cursor anchor from the URL (`?cursor=...`) used to resume pagination. */ + cursor?: string; + /** Called after a page loads so the parent can keep the URL in sync. */ + onCursorChange?: (info: CursorPageInfo) => void; /** Called whenever the payments array changes so the parent can access it. */ onPaymentsChange?: (payments: PaymentRecord[]) => void; /** Called when the user wants to print a receipt for a payment. */ @@ -80,18 +94,19 @@ interface CachedPaymentHistory { const PAYMENT_HISTORY_CACHE_PREFIX = "finchippay:offline-payments:"; -function getPaymentHistoryCacheKey(publicKey: string, limit: number) { - return `${PAYMENT_HISTORY_CACHE_PREFIX}${publicKey}:${limit}`; +function getPaymentHistoryCacheKey(publicKey: string, limit: number, cursor?: string) { + return `${PAYMENT_HISTORY_CACHE_PREFIX}${publicKey}:${limit}:${cursor ?? "latest"}`; } function loadCachedPaymentHistory( publicKey: string, - limit: number + limit: number, + cursor?: string, ): CachedPaymentHistory | null { if (typeof window === "undefined") return null; try { - const raw = window.localStorage.getItem(getPaymentHistoryCacheKey(publicKey, limit)); + const raw = window.localStorage.getItem(getPaymentHistoryCacheKey(publicKey, limit, cursor)); if (!raw) return null; const parsed = JSON.parse(raw) as CachedPaymentHistory; if (!Array.isArray(parsed.records) || typeof parsed.savedAt !== "number") { @@ -106,13 +121,14 @@ function loadCachedPaymentHistory( function savePaymentHistorySnapshot( publicKey: string, limit: number, - snapshot: Omit + cursor: string | undefined, + snapshot: Omit, ) { if (typeof window === "undefined") return; window.localStorage.setItem( - getPaymentHistoryCacheKey(publicKey, limit), - JSON.stringify({ ...snapshot, savedAt: Date.now() }) + getPaymentHistoryCacheKey(publicKey, limit, cursor), + JSON.stringify({ ...snapshot, savedAt: Date.now() }), ); } @@ -127,22 +143,18 @@ function formatSnapshotTime(savedAt: number) { export function filterPayments( payments: PaymentRecord[], - filters: TransactionFilters + filters: TransactionFilters, ): PaymentRecord[] { - const minimumAmount = - filters.minAmount.trim() === "" ? null : Number(filters.minAmount); + const minimumAmount = filters.minAmount.trim() === "" ? null : Number(filters.minAmount); const hasMinimumAmount = minimumAmount !== null && Number.isFinite(minimumAmount) && minimumAmount >= 0; const memoQuery = filters.memoSearch.trim().toLowerCase(); return payments.filter((payment) => { - const matchesDirection = - filters.direction === "all" || payment.type === filters.direction; - const matchesAmount = - !hasMinimumAmount || Number(payment.amount) >= (minimumAmount ?? 0); + const matchesDirection = filters.direction === "all" || payment.type === filters.direction; + const matchesAmount = !hasMinimumAmount || Number(payment.amount) >= (minimumAmount ?? 0); const matchesMemo = - !memoQuery || - (payment.memo && payment.memo.toLowerCase().includes(memoQuery)); + !memoQuery || (payment.memo && payment.memo.toLowerCase().includes(memoQuery)); return matchesDirection && matchesAmount && matchesMemo; }); @@ -153,6 +165,8 @@ function TransactionList({ limit = 20, compact = false, filters = { direction: "all", minAmount: "", memoSearch: "" }, + cursor, + onCursorChange, onPaymentsChange, onPrintReceipt, incomingPayment, @@ -169,27 +183,31 @@ function TransactionList({ const [focusedIndex, setFocusedIndex] = useState(-1); const [stalePaymentsAt, setStalePaymentsAt] = useState(null); const [searchResults, setSearchResults] = useState([]); - + const [isAdvanced, setIsAdvanced] = useState(false); + type PendingAction = | { type: "ADD"; payload: PaymentRecord } | { type: "RESOLVE"; payload: { pendingId: string; resolvedTx: PaymentRecord } } | { type: "REMOVE"; payload: string } | { type: "INIT"; payload: PaymentRecord[] }; - const [pendingPayments, dispatchPending] = useReducer((state: PaymentRecord[], action: PendingAction): PaymentRecord[] => { - switch (action.type) { - case "ADD": - return [action.payload, ...state]; - case "RESOLVE": - return state.filter((tx) => tx.id !== action.payload.pendingId); - case "REMOVE": - return state.filter((tx) => tx.id !== action.payload); - case "INIT": - return action.payload; - default: - return state; - } - }, []); + const [pendingPayments, dispatchPending] = useReducer( + (state: PaymentRecord[], action: PendingAction): PaymentRecord[] => { + switch (action.type) { + case "ADD": + return [action.payload, ...state]; + case "RESOLVE": + return state.filter((tx) => tx.id !== action.payload.pendingId); + case "REMOVE": + return state.filter((tx) => tx.id !== action.payload); + case "INIT": + return action.payload; + default: + return state; + } + }, + [], + ); useEffect(() => { try { @@ -197,17 +215,27 @@ function TransactionList({ dispatchPending({ type: "INIT", payload: stored }); } catch {} - const onPending = (e: CustomEvent) => dispatchPending({ type: "ADD", payload: e.detail as PaymentRecord }); - const onResolved = (e: CustomEvent) => { - dispatchPending({ type: "RESOLVE", payload: e.detail }); + const onPending = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail) return; + dispatchPending({ type: "ADD", payload: detail as PaymentRecord }); + }; + const onResolved = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail) return; + dispatchPending({ type: "RESOLVE", payload: detail }); setPayments((prev) => { - if (prev.some(p => p.id === e.detail.resolvedTx.id)) return prev; - const next = [e.detail.resolvedTx, ...prev]; + if (prev.some((p) => p.id === detail.resolvedTx.id)) return prev; + const next = [detail.resolvedTx, ...prev]; onPaymentsChange?.(next); return next; }); }; - const onFailed = (e: CustomEvent) => dispatchPending({ type: "REMOVE", payload: e.detail.pendingId }); + const onFailed = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail) return; + dispatchPending({ type: "REMOVE", payload: detail.pendingId }); + }; window.addEventListener("finchippay:pending-tx", onPending); window.addEventListener("finchippay:resolved-tx", onResolved); @@ -229,6 +257,14 @@ function TransactionList({ const router = useRouter(); const lastPagingTokenRef = useRef(undefined); + // Cursor the parent has already written to the URL. Used to ignore the echo + // of our own `onCursorChange` reports so the URL never triggers refetch loops. + const echoCursorRef = useRef(undefined); + // Cursor we are currently anchored at (position of the newest loaded page). + const anchoredCursorRef = useRef(cursor); + // Last cursor the URL sent us. Initialized to the prop so deep links on mount + // are handled by the initial fetch instead of triggering a second request. + const externalCursorRef = useRef(cursor); const [infiniteScroll, setInfiniteScroll] = useState(false); // Sentinel ref for IntersectionObserver — defer initial fetch until visible @@ -249,7 +285,7 @@ function TransactionList({ observer.disconnect(); } }, - { rootMargin: "200px" } + { rootMargin: "200px" }, ); observer.observe(el); return () => observer.disconnect(); @@ -260,75 +296,130 @@ function TransactionList({ setPayments(next); onPaymentsChange?.(next); }, - [onPaymentsChange] + [onPaymentsChange], ); const fetchPayments = useCallback( - async (isLoadMore = false) => { + async (isLoadMore = false, navigateCursor?: string) => { + const isNavigate = navigateCursor !== undefined; if (isLoadMore) { setLoadingMore(true); } else { setLoading(true); - updatePayments([]); + if (!isNavigate) { + updatePayments([]); + } lastPagingTokenRef.current = undefined; setHasMore(true); } setError(null); try { - const cursorToUse = isLoadMore ? lastPagingTokenRef.current : undefined; - const data: PaymentHistoryResponse = await getPaymentHistory( - publicKey, - limit, - cursorToUse - ); + const cursorToUse = isLoadMore + ? lastPagingTokenRef.current + : isNavigate + ? navigateCursor + : anchoredCursorRef.current; + const data: PaymentHistoryResponse = await getPaymentHistory(publicKey, limit, cursorToUse); if (isLoadMore) { setPayments((prev) => { - const merged = [...prev, ...data.records]; - onPaymentsChange?.(merged); - savePaymentHistorySnapshot(publicKey, limit, { - records: merged, + const merged = mergeRecordPages(prev, data.records); + const trimmed = + merged.length > MAX_PAGINATED_PAYMENTS + ? merged.slice(0, MAX_PAGINATED_PAYMENTS) + : merged; + onPaymentsChange?.(trimmed); + savePaymentHistorySnapshot(publicKey, limit, cursorToUse, { + records: trimmed, hasMore: data.hasMore, nextCursor: data.nextCursor, }); - return merged; + return trimmed; }); } else { - updatePayments(data.records); - savePaymentHistorySnapshot(publicKey, limit, { - records: data.records, + const refreshed = isNavigate ? data.records : data.records; + updatePayments(refreshed); + savePaymentHistorySnapshot(publicKey, limit, cursorToUse, { + records: refreshed, hasMore: data.hasMore, nextCursor: data.nextCursor, }); } setHasMore(data.hasMore); - const nextToken = data.records[data.records.length - 1]?.pagingToken; - lastPagingTokenRef.current = nextToken; + if (isLoadMore) { + lastPagingTokenRef.current = data.records[data.records.length - 1]?.pagingToken; + if (cursorToUse) setIsAdvanced(true); + } else { + anchoredCursorRef.current = cursorToUse; + lastPagingTokenRef.current = data.records[data.records.length - 1]?.pagingToken; + if (cursorToUse) setIsAdvanced(true); + } setStalePaymentsAt(null); + // Report the page so the parent can sync `?cursor=` and reset the + // "Newest" affordance. Echo-guarded upstream to avoid refetch loops: + // we mirror the cursor that the parent will write back to the URL. + echoCursorRef.current = cursorToUse; + onCursorChange?.({ + cursorUsed: cursorToUse, + nextCursor: data.nextCursor, + hasMore: data.hasMore, + }); } catch (err) { const cached = !isLoadMore - ? loadCachedPaymentHistory(publicKey, limit) + ? loadCachedPaymentHistory(publicKey, limit, navigateCursor ?? anchoredCursorRef.current) : null; if (cached) { updatePayments(cached.records); setHasMore(cached.hasMore); lastPagingTokenRef.current = cached.records[cached.records.length - 1]?.pagingToken; + anchoredCursorRef.current = navigateCursor ?? anchoredCursorRef.current; setStalePaymentsAt(cached.savedAt); setError(null); + echoCursorRef.current = anchoredCursorRef.current; + onCursorChange?.({ + cursorUsed: anchoredCursorRef.current, + nextCursor: cached.nextCursor, + hasMore: cached.hasMore, + }); return; } setError("Could not load transaction history."); - logger.error(err); + logger.error("Failed to load payment history", {}, err instanceof Error ? err : undefined); } finally { setLoading(false); setLoadingMore(false); } }, - [publicKey, limit, updatePayments, onPaymentsChange] + [publicKey, limit, updatePayments, onPaymentsChange, onCursorChange], ); + // React to external cursor navigation (deep links, browser back/forward, + // the "Newest" button). We skip cursors we just reported ourselves, which + // prevents the URL-sync → refetch loop. + useEffect(() => { + if (cursor === externalCursorRef.current) return; + if (cursor === echoCursorRef.current) return; + externalCursorRef.current = cursor; + if (isVisible) { + void fetchPayments(false, cursor); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cursor, isVisible]); + + // Jump back to the newest transaction. Re-anchors the pagination and clears + // the URL cursor (via the next onCursorChange report). + const goToNewest = useCallback(() => { + setFocusedIndex(-1); + setIsAdvanced(false); + externalCursorRef.current = undefined; + echoCursorRef.current = undefined; + anchoredCursorRef.current = undefined; + lastPagingTokenRef.current = undefined; + void fetchPayments(false); + }, [fetchPayments]); + // IntersectionObserver effect for Infinite Scroll useEffect(() => { if (!infiniteScroll || !hasMore || loadingMore || loading) return; @@ -342,7 +433,7 @@ function TransactionList({ fetchPayments(true); } }, - { rootMargin: "200px" } + { rootMargin: "200px" }, ); observer.observe(el); return () => observer.disconnect(); @@ -365,7 +456,7 @@ function TransactionList({ const existing = contacts.find((contact) => contact.publicKey === address); const nickname = window.prompt( existing ? "Update contact nickname:" : "Nickname for this contact:", - existing?.name || address.slice(0, 8) + existing?.name || address.slice(0, 8), ); if (!nickname) return; @@ -423,7 +514,9 @@ function TransactionList({ const visiblePayments = filterPayments([...pendingPayments, ...payments], filters); const hasActiveFilters = - filters.direction !== "all" || filters.minAmount.trim() !== "" || filters.memoSearch.trim() !== ""; + filters.direction !== "all" || + filters.minAmount.trim() !== "" || + filters.memoSearch.trim() !== ""; if (loading) { return ( @@ -436,10 +529,7 @@ function TransactionList({ )}
{Array.from({ length: 5 }).map((_, i) => ( -
+
@@ -461,10 +551,7 @@ function TransactionList({

{error}

-
@@ -480,10 +567,10 @@ function TransactionList({
-

{t("transactions.noTransactions")}

-

- {t("transactions.startMessage")} +

+ {t("transactions.noTransactions")}

+

{t("transactions.startMessage")}

{process.env.NEXT_PUBLIC_STELLAR_NETWORK !== "mainnet" && (

{t("transactions.needTestXlm")}{" "} @@ -503,101 +590,119 @@ function TransactionList({ } return ( -

-
- {!compact && ( -
-

- - {t("transactions.title")} -

-
- {/* Premium Infinite Scroll Toggle */} -