From 41c8c25227fb6e0106a78af36b46a61023e73864 Mon Sep 17 00:00:00 2001 From: satyakwok <119509589+satyakwok@users.noreply.github.com> Date: Thu, 25 Jun 2026 07:19:53 +0200 Subject: [PATCH] fix(scan): error states for home txs, tokens, and address lists Phase 1 of the fetcher error-state rollout. fetchLatestTransactions, fetchAccountHistory, fetchNativeTokens/fetchTokens, and fetchAccountTokens returned [] on a failed request, so an outage looked like an empty list. They now return null on failure so the hook flags an error. Added a shared FetchError component and wired it into the home transactions list, the tokens page, and the address tx-history and token-holdings tabs. --- apps/scan/app/[locale]/HomeContent.tsx | 5 ++- .../scan/app/[locale]/address/[addr]/page.tsx | 9 ++++-- apps/scan/app/[locale]/tokens/page.tsx | 5 ++- apps/scan/components/common/FetchError.tsx | 32 +++++++++++++++++++ apps/scan/lib/api.ts | 19 +++++++---- 5 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 apps/scan/components/common/FetchError.tsx diff --git a/apps/scan/app/[locale]/HomeContent.tsx b/apps/scan/app/[locale]/HomeContent.tsx index fb955ec..a0f6cd4 100644 --- a/apps/scan/app/[locale]/HomeContent.tsx +++ b/apps/scan/app/[locale]/HomeContent.tsx @@ -8,6 +8,7 @@ import { Link } from "@/i18n/navigation"; import { Blocks, ArrowUpDown, Search, Clock, Loader2, AlertTriangle } from "lucide-react"; import { toast } from "sonner"; import { RailBadge, classifyRail, type Rail } from "@/components/common/RailBadge"; +import { FetchError } from "@/components/common/FetchError"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { StatCardSkeleton } from "@/components/common/skeletons"; import { Skeleton } from "@/components/ui/skeleton"; @@ -92,7 +93,7 @@ export function HomeContent({ initial }: { initial: HomeBundle }) { const [railFilter, setRailFilter] = useState<"all" | Rail>("all"); const { data: stats, loading: statsLoading, error: statsError, refetch: refetchStats, retry: retryStats } = useStats(network, initial.stats); const { data: blocks, loading: blocksLoading, refetch: refetchBlocks } = useBlocks(network, 10, initial.blocks); - const { data: txs, loading: txsLoading, refetch: refetchTxs } = useTransactions(network, 10, initial.txs); + const { data: txs, loading: txsLoading, error: txsError, refetch: refetchTxs, retry: retryTxs } = useTransactions(network, 10, initial.txs); // Live block height via WebSocket. newHeads (proposed) + sentrix_finalized // (BFT-supermajority sealed) — both fed in so the UI can show the proposer // tip + the canonical finality cursor as separate values. Each new head @@ -526,6 +527,8 @@ export function HomeContent({ initial }: { initial: HomeBundle }) {
{Array.from({ length: 8 }).map((_, i) => )}
+ ) : txsError ? ( + ) : txs && txs.length > 0 ? ( (() => { // Apply rail filter. classifyRail wants {to_address, data}, our diff --git a/apps/scan/app/[locale]/address/[addr]/page.tsx b/apps/scan/app/[locale]/address/[addr]/page.tsx index d1f231f..33e7d9a 100644 --- a/apps/scan/app/[locale]/address/[addr]/page.tsx +++ b/apps/scan/app/[locale]/address/[addr]/page.tsx @@ -13,6 +13,7 @@ import { Copyable } from "@/components/common/Copyable"; import { Pagination } from "@/components/common/Pagination"; import { PageHeader } from "@/components/common/PageHeader"; import { EmptyState } from "@/components/common/EmptyState"; +import { FetchError } from "@/components/common/FetchError"; import { StatusBadge } from "@/components/common/StatusBadge"; import { useNetwork, useNetworkFromQuery } from "@/lib/network-context"; import { useAddress, useAddressHistory, useAccountTokens, useEventLogs } from "@/lib/hooks"; @@ -92,8 +93,8 @@ export default function AddressDetailPage({ params }: { params: Promise<{ addr: const [railFilter, setRailFilter] = useState("all"); const [activeTab, setActiveTab] = useState("history"); const { data: account, loading: accountLoading } = useAddress(network, addr); - const { data: history, loading: historyLoading } = useAddressHistory(network, addr, page, HISTORY_PAGE_SIZE); - const { data: tokens, loading: tokensLoading } = useAccountTokens(network, addr); + const { data: history, loading: historyLoading, error: historyError, retry: retryHistory } = useAddressHistory(network, addr, page, HISTORY_PAGE_SIZE); + const { data: tokens, loading: tokensLoading, error: tokensError, retry: retryTokens } = useAccountTokens(network, addr); const { data: eventLogs, loading: eventLogsLoading } = useEventLogs(network, addr); const label = useAddressLabel(addr); @@ -293,6 +294,8 @@ export default function AddressDetailPage({ params }: { params: Promise<{ addr:
{Array.from({ length: 8 }).map((_, i) => )}
+ ) : historyError ? ( + ) : filtered.length > 0 ? ( <> {/* Desktop table — single column hidden md+ on narrow viewports. @@ -445,6 +448,8 @@ export default function AddressDetailPage({ params }: { params: Promise<{ addr:
{Array.from({ length: 4 }).map((_, i) => )}
+ ) : tokensError ? ( + ) : tokens && tokens.length > 0 ? (
diff --git a/apps/scan/app/[locale]/tokens/page.tsx b/apps/scan/app/[locale]/tokens/page.tsx index b88dc68..6756372 100644 --- a/apps/scan/app/[locale]/tokens/page.tsx +++ b/apps/scan/app/[locale]/tokens/page.tsx @@ -11,6 +11,7 @@ import { Copyable } from "@/components/common/Copyable"; import { Pagination } from "@/components/common/Pagination"; import { PageHeader } from "@/components/common/PageHeader"; import { EmptyState } from "@/components/common/EmptyState"; +import { FetchError } from "@/components/common/FetchError"; import { useNetwork, useNetworkFromQuery } from "@/lib/network-context"; import { useTokens } from "@/lib/hooks"; import { formatNumber, shortenAddress } from "@/lib/format"; @@ -25,7 +26,7 @@ export default function TokensPage() { // Deeplink network switch. useNetworkFromQuery(); const searchParams = useSearchParams(); - const { data: tokens, loading } = useTokens(network); + const { data: tokens, loading, error, retry } = useTokens(network); const [sortKey, setSortKey] = useState("none"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); // ?standard=tokenop|evm pre-selects the filter so the Native rail's @@ -204,6 +205,8 @@ export default function TokensPage() {
{Array.from({ length: 8 }).map((_, i) => )}
+ ) : error ? ( + ) : paged.length > 0 ? ( <> {/* Desktop table */} diff --git a/apps/scan/components/common/FetchError.tsx b/apps/scan/components/common/FetchError.tsx new file mode 100644 index 0000000..ee5a485 --- /dev/null +++ b/apps/scan/components/common/FetchError.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { AlertTriangle, RotateCw } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { EmptyState } from "./EmptyState"; + +// Shown when a list/table fetch fails with no data. Kept distinct from the +// empty state so an outage doesn't read as "nothing here". Pass the hook's +// retry to offer a one-click refetch. +export function FetchError({ onRetry }: { onRetry?: () => void }) { + const tc = useTranslations("common"); + return ( + + + {tc("retry")} + + ) : undefined + } + /> + ); +} diff --git a/apps/scan/lib/api.ts b/apps/scan/lib/api.ts index b919e58..74ef171 100644 --- a/apps/scan/lib/api.ts +++ b/apps/scan/lib/api.ts @@ -480,7 +480,7 @@ export async function fetchLatestTransactions(network: NetworkId, count = 10) { network, `/transactions?limit=${count}`, ); - if (!res) return []; + if (res === null) return null; const rows = Array.isArray(res) ? res : (res.transactions ?? []); return rows.map((t): TransactionData => ({ id: t.txid && !t.txid.startsWith("0x") ? `0x${t.txid}` : t.txid, @@ -537,13 +537,14 @@ export async function fetchAccountHistory( address: string, page = 1, limit = 20, -): Promise { +): Promise { const offset = (page - 1) * limit; const res = await apiFetch<{ transactions: RawHistoryItem[] }>( network, `/address/${normalizeAddress(address)}/history?limit=${limit}&offset=${offset}`, ); - if (!res?.transactions) return []; + if (res === null) return null; + if (!res.transactions) return []; return res.transactions.map((t) => ({ id: t.txid, from: t.from, @@ -679,6 +680,9 @@ export async function fetchTokens(network: NetworkId) { fetchNativeTokens(network), fetchEvmTokensFromFactory(network), ]); + // Native is the primary token source; if it failed, surface the error + // rather than showing an EVM-only (or empty) list as if it were complete. + if (native === null) return null; // Dedupe by contract_address (lowercase). Native takes precedence // for any collision because it's the chain's own ledger; EVM events // could theoretically be replayed on a fork, so we trust native more. @@ -697,9 +701,9 @@ export async function fetchTokens(network: NetworkId) { // pages, which is where users actually need them as labels. export const fetchTokensForLabels = fetchNativeTokens; -async function fetchNativeTokens(network: NetworkId): Promise { +async function fetchNativeTokens(network: NetworkId): Promise { const res = await apiFetch<{ tokens: TokenData[] } | TokenData[]>(network, "/tokens"); - if (!res) return []; + if (res === null) return null; const list = Array.isArray(res) ? res : (res.tokens ?? []); return list.map((t) => ({ ...t, standard: "tokenop" as const })); } @@ -1096,9 +1100,10 @@ interface RawAccountTokenHolding { balance_raw?: number; } -export async function fetchAccountTokens(network: NetworkId, address: string): Promise { +export async function fetchAccountTokens(network: NetworkId, address: string): Promise { const res = await apiFetch<{ tokens: RawAccountTokenHolding[] }>(network, `/accounts/${normalizeAddress(address)}/tokens`); - if (!res?.tokens) return []; + if (res === null) return null; + if (!res.tokens) return []; return res.tokens.map((t) => ({ contract_address: t.contract_address ?? t.contract ?? "", symbol: t.symbol ?? "",