fix: prevent stale terminal buffer flash on worktree switch (#654)

This commit is contained in:
Jinwoo Hong 2026-04-14 17:38:41 -04:00 committed by GitHub
parent 033b8bcc31
commit 901c7335a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -36,9 +36,9 @@ export function useTerminalPaneGlobalEffects({
// function can cancel it if the pane deactivates mid-flush.
const pendingFlushRef = useRef<ReturnType<typeof setTimeout> | 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<number | null>(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 100500 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 100500 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