diff --git a/src/hooks/__tests__/useOracle.test.ts b/src/hooks/__tests__/useOracle.test.ts index 06a1d35..9ec3fc7 100644 --- a/src/hooks/__tests__/useOracle.test.ts +++ b/src/hooks/__tests__/useOracle.test.ts @@ -1,6 +1,7 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { useOracleReading } from '../useOracle'; +import { useOracleReading, useAllOracleReadings, VISIBILITY_REFETCH_MIN_MS } from '../useOracle'; +import { fetchAllOracleReadings } from '@/lib/api'; const mockFetchOracleReading = vi.fn(); @@ -220,4 +221,57 @@ describe('useOracleReading', () => { addSpy.mockRestore(); removeSpy.mockRestore(); }); + + // Issue #519: rapid tab switching must not trigger a fetch per + // visibilitychange -- only once the last fetch is older than + // VISIBILITY_REFETCH_MIN_MS. + it('throttles visibilitychange refetches to VISIBILITY_REFETCH_MIN_MS', async () => { + mockFetchOracleReading.mockResolvedValue({ + key: 'weather-abuja', + dataType: 'weather', + value: '324000000', + confidence: 95, + timestamp: 1000, + source: 'mock', + }); + let now = 1_000_000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + + renderHook(() => useOracleReading('weather-abuja')); + await waitFor(() => expect(mockFetchOracleReading).toHaveBeenCalledTimes(1)); + + for (let i = 0; i < 10; i++) { + now += 100; + act(() => { document.dispatchEvent(new Event('visibilitychange')); }); + } + expect(mockFetchOracleReading).toHaveBeenCalledTimes(1); + + now += VISIBILITY_REFETCH_MIN_MS; + act(() => { document.dispatchEvent(new Event('visibilitychange')); }); + await waitFor(() => expect(mockFetchOracleReading).toHaveBeenCalledTimes(2)); + + nowSpy.mockRestore(); + }); +}); + +describe('useAllOracleReadings', () => { + it('throttles visibilitychange refetches to VISIBILITY_REFETCH_MIN_MS', async () => { + const mockFetchAll = vi.mocked(fetchAllOracleReadings); + mockFetchAll.mockClear(); + let now = 5_000_000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + + renderHook(() => useAllOracleReadings()); + await waitFor(() => expect(mockFetchAll).toHaveBeenCalledTimes(1)); + + now += 100; + act(() => { document.dispatchEvent(new Event('visibilitychange')); }); + expect(mockFetchAll).toHaveBeenCalledTimes(1); + + now += VISIBILITY_REFETCH_MIN_MS; + act(() => { document.dispatchEvent(new Event('visibilitychange')); }); + await waitFor(() => expect(mockFetchAll).toHaveBeenCalledTimes(2)); + + nowSpy.mockRestore(); + }); }); diff --git a/src/hooks/useOracle.ts b/src/hooks/useOracle.ts index fe95d97..de6caf1 100644 --- a/src/hooks/useOracle.ts +++ b/src/hooks/useOracle.ts @@ -5,6 +5,15 @@ import { fetchOracleReading, fetchAllOracleReadings } from '@/lib/api'; import type { OracleReading } from '@/types'; import { ORACLE_REFRESH_INTERVAL_MS } from '@/lib/constants'; +// A tab becoming visible only triggers a refetch if at least this long has +// passed since the last fetch, so rapid tab switching can't flood the API +// (#519). Interval polling remains the primary refresh mechanism. +export const VISIBILITY_REFETCH_MIN_MS = ORACLE_REFRESH_INTERVAL_MS / 2; + +function isStale(lastFetchAt: number): boolean { + return Date.now() - lastFetchAt >= VISIBILITY_REFETCH_MIN_MS; +} + export function useOracleReading(key: string | null) { const [reading, setReading] = useState(null); const [loading, setLoading] = useState(false); @@ -12,10 +21,12 @@ export function useOracleReading(key: string | null) { const isFirstLoad = useRef(true); const currentKeyRef = useRef(key); const prevKeyRef = useRef(key); + const lastFetchAtRef = useRef(0); const load = useCallback(async () => { if (!key) return; currentKeyRef.current = key; + lastFetchAtRef.current = Date.now(); const isFirst = isFirstLoad.current; if (isFirst) { setLoading(true); @@ -56,7 +67,9 @@ export function useOracleReading(key: string | null) { if (!key) return; void load(); const interval = setInterval(() => { if (!document.hidden) void load(); }, ORACLE_REFRESH_INTERVAL_MS); - const onVisible = () => { if (!document.hidden) void load(); }; + const onVisible = () => { + if (!document.hidden && isStale(lastFetchAtRef.current)) void load(); + }; document.addEventListener('visibilitychange', onVisible); return () => { clearInterval(interval); @@ -73,11 +86,13 @@ export function useAllOracleReadings() { const [error, setError] = useState(null); const isFirstLoad = useRef(true); const refetchController = useRef(null); + const lastFetchAtRef = useRef(0); // Guards against a slower in-flight response overwriting a newer one's // state (#450) -- mirrors usePolicies'/useClaims' AbortController pattern, // the codebase's established fix for this class of stale-response race. const load = useCallback(async (signal: AbortSignal) => { + lastFetchAtRef.current = Date.now(); const isFirst = isFirstLoad.current; if (isFirst) { setLoading(true); @@ -104,7 +119,9 @@ export function useAllOracleReadings() { const interval = setInterval(() => { if (!document.hidden) void load(controller.signal); }, ORACLE_REFRESH_INTERVAL_MS); - const onVisible = () => { if (!document.hidden) void load(controller.signal); }; + const onVisible = () => { + if (!document.hidden && isStale(lastFetchAtRef.current)) void load(controller.signal); + }; document.addEventListener('visibilitychange', onVisible); return () => { controller.abort();