From 901c7335a2107e5595aa8a7826de3c66403c41f4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:38:41 -0400 Subject: [PATCH] fix: prevent stale terminal buffer flash on worktree switch (#654) --- .../use-terminal-pane-global-effects.ts | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 25c1068e2..2859d1df3 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -36,9 +36,9 @@ export function useTerminalPaneGlobalEffects({ // function can cancel it if the pane deactivates mid-flush. const pendingFlushRef = useRef | null>(null) - // Why: the deferred rAF (guardedResumeAndFit) must be cancellable when - // the pane deactivates before the rAF fires — otherwise it would call - // resumeRendering() on an already-suspended manager. + // Why: the deferred rAF (guardedFit) must be cancellable when the pane + // deactivates before the rAF fires — otherwise it would call + // fitAndFocusPanes() on a suspended manager. const pendingRafRef = useRef(null) // Why: two independent code paths schedule fitPanes() after a worktree @@ -58,12 +58,13 @@ export function useTerminalPaneGlobalEffects({ return } if (isActive) { - // Why: resumeRendering() creates WebGL contexts for each pane, which - // blocks the renderer for 100–500 ms per pane on Windows (ANGLE → - // D3D11). Deferring it into the rAF that runs after the pending-write - // drain lets the browser paint one frame with the DOM renderer so the - // terminal content appears immediately. WebGL takes over seamlessly - // in the next frame without a visible flash. + // Why: resume WebGL immediately so the terminal shows its last-known + // state on the first painted frame. On macOS, WebGL context creation + // is ~5 ms — fast enough to feel instant. On Windows (ANGLE → D3D11) + // it can take 100–500 ms, but the alternative (deferring to a rAF + // after the pending-write drain) leaves the terminal blank for multiple + // frames, which is a worse UX tradeoff. + manager.resumeRendering() fitEpochRef.current++ const epoch = fitEpochRef.current @@ -89,16 +90,12 @@ export function useTerminalPaneGlobalEffects({ pendingWritesRef.current.set(paneId, '') } - const guardedResumeAndFit = (): void => { + const guardedFit = (): void => { pendingRafRef.current = null - // Why: read managerRef.current at rAF time instead of capturing - // it at effect entry — the PaneManager instance can change if the - // component unmounts and remounts during rapid tab switches. const mgr = managerRef.current if (!mgr) { return } - mgr.resumeRendering() // Why: three-layer guard prevents redundant and stale fits. // 1. Staleness — reject callbacks from a superseded activation // (e.g. rapid A→B→C worktree switch). @@ -117,7 +114,7 @@ export function useTerminalPaneGlobalEffects({ } if (entries.length === 0) { - pendingRafRef.current = requestAnimationFrame(guardedResumeAndFit) + pendingRafRef.current = requestAnimationFrame(guardedFit) } else { let entryIdx = 0 let offset = 0 @@ -125,7 +122,7 @@ export function useTerminalPaneGlobalEffects({ const drainNextChunk = (): void => { if (entryIdx >= entries.length) { pendingFlushRef.current = null - pendingRafRef.current = requestAnimationFrame(guardedResumeAndFit) + pendingRafRef.current = requestAnimationFrame(guardedFit) return } @@ -159,8 +156,8 @@ export function useTerminalPaneGlobalEffects({ clearTimeout(pendingFlushRef.current) pendingFlushRef.current = null } - // Cancel any pending rAF so guardedResumeAndFit doesn't call - // resumeRendering() on an already-suspended manager. + // Cancel any pending rAF so guardedFit doesn't run on a + // suspended manager. if (pendingRafRef.current !== null) { cancelAnimationFrame(pendingRafRef.current) pendingRafRef.current = null