diff --git a/web/src/components/layout/Header.dom.test.tsx b/web/src/components/layout/Header.dom.test.tsx new file mode 100644 index 0000000..f1aa471 --- /dev/null +++ b/web/src/components/layout/Header.dom.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AppContext, type AppContextValue } from '../../context/AppContext'; +import { Header } from './Header'; + +const { fetchMarketIndicesMock } = vi.hoisted(() => ({ + fetchMarketIndicesMock: vi.fn(), +})); + +vi.mock('../../lib/marketApi', () => ({ + fetchMarketIndices: (...args: any[]) => fetchMarketIndicesMock(...args), +})); + +const appContext: AppContextValue = { + botState: { + settings: {}, + symbols: {}, + health: {}, + alerts: [], + positions: [], + orders: [], + history: [], + uptime: 0, + } as any, + socket: null, + connected: true, + activeSymbol: 'AAPL', + setActiveSymbol: vi.fn(), + isAdmin: false, + user: { id: 'u1' }, + profile: {}, + showBacktestTab: false, + showMarketplaceTab: false, + handleSignOut: vi.fn(), +}; + +function setVisibilityState(value: DocumentVisibilityState) { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value, + }); +} + +function renderHeader() { + return render( + + +
+ + , + ); +} + +describe('Header market index polling', () => { + beforeEach(() => { + vi.useFakeTimers(); + fetchMarketIndicesMock.mockReset(); + fetchMarketIndicesMock.mockResolvedValue([]); + setVisibilityState('visible'); + }); + + afterEach(() => { + vi.useRealTimers(); + setVisibilityState('visible'); + }); + + it('pauses index polling while the document is hidden and resumes when visible', async () => { + setVisibilityState('hidden'); + renderHeader(); + + expect(fetchMarketIndicesMock).not.toHaveBeenCalled(); + act(() => { + vi.advanceTimersByTime(60_000); + }); + expect(fetchMarketIndicesMock).not.toHaveBeenCalled(); + + setVisibilityState('visible'); + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(fetchMarketIndicesMock).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(60_000); + }); + expect(fetchMarketIndicesMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 9eee3d9..29b5413 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -12,16 +12,28 @@ export function Header() { const navigate = useNavigate(); const inputRef = useRef(null); - // Fetch live market indices once on mount, refresh every 60s + // Fetch live market indices while visible; hidden tabs should not burn API quota. useEffect(() => { let cancelled = false; - const load = () => + const isVisible = () => typeof document === 'undefined' || document.visibilityState === 'visible'; + const load = () => { + if (!isVisible()) return; fetchMarketIndices() .then(data => { if (!cancelled) setIndices(data); }) .catch(() => {/* ignore — keep stale data */}); + }; + const handleVisibilityChange = () => { + if (isVisible()) load(); + }; + load(); const interval = setInterval(load, 60_000); - return () => { cancelled = true; clearInterval(interval); }; + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + cancelled = true; + clearInterval(interval); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; }, []); const handleSearch = (raw: string) => {