From 7a7dfca22175ea6ca6080d3aa6cc8c121c372fbe Mon Sep 17 00:00:00 2001
From: defiQUG
Date: Fri, 22 May 2026 20:40:11 -0700
Subject: [PATCH] feat(explorer): mission-control resilience, ops token labels,
and CI validate
Add SSE reconnect with backoff, fallback REST polling, visibility-aware refresh,
extended token-list labels on operations pages, validate-on-pr workflow, and smoke coverage.
Co-authored-by: Cursor
---
.gitea/workflows/validate-on-pr.yml | 70 +++++++++++++++++++
docs/TOKEN_LIST_SURFACES.md | 10 ++-
.../common/TokenListSurfaceNote.tsx | 13 ++++
.../explorer/LiquidityOperationsPage.tsx | 7 +-
.../components/explorer/OperationsHubPage.tsx | 5 +-
.../explorer/PoolsOperationsPage.tsx | 7 +-
.../explorer/SystemOperationsPage.tsx | 5 +-
frontend/src/components/home/HomePage.tsx | 52 +++++++++-----
frontend/src/components/wallet/WalletPage.tsx | 15 ++--
frontend/src/pages/liquidity/index.tsx | 3 +-
frontend/src/pages/operations/index.tsx | 3 +-
frontend/src/pages/system/index.tsx | 3 +-
frontend/src/services/api/missionControl.ts | 37 ++++++++--
.../src/services/api/tokenListSurfaces.ts | 12 ++++
frontend/src/utils/visibilityRefresh.test.ts | 20 ++++++
frontend/src/utils/visibilityRefresh.ts | 43 ++++++++++++
scripts/e2e-sprint-smoke.spec.ts | 5 ++
17 files changed, 268 insertions(+), 42 deletions(-)
create mode 100644 .gitea/workflows/validate-on-pr.yml
create mode 100644 frontend/src/components/common/TokenListSurfaceNote.tsx
create mode 100644 frontend/src/utils/visibilityRefresh.test.ts
create mode 100644 frontend/src/utils/visibilityRefresh.ts
diff --git a/.gitea/workflows/validate-on-pr.yml b/.gitea/workflows/validate-on-pr.yml
new file mode 100644
index 0000000..95c719c
--- /dev/null
+++ b/.gitea/workflows/validate-on-pr.yml
@@ -0,0 +1,70 @@
+name: Validate Explorer
+
+on:
+ pull_request:
+ branches: [main, master]
+ paths:
+ - '.gitea/workflows/validate-on-pr.yml'
+ - 'frontend/**'
+ - 'scripts/e2e-*.spec.ts'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'playwright.config.ts'
+ push:
+ branches: [main, master]
+ paths:
+ - '.gitea/workflows/validate-on-pr.yml'
+ - 'frontend/**'
+ - 'scripts/e2e-*.spec.ts'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'playwright.config.ts'
+
+jobs:
+ frontend:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: frontend
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Lint, type-check, and unit tests
+ run: npm test
+
+ smoke-e2e:
+ runs-on: ubuntu-latest
+ needs: frontend
+ if: github.event_name == 'push'
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ cache-dependency-path: package-lock.json
+
+ - name: Install root dependencies
+ run: npm ci
+
+ - name: Install Playwright browser
+ run: npx playwright install chromium
+
+ - name: Run live sprint smoke tests
+ env:
+ EXPLORER_URL: https://explorer.d-bis.org
+ run: npm run e2e -- scripts/e2e-sprint-smoke.spec.ts
diff --git a/docs/TOKEN_LIST_SURFACES.md b/docs/TOKEN_LIST_SURFACES.md
index 4098c44..814ffe1 100644
--- a/docs/TOKEN_LIST_SURFACES.md
+++ b/docs/TOKEN_LIST_SURFACES.md
@@ -6,6 +6,14 @@ The explorer uses two public token-list endpoints. Application code should pick
|---------|----------|----------|
| `wallet` | `/api/v1/report/token-list?chainId=138` (fallback: config) | Wallet SSR, MetaMask watch list, featured-token dedup inputs |
| `catalog` | report (fallback: config) | `/tokens`, search token inference, homepage price feed curation |
-| `extended` | `/api/config/token-list` | Full Metamask dual-chain catalog, provenance lookup merge |
+| `extended` | `/api/config/token-list` | Full Metamask dual-chain catalog, provenance lookup merge, operations/liquidity/system/pools inventory |
Report list is the canonical Chain 138 trading set (31 tokens live). Config list is the extended catalog (190+ entries across chains).
+
+## Page mapping
+
+| Page / surface | Surface | Notes |
+|----------------|---------|-------|
+| `/wallet` | `wallet` | SSR + MetaMask watch list |
+| `/tokens`, `/search`, homepage price feed | `catalog` | Canonical trading set with config fallback |
+| `/liquidity`, `/operations`, `/system`, `/pools` | `extended` | Full catalog with `TokenListSurfaceNote` label |
diff --git a/frontend/src/components/common/TokenListSurfaceNote.tsx b/frontend/src/components/common/TokenListSurfaceNote.tsx
new file mode 100644
index 0000000..7a2f748
--- /dev/null
+++ b/frontend/src/components/common/TokenListSurfaceNote.tsx
@@ -0,0 +1,13 @@
+import { TOKEN_LIST_SURFACE_LABELS, type TokenListSurface } from '@/services/api/tokenListSurfaces'
+
+interface TokenListSurfaceNoteProps {
+ surface?: TokenListSurface
+ className?: string
+}
+
+export default function TokenListSurfaceNote({
+ surface = 'extended',
+ className = 'text-sm text-gray-600 dark:text-gray-400',
+}: TokenListSurfaceNoteProps) {
+ return {TOKEN_LIST_SURFACE_LABELS[surface]}
+}
diff --git a/frontend/src/components/explorer/LiquidityOperationsPage.tsx b/frontend/src/components/explorer/LiquidityOperationsPage.tsx
index f56ea22..b4dee5f 100644
--- a/frontend/src/components/explorer/LiquidityOperationsPage.tsx
+++ b/frontend/src/components/explorer/LiquidityOperationsPage.tsx
@@ -3,7 +3,8 @@
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
-import { configApi, type TokenListResponse } from '@/services/api/config'
+import { type TokenListResponse } from '@/services/api/config'
+import { tokensApi } from '@/services/api/tokens'
import {
aggregateLiquidityPools,
featuredLiquiditySymbols,
@@ -22,6 +23,7 @@ import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
+import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import {
formatCurrency,
@@ -98,7 +100,7 @@ export default function LiquidityOperationsPage({
const load = async () => {
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult, statsResult, bridgeResult] =
await Promise.allSettled([
- configApi.getTokenList(),
+ tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
routesApi.getRouteMatrix(),
plannerApi.getCapabilities(),
plannerApi.getInternalExecutionPlan(),
@@ -273,6 +275,7 @@ export default function LiquidityOperationsPage({
public route matrix, planner capabilities, and mission-control token pool inventory together
so integrators can inspect what Chain 138 is actually serving right now.
+
{loadingError ? (
diff --git a/frontend/src/components/explorer/OperationsHubPage.tsx b/frontend/src/components/explorer/OperationsHubPage.tsx
index 342b5f9..acdf256 100644
--- a/frontend/src/components/explorer/OperationsHubPage.tsx
+++ b/frontend/src/components/explorer/OperationsHubPage.tsx
@@ -3,6 +3,7 @@ import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import { explorerFeaturePages } from '@/data/explorerOperations'
import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config'
+import { tokensApi } from '@/services/api/tokens'
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
import { useUiMode } from '@/components/common/UiModeContext'
@@ -10,6 +11,7 @@ import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
+import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
@@ -88,7 +90,7 @@ export default function OperationsHubPage({
missionControlApi.getBridgeStatus(),
routesApi.getRouteMatrix(),
configApi.getNetworks(),
- configApi.getTokenList(),
+ tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
configApi.getCapabilities(),
statsApi.get(),
])
@@ -248,6 +250,7 @@ export default function OperationsHubPage({
Chains ยท {(tokenList?.tokens || []).length} tokens across {tokenChainCoverage} networks
+
diff --git a/frontend/src/components/explorer/PoolsOperationsPage.tsx b/frontend/src/components/explorer/PoolsOperationsPage.tsx
index 27ddabc..34a4595 100644
--- a/frontend/src/components/explorer/PoolsOperationsPage.tsx
+++ b/frontend/src/components/explorer/PoolsOperationsPage.tsx
@@ -3,7 +3,9 @@
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
-import { configApi, type TokenListResponse } from '@/services/api/config'
+import { type TokenListResponse } from '@/services/api/config'
+import { tokensApi } from '@/services/api/tokens'
+import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import {
aggregateLiquidityPools,
getRouteBackedPoolAddresses,
@@ -28,7 +30,7 @@ export default function PoolsOperationsPage() {
const load = async () => {
const [tokenListResult, routeMatrixResult] = await Promise.allSettled([
- configApi.getTokenList(),
+ tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
routesApi.getRouteMatrix(),
])
@@ -100,6 +102,7 @@ export default function PoolsOperationsPage() {
This page now summarizes the live pool inventory discovered through mission-control token
pool endpoints and cross-checks it against the current route matrix.
+
{loadingError ? (
diff --git a/frontend/src/components/explorer/SystemOperationsPage.tsx b/frontend/src/components/explorer/SystemOperationsPage.tsx
index bbf5471..708b5ba 100644
--- a/frontend/src/components/explorer/SystemOperationsPage.tsx
+++ b/frontend/src/components/explorer/SystemOperationsPage.tsx
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives'
import { explorerFeaturePages } from '@/data/explorerOperations'
import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config'
+import { tokensApi } from '@/services/api/tokens'
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
@@ -11,6 +12,7 @@ import OperationsPageShell, {
formatNumber,
relativeAge,
} from './OperationsPageShell'
+import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
interface SystemOperationsPageProps {
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
@@ -46,7 +48,7 @@ export default function SystemOperationsPage({
await Promise.allSettled([
missionControlApi.getBridgeStatus(),
configApi.getNetworks(),
- configApi.getTokenList(),
+ tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
configApi.getCapabilities(),
routesApi.getRouteMatrix(),
statsApi.get(),
@@ -125,6 +127,7 @@ export default function SystemOperationsPage({
description={`${formatNumber(capabilities?.tracing?.supportedMethods?.length)} tracing methods published.`}
/>
+
diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx
index 3089006..479986c 100644
--- a/frontend/src/components/home/HomePage.tsx
+++ b/frontend/src/components/home/HomePage.tsx
@@ -32,6 +32,7 @@ import {
HOME_PRICE_FEED_REFRESH_MS,
resolveHomePriceFeedAddresses,
} from '@/utils/featuredTokens'
+import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
type HomeStats = ExplorerStats
@@ -180,14 +181,11 @@ export default function Home({
}, [chainId])
useEffect(() => {
- loadDashboard()
- const timer = window.setInterval(() => {
- void loadDashboard()
- }, HOME_DASHBOARD_REFRESH_MS)
-
- return () => {
- window.clearInterval(timer)
- }
+ void loadDashboard()
+ return createVisibilityAwarePoller({
+ intervalMs: HOME_DASHBOARD_REFRESH_MS,
+ task: loadDashboard,
+ })
}, [loadDashboard])
useEffect(() => {
@@ -210,7 +208,6 @@ export default function Home({
useEffect(() => {
let cancelled = false
- let timer: ReturnType | null = null
const refreshFeaturedPrices = async () => {
try {
@@ -221,20 +218,18 @@ export default function Home({
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to load featured token prices:', error)
}
- } finally {
- if (!cancelled) {
- timer = setTimeout(() => {
- void refreshFeaturedPrices()
- }, HOME_PRICE_FEED_REFRESH_MS)
- }
}
}
void refreshFeaturedPrices()
+ const stop = createVisibilityAwarePoller({
+ intervalMs: HOME_PRICE_FEED_REFRESH_MS,
+ task: refreshFeaturedPrices,
+ })
return () => {
cancelled = true
- if (timer) clearTimeout(timer)
+ stop()
}
}, [loadFeaturedPrices])
@@ -307,6 +302,31 @@ export default function Home({
}
}, [])
+ useEffect(() => {
+ if (relayFeedState !== 'fallback') return
+
+ let cancelled = false
+
+ const refreshSnapshot = async () => {
+ try {
+ const status = await missionControlApi.getBridgeStatus()
+ if (!cancelled) {
+ setBridgeStatus(status)
+ setRelaySummary(summarizeMissionControlRelay(status))
+ }
+ } catch (error) {
+ if (!cancelled && process.env.NODE_ENV !== 'production') {
+ console.warn('Failed to refresh mission control snapshot:', error)
+ }
+ }
+ }
+
+ return createVisibilityAwarePoller({
+ intervalMs: HOME_DASHBOARD_REFRESH_MS,
+ task: refreshSnapshot,
+ })
+ }, [relayFeedState])
+
const relayToneClasses =
relaySummary?.tone === 'danger'
? 'border-red-200 bg-red-50 text-red-900 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100'
diff --git a/frontend/src/components/wallet/WalletPage.tsx b/frontend/src/components/wallet/WalletPage.tsx
index 54ca02f..79889e5 100644
--- a/frontend/src/components/wallet/WalletPage.tsx
+++ b/frontend/src/components/wallet/WalletPage.tsx
@@ -18,6 +18,7 @@ import {
type TransactionSummary,
} from '@/services/api/addresses'
import { WALLET_SNAPSHOT_REFRESH_MS } from '@/utils/featuredTokens'
+import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
import { formatRelativeAge, formatTokenAmount } from '@/utils/format'
import {
isWatchlistEntry,
@@ -146,8 +147,6 @@ export default function WalletPage(props: WalletPageProps) {
useEffect(() => {
let cancelled = false
- let timer: ReturnType | null = null
-
if (!walletSession?.address) {
setAddressInfo(null)
setRecentAddressTransactions([])
@@ -170,20 +169,18 @@ export default function WalletPage(props: WalletPageProps) {
setTokenBalances([])
setTokenTransfers([])
}
- } finally {
- if (!cancelled) {
- timer = setTimeout(() => {
- void refreshSnapshot()
- }, WALLET_SNAPSHOT_REFRESH_MS)
- }
}
}
void refreshSnapshot()
+ const stop = createVisibilityAwarePoller({
+ intervalMs: WALLET_SNAPSHOT_REFRESH_MS,
+ task: refreshSnapshot,
+ })
return () => {
cancelled = true
- if (timer) clearTimeout(timer)
+ stop()
}
}, [loadWalletSnapshot, walletSession?.address])
diff --git a/frontend/src/pages/liquidity/index.tsx b/frontend/src/pages/liquidity/index.tsx
index 2c41051..34e992e 100644
--- a/frontend/src/pages/liquidity/index.tsx
+++ b/frontend/src/pages/liquidity/index.tsx
@@ -7,6 +7,7 @@ import type { MissionControlBridgeStatusResponse } from '@/services/api/missionC
import type { ExplorerStats } from '@/services/api/stats'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
+import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces'
interface TokenPoolRecord {
symbol: string
@@ -46,7 +47,7 @@ export default function LiquidityPage(props: LiquidityPageProps) {
export const getServerSideProps: GetServerSideProps = async () => {
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult, truthContext] =
await Promise.all([
- fetchPublicJson('/api/config/token-list').catch(() => null),
+ loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch(
() => null,
diff --git a/frontend/src/pages/operations/index.tsx b/frontend/src/pages/operations/index.tsx
index 6ef20fc..8633554 100644
--- a/frontend/src/pages/operations/index.tsx
+++ b/frontend/src/pages/operations/index.tsx
@@ -6,6 +6,7 @@ import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse }
import type { ExplorerStats } from '@/services/api/stats'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
+import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces'
interface OperationsPageProps {
initialBridgeStatus: MissionControlBridgeStatusResponse | null
@@ -24,7 +25,7 @@ export const getStaticProps: GetStaticProps = async () => {
const [routesResult, networksResult, tokenListResult, capabilitiesResult, truthContext] = await Promise.all([
fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson('/api/config/networks').catch(() => null),
- fetchPublicJson('/api/config/token-list').catch(() => null),
+ loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
fetchPublicJson('/api/config/capabilities').catch(() => null),
fetchExplorerTruthContext(),
])
diff --git a/frontend/src/pages/system/index.tsx b/frontend/src/pages/system/index.tsx
index c891852..acb6fb4 100644
--- a/frontend/src/pages/system/index.tsx
+++ b/frontend/src/pages/system/index.tsx
@@ -1,6 +1,7 @@
import type { GetServerSideProps } from 'next'
import SystemOperationsPage from '@/components/explorer/SystemOperationsPage'
import { fetchPublicJson } from '@/utils/publicExplorer'
+import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces'
import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse } from '@/services/api/config'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import type { RouteMatrixResponse } from '@/services/api/routes'
@@ -23,7 +24,7 @@ export const getServerSideProps: GetServerSideProps = async ()
const [bridgeStatus, networksConfig, tokenList, capabilities, routeMatrix, stats] = await Promise.all([
fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null),
fetchPublicJson('/api/config/networks').catch(() => null),
- fetchPublicJson('/api/config/token-list').catch(() => null),
+ loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
fetchPublicJson('/api/config/capabilities').catch(() => null),
fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson('/api/v2/stats').then((value) => normalizeExplorerStats(value as never)).catch(() => null),
diff --git a/frontend/src/services/api/missionControl.ts b/frontend/src/services/api/missionControl.ts
index 220040b..63a3df7 100644
--- a/frontend/src/services/api/missionControl.ts
+++ b/frontend/src/services/api/missionControl.ts
@@ -280,27 +280,50 @@ export const missionControlApi = {
return () => {}
}
- const eventSource = new window.EventSource(getMissionControlStreamUrl())
+ let closed = false
+ let eventSource: EventSource | null = null
+ let reconnectTimer: ReturnType | null = null
+ let retryMs = 5_000
+ const maxRetryMs = 60_000
const handleMessage = (event: MessageEvent) => {
try {
const payload = JSON.parse(event.data) as MissionControlBridgeStatusResponse
+ retryMs = 5_000
onStatus(payload)
} catch (error) {
onError?.(error)
}
}
- eventSource.addEventListener('mission-control', handleMessage)
- eventSource.onmessage = handleMessage
+ const connect = () => {
+ if (closed) return
- eventSource.onerror = () => {
- onError?.(new Error('Mission-control live stream connection lost'))
+ eventSource?.close()
+ eventSource = new window.EventSource(getMissionControlStreamUrl())
+ eventSource.addEventListener('mission-control', handleMessage)
+ eventSource.onmessage = handleMessage
+ eventSource.onerror = () => {
+ eventSource?.close()
+ eventSource = null
+ onError?.(new Error('Mission-control live stream connection lost'))
+ if (closed) return
+ if (reconnectTimer) clearTimeout(reconnectTimer)
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null
+ connect()
+ }, retryMs)
+ retryMs = Math.min(retryMs * 2, maxRetryMs)
+ }
}
+ connect()
+
return () => {
- eventSource.removeEventListener('mission-control', handleMessage)
- eventSource.close()
+ closed = true
+ if (reconnectTimer) clearTimeout(reconnectTimer)
+ eventSource?.removeEventListener('mission-control', handleMessage)
+ eventSource?.close()
}
},
}
diff --git a/frontend/src/services/api/tokenListSurfaces.ts b/frontend/src/services/api/tokenListSurfaces.ts
index 3ad44b2..ccd7db9 100644
--- a/frontend/src/services/api/tokenListSurfaces.ts
+++ b/frontend/src/services/api/tokenListSurfaces.ts
@@ -64,3 +64,15 @@ export async function fetchTokenListForSurface(
export function resolveTokenListClientPath(surface: TokenListSurface, chainId = 138): string {
return surface === 'extended' ? CONFIG_TOKEN_LIST_PATH : `/api/v1/report/token-list?chainId=${chainId}`
}
+
+export async function loadTokenListResponseForSurface(
+ surface: TokenListSurface,
+ chainId = 138,
+): Promise<{ response: { tokens: TokenListToken[] }; source: 'report' | 'config'; label: string }> {
+ const { tokens, source } = await fetchTokenListForSurface(surface, chainId)
+ return {
+ response: { tokens },
+ source,
+ label: TOKEN_LIST_SURFACE_LABELS[surface],
+ }
+}
diff --git a/frontend/src/utils/visibilityRefresh.test.ts b/frontend/src/utils/visibilityRefresh.test.ts
new file mode 100644
index 0000000..ffdba24
--- /dev/null
+++ b/frontend/src/utils/visibilityRefresh.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+
+import { createVisibilityAwarePoller } from './visibilityRefresh'
+
+describe('createVisibilityAwarePoller', () => {
+ it('runs the task immediately on schedule and stops when cancelled', async () => {
+ let count = 0
+ const stop = createVisibilityAwarePoller({
+ intervalMs: 50,
+ task: () => {
+ count += 1
+ },
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, 120))
+ stop()
+
+ expect(count).toBeGreaterThanOrEqual(2)
+ })
+})
diff --git a/frontend/src/utils/visibilityRefresh.ts b/frontend/src/utils/visibilityRefresh.ts
new file mode 100644
index 0000000..41fc474
--- /dev/null
+++ b/frontend/src/utils/visibilityRefresh.ts
@@ -0,0 +1,43 @@
+export function createVisibilityAwarePoller(options: {
+ intervalMs: number
+ task: () => void | Promise
+}): () => void {
+ let cancelled = false
+ let timeoutId: ReturnType | null = null
+
+ const schedule = () => {
+ if (cancelled) return
+ if (timeoutId) clearTimeout(timeoutId)
+ timeoutId = setTimeout(async () => {
+ if (cancelled) return
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
+ schedule()
+ return
+ }
+ try {
+ await options.task()
+ } finally {
+ schedule()
+ }
+ }, options.intervalMs)
+ }
+
+ const onVisibility = () => {
+ if (!cancelled && typeof document !== 'undefined' && document.visibilityState === 'visible') {
+ void options.task()
+ }
+ }
+
+ schedule()
+ if (typeof document !== 'undefined') {
+ document.addEventListener('visibilitychange', onVisibility)
+ }
+
+ return () => {
+ cancelled = true
+ if (timeoutId) clearTimeout(timeoutId)
+ if (typeof document !== 'undefined') {
+ document.removeEventListener('visibilitychange', onVisibility)
+ }
+ }
+}
diff --git a/scripts/e2e-sprint-smoke.spec.ts b/scripts/e2e-sprint-smoke.spec.ts
index 4f0032b..81c0a5d 100644
--- a/scripts/e2e-sprint-smoke.spec.ts
+++ b/scripts/e2e-sprint-smoke.spec.ts
@@ -25,4 +25,9 @@ test.describe('Explorer sprint smoke', () => {
await page.goto(`${EXPLORER_URL}/tokens/${CANONICAL_CUSDT}`, { waitUntil: 'domcontentloaded', timeout: 20000 })
await expect(page.getByText(/cUSDT|Tether/i).first()).toBeVisible({ timeout: 10000 })
})
+
+ test('operations hub loads extended token list note', async ({ page }) => {
+ await page.goto(`${EXPLORER_URL}/operations`, { waitUntil: 'domcontentloaded', timeout: 20000 })
+ await expect(page.getByText(/Extended Metamask dual-chain catalog/i)).toBeVisible({ timeout: 10000 })
+ })
})