fix(C5): pause index polling when hidden
Guard Header market-index refreshes with document.visibilityState and resume on visibilitychange so hidden tabs stop polling the backend while preserving stale index data. Refs: docs/AUDIT_REDESIGN.md item C5. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
This commit is contained in:
parent
a1f5234617
commit
e089832039
90
web/src/components/layout/Header.dom.test.tsx
Normal file
90
web/src/components/layout/Header.dom.test.tsx
Normal file
@ -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(
|
||||||
|
<AppContext.Provider value={appContext}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<Header />
|
||||||
|
</MemoryRouter>
|
||||||
|
</AppContext.Provider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -12,16 +12,28 @@ export function Header() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const load = () =>
|
const isVisible = () => typeof document === 'undefined' || document.visibilityState === 'visible';
|
||||||
|
const load = () => {
|
||||||
|
if (!isVisible()) return;
|
||||||
fetchMarketIndices()
|
fetchMarketIndices()
|
||||||
.then(data => { if (!cancelled) setIndices(data); })
|
.then(data => { if (!cancelled) setIndices(data); })
|
||||||
.catch(() => {/* ignore — keep stale data */});
|
.catch(() => {/* ignore — keep stale data */});
|
||||||
|
};
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (isVisible()) load();
|
||||||
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
const interval = setInterval(load, 60_000);
|
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) => {
|
const handleSearch = (raw: string) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user