diff --git a/app/app/dashboard.tsx b/app/app/dashboard.tsx
index 076c8f1..e5144f1 100644
--- a/app/app/dashboard.tsx
+++ b/app/app/dashboard.tsx
@@ -1,12 +1,13 @@
'use client'
-
import Link from 'next/link'
import { useState } from 'react'
+import { Plus, ArrowDownToLine, RefreshCw } from 'lucide-react'
import { Plus, ArrowDownToLine, WifiOff } from 'lucide-react'
import { RequireWallet } from '@/components/layout/require-wallet'
import { TestnetFaucetBanner } from '@/components/layout/testnet-faucet-banner'
import { DashboardStats, DashboardStatsSkeleton } from '@/components/streams/dashboard-stats'
-import { StreamCard, StreamCardSkeleton } from '@/components/streams/stream-card'
+import { StreamCardSkeleton } from '@/components/streams/stream-card'
+import { VirtualStreamList } from '@/components/streams/virtual-stream-list'
import { EmptyStreams } from '@/components/streams/empty-state'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
@@ -20,6 +21,13 @@ import { getWithdrawableAmount } from '@/lib/stream-utils'
import type { StreamData } from '@/types/stream'
export function Dashboard() {
+ const { sent, received, all, loading, isRefreshingAfterHidden } = useStreams()
+ const { withdrawAll, pending } = useContract()
+ const now = useNow(1000)
+ const [withdrawProgress, setWithdrawProgress] = useState<{
+ current: number
+ total: number
+ } | null>(null)
const { sent, received: allReceived, all: allStreams, loading, stale, lastUpdated } = useStreams()
const { withdrawAll, pending } = useContract()
const now = useNow(1000)
@@ -47,45 +55,59 @@ export function Dashboard() {
}
return (
-
- {/* Header */}
-
-
-
Dashboard
-
- Your active and historical token streams.
-
-
-
- {withdrawableStreams.length > 0 && (
-
+
+
)
}
diff --git a/app/app/streams/page.tsx b/app/app/streams/page.tsx
index a73e72f..330506e 100644
--- a/app/app/streams/page.tsx
+++ b/app/app/streams/page.tsx
@@ -1,7 +1,11 @@
'use client'
-
import { Suspense, useCallback, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
+import { Search, Download, ListChecks, ArrowDownToLine, Ban, X, RefreshCw } from 'lucide-react'
+import { RequireWallet } from '@/components/layout/require-wallet'
+import { Button } from '@/components/ui/button'
+import { streamsToCSV, downloadCSV } from '@/lib/export'
+import { VirtualStreamList } from '@/components/streams/virtual-stream-list'
import { Search, Download, ListChecks, ArrowDownToLine, Ban, X, EyeOff, Eye, LayoutList, GanttChartSquare } from 'lucide-react'
import { RequireWallet } from '@/components/layout/require-wallet'
import { Button } from '@/components/ui/button'
@@ -34,7 +38,7 @@ const TOKEN_OPTIONS = ['all', 'XLM', 'USDC', 'EURC'] as const
function StreamsPage() {
const router = useRouter()
const searchParams = useSearchParams()
- const { all } = useStreams()
+ const { all, isRefreshingAfterHidden } = useStreams()
const now = useNow(5000)
const { address } = useWallet()
const { withdraw, cancel } = useContract()
@@ -69,6 +73,8 @@ function StreamsPage() {
}, [router])
const filtered = all.filter((s) => {
+ const matchesStatus =
+ statusFilter === 'all' || getStreamStatus(s, now) === statusFilter
// When "Show hidden streams" is off, hidden/blocked streams don't appear
// at all (issue #151). When it's on, only the concealed ones are shown,
// so the user can review/un-hide them.
@@ -77,7 +83,8 @@ function StreamsPage() {
const matchesStatus = statusFilter === 'all' || getStreamStatus(s, now) === statusFilter
const matchesToken =
- tokenFilter === 'all' || s.token.symbol.toUpperCase() === tokenFilter.toUpperCase()
+ tokenFilter === 'all' ||
+ s.token.symbol.toUpperCase() === tokenFilter.toUpperCase()
const q = search.toLowerCase()
const matchesSearch =
!q ||
@@ -92,6 +99,7 @@ function StreamsPage() {
const { selected, selectedItems, allSelected, someSelected, toggle, toggleAll, clear } =
useBulkSelect(filtered)
+
const {
status: bulkStatus,
results: bulkResults,
@@ -104,8 +112,14 @@ function StreamsPage() {
const eligibleWithdrawIds = selectedItems
.filter((s) => s.recipient === address && getWithdrawableAmount(s, now) > 0n)
.map((s) => s.id)
+
const eligibleCancelIds = selectedItems
- .filter((s) => s.sender === address && !s.cancelled && getStreamStatus(s, now) !== 'completed')
+ .filter(
+ (s) =>
+ s.sender === address &&
+ !s.cancelled &&
+ getStreamStatus(s, now) !== 'completed',
+ )
.map((s) => s.id)
const isBulkRunning = bulkStatus === 'running'
@@ -134,11 +148,40 @@ function StreamsPage() {
}
return (
-
-
-
-
Streams
-
All streams you've sent or received.
+
+
+ {/* Header */}
+
+
+
Streams
+
+ All streams you've sent or received.
+
+
+
+ {/* Tab re-focus refreshing indicator */}
+ {isRefreshingAfterHidden && (
+
+
+ Refreshing…
+
+ )}
+ {
+ const csv = streamsToCSV(all, now)
+ downloadCSV(csv, `flowstar-streams-${new Date().toISOString().slice(0, 10)}.csv`)
+ }}
+ >
+
+ Download CSV
+
+
{/* List / Timeline view toggle */}
@@ -226,75 +269,83 @@ function StreamsPage() {
)}
- {/* Bulk action bar */}
- {selectMode && someSelected && (
-
-
- {selected.size} selected
-
-
-
- Withdraw ({eligibleWithdrawIds.length})
-
+ {/* Bulk select toggle */}
+
(selectMode ? exitSelectMode() : setSelectMode(true))}
+ data-testid="bulk-select-toggle"
>
-
- Cancel ({eligibleCancelIds.length})
-
-
-
- Clear
+
+ {selectMode ? 'Done selecting' : 'Select'}
+ {selectMode && (
+ toggleAll()}>
+ Select all ({filtered.length})
+
+ )}
- )}
- {/* Bulk action results */}
- {showBulkResults && (
-
-
- {succeeded} succeeded, {failed} failed
-
-
- Dismiss
-
-
- )}
+ {/* Bulk action bar */}
+ {selectMode && someSelected && (
+
+
{selected.size} selected
+
+
+ Withdraw ({eligibleWithdrawIds.length})
+
+
+
+ Cancel ({eligibleCancelIds.length})
+
+
clear()}>
+
+ Clear
+
+
+ )}
+
+ {/* Bulk action results */}
+ {showBulkResults && (
+
+
+ {succeeded} succeeded, {failed} failed
+
+
+ Dismiss
+
+
+ )}
- {/* Filters */}
-
-
-
-
+ {/* Filters */}
+
+
+
setParam('q', e.target.value)}
className="pl-9"
data-testid="streams-search-input"
/>
+
{/* Token filter */}
{TOKEN_OPTIONS.map((t) => (
setParam('token', t)}
aria-pressed={tokenFilter === t}
className={
@@ -308,43 +359,49 @@ function StreamsPage() {
))}
-
- {/* Status filter */}
-
- {STATUS_FILTERS.map((f) => (
-
setParam('status', f.value)}
- aria-pressed={statusFilter === f.value}
- className={
- 'rounded-full border px-3 py-1 text-xs font-medium transition-colors ' +
- (statusFilter === f.value
- ? 'border-primary bg-primary/10 text-primary'
- : 'border-border bg-card text-muted-foreground hover:text-foreground')
- }
- >
- {f.label}
-
- ))}
+ {/* Status filter */}
+
+ {STATUS_FILTERS.map((f) => (
+ setParam('status', f.value)}
+ aria-pressed={statusFilter === f.value}
+ className={
+ 'rounded-full border px-3 py-1 text-xs font-medium transition-colors ' +
+ (statusFilter === f.value
+ ? 'border-primary bg-primary/10 text-primary'
+ : 'border-border bg-card text-muted-foreground hover:text-foreground')
+ }
+ >
+ {f.label}
+
+ ))}
+
-
- {/* Results */}
- {filtered.length === 0 ? (
- hasFilters ? (
-
-
No streams match your filters
-
- Clear filters
-
-
+ {/* Results */}
+ {filtered.length === 0 ? (
+ hasFilters ? (
+
+
No streams match your filters
+
+ Clear filters
+
+
+ ) : (
+
+ )
) : (
+
+ )}
+
+
)
) : view === 'timeline' ? (
@@ -369,10 +426,8 @@ function StreamsPage() {
export default function StreamsRoute() {
return (
-
-
-
-
-
+
+
+
)
}
diff --git a/components/streams/virtual-stream-list.tsx b/components/streams/virtual-stream-list.tsx
new file mode 100644
index 0000000..bbe569e
--- /dev/null
+++ b/components/streams/virtual-stream-list.tsx
@@ -0,0 +1,173 @@
+'use client'
+import { useRef, useCallback } from 'react'
+import { useVirtualizer } from '@tanstack/react-virtual'
+import { StreamCard, StreamCardSkeleton } from '@/components/streams/stream-card'
+import type { StreamData } from '@/types/stream'
+
+// ─── Constants ─────────────────────────────────────────────────────────────────
+// Virtualization only pays off above this threshold. Below it the overhead of
+// position calculations outweighs the DOM savings.
+const VIRTUALIZATION_THRESHOLD = 50
+
+// Estimated card height used before a card has been measured. Keeps the
+// scrollbar thumb size reasonable on first render. Tweak if your design changes.
+const ESTIMATED_CARD_HEIGHT = 160
+
+// Cards to render above and below the visible area. Per the issue spec: 3–5.
+const OVERSCAN_COUNT = 4
+
+// ─── Types ─────────────────────────────────────────────────────────────────────
+interface VirtualStreamListProps {
+ streams: StreamData[]
+ loading?: boolean
+ skeletonCount?: number
+ selectable?: boolean
+ selectedIds?: Set
+ onToggleSelect?: (id: string) => void
+ className?: string
+}
+
+// ─── Flat list (< VIRTUALIZATION_THRESHOLD items) ──────────────────────────────
+function FlatStreamList({
+ streams,
+ selectable,
+ selectedIds,
+ onToggleSelect,
+ className = '',
+}: Omit) {
+ return (
+
+ {streams.map((s) => (
+
+ ))}
+
+ )
+}
+
+// ─── Virtualized list (≥ VIRTUALIZATION_THRESHOLD items) ───────────────────────
+function VirtualList({
+ streams,
+ selectable,
+ selectedIds,
+ onToggleSelect,
+ className = '',
+}: Omit) {
+ const parentRef = useRef(null)
+
+ // Per-item height cache: keyed by stream id so that remeasuring one card
+ // doesn't invalidate unrelated entries.
+ const sizeCache = useRef>({})
+
+ const estimateSize = useCallback(
+ (index: number) => {
+ const id = streams[index]?.id
+ return id ? (sizeCache.current[id] ?? ESTIMATED_CARD_HEIGHT) : ESTIMATED_CARD_HEIGHT
+ },
+ [streams],
+ )
+
+ const virtualizer = useVirtualizer({
+ count: streams.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize,
+ overscan: OVERSCAN_COUNT,
+ // Dynamic height measurement: after each item renders, the virtualizer
+ // calls this to get the real height and adjusts its layout.
+ measureElement: (element) => element.getBoundingClientRect().height,
+ })
+
+ const totalHeight = virtualizer.getTotalSize()
+ const items = virtualizer.getVirtualItems()
+
+ return (
+
+ {/* Spacer div — tells the browser the full scrollable height so the
+ scrollbar thumb is sized correctly even before all items render. */}
+
+ {items.map((virtualRow) => {
+ const stream = streams[virtualRow.index]
+ return (
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+// ─── Skeleton list ──────────────────────────────────────────────────────────────
+function SkeletonList({ count }: { count: number }) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ )
+}
+
+// ─── Public component ───────────────────────────────────────────────────────────
+/**
+ * Renders a list of stream cards with automatic virtualization for large lists.
+ *
+ * - Flat list (no virtual overhead) when streams.length < 50
+ * - @tanstack/react-virtual with dynamic row heights for 50+ streams
+ * - 4-item overscan buffer above and below the viewport
+ * - Falls back to skeletons while loading
+ */
+export function VirtualStreamList({
+ streams,
+ loading = false,
+ skeletonCount = 3,
+ selectable,
+ selectedIds,
+ onToggleSelect,
+ className,
+}: VirtualStreamListProps) {
+ if (loading) {
+ return
+ }
+
+ const props = { streams, selectable, selectedIds, onToggleSelect, className }
+
+ return streams.length >= VIRTUALIZATION_THRESHOLD ? (
+
+ ) : (
+
+ )
+}
diff --git a/hooks/use-page-visibility.ts b/hooks/use-page-visibility.ts
new file mode 100644
index 0000000..d3cc311
--- /dev/null
+++ b/hooks/use-page-visibility.ts
@@ -0,0 +1,42 @@
+'use client'
+import { useEffect, useRef } from 'react'
+
+/**
+ * Subscribes to the Page Visibility API.
+ *
+ * Calls `onVisible` when the document becomes visible (tab refocused /
+ * browser restored) and `onHidden` when it becomes hidden.
+ *
+ * - Safe in SSR — no DOM access until `useEffect` runs in the browser.
+ * - Works correctly with multiple FlowStar tabs: each tab's document fires
+ * its own `visibilitychange` event independently.
+ * - The event listener is registered once and kept stable; callback identity
+ * changes are handled through refs so no teardown/re-register cycle occurs.
+ */
+export function usePageVisibility({
+ onVisible,
+ onHidden,
+}: {
+ onVisible?: () => void
+ onHidden?: () => void
+}) {
+ // Keep a live ref to each callback so we never need to re-register the
+ // DOM listener just because the caller re-renders with a new function ref.
+ const onVisibleRef = useRef(onVisible)
+ const onHiddenRef = useRef(onHidden)
+ onVisibleRef.current = onVisible
+ onHiddenRef.current = onHidden
+
+ useEffect(() => {
+ const handleChange = () => {
+ if (document.hidden) {
+ onHiddenRef.current?.()
+ } else {
+ onVisibleRef.current?.()
+ }
+ }
+
+ document.addEventListener('visibilitychange', handleChange)
+ return () => document.removeEventListener('visibilitychange', handleChange)
+ }, []) // intentionally empty — refs keep callbacks current without re-running
+}
diff --git a/hooks/use-streams.ts b/hooks/use-streams.ts
index 0cc7a1f..1d18f3f 100644
--- a/hooks/use-streams.ts
+++ b/hooks/use-streams.ts
@@ -1,20 +1,18 @@
'use client'
-
import { useState, useEffect, useCallback, useRef } from 'react'
import { fetchStreamsForAddress, fetchStream } from '@/lib/contract'
import type { StreamData } from '@/types/stream'
import { useWallet } from '@/hooks/use-wallet'
import { useNetwork } from '@/components/providers/network-provider'
import { captureError } from '@/lib/sentry'
+import { usePageVisibility } from '@/hooks/use-page-visibility'
import { readCachedStreams, writeCachedStreams } from '@/lib/streams-cache'
-// ─── Refresh bus ─────────────────────────────────────────────────────────────
+// ─── Refresh bus ───────────────────────────────────────────────────────────────
// Components call `invalidateStreams()` after a write so all stream hooks
// re-fetch without prop-drilling or global state.
-
type Listener = () => void
const listeners = new Set()
-
export function invalidateStreams() {
listeners.forEach((l) => l())
}
@@ -25,17 +23,20 @@ function useInvalidation(cb: () => void) {
useEffect(() => {
const handler = () => cbRef.current()
listeners.add(handler)
- return () => { listeners.delete(handler) }
+ return () => {
+ listeners.delete(handler)
+ }
}, [])
}
-// ─── Hooks ───────────────────────────────────────────────────────────────────
-
+// ─── Hooks ─────────────────────────────────────────────────────────────────────
export interface CategorizedStreams {
sent: StreamData[]
received: StreamData[]
all: StreamData[]
loading: boolean
+ /** True when the tab just became visible after being hidden ≥ 3 seconds. */
+ isRefreshingAfterHidden: boolean
refetch: () => void
/** True when `all` is being served from the offline cache rather than a live fetch. */
stale: boolean
@@ -48,11 +49,20 @@ interface UseStreamsOptions {
pollInterval?: number
}
+// How long the tab must have been hidden before we show "Refreshing…"
+const STALE_THRESHOLD_MS = 3_000
+
export function useStreams(options?: UseStreamsOptions): CategorizedStreams {
const { address } = useWallet()
const { network } = useNetwork()
const [streams, setStreams] = useState([])
const [loading, setLoading] = useState(false)
+ const [isRefreshingAfterHidden, setIsRefreshingAfterHidden] = useState(false)
+
+ const pollIntervalRef = useRef | null>(null)
+ // Tracks whether the polling interval is currently running.
+ const pollingActiveRef = useRef(false)
+
// True while `streams` is serving the offline cache instead of a live fetch.
const [stale, setStale] = useState(false)
const [lastUpdated, setLastUpdated] = useState(null)
@@ -60,24 +70,73 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams {
// Monotonically increasing request ID — any response whose ID doesn't
// match the current value is from a stale request and is discarded.
const requestIdRef = useRef(0)
+
// Holds the AbortController for the currently in-flight fetch so we can
// cancel the underlying network request when address/network changes,
// not just guard the state update.
const abortCtrlRef = useRef(null)
+ // Tracks when the tab was hidden so we can decide whether to show the
+ // "Refreshing…" indicator on tab re-focus.
+ const hiddenAtRef = useRef(null)
+
const { enablePolling = true, pollInterval = 30000 } = options ?? {}
- const fetch = useCallback(async () => {
- // Cancel any previous in-flight request at the network level.
- abortCtrlRef.current?.abort()
- const ctrl = new AbortController()
- abortCtrlRef.current = ctrl
+ const fetch = useCallback(
+ async () => {
+ // Cancel any previous in-flight request at the network level.
+ abortCtrlRef.current?.abort()
+ const ctrl = new AbortController()
+ abortCtrlRef.current = ctrl
- // Bump the generation counter so stale responses are discarded even
- // if AbortController doesn't reach every internal fetch call.
- requestIdRef.current += 1
- const req = requestIdRef.current
+ // Bump the generation counter so stale responses are discarded even
+ // if AbortController doesn't reach every internal fetch call.
+ requestIdRef.current += 1
+ const req = requestIdRef.current
+
+ if (!address) {
+ setStreams([])
+ if (req === requestIdRef.current) setLoading(false)
+ return
+ }
+ setLoading(true)
+ try {
+ const data = await fetchStreamsForAddress(network, address)
+ // Discard if a newer request has already started.
+ if (req !== requestIdRef.current) return
+ setStreams(data)
+ } catch (e) {
+ if (req !== requestIdRef.current) return
+ // Suppress errors from intentionally aborted requests.
+ if (e instanceof DOMException && e.name === 'AbortError') return
+ captureError(e, { operation: 'use-streams:fetch' })
+ } finally {
+ if (req === requestIdRef.current) setLoading(false)
+ }
+ },
+ [address, network],
+ )
+
+ // Fetch on mount and when address changes
+ useEffect(() => {
+ fetch()
+ }, [fetch])
+
+ // Re-fetch when a write invalidates the cache
+ useInvalidation(fetch)
+
+ // ─── Polling helpers ───────────────────────────────────────────────────────
+ const startPolling = useCallback(() => {
+ if (pollingActiveRef.current || !enablePolling || !address) return
+ pollIntervalRef.current = setInterval(fetch, pollInterval)
+ pollingActiveRef.current = true
+ }, [enablePolling, address, fetch, pollInterval])
+
+ const stopPolling = useCallback(() => {
+ if (pollIntervalRef.current) {
+ clearInterval(pollIntervalRef.current)
+ pollIntervalRef.current = null
if (!address) {
setStreams([])
setStale(false)
@@ -125,13 +184,51 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams {
} finally {
if (req === requestIdRef.current) setLoading(false)
}
- }, [address, network])
+ pollingActiveRef.current = false
+ }, [])
- // Fetch on mount and when address changes
- useEffect(() => { fetch() }, [fetch])
+ // ─── Page Visibility integration ──────────────────────────────────────────
+ //
+ // When the tab is hidden we pause the polling interval to avoid wasting
+ // bandwidth, battery, and RPC rate-limit quota.
+ // When it becomes visible again we:
+ // 1. Immediately fire a fresh fetch (no stale data shown).
+ // 2. Restart the polling interval.
+ // 3. Briefly show "Refreshing…" if the tab was hidden long enough that
+ // the cached data could be considered stale.
+ //
+ // Note: auto-withdraw (useAutoWithdraw) manages its own separate interval
+ // and is intentionally not affected here.
- // Re-fetch when a write invalidates the cache
- useInvalidation(fetch)
+ usePageVisibility({
+ onHidden: useCallback(() => {
+ hiddenAtRef.current = Date.now()
+ stopPolling()
+ }, [stopPolling]),
+
+ onVisible: useCallback(() => {
+ const hiddenDuration = hiddenAtRef.current
+ ? Date.now() - hiddenAtRef.current
+ : 0
+ hiddenAtRef.current = null
+
+ // Show "Refreshing…" indicator only when data could be noticeably stale.
+ if (hiddenDuration >= STALE_THRESHOLD_MS) {
+ setIsRefreshingAfterHidden(true)
+ fetch().finally(() => setIsRefreshingAfterHidden(false))
+ } else {
+ fetch()
+ }
+
+ startPolling()
+ }, [fetch, startPolling]),
+ })
+
+ // ─── Main polling setup ───────────────────────────────────────────────────
+ //
+ // This effect owns the lifecycle of the polling interval. The visibility
+ // handlers above call startPolling/stopPolling without re-running this
+ // effect so there is no double-interval risk.
// Re-fetch as soon as connectivity returns, so the stale/cached view
// refreshes without waiting for the next poll tick.
@@ -144,31 +241,30 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams {
// Set up polling for real-time updates
useEffect(() => {
if (!enablePolling || !address) {
- if (pollIntervalRef.current) {
- clearInterval(pollIntervalRef.current)
- pollIntervalRef.current = null
- }
+ stopPolling()
return
}
-
- // Poll for dashboard updates
- pollIntervalRef.current = setInterval(fetch, pollInterval)
-
- return () => {
- if (pollIntervalRef.current) {
- clearInterval(pollIntervalRef.current)
- pollIntervalRef.current = null
- }
+ // Only start if the page is currently visible. If the page starts hidden
+ // (e.g. opened via Ctrl+click then later focused), the onVisible handler
+ // will call startPolling when the tab is first viewed.
+ if (typeof document === 'undefined' || !document.hidden) {
+ startPolling()
}
- }, [enablePolling, address, fetch, pollInterval])
+ return () => stopPolling()
+ }, [enablePolling, address, startPolling, stopPolling])
const sent = streams.filter((s) => s.sender === address)
const received = streams.filter((s) => s.recipient === address)
+ return { all: streams, sent, received, loading, isRefreshingAfterHidden, refetch: fetch }
return { all: streams, sent, received, loading, refetch: fetch, stale, lastUpdated }
}
-export function useStream(id: string): { stream: StreamData | null; loading: boolean; refetch: () => void } {
+export function useStream(id: string): {
+ stream: StreamData | null
+ loading: boolean
+ refetch: () => void
+} {
const { network } = useNetwork()
const [stream, setStream] = useState(null)
const [loading, setLoading] = useState(false)
@@ -188,7 +284,6 @@ export function useStream(id: string): { stream: StreamData | null; loading: boo
if (req === requestIdRef.current) setLoading(false)
return
}
-
setLoading(true)
try {
const data = await fetchStream(network, id)
@@ -203,7 +298,10 @@ export function useStream(id: string): { stream: StreamData | null; loading: boo
}
}, [id, network])
- useEffect(() => { fetch() }, [fetch])
+ useEffect(() => {
+ fetch()
+ }, [fetch])
+
useInvalidation(fetch)
return { stream, loading, refetch: fetch }
diff --git a/package.json b/package.json
index ec034fa..44edd05 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"@sentry/nextjs": "^8.0.0",
"@stellar/freighter-api": "^6.0.1",
"@stellar/stellar-sdk": "^13.3.0",
+ "@tanstack/react-virtual": "^3.14.10",
"@vercel/analytics": "1.6.1",
"@vercel/og": "^0.11.1",
"@walletconnect/modal": "^2.6.2",
@@ -68,4 +69,4 @@
"typescript": "5.7.3",
"vitest": "^2.1.9"
}
-}
+}
\ No newline at end of file