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 即转绿。
|
**当前状态**:两个 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 ...')`。
|
**症状**:`frontend/web/src/pages/CronPoolMonitor.test.tsx` 的 `it.fails('listens to visibilitychange ...')`。
|
||||||
CronPoolMonitor 当前的 `useEffect` 只做了 `setInterval(loadData, 5000)` + `clearInterval` 清理,不监听 `document.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
|
* 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
|
* used without restarting the interval, so callers can pass a fresh closure
|
||||||
* each render (e.g. `() => reload(true)`) without churning the timer.
|
* 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)
|
const fnRef = useRef(fn)
|
||||||
fnRef.current = fn
|
fnRef.current = fn
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return
|
if (!enabled) return
|
||||||
const id = window.setInterval(() => fnRef.current(), ms)
|
|
||||||
return () => window.clearInterval(id)
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
}, [enabled, ms])
|
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
|
const after = vi.mocked(openclawCronPoolApi.getStatus).mock.calls.length
|
||||||
expect(after).toBe(before)
|
expect(after).toBe(before)
|
||||||
})
|
})
|
||||||
it.fails('listens to visibilitychange to pause polling when tab is hidden', async () => {
|
it('pauses polling while the document is hidden and resumes on visible', async () => {
|
||||||
const addSpy = vi.spyOn(document, 'addEventListener')
|
// Default starting state is visible.
|
||||||
|
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
|
||||||
|
|
||||||
render(<CronPoolMonitor />)
|
render(<CronPoolMonitor />)
|
||||||
await settle()
|
await settle()
|
||||||
const hasVisibility = addSpy.mock.calls.some(
|
const initial = vi.mocked(openclawCronPoolApi.getStatus).mock.calls.length
|
||||||
(call: unknown[]) => call[0] === 'visibilitychange',
|
expect(initial).toBeGreaterThanOrEqual(1)
|
||||||
)
|
|
||||||
expect(hasVisibility).toBe(true)
|
// While hidden, advancing the clock must NOT trigger another poll.
|
||||||
addSpy.mockRestore()
|
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 type { ColumnsType } from 'antd/es/table'
|
||||||
import { ReloadOutlined, WarningOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, WarningOutlined } from '@ant-design/icons'
|
||||||
import { openclawCronPoolApi, type CronPoolAlert, type CronPoolMetrics, type CronPoolStatus } from '../api'
|
import { openclawCronPoolApi, type CronPoolAlert, type CronPoolMetrics, type CronPoolStatus } from '../api'
|
||||||
|
import { usePolling } from '../hooks/usePolling'
|
||||||
import { colors } from '../tokens'
|
import { colors } from '../tokens'
|
||||||
import { formatDateTime } from '../utils/date'
|
import { formatDateTime } from '../utils/date'
|
||||||
|
|
||||||
@ -34,12 +35,17 @@ export default function CronPoolMonitor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initial fetch (usePolling owns the 5s interval + visibility pause).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadData()
|
void loadData()
|
||||||
const interval = setInterval(() => void loadData(), 5000)
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 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 () => {
|
const handleScale = async () => {
|
||||||
setScaleBusy(true)
|
setScaleBusy(true)
|
||||||
try {
|
try {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user