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 <cursoragent@cursor.com>
This commit is contained in:
13
frontend/src/components/common/TokenListSurfaceNote.tsx
Normal file
13
frontend/src/components/common/TokenListSurfaceNote.tsx
Normal file
@@ -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 <p className={className}>{TOKEN_LIST_SURFACE_LABELS[surface]}</p>
|
||||
}
|
||||
@@ -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.
|
||||
</p>
|
||||
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
|
||||
{loadingError ? (
|
||||
|
||||
@@ -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({
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Chains · {(tokenList?.tokens || []).length} tokens across {tokenChainCoverage} networks
|
||||
</div>
|
||||
<TokenListSurfaceNote className="mt-2 text-xs text-gray-500 dark:text-gray-400" />
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
|
||||
{loadingError ? (
|
||||
|
||||
@@ -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.`}
|
||||
/>
|
||||
</div>
|
||||
<TokenListSurfaceNote className="mb-6 text-xs text-gray-500 dark:text-gray-400" />
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Topology Snapshot">
|
||||
|
||||
@@ -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<typeof setTimeout> | 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'
|
||||
|
||||
@@ -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<typeof setTimeout> | 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])
|
||||
|
||||
|
||||
@@ -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<LiquidityPageProps> = async () => {
|
||||
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult, truthContext] =
|
||||
await Promise.all([
|
||||
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
|
||||
loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
|
||||
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
||||
fetchPublicJson<PlannerCapabilitiesResponse>('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch(
|
||||
() => null,
|
||||
|
||||
@@ -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<OperationsPageProps> = async () => {
|
||||
const [routesResult, networksResult, tokenListResult, capabilitiesResult, truthContext] = await Promise.all([
|
||||
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
||||
fetchPublicJson<NetworksConfigResponse>('/api/config/networks').catch(() => null),
|
||||
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
|
||||
loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
|
||||
fetchPublicJson<CapabilitiesResponse>('/api/config/capabilities').catch(() => null),
|
||||
fetchExplorerTruthContext(),
|
||||
])
|
||||
|
||||
@@ -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<SystemPageProps> = async ()
|
||||
const [bridgeStatus, networksConfig, tokenList, capabilities, routeMatrix, stats] = await Promise.all([
|
||||
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
||||
fetchPublicJson<NetworksConfigResponse>('/api/config/networks').catch(() => null),
|
||||
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
|
||||
loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
|
||||
fetchPublicJson<CapabilitiesResponse>('/api/config/capabilities').catch(() => null),
|
||||
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
||||
fetchPublicJson('/api/v2/stats').then((value) => normalizeExplorerStats(value as never)).catch(() => null),
|
||||
|
||||
@@ -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<typeof setTimeout> | null = null
|
||||
let retryMs = 5_000
|
||||
const maxRetryMs = 60_000
|
||||
|
||||
const handleMessage = (event: MessageEvent<string>) => {
|
||||
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()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
20
frontend/src/utils/visibilityRefresh.test.ts
Normal file
20
frontend/src/utils/visibilityRefresh.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
43
frontend/src/utils/visibilityRefresh.ts
Normal file
43
frontend/src/utils/visibilityRefresh.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export function createVisibilityAwarePoller(options: {
|
||||
intervalMs: number
|
||||
task: () => void | Promise<void>
|
||||
}): () => void {
|
||||
let cancelled = false
|
||||
let timeoutId: ReturnType<typeof setTimeout> | 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user