diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts index b3990f20400..2ba2a0cda23 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -63,6 +63,42 @@ function createFittablePane(): ManagedPaneInternal { return pane } +type FakeRenderService = { + _isPaused: boolean + _needsFullRefresh: boolean + refreshRows: ReturnType +} + +/** A pane whose xterm render service is paused, as it is whenever the pane has + * no layout box: `display` decides whether the element is one xterm's + * IntersectionObserver would report as intersecting. */ +function createPausedPane(display: 'block' | 'none'): { + pane: ManagedPaneInternal + renderService: FakeRenderService +} { + const pane = createPane() + const renderService: FakeRenderService = { + _isPaused: true, + _needsFullRefresh: false, + // Mirrors xterm: a refresh while paused only latches the pending full repaint. + refreshRows: vi.fn(() => { + if (renderService._isPaused) { + renderService._needsFullRefresh = true + } + }) + } + const view = { getComputedStyle: () => ({ display }) } + const element = { ownerDocument: { defaultView: view }, parentElement: null } + pane.container = element as never + pane.xtermContainer = element as never + pane.terminal = { + ...pane.terminal, + refresh: vi.fn(() => renderService.refreshRows(0, pane.terminal.rows - 1)), + _core: { _renderService: renderService } + } as never + return { pane, renderService } +} + describe('terminal WebGL addon lifecycle', () => { beforeEach(() => { resetTerminalWebglSuggestion() @@ -132,6 +168,36 @@ describe('terminal WebGL addon lifecycle', () => { expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) }) + it('keeps the render pause latched when resetting a pane that has no layout box', () => { + // Regression: the atlas reset released xterm's pause for every pane of a + // visible manager, including a collapsed sibling of an expanded pane. That + // painted the freshly cleared model into an element with no box, and left + // the service unpaused for good — the IntersectionObserver only fires on a + // change, so it never re-pauses. Clearing _needsFullRefresh with it drops + // the repaint the observer owes the pane on reveal, and the deferred + // _pausedResizeTask that rides along with it. + const { pane, renderService } = createPausedPane('none') + + resetWebglTextureAtlas(pane) + + expect(renderService._isPaused).toBe(true) + expect(renderService._needsFullRefresh).toBe(true) + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('still forces the paused render through for a pane that has a layout box', () => { + // The reveal case the release exists for: DOM-visible, but xterm's observer + // has not caught up, so a plain refresh() would be swallowed. + const { pane, renderService } = createPausedPane('block') + + resetWebglTextureAtlas(pane) + + expect(renderService._isPaused).toBe(false) + expect(renderService._needsFullRefresh).toBe(false) + expect(renderService.refreshRows).toHaveBeenCalledWith(0, 23, true) + expect(pane.terminal.refresh).not.toHaveBeenCalled() + }) + it('skips the reset while WebGL is latched off after a context loss', () => { const pane = createPane() pane.webglDisabledAfterContextLoss = true 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 f162052cebe..9d05e6b876b 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -2,6 +2,7 @@ import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' import { getLivePaneCensus } from './pane-manager-registry' +import { isManagedPaneDisplayNone } from './pane-display-visibility' import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' import { getTerminalWebglAutoDecision, @@ -151,7 +152,17 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { // paused-render gate and the cleared model never repaints (stale bottom rows // until a drag-select forces a redraw). Force the paused render through // first; only fall back to refresh() when the terminal was not gated. - if (!forceRepaintThroughRenderPause(pane.terminal)) { + // + // Why the display check: that release is only right for a pane that is + // DOM-visible. A pane with no box at all (collapsed sibling of an expanded + // pane, a restore that stays display:none for its whole reattach) is + // legitimately paused, and releasing it paints the just-cleared model into + // nothing and then leaves the service unpaused for good — the observer only + // fires on a change, so it never re-pauses. Clearing _needsFullRefresh with + // it also drops the full repaint the observer owes the pane on reveal, and + // the deferred _pausedResizeTask that flushes alongside it. Latching is what + // xterm's own gate does, and the reveal repaints from the latch. + if (isManagedPaneDisplayNone(pane) || !forceRepaintThroughRenderPause(pane.terminal)) { // Why: refresh even without a WebGL addon so recovery never silently // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. pane.terminal.refresh(0, pane.terminal.rows - 1)