Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion src/hooks/__tests__/useOracle.test.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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();
});
});
21 changes: 19 additions & 2 deletions src/hooks/useOracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,28 @@ 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<OracleReading | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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);
Expand Down Expand Up @@ -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);
Expand All @@ -73,11 +86,13 @@ export function useAllOracleReadings() {
const [error, setError] = useState<string | null>(null);
const isFirstLoad = useRef(true);
const refetchController = useRef<AbortController | null>(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);
Expand All @@ -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();
Expand Down
Loading