fix(web): tolerant polling, objectURL cleanup, throttled pointermove

- home/detail video pollers no longer clearInterval on a single transient error
  (give up only after 10 consecutive failures), matching the agent page
- agent page creates preview objectURLs inside useEffect so each has a matching
  revoke under strict-mode double-invocation
- login pointermove throttled via rAF and skipped on coarse pointers
- source-analysis.html: changelog entry for this hardening pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 02:04:59 +08:00
parent b56d5177e5
commit 6201ee9a7d
5 changed files with 54 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { useEffect, useRef, useState } from "react"
import {
ArrowDownToLine,
CheckCircle2,
@@ -73,8 +73,14 @@ export default function AgentPage() {
const [error, setError] = useState("")
const terminalRef = useRef<HTMLDivElement>(null)
const previews = useMemo(() => files.map((file) => ({ file, url: URL.createObjectURL(file) })), [files])
useEffect(() => () => previews.forEach((item) => URL.revokeObjectURL(item.url)), [previews])
// create object URLs inside the effect (not during render) so every URL has a
// matching revoke even under React strict-mode double-invocation
const [previews, setPreviews] = useState<{ file: File; url: string }[]>([])
useEffect(() => {
const next = files.map((file) => ({ file, url: URL.createObjectURL(file) }))
setPreviews(next)
return () => next.forEach((item) => URL.revokeObjectURL(item.url))
}, [files])
useEffect(() => {
fetch(`${API_BASE}/agent-runs?limit=8`, { cache: "no-store" })

View File

@@ -116,11 +116,16 @@ export default function DetailPage() {
useEffect(() => {
if (!job || !runningVideo) return
let failures = 0
const timer = window.setInterval(async () => {
try {
setJob(await getJob(job.id))
failures = 0
} catch {
window.clearInterval(timer)
// one transient 5xx / network blip must not freeze progress forever;
// only give up after sustained failures
failures += 1
if (failures >= 10) window.clearInterval(timer)
}
}, 2600)
return () => window.clearInterval(timer)

View File

@@ -61,15 +61,26 @@ export default function LoginPage() {
}, [])
useEffect(() => {
// skip touch / coarse pointers — the eye-follow effect is pointless there and
// would thrash state (and battery) on scroll-driven pointer events
if (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches) return
let frame = 0
const onPointerMove = (event: PointerEvent) => {
const centerX = window.innerWidth / 2
const centerY = window.innerHeight / 2
const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX))
const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY))
setEyeOffset({ x: nextX * 8, y: nextY * 5.5 })
if (frame) return // coalesce to at most one state update per animation frame
frame = window.requestAnimationFrame(() => {
frame = 0
const centerX = window.innerWidth / 2
const centerY = window.innerHeight / 2
const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX))
const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY))
setEyeOffset({ x: nextX * 8, y: nextY * 5.5 })
})
}
window.addEventListener("pointermove", onPointerMove)
return () => window.removeEventListener("pointermove", onPointerMove)
return () => {
window.removeEventListener("pointermove", onPointerMove)
if (frame) window.cancelAnimationFrame(frame)
}
}, [])
const disabled = status === "loading" || status === "success"

View File

@@ -191,11 +191,16 @@ export default function Home() {
useEffect(() => {
if (!job || !runningVideo) return
let failures = 0
const timer = window.setInterval(async () => {
try {
setJob(await getJob(job.id))
failures = 0
} catch {
window.clearInterval(timer)
// one transient 5xx / network blip must not freeze progress forever;
// only give up after sustained failures
failures += 1
if (failures >= 10) window.clearInterval(timer)
}
}, 2600)
return () => window.clearInterval(timer)