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) => {