fix(terminal): keep xterm's render pause latched for a pane with no layout box

resetWebglTextureAtlas() released xterm's paused-render gate for every pane of
a visible manager, including panes that are display:none (a collapsed sibling
of an expanded pane, a restore that stays display:none for its whole reattach).

forceRepaintThroughRenderPause exists for a pane that is already DOM-visible
while xterm's IntersectionObserver lags a frame. On a pane with no box it
paints the freshly cleared render model into nothing, and because the observer
only fires on a state change it never re-pauses the service. It also clears
_needsFullRefresh, which is the only thing that makes _handleIntersectionChange
repaint on reveal and flush the deferred _pausedResizeTask.

Latch instead for those panes: terminal.refresh() re-arms _needsFullRefresh and
xterm repaints from it on reveal.
This commit is contained in:
Brennan Benson
2026-08-19 19:02:49 -07:00
parent a76a95d111
commit 08c1d838e4
2 changed files with 78 additions and 1 deletions
@@ -63,6 +63,42 @@ function createFittablePane(): ManagedPaneInternal {
return pane
}
type FakeRenderService = {
_isPaused: boolean
_needsFullRefresh: boolean
refreshRows: ReturnType<typeof vi.fn>
}
/** 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
@@ -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)