From 1b26daa0d7d7cb2f9fb0b8287f4d828f3643f8cf Mon Sep 17 00:00:00 2001 From: omipheo Date: Mon, 11 May 2026 05:55:19 +0200 Subject: [PATCH] fix(dashboard): show QueryError fallback when data fetches fail useApiQuery has retry: false, so the first failed fetch left dashboard panels stuck on a shimmer skeleton with no recovery path: users had to reload the page to retry, and they had no signal that anything had gone wrong. Adds a small shared QueryError component (icon + label + Retry button, styled to match the existing card chrome) and wires it into each consumer that renders a skeleton: StatsPanel, MinerRatesTable, OrderbookDepth, EventFeed, SwapTracker, and the SwapDetailPage. Each consumer destructures isError + refetch from useApiQuery and renders QueryError only when the query has errored AND has no data to fall back on. SwapDetailPage renders the fallback inside PageWrapper so the header and back-link stay in place. Refetch failures while data already exists stay silent, so the existing keepPreviousData behavior is preserved and the panels don't flash an error over a working view during a transient SSE/network blip. Out of scope (per the issue): no change to useApiQuery's retry policy, no global ErrorBoundary, no telemetry hook, no SSE-reconnect timer. Closes #34 --- src/components/QueryError.tsx | 67 ++++++++++++++++++++ src/components/dashboard/EventFeed.tsx | 7 +- src/components/dashboard/MinerRatesTable.tsx | 7 +- src/components/dashboard/OrderbookDepth.tsx | 9 ++- src/components/dashboard/StatsPanel.tsx | 7 +- src/components/dashboard/SwapTracker.tsx | 21 +++++- src/components/index.ts | 1 + src/pages/SwapDetailPage.tsx | 14 +++- 8 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 src/components/QueryError.tsx diff --git a/src/components/QueryError.tsx b/src/components/QueryError.tsx new file mode 100644 index 0000000..7692b3e --- /dev/null +++ b/src/components/QueryError.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Button, Stack, Typography } from '@mui/material'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { FONTS } from '../theme'; + +interface QueryErrorProps { + /** + * Optional one-line description of what failed to load. Falls back to a + * generic "Couldn't load data" message. + */ + label?: string; + /** + * Called when the user clicks Retry. Pass the consumer's `refetch` from + * react-query. + */ + onRetry: () => void; +} + +const QueryError: React.FC = ({ + label = "Couldn't load data", + onRetry, +}) => ( + + + + {label} + + + +); + +export default QueryError; diff --git a/src/components/dashboard/EventFeed.tsx b/src/components/dashboard/EventFeed.tsx index 590a6ff..d1bb928 100644 --- a/src/components/dashboard/EventFeed.tsx +++ b/src/components/dashboard/EventFeed.tsx @@ -15,6 +15,7 @@ import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import { displayEventType, useLatestEvents } from '../../api'; import { FONTS } from '../../theme'; import CopyableAddress from '../CopyableAddress'; +import QueryError from '../QueryError'; import { EventFeedSkeleton } from './Skeletons'; const getEventColor = ( @@ -56,7 +57,7 @@ const getEventColor = ( const EventFeed: React.FC = () => { const theme = useTheme(); - const { data: events, isLoading } = useLatestEvents(); + const { data: events, isLoading, isError, refetch } = useLatestEvents(); const scrollRef = useRef(null); const [scrolled, setScrolled] = useState(false); @@ -69,6 +70,10 @@ const EventFeed: React.FC = () => { scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); }, []); + if (isError && !events) { + return ; + } + return isLoading || !events ? ( ) : ( diff --git a/src/components/dashboard/MinerRatesTable.tsx b/src/components/dashboard/MinerRatesTable.tsx index 1ecc7ab..faf60ef 100644 --- a/src/components/dashboard/MinerRatesTable.tsx +++ b/src/components/dashboard/MinerRatesTable.tsx @@ -23,6 +23,7 @@ import SearchIcon from '@mui/icons-material/Search'; import { useMiners, type Miner } from '../../api'; import { FONTS } from '../../theme'; import CopyableAddress from '../CopyableAddress'; +import QueryError from '../QueryError'; import { MinerRatesTableSkeleton } from './Skeletons'; import { formatRate } from '../../utils/format'; @@ -130,7 +131,7 @@ const MinerRatesTable: React.FC = () => { collateral: { textAlign: 'center' }, }; - const { data: miners, isLoading } = useMiners(); + const { data: miners, isLoading, isError, refetch } = useMiners(); const [sortKey, setSortKey] = useState('rate'); const [sortDir, setSortDir] = useState('desc'); const [search, setSearch] = useState(''); @@ -345,6 +346,10 @@ const MinerRatesTable: React.FC = () => { return hasForward !== hasReverse; }; + if (isError && !miners) { + return ; + } + return isLoading || !miners ? ( ) : ( diff --git a/src/components/dashboard/OrderbookDepth.tsx b/src/components/dashboard/OrderbookDepth.tsx index 6fa12bf..f9877d8 100644 --- a/src/components/dashboard/OrderbookDepth.tsx +++ b/src/components/dashboard/OrderbookDepth.tsx @@ -18,6 +18,7 @@ import { import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { useMiners } from '../../api'; import { FONTS } from '../../theme'; +import QueryError from '../QueryError'; import { OrderbookDepthSkeleton } from './Skeletons'; const OrderbookDepth: React.FC = () => { @@ -99,7 +100,7 @@ const OrderbookDepth: React.FC = () => { borderBottom: `1px solid ${theme.palette.divider}`, }; - const { data: miners, isLoading } = useMiners(); + const { data: miners, isLoading, isError, refetch } = useMiners(); type Direction = 'forward' | 'reverse'; type DirectionOption = { asset: string; @@ -193,6 +194,12 @@ const OrderbookDepth: React.FC = () => { [depthData], ); + if (isError && !miners) { + return ( + + ); + } + return isLoading || !miners ? ( ) : ( diff --git a/src/components/dashboard/StatsPanel.tsx b/src/components/dashboard/StatsPanel.tsx index daa9eb5..1fdbec0 100644 --- a/src/components/dashboard/StatsPanel.tsx +++ b/src/components/dashboard/StatsPanel.tsx @@ -3,6 +3,7 @@ import { Box, Grid, Typography } from '@mui/material'; import { useStats } from '../../api'; import { FONTS } from '../../theme'; import { RollingValue } from '../animated'; +import QueryError from '../QueryError'; import { StatsPanelSkeleton } from './Skeletons'; const StatCard: React.FC<{ label: string; value: string }> = ({ @@ -46,7 +47,11 @@ const StatCard: React.FC<{ label: string; value: string }> = ({ ); const StatsPanel: React.FC = () => { - const { data: stats, isLoading } = useStats(); + const { data: stats, isLoading, isError, refetch } = useStats(); + + if (isError && !stats) { + return ; + } const volume = stats ? parseFloat(stats.totalVolumeTao).toFixed(2) : '0'; diff --git a/src/components/dashboard/SwapTracker.tsx b/src/components/dashboard/SwapTracker.tsx index f79f695..ff04b8a 100644 --- a/src/components/dashboard/SwapTracker.tsx +++ b/src/components/dashboard/SwapTracker.tsx @@ -14,6 +14,7 @@ import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { useAllSwaps, useSwapDetail } from '../../api'; import { FONTS } from '../../theme'; import CopyableAddress from '../CopyableAddress'; +import QueryError from '../QueryError'; import { SwapTrackerSkeleton } from './Skeletons'; import { formatAmount } from '../../utils/format'; @@ -66,14 +67,26 @@ const SwapTracker: React.FC = () => { const idMatch = debouncedSearch.trim().match(/^#?(\d+)$/); const exactSwapId = idMatch?.[1] ?? ''; - const { data: detail, isLoading: detailLoading } = useSwapDetail(exactSwapId); - const { data: fuzzy, isLoading: fuzzyLoading } = useAllSwaps( + const { + data: detail, + isLoading: detailLoading, + isError: detailError, + refetch: refetchDetail, + } = useSwapDetail(exactSwapId); + const { + data: fuzzy, + isLoading: fuzzyLoading, + isError: fuzzyError, + refetch: refetchFuzzy, + } = useAllSwaps( { search: debouncedSearch || undefined, limit }, !exactSwapId, ); const swaps = exactSwapId ? (detail?.swap ? [detail.swap] : []) : fuzzy; const isLoading = exactSwapId ? detailLoading : fuzzyLoading; + const isError = exactSwapId ? detailError : fuzzyError; + const refetch = exactSwapId ? refetchDetail : refetchFuzzy; // Reset limit when search changes React.useEffect(() => { @@ -91,6 +104,10 @@ const SwapTracker: React.FC = () => { } }, [hasMore]); + if (isError && !swaps) { + return ; + } + return isLoading && !swaps ? ( ) : ( diff --git a/src/components/index.ts b/src/components/index.ts index d634208..30471fa 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ export { default as Card } from './Card'; export { default as HoverCard } from './HoverCard'; export { default as LabelValue } from './LabelValue'; export { default as PageWrapper } from './PageWrapper'; +export { default as QueryError } from './QueryError'; export { TimelineStep, SectionTitle } from './Timeline'; export type { TimelineStepState } from './Timeline'; export { SEO } from './SEO'; diff --git a/src/pages/SwapDetailPage.tsx b/src/pages/SwapDetailPage.tsx index 1bca50b..1ce8b21 100644 --- a/src/pages/SwapDetailPage.tsx +++ b/src/pages/SwapDetailPage.tsx @@ -22,6 +22,7 @@ import { Card, LabelValue, PageWrapper, + QueryError, SectionTitle, TimelineStep, type TimelineStepState, @@ -70,11 +71,22 @@ const SwapDetailPage: React.FC = () => { const { swapId } = useParams<{ swapId: string }>(); const theme = useTheme(); - const { data, isLoading } = useSwapDetail(swapId ?? ''); + const { data, isLoading, isError, refetch } = useSwapDetail(swapId ?? ''); const { data: protocol } = useProtocolConstants(); const { data: chainState } = useChainState(); const currentBlock = chainState?.currentBlock ?? 0; + if (isError && !data) { + return ( + + + + ); + } + if (isLoading) { return (