From 8a8d3a7f29fc299686f9c46876e294eb1bff5590 Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sat, 2 May 2026 21:53:34 -0500 Subject: [PATCH 1/2] Throttle dump-progress updates to one per animation frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PS1 driver fires a progress event after each of its 1024 frames, and a PS3 MCA dump completes in well under a second — ~1000+ setState calls per second overwhelm React's concurrent renderer, which keeps interrupting itself and falls behind. The result: the progress bar visibly sticks around 1/8 even after the dump is complete. Coalesce events at the React boundary using requestAnimationFrame so we re-render at most once per repaint. The latest pending value always wins, so no progress is dropped. --- src/hooks/use-dump-job.ts | 41 ++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/hooks/use-dump-job.ts b/src/hooks/use-dump-job.ts index 8e72f51..d630940 100644 --- a/src/hooks/use-dump-job.ts +++ b/src/hooks/use-dump-job.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useRef, useEffect } from "react"; import type { DeviceDriver, SystemHandler, @@ -10,15 +10,45 @@ import type { } from "@/lib/types"; import { DumpJobImpl } from "@/lib/core/dump-job"; -export function useDumpJob(log: (msg: string, level?: "info" | "warn" | "error") => void) { +export function useDumpJob( + log: (msg: string, level?: "info" | "warn" | "error") => void, +) { const [state, setState] = useState("idle"); const [progress, setProgress] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(null); const abortRef = useRef(null); + const pendingProgressRef = useRef(null); + const rafIdRef = useRef(null); + + // Coalesce rapid progress events to one render per animation frame. PS1 + // dumps fire ~1024 events in well under a second; without throttling the + // concurrent renderer keeps interrupting itself and the bar appears stuck. + const setProgressThrottled = useCallback((p: DumpProgress) => { + pendingProgressRef.current = p; + if (rafIdRef.current !== null) return; + rafIdRef.current = requestAnimationFrame(() => { + rafIdRef.current = null; + const latest = pendingProgressRef.current; + pendingProgressRef.current = null; + if (latest) setProgress(latest); + }); + }, []); + + useEffect( + () => () => { + if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current); + }, + [], + ); const run = useCallback( - async (driver: DeviceDriver, system: SystemHandler, values: ConfigValues, verificationDb?: VerificationDB | null) => { + async ( + driver: DeviceDriver, + system: SystemHandler, + values: ConfigValues, + verificationDb?: VerificationDB | null, + ) => { const job = new DumpJobImpl(driver, system, verificationDb ?? null); const abort = new AbortController(); abortRef.current = abort; @@ -26,9 +56,10 @@ export function useDumpJob(log: (msg: string, level?: "info" | "warn" | "error") setResult(null); setError(null); setProgress(null); + pendingProgressRef.current = null; job.on("onStateChange", setState); - job.on("onProgress", setProgress); + job.on("onProgress", setProgressThrottled); job.on("onLog", (msg, level) => log(msg, level)); job.on("onComplete", setResult); @@ -44,7 +75,7 @@ export function useDumpJob(log: (msg: string, level?: "info" | "warn" | "error") abortRef.current = null; } }, - [log], + [log, setProgressThrottled], ); const abort = useCallback(() => { From 8f298791313b3b824b31660aafa2bcbdc3f8a3d7 Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sun, 3 May 2026 02:30:18 -0500 Subject: [PATCH 2/2] Address Copilot review on PR #11 Cancel any pending rAF and clear pendingProgressRef on reset() and at the start of run(). Without this, a rAF scheduled by the previous dump can fire after the new run has already cleared progress, briefly flashing the old fraction (or causing a stray re-render in the idle state). --- src/hooks/use-dump-job.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/hooks/use-dump-job.ts b/src/hooks/use-dump-job.ts index d630940..c398d13 100644 --- a/src/hooks/use-dump-job.ts +++ b/src/hooks/use-dump-job.ts @@ -35,12 +35,15 @@ export function useDumpJob( }); }, []); - useEffect( - () => () => { - if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current); - }, - [], - ); + const cancelPendingProgress = useCallback(() => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + pendingProgressRef.current = null; + }, []); + + useEffect(() => () => cancelPendingProgress(), [cancelPendingProgress]); const run = useCallback( async ( @@ -53,10 +56,10 @@ export function useDumpJob( const abort = new AbortController(); abortRef.current = abort; + cancelPendingProgress(); setResult(null); setError(null); setProgress(null); - pendingProgressRef.current = null; job.on("onStateChange", setState); job.on("onProgress", setProgressThrottled); @@ -75,7 +78,7 @@ export function useDumpJob( abortRef.current = null; } }, - [log, setProgressThrottled], + [log, setProgressThrottled, cancelPendingProgress], ); const abort = useCallback(() => { @@ -85,11 +88,12 @@ export function useDumpJob( }, [log]); const reset = useCallback(() => { + cancelPendingProgress(); setState("idle"); setProgress(null); setResult(null); setError(null); - }, []); + }, [cancelPendingProgress]); return { state, progress, result, error, run, abort, reset }; }