From 8397973e89a7f45f5e7d5ccf49ceac41e84ebbd9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 17:59:27 -0700 Subject: [PATCH] fix: cancel pane webgl refresh frame (#3397) --- .../src/lib/pane-manager/pane-lifecycle.ts | 9 +- .../lib/pane-manager/pane-manager-types.ts | 2 + .../pane-webgl-refresh-lifecycle.test.ts | 83 +++++++++++++++++++ .../lib/pane-manager/pane-webgl-renderer.ts | 11 ++- 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 7be9cd0438c..30ec3e5c5d0 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -23,7 +23,12 @@ import { detachPaneFitResizeObserver } from './pane-fit-resize-observer' import { buildDefaultTerminalOptions } from './pane-terminal-options' -import { ENABLE_WEBGL_RENDERER, attachWebgl, disposeWebgl } from './pane-webgl-renderer' +import { + ENABLE_WEBGL_RENDERER, + attachWebgl, + cancelPendingWebglRefresh, + disposeWebgl +} from './pane-webgl-renderer' import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus' // --------------------------------------------------------------------------- @@ -120,6 +125,7 @@ export function createPaneDOM( fitAddon, fitResizeObserver: null, pendingInitialFitRafId: null, + pendingWebglRefreshRafId: null, pendingObservedFitRafId: null, searchAddon, serializeAddon, @@ -299,6 +305,7 @@ export function disposePane( cancelAnimationFrame(pane.pendingInitialFitRafId) pane.pendingInitialFitRafId = null } + cancelPendingWebglRefresh(pane) detachPaneFitResizeObserver(pane) if (pane.compositionHandler) { pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index e192ca4b903..850872201af 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -107,6 +107,8 @@ export type ManagedPaneInternal = { fitResizeObserver: ResizeObserver | null // Stored so disposePane() can cancel the first post-open fit if a pane closes before paint. pendingInitialFitRafId?: number | null + // Stored so disposePane() can cancel the post-WebGL-teardown refresh frame. + pendingWebglRefreshRafId?: number | null pendingObservedFitRafId: number | null serializeAddon: SerializeAddon unicode11Addon: Unicode11Addon diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts new file mode 100644 index 00000000000..6cff7287564 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import { disposePane } from './pane-lifecycle' +import { disposeWebgl } from './pane-webgl-renderer' + +function createPane( + overrides: Partial> = {} +): ManagedPaneInternal { + const leafId = '11111111-1111-4111-8111-111111111111' as never + return { + id: 1, + leafId, + stablePaneId: leafId, + terminal: { + element: null, + rows: 24, + refresh: vi.fn(), + dispose: vi.fn() + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'off', + gpuRenderingEnabled: false, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + fitAddon: { + fit: vi.fn(), + dispose: vi.fn() + } as never, + fitResizeObserver: null, + pendingInitialFitRafId: null, + pendingWebglRefreshRafId: null, + pendingObservedFitRafId: null, + searchAddon: { dispose: vi.fn() } as never, + serializeAddon: { dispose: vi.fn() } as never, + unicode11Addon: { dispose: vi.fn() } as never, + webLinksAddon: { dispose: vi.fn() } as never, + webglAddon: { dispose: vi.fn() } as never, + ligaturesAddon: null, + compositionHandler: null, + pendingSplitScrollState: null, + pendingSplitScrollBufferDisposable: null, + debugLabel: null, + ...overrides + } +} + +describe('pane WebGL refresh lifecycle', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('tracks the deferred refresh frame after WebGL teardown', () => { + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 29) + ) + const pane = createPane() + + disposeWebgl(pane, { refreshDimensions: true }) + + expect(pane.webglAddon).toBeNull() + expect(pane.pendingWebglRefreshRafId).toBe(29) + }) + + it('cancels a pending WebGL refresh when the pane is disposed', () => { + const cancelAnimationFrame = vi.fn() + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) + const pane = createPane({ + pendingWebglRefreshRafId: 31, + webglAddon: null + }) + const panes = new Map([[pane.id, pane]]) + + disposePane(pane, panes) + + expect(cancelAnimationFrame).toHaveBeenCalledWith(31) + expect(pane.pendingWebglRefreshRafId).toBeNull() + expect(panes.has(pane.id)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index caea5e67f5e..404b8fa99d2 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -43,10 +43,18 @@ function refreshTerminalAfterWebglAttach(pane: ManagedPaneInternal): void { } } +export function cancelPendingWebglRefresh(pane: ManagedPaneInternal): void { + if (pane.pendingWebglRefreshRafId != null) { + cancelAnimationFrame(pane.pendingWebglRefreshRafId) + pane.pendingWebglRefreshRafId = null + } +} + export function disposeWebgl( pane: ManagedPaneInternal, options?: { refreshDimensions?: boolean } ): void { + cancelPendingWebglRefresh(pane) if (!pane.webglAddon) { return } @@ -60,7 +68,8 @@ export function disposeWebgl( // Why: VS Code refreshes terminal dimensions after WebGL teardown because // DOM and WebGL renderer cell metrics differ. Without this, Linux DOM // scrollbars can desync and trigger visible reflow jitter. - requestAnimationFrame(() => { + pane.pendingWebglRefreshRafId = requestAnimationFrame(() => { + pane.pendingWebglRefreshRafId = null try { pane.fitAddon.fit() pane.terminal.refresh(0, pane.terminal.rows - 1)