refactor(frontend): unify polling via usePolling + visibility pause (S6)
All checks were successful
CI / test (push) Successful in 3m57s

- usePolling gains a pauseWhenHidden option (default true): the timer
  pauses while the document is hidden (background tab / minimised) and
  resumes with an immediate reload on returning to visible. Callers keep
  owning their initial fetch, so no duplicate first request.
- CronPoolMonitor now delegates its 5s poll to the shared usePolling hook
  (initial load stays in its own useEffect); removes the bespoke
  setInterval + manual visibility gap.
- CronPoolMonitor.test: visibility guard now passes (3/3), removing the
  previous it.fails. Frontend suite: 18/18; tsc clean; backend 878.

Resolves scan §6.3.
This commit is contained in:
sinohqb 2026-08-14 15:57:07 +08:00
parent deaeabcf74
commit 0c1e4578c9
4 changed files with 92 additions and 14 deletions

View File

@ -113,7 +113,7 @@ except Exception as e:
**当前状态**:两个 xfail 守卫在 `test_openclaw_client_and_webhook.py`CI 不阻塞;修复后移除 xfail 即转绿。
### 6.3 前端 CronPoolMonitor 不响应 visibilitychangeS6 缺口T2 暴露)
### 6.3 前端 CronPoolMonitor 不响应 visibilitychangeS6 缺口T2 暴露)— ✅ RESOLVED (S6 重构)
**症状**`frontend/web/src/pages/CronPoolMonitor.test.tsx` 的 `it.fails('listens to visibilitychange ...')`
CronPoolMonitor 当前的 `useEffect` 只做了 `setInterval(loadData, 5000)` + `clearInterval` 清理,不监听 `document.visibilitychange`

View File

@ -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<typeof setInterval> | 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])
}

View File

@ -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(<CronPoolMonitor />)
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)
})
})

View File

@ -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 {