From ed6092a4e9a89ddabdd686238921937ac62dd26b Mon Sep 17 00:00:00 2001 From: arookieofc Date: Fri, 30 Oct 2026 19:16:42 +0800 Subject: [PATCH] fix(run): keep console output flowing in background tabs (#400) Run console output is flushed on requestAnimationFrame, which stops while the page is hidden, so output from a run in a hidden window piled up and replayed when it was shown again. Pending flushes now move to a 100 ms timeout while hidden and back to rAF when visible. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/features/run/index.ts | 7 ++++++ src/features/run/store.ts | 48 +++++++++++++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/features/run/index.ts b/src/features/run/index.ts index cb3cd274..1a5cd1ac 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -52,6 +52,7 @@ import { CURRENT_FILE, currentFileSelected, prepareLaunch, + rescheduleConsoleFlushes, setBeforeLaunchRunner, loadConfigs, failedTests, @@ -534,6 +535,12 @@ export function activate(): () => void { }), ); + // Hidden tabs pause rAF: move pending console flushes to timeouts (and back on + // return) so output keeps draining while a run continues in the background. + const onVisibility = () => rescheduleConsoleFlushes(); + document.addEventListener("visibilitychange", onVisibility); + disposers.push(() => document.removeEventListener("visibilitychange", onVisibility)); + return () => { clearTimeout(reloadTimer); disposers.reverse().forEach((d) => d()); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 0dcb34d2..65b6bb01 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -71,7 +71,8 @@ interface SessionRuntime { tests: TestResults | null; parser: TestOutputParser | null; queue: OutputChunk[]; - flushHandle: number | null; + /** rAF id while the page is visible, timeout id while hidden (rAF pauses in background tabs). */ + flushHandle: { raf: number } | { timeout: number } | null; /** Bumped per start so output from a previous run in the same tab is ignored. */ generation: number; /** Called with every complete output line (e.g. waiting for a JDWP agent). */ @@ -340,10 +341,7 @@ function getSession(id: string): RunSession | undefined { function flush(sessionId: string) { const rt = runtimes.get(sessionId); if (!rt) return; - if (rt.flushHandle !== null) { - cancelAnimationFrame(rt.flushHandle); - rt.flushHandle = null; - } + cancelFlush(rt); if (rt.queue.length === 0) return; const chunks = rt.queue.splice(0); for (const c of chunks) rt.console.append(c); @@ -358,7 +356,39 @@ function enqueue(sessionId: string, chunk: OutputChunk) { return; } rt.queue.push(chunk); - if (rt.flushHandle === null) rt.flushHandle = requestAnimationFrame(() => flush(sessionId)); + if (rt.flushHandle === null) { + // rAF pauses in background tabs; a timeout keeps consuming output so it doesn't + // pile up until the console is shown again (issue #319: the console "kept + // scrolling" the previous run's backlog after switching back to it). + rt.flushHandle = document.hidden + ? { timeout: window.setTimeout(() => flush(sessionId), 100) } + : { raf: requestAnimationFrame(() => flush(sessionId)) }; + } +} + +function cancelFlush(rt: SessionRuntime) { + if (rt.flushHandle === null) return; + if ("raf" in rt.flushHandle) cancelAnimationFrame(rt.flushHandle.raf); + else clearTimeout(rt.flushHandle.timeout); + rt.flushHandle = null; +} + +/** + * `visibilitychange`: rAF stops firing while the page is hidden, so any pending + * rAF flush is moved to a timeout (and back on return) — otherwise output that + * arrives while a background tab is hidden would only land when it is shown. + */ +export function rescheduleConsoleFlushes() { + for (const [id, rt] of runtimes) { + if (rt.flushHandle === null) continue; + const hidden = document.hidden; + const isTimeout = "timeout" in rt.flushHandle; + if (hidden === isTimeout) continue; + cancelFlush(rt); + rt.flushHandle = hidden + ? { timeout: window.setTimeout(() => flush(id), 100) } + : { raf: requestAnimationFrame(() => flush(id)) }; + } } /** Settings › Console › Override console cycle buffer size, in characters (null = line cap only). */ @@ -504,7 +534,7 @@ export async function startRun( const prev = runtimes.get(session.id); const rt = newRuntime(config); rt.generation = (prev?.generation ?? 0) + 1; - if (prev?.flushHandle != null) cancelAnimationFrame(prev.flushHandle); + if (prev) cancelFlush(prev); runtimes.set(session.id, rt); session = { ...session, @@ -964,7 +994,7 @@ export function closeSession(sessionId: string) { if (!session) return; if (session.status === "running") void stopSession(sessionId); const rt = runtimes.get(sessionId); - if (rt?.flushHandle != null) cancelAnimationFrame(rt.flushHandle); + if (rt) cancelFlush(rt); runtimes.delete(sessionId); useRun.setState((s) => { const index = s.sessions.findIndex((x) => x.id === sessionId); @@ -984,7 +1014,7 @@ export function runningSessions(): RunSession[] { /** Stops everything and forgets sessions (project closed). */ export function resetRuns() { for (const s of runningSessions()) void stopSession(s.id); - for (const rt of runtimes.values()) if (rt.flushHandle != null) cancelAnimationFrame(rt.flushHandle); + for (const rt of runtimes.values()) cancelFlush(rt); runtimes.clear(); pendingExits.clear(); useRun.setState({