refactor(frontend): unify polling via usePolling + visibility pause (S6)
All checks were successful
CI / test (push) Successful in 3m57s
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:
parent
deaeabcf74
commit
0c1e4578c9
@ -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`。
|
||||
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user