diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index bf70c770f1e..35cd5eaf169 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -122,6 +122,13 @@ export class PaneManager { // the authoritative restore to the timeout below. existing.pendingSplitScrollState = scrollState + // Why: Chromium can silently invalidate a WebGL context when its canvas + // moves in the DOM without firing contextlost. Dispose before reparent, + // re-attach after layout settles (scheduleSplitScrollRestore 200ms timer). + const hadWebgl = !!existing.webglAddon + if (hadWebgl) { + disposeWebgl(existing) + } wrapInSplit(existing.container, newPane.container, isVertical, divider, opts) openTerminal(newPane) @@ -144,7 +151,8 @@ export class PaneManager { (id) => this.panes.get(id), existing.id, scrollState, - () => this.destroyed + () => this.destroyed, + hadWebgl ) return toPublicPane(newPane) diff --git a/src/renderer/src/lib/pane-manager/pane-split-scroll.ts b/src/renderer/src/lib/pane-manager/pane-split-scroll.ts index f33637845d5..bbbc41ac1af 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-scroll.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-scroll.ts @@ -1,13 +1,7 @@ import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' import { restoreScrollState } from './pane-scroll' +import { attachWebgl } from './pane-lifecycle' -// Why: wrapInSplit reparents the existing pane's container, which briefly -// detaches the WebGL canvas from the DOM. The WebGL renderer's internal -// render state can become stale after the re-attachment, leaving the canvas -// blank even though the terminal buffer has data. This mirrors the explicit -// refresh in resumeRendering() (pane-manager.ts) and the onContextLoss -// handler (pane-lifecycle.ts) which address the same "frozen terminal" -// symptom for analogous WebGL state transitions. function refreshAfterReparent(pane: ManagedPaneInternal): void { try { pane.terminal.refresh(0, pane.terminal.rows - 1) @@ -20,11 +14,17 @@ function refreshAfterReparent(pane: ManagedPaneInternal): void { // scroll position (browser clears scrollTop on DOM move). This schedules a // two-phase restore: an early double-rAF (~32ms) to minimise the visible // flash, plus a 200ms authoritative restore that also clears the scroll lock. +// +// When `reattachWebgl` is true, the 200ms timer re-creates the WebGL addon +// that splitPane disposed before wrapInSplit. Waiting 200ms ensures the DOM +// has fully settled — attaching WebGL to a canvas that's mid-reparent can +// silently produce a dead context. export function scheduleSplitScrollRestore( getPaneById: (id: number) => ManagedPaneInternal | undefined, paneId: number, scrollState: ScrollState, - isDestroyed: () => boolean + isDestroyed: () => boolean, + reattachWebgl?: boolean ): void { requestAnimationFrame(() => { requestAnimationFrame(() => { @@ -49,6 +49,16 @@ export function scheduleSplitScrollRestore( } live.pendingSplitScrollState = null restoreScrollState(live.terminal, scrollState) + + // Why: splitPane() disposed WebGL before wrapInSplit to prevent Chromium + // from silently invalidating the context during the DOM reparent. Now that + // layout has settled, re-attach a fresh WebGL addon so the pane returns to + // GPU rendering. The refresh after attachment paints the buffer content + // that accumulated while the pane was on the DOM fallback renderer. + if (reattachWebgl && live.gpuRenderingEnabled && !live.webglDisabledAfterContextLoss) { + attachWebgl(live) + } + refreshAfterReparent(live) }, 200) } diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 31b37b8c3cc..b1d341955f4 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -1,5 +1,6 @@ import type { DropZone, ManagedPaneInternal, PaneStyleOptions } from './pane-manager-types' import { createDivider } from './pane-divider' +import { disposeWebgl, attachWebgl } from './pane-lifecycle' export { findLineByContent, captureScrollState, restoreScrollState } from './pane-scroll' @@ -27,12 +28,6 @@ function getProposedDimensions(pane: ManagedPaneInternal): { cols: number; rows: // viewportY across reflows — see scroll-reflow.test.ts "reference: undisturbed". // A plain fit() is all we need during sidebar drags, divider drags, and window // resizes. This matches how Superset and VSCode handle the same cases. -// -// pendingSplitScrollState is the one case where fit() alone isn't enough: -// wrapInSplit() reparents the container, which makes the browser reset -// scrollTop to 0 asynchronously. splitPane captures the pre-split state and -// scheduleSplitScrollRestore owns the authoritative restore on a timer, so -// safeFit here just fits and lets the scheduled restore do its job. export function safeFit(pane: ManagedPaneInternal): void { try { const dims = getProposedDimensions(pane) @@ -40,29 +35,6 @@ export function safeFit(pane: ManagedPaneInternal): void { // Why: divider drags fire refits every frame, but most frames do not // cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh() // churn, which was causing visible terminal blinking while resizing. - // - // Why: wrapInSplit() reparents the pane's container, which can leave - // the WebGL canvas stale even when proposed dimensions match current - // (the browser detaches and reattaches the canvas during the DOM move). - // When pendingSplitScrollState is set we must force a fit + refresh so - // the WebGL renderer repaints. Without this, the pane appears blank - // until something forces a dimension change. - if (pane.pendingSplitScrollState) { - console.warn( - '[terminal] safeFit forcing fit+refresh during pending split for pane', - pane.id, - `— dims ${dims.cols}×${dims.rows} match current, webgl:`, - !!pane.webglAddon, - pane.debugLabel ? `(${pane.debugLabel})` : '' - ) - pane.fitAddon.fit() - try { - pane.terminal.refresh(0, pane.terminal.rows - 1) - } catch { - /* ignore — terminal may not be fully initialised */ - } - return - } return } pane.fitAddon.fit() @@ -183,6 +155,17 @@ export function insertPaneNextTo( applyPaneFlexStyle(source.container) applyPaneFlexStyle(targetContainer) + // Why: dispose WebGL before reparenting so Chromium cannot silently + // invalidate the context during the DOM move (same as splitPane). + const sourceHadWebgl = !!source.webglAddon + const targetHadWebgl = !!target.webglAddon + if (sourceHadWebgl) { + disposeWebgl(source) + } + if (targetHadWebgl) { + disposeWebgl(target) + } + // Replace target with the split in the DOM parent.replaceChild(split, targetContainer) @@ -197,11 +180,14 @@ export function insertPaneNextTo( split.appendChild(source.container) } - // Refit both and refresh rendering surfaces — both panes were reparented - // into the new split wrapper, which can leave the WebGL canvas in a stale - // state (same mechanism as wrapInSplit; see refreshAfterReparent in - // pane-split-scroll.ts). + // Re-attach WebGL after the DOM has settled, then refit both panes. requestAnimationFrame(() => { + if (sourceHadWebgl && source.gpuRenderingEnabled && !source.webglDisabledAfterContextLoss) { + attachWebgl(source) + } + if (targetHadWebgl && target.gpuRenderingEnabled && !target.webglDisabledAfterContextLoss) { + attachWebgl(target) + } callbacks.safeFit(source) callbacks.safeFit(target) try {