From 73343d16ea4a3651666a3b222cf85403f0b36703 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:22:06 +0800 Subject: [PATCH] fix(models): use useReducer for token usage fetch state to fix lint errors and reduce loading time (#586) --- src/pages/Models/index.tsx | 66 +++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/src/pages/Models/index.tsx b/src/pages/Models/index.tsx index 473364fad..60d608c09 100644 --- a/src/pages/Models/index.tsx +++ b/src/pages/Models/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useReducer, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ChevronLeft, @@ -19,8 +19,8 @@ import { type UsageHistoryEntry, type UsageWindow, } from './usage-history'; -const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 6; -const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 10; +const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 2; +const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 3; const USAGE_FETCH_RETRY_DELAY_MS = 1500; export function Models() { @@ -32,23 +32,39 @@ export function Models() { ? WINDOWS_USAGE_FETCH_MAX_ATTEMPTS : DEFAULT_USAGE_FETCH_MAX_ATTEMPTS; - const [usageHistory, setUsageHistory] = useState([]); const [usageGroupBy, setUsageGroupBy] = useState('model'); const [usageWindow, setUsageWindow] = useState('7d'); const [usagePage, setUsagePage] = useState(1); const [selectedUsageEntry, setSelectedUsageEntry] = useState(null); - const [usageFetchDoneKey, setUsageFetchDoneKey] = useState(null); + + type FetchState = { + status: 'idle' | 'loading' | 'done'; + data: UsageHistoryEntry[]; + }; + type FetchAction = + | { type: 'start' } + | { type: 'done'; data: UsageHistoryEntry[] } + | { type: 'reset' }; + + const [fetchState, dispatchFetch] = useReducer( + (state: FetchState, action: FetchAction): FetchState => { + switch (action.type) { + case 'start': + return { status: 'loading', data: state.data }; + case 'done': + return { status: 'done', data: action.data }; + case 'reset': + return { status: 'idle', data: [] }; + default: + return state; + } + }, + { status: 'idle' as const, data: [] as UsageHistoryEntry[] }, + ); + const usageFetchTimerRef = useRef | null>(null); const usageFetchGenerationRef = useRef(0); - // Stable key derived from the effect's dependencies — changes whenever a new - // fetch cycle should start. Comparing this to `usageFetchDoneKey` lets us - // derive the loading state without calling setState in the effect body or - // reading refs during render. - const usageFetchKey = isGatewayRunning - ? `${gatewayStatus.pid ?? 'na'}:${gatewayStatus.connectedAt ?? 'na'}:${usageFetchMaxAttempts}` - : null; - useEffect(() => { trackUiEvent('models.page_viewed'); }, []); @@ -60,10 +76,11 @@ export function Models() { } if (!isGatewayRunning) { + dispatchFetch({ type: 'reset' }); return; } - const fetchKey = `${gatewayStatus.pid ?? 'na'}:${gatewayStatus.connectedAt ?? 'na'}:${usageFetchMaxAttempts}`; + dispatchFetch({ type: 'start' }); const generation = usageFetchGenerationRef.current + 1; usageFetchGenerationRef.current = generation; const restartMarker = `${gatewayStatus.pid ?? 'na'}:${gatewayStatus.connectedAt ?? 'na'}`; @@ -72,6 +89,17 @@ export function Models() { restartMarker, }); + // Safety timeout: if the fetch cycle hasn't resolved after 30 s, + // force-resolve to "done" with empty data to avoid an infinite spinner. + const safetyTimeout = setTimeout(() => { + if (usageFetchGenerationRef.current !== generation) return; + trackUiEvent('models.token_usage_fetch_safety_timeout', { + generation, + restartMarker, + }); + dispatchFetch({ type: 'done', data: [] }); + }, 30_000); + const fetchUsageHistoryWithRetry = async (attempt: number) => { trackUiEvent('models.token_usage_fetch_attempt', { generation, @@ -83,7 +111,6 @@ export function Models() { if (usageFetchGenerationRef.current !== generation) return; const normalized = Array.isArray(entries) ? entries : []; - setUsageHistory(normalized); setUsagePage(1); trackUiEvent('models.token_usage_fetch_succeeded', { generation, @@ -111,7 +138,7 @@ export function Models() { restartMarker, }); } - setUsageFetchDoneKey(fetchKey); + dispatchFetch({ type: 'done', data: normalized }); } } catch (error) { if (usageFetchGenerationRef.current !== generation) return; @@ -133,8 +160,7 @@ export function Models() { }, USAGE_FETCH_RETRY_DELAY_MS); return; } - setUsageHistory([]); - setUsageFetchDoneKey(fetchKey); + dispatchFetch({ type: 'done', data: [] }); trackUiEvent('models.token_usage_fetch_exhausted', { generation, attempt, @@ -147,6 +173,7 @@ export function Models() { void fetchUsageHistoryWithRetry(1); return () => { + clearTimeout(safetyTimeout); if (usageFetchTimerRef.current) { clearTimeout(usageFetchTimerRef.current); usageFetchTimerRef.current = null; @@ -154,6 +181,7 @@ export function Models() { }; }, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]); + const usageHistory = fetchState.data; const visibleUsageHistory = isGatewayRunning ? usageHistory : []; const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow); const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy); @@ -161,7 +189,7 @@ export function Models() { const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize)); const safeUsagePage = Math.min(usagePage, usageTotalPages); const pagedUsageHistory = filteredUsageHistory.slice((safeUsagePage - 1) * usagePageSize, safeUsagePage * usagePageSize); - const usageLoading = isGatewayRunning && usageFetchDoneKey !== usageFetchKey; + const usageLoading = isGatewayRunning && fetchState.status === 'loading'; return (