fix terminal resize flicker during pane drag (#962)

This commit is contained in:
Neil 2026-04-22 17:50:14 -07:00 committed by GitHub
parent 91b7f04594
commit c07db38dfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 364 additions and 138 deletions

View File

@ -1,65 +0,0 @@
diff --git a/lib/addon-fit.js b/lib/addon-fit.js
index 9b25be35bbc0a6826b769e9bbc09f0d8ea1816e4..8c11959d05e8d51350ee79b7be415fa85e0b1468 100644
--- a/lib/addon-fit.js
+++ b/lib/addon-fit.js
@@ -1,2 +1,2 @@
-!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
+!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;const i=this._terminal.cols!==e.cols,r2=this._terminal.rows!==e.rows,b=this._terminal.buffer.active,w=b.viewportY>=b.baseY;t._renderService.clear();(i||r2)&&(this._terminal.resize(e.cols,e.rows),w&&this._terminal.scrollToBottom());this._terminal.refresh(0,this._terminal.rows-1)}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
\ No newline at end of file
diff --git a/lib/addon-fit.mjs b/lib/addon-fit.mjs
index 24d4f61877d369edb7f2773570575a1c10edf832..a6a841b9fb2e03082f417bde57a8e74376e4c22f 100644
--- a/lib/addon-fit.mjs
+++ b/lib/addon-fit.mjs
@@ -14,5 +14,5 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-var h=2,_=1,o=class{activate(e){this._terminal=e}dispose(){}fit(){let e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;let t=this._terminal._core;(this._terminal.rows!==e.rows||this._terminal.cols!==e.cols)&&(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;let t=this._terminal._core._renderService.dimensions;if(t.css.cell.width===0||t.css.cell.height===0)return;let s=this._terminal.options.scrollback===0?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),l=parseInt(r.getPropertyValue("height")),a=Math.max(0,parseInt(r.getPropertyValue("width"))),i=window.getComputedStyle(this._terminal.element),n={top:parseInt(i.getPropertyValue("padding-top")),bottom:parseInt(i.getPropertyValue("padding-bottom")),right:parseInt(i.getPropertyValue("padding-right")),left:parseInt(i.getPropertyValue("padding-left"))},m=n.top+n.bottom,d=n.right+n.left,c=l-m,p=a-d-s;return{cols:Math.max(h,Math.floor(p/t.css.cell.width)),rows:Math.max(_,Math.floor(c/t.css.cell.height))}}};export{o as FitAddon};
+var h=2,_=1,o=class{activate(e){this._terminal=e}dispose(){}fit(){let e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;let t=this._terminal._core,i=this._terminal.cols!==e.cols,r=this._terminal.rows!==e.rows,b=this._terminal.buffer.active,w=b.viewportY>=b.baseY;t._renderService.clear();(i||r)&&(this._terminal.resize(e.cols,e.rows),w&&this._terminal.scrollToBottom());this._terminal.refresh(0,this._terminal.rows-1)}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;let t=this._terminal._core._renderService.dimensions;if(t.css.cell.width===0||t.css.cell.height===0)return;let s=this._terminal.options.scrollback===0?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),l=parseInt(r.getPropertyValue("height")),a=Math.max(0,parseInt(r.getPropertyValue("width"))),i=window.getComputedStyle(this._terminal.element),n={top:parseInt(i.getPropertyValue("padding-top")),bottom:parseInt(i.getPropertyValue("padding-bottom")),right:parseInt(i.getPropertyValue("padding-right")),left:parseInt(i.getPropertyValue("padding-left"))},m=n.top+n.bottom,d=n.right+n.left,c=l-m,p=a-d-s;return{cols:Math.max(h,Math.floor(p/t.css.cell.width)),rows:Math.max(_,Math.floor(c/t.css.cell.height))}}};export{o as FitAddon};
//# sourceMappingURL=addon-fit.mjs.map
diff --git a/src/FitAddon.ts b/src/FitAddon.ts
index a282ed3f8c607f5d073af3d0f1585e8cbc7b9f63..63796e8cc23f5d3dff61f322ee9a16747da51c4c 100644
--- a/src/FitAddon.ts
+++ b/src/FitAddon.ts
@@ -41,10 +41,37 @@ export class FitAddon implements ITerminalAddon , IFitApi {
// TODO: Remove reliance on private API
const core = (this._terminal as any)._core;
- // Force a full render
- if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) {
- core._renderService.clear();
+ const colsChanged = this._terminal.cols !== dims.cols;
+ const rowsChanged = this._terminal.rows !== dims.rows;
+
+ // Capture viewport scroll position before resize so we can restore it
+ // after xterm.js reflows the scrollback buffer. `baseY` is the number
+ // of rows scrolled into the scrollback; `viewportY === baseY` means the
+ // viewport is pinned to the bottom (the common interactive-shell case).
+ const buf = this._terminal.buffer.active;
+ const wasAtBottom = buf.viewportY >= buf.baseY;
+
+ // Always clear the renderer — even same-dimension refits may need a
+ // repaint when the container's pixel size changed by less than one cell
+ // (upstream fix: xtermjs/xterm.js#5777).
+ core._renderService.clear();
+
+ if (colsChanged || rowsChanged) {
this._terminal.resize(dims.cols, dims.rows);
+
+ // After reflow the viewport can land at a stale scroll offset — the
+ // scrollback appears to vanish (blank rows at top, recent output at
+ // bottom). Snap back to the bottom if the user was following output;
+ // otherwise preserve their scroll-up position.
+ if (wasAtBottom) {
+ this._terminal.scrollToBottom();
+ }
}
+
+ // Always refresh the full viewport after clear() — even when cols/rows
+ // did not change, the pixel-level container resize can leave the WebGL
+ // or DOM renderer with blank content (e.g. when a new pane opens and the
+ // container shrinks by less than one cell width).
+ this._terminal.refresh(0, this._terminal.rows - 1);
}

View File

@ -65,14 +65,14 @@
"@tiptap/pm": "^3.22.4",
"@tiptap/react": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/headless": "^6.0.0",
"@xterm/xterm": "^6.0.0",
"@xterm/addon-fit": "0.12.0-beta.198",
"@xterm/addon-search": "0.17.0-beta.198",
"@xterm/addon-serialize": "0.15.0-beta.198",
"@xterm/addon-unicode11": "0.10.0-beta.198",
"@xterm/addon-web-links": "0.13.0-beta.198",
"@xterm/addon-webgl": "0.20.0-beta.197",
"@xterm/headless": "6.1.0-beta.198",
"@xterm/xterm": "6.1.0-beta.198",
"agent-browser": "~0.24.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -151,7 +151,6 @@
"node-pty"
],
"patchedDependencies": {
"@xterm/addon-fit@0.11.0": "config/patches/@xterm__addon-fit@0.11.0.patch",
"node-pty@1.1.0": "patches/node-pty@1.1.0.patch"
}
}

View File

@ -5,9 +5,6 @@ settings:
excludeLinksFromLockfile: false
patchedDependencies:
'@xterm/addon-fit@0.11.0':
hash: 12b1dc2811a859b6703ebb63be654d1682ff1bb6045713a54e7d47e28deb7776
path: config/patches/@xterm__addon-fit@0.11.0.patch
node-pty@1.1.0:
hash: 02e16954edbb8e511963557e3b2c80f8a6fb230cd0c6a75ade936022f3347322
path: patches/node-pty@1.1.0.patch
@ -83,29 +80,29 @@ importers:
specifier: ^3.22.4
version: 3.22.4
'@xterm/addon-fit':
specifier: ^0.11.0
version: 0.11.0(patch_hash=12b1dc2811a859b6703ebb63be654d1682ff1bb6045713a54e7d47e28deb7776)
specifier: 0.12.0-beta.198
version: 0.12.0-beta.198(@xterm/xterm@6.1.0-beta.198)
'@xterm/addon-search':
specifier: ^0.16.0
version: 0.16.0
specifier: 0.17.0-beta.198
version: 0.17.0-beta.198(@xterm/xterm@6.1.0-beta.198)
'@xterm/addon-serialize':
specifier: ^0.14.0
version: 0.14.0
specifier: 0.15.0-beta.198
version: 0.15.0-beta.198(@xterm/xterm@6.1.0-beta.198)
'@xterm/addon-unicode11':
specifier: ^0.9.0
version: 0.9.0
specifier: 0.10.0-beta.198
version: 0.10.0-beta.198(@xterm/xterm@6.1.0-beta.198)
'@xterm/addon-web-links':
specifier: ^0.12.0
version: 0.12.0
specifier: 0.13.0-beta.198
version: 0.13.0-beta.198(@xterm/xterm@6.1.0-beta.198)
'@xterm/addon-webgl':
specifier: ^0.19.0
version: 0.19.0
specifier: 0.20.0-beta.197
version: 0.20.0-beta.197(@xterm/xterm@6.1.0-beta.198)
'@xterm/headless':
specifier: ^6.0.0
version: 6.0.0
specifier: 6.1.0-beta.198
version: 6.1.0-beta.198
'@xterm/xterm':
specifier: ^6.0.0
version: 6.0.0
specifier: 6.1.0-beta.198
version: 6.1.0-beta.198
agent-browser:
specifier: ~0.24.1
version: 0.24.1
@ -2917,29 +2914,41 @@ packages:
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/addon-fit@0.12.0-beta.198':
resolution: {integrity: sha512-1EZFzOaUjRnlo5p2diIV9+HHVtHqr0O7TEFCstSuFqV/5QaKkRiWiKaMfsPEg2pF5P7yBOASk73yUPII7oquaA==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.198
'@xterm/addon-search@0.16.0':
resolution: {integrity: sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==}
'@xterm/addon-search@0.17.0-beta.198':
resolution: {integrity: sha512-loNSX0cB4P+9Sg13sgB/cQPPBgdOi1hxRyZ75Jj90+4iKe227mRmgzdqU3wxr10nyFWJWCpysCjVT+47tdC8mQ==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.198
'@xterm/addon-serialize@0.14.0':
resolution: {integrity: sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==}
'@xterm/addon-serialize@0.15.0-beta.198':
resolution: {integrity: sha512-0yf13cLPaKX8nkgUqclcdbU5FexacA/xnvWzHzGFYTku0WH+SqygbQcDOcg4bTUGgCcvfZqntEb1NQpr+69KgQ==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.198
'@xterm/addon-unicode11@0.9.0':
resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==}
'@xterm/addon-unicode11@0.10.0-beta.198':
resolution: {integrity: sha512-3itjZ5lTQ5tnDcpUAplX88BsTJTZOvEzX9wNTNMO2aPuctLavMNg2+pXU8KTkeRhUZnDyhx9Lsv81dDs94vWDQ==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.198
'@xterm/addon-web-links@0.12.0':
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
'@xterm/addon-web-links@0.13.0-beta.198':
resolution: {integrity: sha512-qA/B1tuNLpqlzscrAVFjLKjuH0CmKKyfbTn78Y3RLKs6H7xxdYSE6/DjJ1zg9C6aDtLwF9lsaOFpPSPf2AfpYQ==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.198
'@xterm/addon-webgl@0.19.0':
resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==}
'@xterm/addon-webgl@0.20.0-beta.197':
resolution: {integrity: sha512-0o30FvLzfd9ODuB1gbpwYMGQHYiz0FebmVaMP0Idesw1sLIG3qc7aIIBQEUnWiBDsKbDHhaysW8WTxacg2AhaQ==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.198
'@xterm/headless@6.0.0':
resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==}
'@xterm/headless@6.1.0-beta.198':
resolution: {integrity: sha512-pDS/I5HLGwwJf8sWZFaT+qbHxgFu0qdVQv5/XDzhNyKXMXQJ7cL3ZsJsIox5JxLo2ZuYLVVY8/lirHronhOSfA==}
'@xterm/xterm@6.0.0':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
'@xterm/xterm@6.1.0-beta.198':
resolution: {integrity: sha512-udjhNFi4YfypbVBJV7gFNwQV3KziZFJdq+fg87WefGF5JkGbC+4LvIhEsHXR0Kb5Ez8ESxeqM7Yjvj+kw8zDAg==}
abbrev@3.0.1:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
@ -8671,21 +8680,33 @@ snapshots:
'@xmldom/xmldom@0.8.11': {}
'@xterm/addon-fit@0.11.0(patch_hash=12b1dc2811a859b6703ebb63be654d1682ff1bb6045713a54e7d47e28deb7776)': {}
'@xterm/addon-fit@0.12.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
dependencies:
'@xterm/xterm': 6.1.0-beta.198
'@xterm/addon-search@0.16.0': {}
'@xterm/addon-search@0.17.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
dependencies:
'@xterm/xterm': 6.1.0-beta.198
'@xterm/addon-serialize@0.14.0': {}
'@xterm/addon-serialize@0.15.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
dependencies:
'@xterm/xterm': 6.1.0-beta.198
'@xterm/addon-unicode11@0.9.0': {}
'@xterm/addon-unicode11@0.10.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
dependencies:
'@xterm/xterm': 6.1.0-beta.198
'@xterm/addon-web-links@0.12.0': {}
'@xterm/addon-web-links@0.13.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
dependencies:
'@xterm/xterm': 6.1.0-beta.198
'@xterm/addon-webgl@0.19.0': {}
'@xterm/addon-webgl@0.20.0-beta.197(@xterm/xterm@6.1.0-beta.198)':
dependencies:
'@xterm/xterm': 6.1.0-beta.198
'@xterm/headless@6.0.0': {}
'@xterm/headless@6.1.0-beta.198': {}
'@xterm/xterm@6.0.0': {}
'@xterm/xterm@6.1.0-beta.198': {}
abbrev@3.0.1: {}

View File

@ -94,11 +94,6 @@ function attachDividerDrag(
callbacks.lockDragScroll(nextEl)
}
// Why: fitAddon.fit() triggers a full xterm.js reflow which can take
// hundreds of ms with large scrollbacks. Gating behind rAF caps refit
// to once per paint frame instead of once per pointer event (~250Hz).
let refitRafId: number | null = null
const onPointerMove = (e: PointerEvent): void => {
if (!dragging || !prevEl || !nextEl) {
return
@ -121,20 +116,11 @@ function attachDividerDrag(
newPrev = totalSize - MIN_PANE_SIZE
}
// Why: keep drag-time work to flex layout only; pane-local ResizeObservers
// schedule the terminal fit on the next frame so the divider stays smooth.
// Use flex-grow proportionally
prevEl.style.flex = `${newPrev} 1 0%`
nextEl.style.flex = `${newNext} 1 0%`
// Refit terminals in affected panes (throttled to one per animation frame)
if (refitRafId === null) {
const p = prevEl
const n = nextEl
refitRafId = requestAnimationFrame(() => {
refitRafId = null
callbacks.refitPanesUnder(p)
callbacks.refitPanesUnder(n)
})
}
}
const onPointerUp = (e: PointerEvent): void => {
@ -142,10 +128,6 @@ function attachDividerDrag(
return
}
dragging = false
if (refitRafId !== null) {
cancelAnimationFrame(refitRafId)
refitRafId = null
}
divider.releasePointerCapture(e.pointerId)
divider.classList.remove('is-dragging')
// Final refit at the exact drop position, then unlock drag scroll state

View File

@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
import {
attachPaneFitResizeObserver,
detachPaneFitResizeObserver
} from './pane-fit-resize-observer'
type ResizeObserverCallbackLike = ConstructorParameters<typeof ResizeObserver>[0]
class MockResizeObserver {
observe = vi.fn()
disconnect = vi.fn()
constructor(private readonly callback: ResizeObserverCallbackLike) {
mockResizeObservers.push(this)
}
trigger(): void {
this.callback([], this as never)
}
}
let mockResizeObservers: MockResizeObserver[] = []
let nextRafId = 1
let pendingRafs = new Map<number, FrameRequestCallback>()
function flushAnimationFrames(timestamp = 16): void {
const callbacks = Array.from(pendingRafs.entries())
pendingRafs = new Map()
for (const [, callback] of callbacks) {
callback(timestamp)
}
}
function createPane(): ManagedPaneInternal {
return {
id: 1,
terminal: {
cols: 79,
rows: 24
} as never,
container: {} as never,
xtermContainer: {} as never,
linkTooltip: {} as never,
gpuRenderingEnabled: true,
fitAddon: {
fit: vi.fn(),
proposeDimensions: vi.fn(() => ({ cols: 80, rows: 24 }))
} as never,
fitResizeObserver: null,
pendingObservedFitRafId: null,
searchAddon: {} as never,
serializeAddon: {} as never,
unicode11Addon: {} as never,
webLinksAddon: {} as never,
webglAddon: null,
compositionHandler: null,
pendingSplitScrollState: {
wasAtBottom: true,
firstVisibleLineContent: '',
viewportY: 0,
totalLines: 24
} satisfies ScrollState,
pendingDragScrollState: null
}
}
describe('attachPaneFitResizeObserver', () => {
beforeEach(() => {
mockResizeObservers = []
nextRafId = 1
pendingRafs = new Map()
vi.stubGlobal('ResizeObserver', MockResizeObserver as never)
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
const id = nextRafId++
pendingRafs.set(id, callback)
return id
})
)
vi.stubGlobal(
'cancelAnimationFrame',
vi.fn((id: number) => {
pendingRafs.delete(id)
})
)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('coalesces repeated observer callbacks into a single fit per frame', () => {
const pane = createPane()
attachPaneFitResizeObserver(pane)
mockResizeObservers[0]?.trigger()
mockResizeObservers[0]?.trigger()
expect(requestAnimationFrame).toHaveBeenCalledTimes(1)
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
flushAnimationFrames()
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
})
it('disconnects the observer and cancels any queued fit', () => {
const pane = createPane()
attachPaneFitResizeObserver(pane)
mockResizeObservers[0]?.trigger()
const scheduledRafId = pane.pendingObservedFitRafId
detachPaneFitResizeObserver(pane)
flushAnimationFrames()
expect(mockResizeObservers[0]?.disconnect).toHaveBeenCalledTimes(1)
expect(cancelAnimationFrame).toHaveBeenCalledWith(scheduledRafId)
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
expect(pane.pendingObservedFitRafId).toBeNull()
})
})

View File

@ -0,0 +1,35 @@
import type { ManagedPaneInternal } from './pane-manager-types'
import { safeFit } from './pane-tree-ops'
export function attachPaneFitResizeObserver(pane: ManagedPaneInternal): void {
detachPaneFitResizeObserver(pane)
if (typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(() => {
if (pane.pendingObservedFitRafId !== null) {
return
}
// Why: keep xterm fit work off the divider pointermove hot path and let
// the browser coalesce drag-driven size changes the same way Superset does.
pane.pendingObservedFitRafId = requestAnimationFrame(() => {
pane.pendingObservedFitRafId = null
safeFit(pane)
})
})
observer.observe(pane.xtermContainer)
pane.fitResizeObserver = observer
}
export function detachPaneFitResizeObserver(pane: ManagedPaneInternal): void {
pane.fitResizeObserver?.disconnect()
pane.fitResizeObserver = null
if (pane.pendingObservedFitRafId !== null) {
cancelAnimationFrame(pane.pendingObservedFitRafId)
pane.pendingObservedFitRafId = null
}
}

View File

@ -12,6 +12,10 @@ import type { DragReorderState } from './pane-drag-reorder'
import type { DragReorderCallbacks } from './pane-drag-reorder'
import { attachPaneDrag } from './pane-drag-reorder'
import { safeFit, captureScrollState, restoreScrollState } from './pane-tree-ops'
import {
attachPaneFitResizeObserver,
detachPaneFitResizeObserver
} from './pane-fit-resize-observer'
// ---------------------------------------------------------------------------
// Pane creation, terminal open/close, addon management
@ -128,6 +132,8 @@ export function createPaneDOM(
linkTooltip,
gpuRenderingEnabled: ENABLE_WEBGL_RENDERER,
fitAddon,
fitResizeObserver: null,
pendingObservedFitRafId: null,
searchAddon,
serializeAddon,
unicode11Addon,
@ -223,6 +229,8 @@ export function openTerminal(pane: ManagedPaneInternal): void {
attachWebgl(pane)
}
attachPaneFitResizeObserver(pane)
// Initial fit (deferred to ensure layout has settled)
requestAnimationFrame(() => {
safeFit(pane)
@ -301,6 +309,7 @@ export function disposePane(
pane: ManagedPaneInternal,
panes: Map<number, ManagedPaneInternal>
): void {
detachPaneFitResizeObserver(pane)
if (pane.compositionHandler) {
pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true)
pane.compositionHandler = null

View File

@ -60,6 +60,8 @@ export type ManagedPaneInternal = {
linkTooltip: HTMLElement
gpuRenderingEnabled: boolean
webglAddon: WebglAddon | null
fitResizeObserver: ResizeObserver | null
pendingObservedFitRafId: number | null
serializeAddon: SerializeAddon
unicode11Addon: Unicode11Addon
webLinksAddon: WebLinksAddon

View File

@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import { safeFit } from './pane-tree-ops'
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
function createPane({
proposedCols,
proposedRows,
terminalCols,
terminalRows
}: {
proposedCols: number
proposedRows: number
terminalCols: number
terminalRows: number
}): ManagedPaneInternal {
const fit = vi.fn()
const proposeDimensions = vi.fn(() => ({ cols: proposedCols, rows: proposedRows }))
const terminal = {
cols: terminalCols,
rows: terminalRows,
buffer: {
active: {
viewportY: 0,
baseY: 0,
getLine: vi.fn(() => ({ translateToString: () => '' }))
}
},
scrollToBottom: vi.fn(),
scrollToLine: vi.fn(),
scrollLines: vi.fn()
}
return {
id: 1,
terminal: terminal as never,
container: {} as never,
xtermContainer: {} as never,
linkTooltip: {} as never,
gpuRenderingEnabled: true,
fitAddon: {
fit,
proposeDimensions
} as never,
fitResizeObserver: null,
pendingObservedFitRafId: null,
searchAddon: {} as never,
serializeAddon: {} as never,
unicode11Addon: {} as never,
webLinksAddon: {} as never,
webglAddon: null,
compositionHandler: null,
pendingSplitScrollState: null,
pendingDragScrollState: null
}
}
describe('safeFit', () => {
it('skips drag-frame refits when the pane grid dimensions did not change', () => {
const pane = createPane({
proposedCols: 120,
proposedRows: 32,
terminalCols: 120,
terminalRows: 32
})
safeFit(pane)
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
})
it('still refits when the proposed grid dimensions changed', () => {
const pane = createPane({
proposedCols: 100,
proposedRows: 32,
terminalCols: 120,
terminalRows: 32
})
safeFit(pane)
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
})
it('still refits when a split-scroll lock is active and the grid changed', () => {
const pane = createPane({
proposedCols: 100,
proposedRows: 32,
terminalCols: 120,
terminalRows: 32
})
pane.pendingSplitScrollState = {
wasAtBottom: true,
firstVisibleLineContent: '',
viewportY: 0,
totalLines: 32
} satisfies ScrollState
safeFit(pane)
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
})
})

View File

@ -18,8 +18,23 @@ type TreeOpsCallbacks = {
onLayoutChanged?: () => void
}
function getProposedDimensions(pane: ManagedPaneInternal): { cols: number; rows: number } | null {
try {
return pane.fitAddon.proposeDimensions() ?? null
} catch {
return null
}
}
export function safeFit(pane: ManagedPaneInternal): void {
try {
const dims = getProposedDimensions(pane)
if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) {
// 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.
return
}
if (pane.pendingSplitScrollState) {
pane.fitAddon.fit()
return
@ -40,7 +55,7 @@ export function safeFit(pane: ManagedPaneInternal): void {
export function fitAllPanesInternal(panes: Map<number, ManagedPaneInternal>): void {
for (const pane of panes.values()) {
try {
const dims = pane.fitAddon.proposeDimensions()
const dims = getProposedDimensions(pane)
if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) {
continue
}