From 2214d7fdabb9bc1df9754627638b6ba16d5cf10b Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 26 May 2026 15:49:07 +0300 Subject: [PATCH] perf(admin-traffic): migrate data flow to React Query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page maintained two parallel cache mechanisms: L1 — adminTrafficApi.getCached + Map with 5min TTL and a 20-entry LRU eviction L2 — manual setState + initialLoading/loading flags + cancelled-flag cleanup in useEffect React Query covers both with stronger guarantees: - dedup of in-flight requests with identical queryKey (the prefetch + main-fetch race could double-fire previously) - staleTime: 5 min matches the old TTL - gcTime: 5 min matches the eviction behaviour - background refetch on stale + tab refocus disabled globally trafficQuery owns the main list; enrichmentQuery is gated on !trafficQuery.isLoading && items.length > 0 (preserves the 'don't tag before tags have anyone to tag' guard). Existing setItems / setNodes / setLoading / setEnrichment state is fed by sync useEffects so all downstream selectors, the table, derived memos, and the column defs are untouched. Prefetch adjacent periods now goes through queryClient.prefetchQuery (same warmup cadence, no separate path). handleRefresh invalidates both queries + the internal Map cache and refetches enrichment imperatively. adminTrafficApi's own Map cache stays in place as a harmless L2 — it backs queryFn calls and any non-RQ caller (none in src/ today, but the API surface is shared). --- src/pages/AdminTrafficUsage.tsx | 130 ++++++++++++++------------------ 1 file changed, 57 insertions(+), 73 deletions(-) diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index e8b0a58..0a0546c 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useReactTable, getCoreRowModel, @@ -13,7 +14,6 @@ import { adminTrafficApi, type UserTrafficItem, type TrafficNodeInfo, - type TrafficUsageResponse, type TrafficParams, type TrafficEnrichmentData, } from '../api/adminTraffic'; @@ -1130,89 +1130,75 @@ export default function AdminTrafficUsage() { customEnd, ]); - const applyData = useCallback((data: TrafficUsageResponse) => { - setItems(data.items); - setNodes(data.nodes); - setTotal(data.total); - setAvailableTariffs(data.available_tariffs); - setAvailableStatuses(data.available_statuses); - setPeriodDays(data.period_days); - }, []); + const queryClient = useQueryClient(); + const params = useMemo(() => buildParams(), [buildParams]); - const loadData = useCallback( - async (skipCache = false) => { - const params = buildParams(); + // 5 min staleTime mirrors the previous in-memory cache TTL, so navigating + // away and back inside the window doesn't refetch. React Query handles + // dedup + per-key caching; the adminTrafficApi's own Map cache stays as a + // harmless L2 (still hit by background prefetch + tests). + const trafficQuery = useQuery({ + queryKey: ['admin-traffic', params] as const, + queryFn: () => adminTrafficApi.getTrafficUsage(params), + staleTime: 5 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }); - // Check cache first — apply instantly without any loading state - if (!skipCache) { - const cached = adminTrafficApi.getCached(params); - if (cached) { - applyData(cached); - setInitialLoading(false); - return; - } - } - - try { - setLoading(true); - const data = await adminTrafficApi.getTrafficUsage(params, { skipCache }); - applyData(data); - } catch { - // silently fail — keep stale data visible - } finally { - setLoading(false); - setInitialLoading(false); - } - }, - [buildParams, applyData], - ); - - // Load on param change + // Sync trafficQuery into the existing state vars so the rest of the + // page (selectors, table, derived memos) is untouched. useEffect(() => { - loadData(); - }, [loadData]); - - // Load enrichment after main data arrives + if (!trafficQuery.data) return; + setItems(trafficQuery.data.items); + setNodes(trafficQuery.data.nodes); + setTotal(trafficQuery.data.total); + setAvailableTariffs(trafficQuery.data.available_tariffs); + setAvailableStatuses(trafficQuery.data.available_statuses); + setPeriodDays(trafficQuery.data.period_days); + }, [trafficQuery.data]); useEffect(() => { - if (initialLoading || items.length === 0) return; - let cancelled = false; - const load = async () => { - setEnrichmentLoading(true); - try { - const res = await adminTrafficApi.getEnrichment(); - if (!cancelled) setEnrichment(res.data); - } catch { - // silently fail — enrichment is optional - } finally { - if (!cancelled) setEnrichmentLoading(false); - } - }; - load(); - return () => { - cancelled = true; - }; - }, [initialLoading, items.length]); + setLoading(trafficQuery.isFetching); + if (!trafficQuery.isLoading) setInitialLoading(false); + }, [trafficQuery.isFetching, trafficQuery.isLoading]); - // Prefetch adjacent periods in background (only in period mode) + // Enrichment query — gated on first data arrival so we don't fetch tags + // before there's anyone to tag. + const enrichmentQuery = useQuery({ + queryKey: ['admin-traffic-enrichment'] as const, + queryFn: () => adminTrafficApi.getEnrichment(), + staleTime: 5 * 60 * 1000, + gcTime: 5 * 60 * 1000, + enabled: !trafficQuery.isLoading && items.length > 0, + }); + useEffect(() => { + if (enrichmentQuery.data) setEnrichment(enrichmentQuery.data.data); + }, [enrichmentQuery.data]); + useEffect(() => { + setEnrichmentLoading(enrichmentQuery.isFetching); + }, [enrichmentQuery.isFetching]); + + // Prefetch adjacent periods in background via queryClient — populates the + // cache so switching period feels instant. useEffect(() => { if (dateMode) return; const prefetchPeriods = PERIODS.filter((p) => p !== period); const timer = setTimeout(() => { prefetchPeriods.forEach((p) => { - const params: TrafficParams = { + const prefetchParams: TrafficParams = { period: p, limit, offset: 0, sort_by: 'total_bytes', sort_desc: true, }; - if (!adminTrafficApi.getCached(params)) { - adminTrafficApi.getTrafficUsage(params).catch(() => {}); - } + queryClient.prefetchQuery({ + queryKey: ['admin-traffic', prefetchParams] as const, + queryFn: () => adminTrafficApi.getTrafficUsage(prefetchParams), + staleTime: 5 * 60 * 1000, + }); }); }, 500); return () => clearTimeout(timer); - }, [period, dateMode]); + }, [period, dateMode, limit, queryClient]); useEffect(() => { if (toast) { @@ -1320,14 +1306,12 @@ export default function AdminTrafficUsage() { }; const handleRefresh = () => { - loadData(true); - setEnrichment(null); - setEnrichmentLoading(true); - adminTrafficApi - .getEnrichment({ skipCache: true }) - .then((res) => setEnrichment(res.data)) - .catch(() => {}) - .finally(() => setEnrichmentLoading(false)); + // Force-bypass both layers: drop the API's Map cache, invalidate React + // Query, and explicitly refetch enrichment. + adminTrafficApi.invalidateCache(); + queryClient.invalidateQueries({ queryKey: ['admin-traffic'] }); + queryClient.invalidateQueries({ queryKey: ['admin-traffic-enrichment'] }); + enrichmentQuery.refetch(); }; const availableCountries = useMemo(() => {