fix(ui-tui): heal same-dimension alt-screen resize drift

- Treat same-dimension resize events in alt-screen mode as a repaint
  signal, because terminal hosts can reflow or restore the physical
  buffer without changing columns/rows.
- Ensure pending resize erases are emitted even when the virtual diff
  is empty, so stale physical glyphs are still cleared.
- Extract alt-screen resize repaint into prepareAltScreenResizeRepaint()
  for readability.
- Add defensive clearTimeout in prepareAltScreenResizeRepaint so rapid
  resize bursts don't stack redundant delayed repaints.
- Add a focused regression test for same-dimension alt-screen resize
  healing.

Addresses #18449
Related to #17961
This commit is contained in:
张安哲 2026-05-02 02:17:53 +08:00 committed by Teknium
parent 2844c888f1
commit 4813aaf0ba
2 changed files with 97 additions and 30 deletions

View file

@ -0,0 +1,50 @@
import { EventEmitter } from 'events'
import React from 'react'
import { describe, expect, it } from 'vitest'
import Text from './components/Text.js'
import Ink from './ink.js'
import { CURSOR_HOME, ERASE_SCREEN } from './termio/csi.js'
class FakeTty extends EventEmitter {
chunks: string[] = []
columns = 20
rows = 5
isTTY = true
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
cb?.()
return true
}
}
const tick = () => new Promise<void>(resolve => queueMicrotask(resolve))
describe('Ink resize healing', () => {
it('heals same-dimension alt-screen resize events with an erase before repaint', async () => {
const stdout = new FakeTty()
const stdin = new FakeTty()
const stderr = new FakeTty()
const ink = new Ink({
exitOnCtrlC: false,
patchConsole: false,
stderr: stderr as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
stdout: stdout as unknown as NodeJS.WriteStream
})
ink.setAltScreenActive(true)
ink.render(React.createElement(Text, null, 'hello'))
ink.onRender()
stdout.chunks = []
stdout.emit('resize')
ink.onRender()
await tick()
expect(stdout.chunks.join('')).toContain(ERASE_SCREEN + CURSOR_HOME)
ink.unmount()
})
})

View file

@ -484,17 +484,22 @@ export default class Ink {
private handleResize = () => {
const cols = this.options.stdout.columns || 80
const rows = this.options.stdout.rows || 24
const dimsChanged = cols !== this.terminalColumns || rows !== this.terminalRows
// Terminals often emit 2+ resize events for one user action (window
// settling). Same-dimension events are no-ops; skip to avoid redundant
// frame resets and renders.
if (cols === this.terminalColumns && rows === this.terminalRows) {
// Terminals often emit 2+ resize events for one user action
// (window settling). Same-dimension events are usually no-ops,
// but in alt-screen mode a same-dimension resize can signal a
// terminal host reflow or buffer restore that leaves stale glyphs
// on the physical screen — treat it as a repaint signal.
if (!dimsChanged && !(this.altScreenActive && !this.isPaused && this.options.stdout.isTTY)) {
return
}
this.terminalColumns = cols
this.terminalRows = rows
this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows)
if (dimsChanged) {
this.terminalColumns = cols
this.terminalRows = rows
this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows)
}
// Pending throttled/drain work captured stale dims — cancel so
// the upcoming microtask owns the next frame.
@ -521,26 +526,7 @@ export default class Ink {
// doesn't exit alt-screen. Do NOT write ERASE_SCREEN: render() below
// can take ~80ms; erasing first leaves the screen blank that whole time.
if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) {
if (this.altScreenMouseTracking) {
this.options.stdout.write(ENABLE_MOUSE_TRACKING)
}
this.resetFramesForAltScreen()
this.needsEraseBeforePaint = true
// One last repaint after the resize burst settles closes any host-side
// reflow drift the normal diff path can't see.
this.resizeSettleTimer = setTimeout(() => {
this.resizeSettleTimer = null
if (!this.canAltScreenRepaint()) {
return
}
this.resetFramesForAltScreen()
this.needsEraseBeforePaint = true
this.render(this.currentNode!)
}, 160)
this.prepareAltScreenResizeRepaint()
}
// Already queued: later events in this burst updated dims/alt-screen
@ -573,6 +559,36 @@ export default class Ink {
)
}
private prepareAltScreenResizeRepaint(): void {
// Clear any pending settle timer from a previous resize burst so
// rapid events don't stack redundant delayed repaints. (handleResize
// also clears this, but the defensive clear keeps the method safe
// if it's ever called from other code paths.)
if (this.resizeSettleTimer !== null) {
clearTimeout(this.resizeSettleTimer)
this.resizeSettleTimer = null
}
if (this.altScreenMouseTracking) {
this.options.stdout.write(ENABLE_MOUSE_TRACKING)
}
this.resetFramesForAltScreen()
this.needsEraseBeforePaint = true
this.resizeSettleTimer = setTimeout(() => {
this.resizeSettleTimer = null
if (!this.canAltScreenRepaint()) {
return
}
this.resetFramesForAltScreen()
this.needsEraseBeforePaint = true
this.render(this.currentNode!)
}, 160)
}
resolveExitPromise: () => void = () => {}
rejectExitPromise: (reason?: Error) => void = () => {}
unsubscribeExit: () => void = () => {}
@ -919,8 +935,9 @@ export default class Ink {
const optimized = optimize(diff)
const optimizeMs = performance.now() - tOptimize
const hasDiff = optimized.length > 0
const needsAltScreenErase = this.altScreenActive && this.needsEraseBeforePaint
if (this.altScreenActive && hasDiff) {
if (this.altScreenActive && (hasDiff || needsAltScreenErase)) {
// Prepend CSI H to anchor the physical cursor to (0,0) so
// log-update's relative moves compute from a known spot (self-healing
// against out-of-band cursor drift, see the ALT_SCREEN_ANCHOR_CURSOR
@ -940,7 +957,7 @@ export default class Ink {
// resize, so it gets CSI 3J in this one recovery path. When BSU/ESU is
// supported, the clear+paint lands atomically; otherwise the final state
// is still healed even if the repaint is visible.
if (this.needsEraseBeforePaint) {
if (needsAltScreenErase) {
this.needsEraseBeforePaint = false
optimized.unshift(needsAltScreenResizeScrollbackClear() ? DEEP_ERASE_THEN_HOME_PATCH : ERASE_THEN_HOME_PATCH)
} else {
@ -1062,7 +1079,7 @@ export default class Ink {
this.lastDrainMs = 0
// Only track drain on TTY. Piped/non-TTY stdout bypasses flow control.
const trackDrain = this.options.stdout.isTTY && hasDiff
const trackDrain = this.options.stdout.isTTY && optimized.length > 0
const drainStart = trackDrain ? tWrite : 0
if (trackDrain) {