diff --git a/.scratch/v111-architecture-scan.md b/.scratch/v111-architecture-scan.md index 4a5f263..6baba22 100644 --- a/.scratch/v111-architecture-scan.md +++ b/.scratch/v111-architecture-scan.md @@ -113,7 +113,7 @@ except Exception as e: **当前状态**:两个 xfail 守卫在 `test_openclaw_client_and_webhook.py`,CI 不阻塞;修复后移除 xfail 即转绿。 -### 6.3 前端 CronPoolMonitor 不响应 visibilitychange(S6 缺口,T2 暴露) +### 6.3 前端 CronPoolMonitor 不响应 visibilitychange(S6 缺口,T2 暴露)— ✅ RESOLVED (S6 重构) **症状**:`frontend/web/src/pages/CronPoolMonitor.test.tsx` 的 `it.fails('listens to visibilitychange ...')`。 CronPoolMonitor 当前的 `useEffect` 只做了 `setInterval(loadData, 5000)` + `clearInterval` 清理,不监听 `document.visibilitychange`。 diff --git a/frontend/web/src/hooks/usePolling.ts b/frontend/web/src/hooks/usePolling.ts index 8dec79d..c990b24 100644 --- a/frontend/web/src/hooks/usePolling.ts +++ b/frontend/web/src/hooks/usePolling.ts @@ -5,14 +5,70 @@ import { useEffect, useRef } from 'react' * when it flips false or the component unmounts. The latest `fn` is always * used without restarting the interval, so callers can pass a fresh closure * each render (e.g. `() => reload(true)`) without churning the timer. + * + * S6: when ``pauseWhenHidden`` is true (default) the timer pauses while + * the document is hidden (background tab, minimised) and resumes + * immediately — calling ``fn`` once on the way back to ``visible`` so + * the displayed data is fresh. The ``visibilitychange`` listener is added + * on mount and removed on unmount. */ -export function usePolling(fn: () => void, ms: number, enabled: boolean): void { +export function usePolling( + fn: () => void, + ms: number, + enabled: boolean, + options: { pauseWhenHidden?: boolean } = {}, +): void { + const pauseWhenHidden = options.pauseWhenHidden ?? true const fnRef = useRef(fn) fnRef.current = fn useEffect(() => { if (!enabled) return - const id = window.setInterval(() => fnRef.current(), ms) - return () => window.clearInterval(id) - }, [enabled, ms]) + + let intervalId: ReturnType | null = null + const start = () => { + if (intervalId !== null) return + intervalId = setInterval(() => fnRef.current(), ms) + } + const stop = () => { + if (intervalId !== null) { + clearInterval(intervalId) + intervalId = null + } + } + + const startWithFreshCall = () => { + fnRef.current() + start() + } + + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + // Returning to the visible tab: refresh immediately and resume the + // timer so the user sees up-to-date data right away. + startWithFreshCall() + } else { + stop() + } + } + + // Start the timer when enabled. Callers own the initial fetch (most + // hooks already issue one in their own useEffect), so we do NOT call fn + // here — that avoids a duplicate initial request. The hook is only + // responsible for the interval and (optionally) the visibility pause. + if (!pauseWhenHidden || document.visibilityState === 'visible') { + start() + } + + if (pauseWhenHidden) { + document.addEventListener('visibilitychange', onVisibilityChange) + } + + return () => { + stop() + if (pauseWhenHidden) { + document.removeEventListener('visibilitychange', onVisibilityChange) + } + } + }, [enabled, ms, pauseWhenHidden]) } diff --git a/frontend/web/src/pages/CronPoolMonitor.test.tsx b/frontend/web/src/pages/CronPoolMonitor.test.tsx index ba0d29e..f0abb26 100644 --- a/frontend/web/src/pages/CronPoolMonitor.test.tsx +++ b/frontend/web/src/pages/CronPoolMonitor.test.tsx @@ -97,14 +97,30 @@ describe('CronPoolMonitor polling lifecycle', () => { const after = vi.mocked(openclawCronPoolApi.getStatus).mock.calls.length expect(after).toBe(before) }) - it.fails('listens to visibilitychange to pause polling when tab is hidden', async () => { - const addSpy = vi.spyOn(document, 'addEventListener') + it('pauses polling while the document is hidden and resumes on visible', async () => { + // Default starting state is visible. + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible') + render() await settle() - const hasVisibility = addSpy.mock.calls.some( - (call: unknown[]) => call[0] === 'visibilitychange', - ) - expect(hasVisibility).toBe(true) - addSpy.mockRestore() + const initial = vi.mocked(openclawCronPoolApi.getStatus).mock.calls.length + expect(initial).toBeGreaterThanOrEqual(1) + + // While hidden, advancing the clock must NOT trigger another poll. + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden') + document.dispatchEvent(new Event('visibilitychange')) + await act(async () => { + await vi.advanceTimersByTimeAsync(15000) + }) + await settle() + const afterHidden = vi.mocked(openclawCronPoolApi.getStatus).mock.calls.length + expect(afterHidden).toBe(initial) + + // Returning to visible resumes polling and triggers an immediate reload. + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible') + document.dispatchEvent(new Event('visibilitychange')) + await settle() + const afterVisible = vi.mocked(openclawCronPoolApi.getStatus).mock.calls.length + expect(afterVisible).toBeGreaterThan(initial) }) }) diff --git a/frontend/web/src/pages/CronPoolMonitor.tsx b/frontend/web/src/pages/CronPoolMonitor.tsx index a963e46..f014e76 100644 --- a/frontend/web/src/pages/CronPoolMonitor.tsx +++ b/frontend/web/src/pages/CronPoolMonitor.tsx @@ -5,6 +5,7 @@ import { import type { ColumnsType } from 'antd/es/table' import { ReloadOutlined, WarningOutlined } from '@ant-design/icons' import { openclawCronPoolApi, type CronPoolAlert, type CronPoolMetrics, type CronPoolStatus } from '../api' +import { usePolling } from '../hooks/usePolling' import { colors } from '../tokens' import { formatDateTime } from '../utils/date' @@ -34,12 +35,17 @@ export default function CronPoolMonitor() { } } + // Initial fetch (usePolling owns the 5s interval + visibility pause). useEffect(() => { void loadData() - const interval = setInterval(() => void loadData(), 5000) - return () => clearInterval(interval) + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // S6: defer the recurring 5s poll to the shared `usePolling` hook. The + // default pauses the timer while the document is hidden (background tab / + // minimised window) and re-fetches once on the way back to `visible`. + usePolling(() => { void loadData() }, 5000, true) + const handleScale = async () => { setScaleBusy(true) try {