From 7654d9fff9ea376c4d5ff97640f14aecc38afc3f Mon Sep 17 00:00:00 2001 From: parsakhaz Date: Tue, 7 Jul 2026 10:56:35 -0700 Subject: [PATCH] fix: keep WebGL contexts across panel hides to stop renderer-swap ghosting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disposing WebGL on every panel hide and reattaching on show swaps renderers (WebGL->DOM->WebGL) on every tab switch. A stale frame or texture atlas from one renderer can survive into the next, producing ghosted/overdrawn rows that persist through live output until a manual full refresh — the same family as the blank-canvas-on-refocus bug (#306) and the missed repaint after blur. - Attach WebGL lazily on first show, keep the context while the panel is mounted (bounded by the active session's terminals, ~16-context ceiling is not approached; onContextLoss falls back to DOM renderer) - Detach only after the sustained app-blur timeout and on unmount - On reattach, clear the texture atlas before the deferred full repaint - Update the TerminalPanel lifecycle doc to match --- .../src/components/panels/TerminalPanel.tsx | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/panels/TerminalPanel.tsx b/frontend/src/components/panels/TerminalPanel.tsx index 18475b0a..bf9cff08 100644 --- a/frontend/src/components/panels/TerminalPanel.tsx +++ b/frontend/src/components/panels/TerminalPanel.tsx @@ -128,11 +128,16 @@ function waitForNextPaint(): Promise { * to cover the async WebGL re-attach race. The manual Refresh button always * runs the full path (`handleRefreshTerminal`). * - * WEBGL: one context per VISIBLE terminal. Detached immediately on panel - * hide, kept through short app blurs, detached after a sustained blur - * (WEBGL_APP_BLUR_DETACH_DELAY_MS). Re-attach paints via a refresh deferred - * past the next frame — same-task refreshes can hit an uncomposited canvas. - * While detached, xterm falls back to the DOM renderer. + * WEBGL: attached lazily on a panel's first show, then KEPT while the panel + * is mounted — hides do NOT detach it. Renderer swaps (WebGL→DOM→WebGL) are + * the root of a whole family of paint bugs (blank canvas on refocus #306, + * missed repaint after blur, ghosted/overdrawn rows after tab switches): + * a stale frame or atlas from one renderer survives into the next and only + * a full reset+replay clears it. Contexts detach only after a sustained app + * blur (WEBGL_APP_BLUR_DETACH_DELAY_MS) and on unmount. Re-attach clears the + * texture atlas and repaints via a refresh deferred past the next frame — + * same-task refreshes can hit an uncomposited canvas. While detached, xterm + * falls back to the DOM renderer. * * PERSISTENCE: main owns the raw scrollback (authoritative, ANSI-safe * trimmed); the renderer serializes a formatting-preserving snapshot on @@ -185,7 +190,7 @@ export const TerminalPanel: React.FC = React.memo(({ panel, // rather than a pure window refocus: the fit can visibly reflow the grid, so // mask it with the refresh overlay and move focus. Pure refocus stays silent. const panelActivationRef = useRef(true); - const [webglAllowed, setWebglAllowed] = useState(panelVisible); + const [webglAllowed, setWebglAllowed] = useState(true); const blurDetachTimerRef = useRef | null>(null); // Read CLI state from persisted panel state (handles remount case) @@ -338,6 +343,9 @@ export const TerminalPanel: React.FC = React.memo(({ panel, // uncomposited canvas on blur→refocus reattach. requestAnimationFrame(() => requestAnimationFrame(() => { if (webglAddonRef.current !== addon) return; + // Nuke any glyph state carried across the renderer swap before the + // full repaint — stale atlas entries are how ghost rows survive. + try { terminal.clearTextureAtlas(); } catch { /* renderer not ready */ } if (terminal.rows > 0) terminal.refresh(0, terminal.rows - 1); })); console.log('[TerminalPanel] WebGL renderer loaded for panel', panel.id); @@ -395,26 +403,26 @@ export const TerminalPanel: React.FC = React.memo(({ panel, return () => clearInterval(refreshTimer); }, [effectiveVisible, panel.id, isInitialized]); - // WebGL policy: detach immediately when the panel hides, keep it attached - // through short app blurs, and detach only after a sustained app blur. + // WebGL policy: attach lazily on first show, then KEEP the context while the + // panel stays mounted — including behind display:none. Every renderer swap + // (WebGL→DOM→WebGL) risks a stale frame surviving the transition: the + // blank-canvas-on-refocus bug (#306), the missed-repaint-after-blur bug, and + // ghosted/overdrawn rows after tab switches were all children of the old + // dispose-on-hide churn. Contexts are bounded by mounted terminals of the + // active session (well under the browser's ~16-context ceiling), idle + // contexts cost no GPU time, and onContextLoss falls back to the DOM + // renderer. Detach only after a sustained app blur and on unmount. useEffect(() => { if (blurDetachTimerRef.current) { clearTimeout(blurDetachTimerRef.current); blurDetachTimerRef.current = null; } - if (!panelVisible) { - setWebglAllowed(false); - disposeWebglRenderer('panel-hidden'); - return; - } - if (windowFocused) { setWebglAllowed(true); return; } - setWebglAllowed(true); blurDetachTimerRef.current = setTimeout(() => { blurDetachTimerRef.current = null; setWebglAllowed(false); @@ -427,14 +435,17 @@ export const TerminalPanel: React.FC = React.memo(({ panel, blurDetachTimerRef.current = null; } }; - }, [panelVisible, windowFocused, disposeWebglRenderer]); + }, [windowFocused, disposeWebglRenderer]); useEffect(() => { if (!isInitialized || !xtermRef.current) return; - if (!webglAllowed || !panelVisible) { - disposeWebglRenderer(panelVisible ? 'webgl-not-allowed' : 'panel-hidden'); + if (!webglAllowed) { + disposeWebglRenderer('webgl-not-allowed'); return; } + // Lazy first attach: never-shown background panels stay on the DOM + // renderer; once attached, the context survives hides (see policy above). + if (!panelVisible && !webglAddonRef.current) return; let disposed = false; void loadWebglRenderer(xtermRef.current, () => disposed, windowFocused ? 'visible' : 'short-app-blur');