Details
- Browser and browser version: Chromium 145 (Playwright), also seen in Chrome 151 on Windows 11
- OS version: macOS 15 (arm64); originally reported on Windows 11
- xterm.js version: 6.0.0
Steps to reproduce
A resize() that lands while the renderer is paused (the terminal is off screen, so RenderService._isPaused is true) leaves Viewport's scroll dimensions built from two inconsistent sources, and in the alternate buffer nothing ever recomputes them.
Save as repro.html next to xterm.js / xterm.css and open it, then run await __run() in the console:
<meta charset="utf-8">
<link rel="stylesheet" href="./xterm.css">
<style>
body { margin: 0; background: #1e1e1e; }
#small { width: 640px; height: 544px; }
#big { width: 1300px; height: 867px; }
</style>
<div id="small"></div>
<div id="big"></div>
<script src="./xterm.js"></script>
<script>
const term = new Terminal({ fontSize: 14, fontFamily: "monospace" });
const host = document.createElement("div");
host.style.width = "100%";
host.style.height = "100%";
document.getElementById("small").appendChild(host);
term.open(host);
window.__report = () => {
const bar = term.element.querySelector(".xterm-scrollable-element > .scrollbar.vertical");
const slider = bar && bar.querySelector(".slider");
const screen = term.element.querySelector(".xterm-screen");
return {
bufferType: term.buffer.active.type,
rows: term.rows,
bufferLines: term.buffer.active.length,
scrollbarClass: bar && bar.className,
scrollbarHeight: bar && Math.round(bar.getBoundingClientRect().height),
sliderHeight: slider && slider.style.height,
screenHeight: screen && Math.round(screen.getBoundingClientRect().height),
};
};
window.__run = async () => {
term.resize(78, 34); // the small container
await new Promise((r) => term.write("\x1b[?1049h", r)); // alternate screen
await new Promise((r) => term.write("\x1b[H" + "row\r\n".repeat(20), r));
await new Promise((r) => setTimeout(r, 300));
const before = window.__report();
// Off screen long enough for the IntersectionObserver to pause the renderer, then back at a
// bigger size with the resize in the same task. This is what a layout that moves a terminal
// between two containers does.
host.remove();
await new Promise((r) => setTimeout(r, 60));
document.getElementById("big").appendChild(host);
term.resize(160, 54); // the big container
await new Promise((r) => setTimeout(r, 800));
return { before, after: window.__report() };
};
</script>
No addons, no fit addon — plain term.resize().
The 60 ms detached window matters: it is what lets the IntersectionObserver deliver isIntersecting: false before the resize. With 0 the bug does not occur, because the pause flag has not been set yet.
Result
before: {"bufferType":"alternate","rows":34,"bufferLines":34,
"scrollbarClass":"invisible scrollbar vertical",
"scrollbarHeight":544,"sliderHeight":"544px","screenHeight":544}
after: {"bufferType":"alternate","rows":54,"bufferLines":54,
"scrollbarClass":"invisible scrollbar vertical fade", <-- now considered "needed"
"scrollbarHeight":544, <-- stale viewport height
"sliderHeight":"342px","screenHeight":864} <-- canvas is 864
A 54-row alternate buffer holding exactly 54 lines has nothing to scroll, yet the scrollbar reports a scrollable range of ~864 px inside a 544 px viewport. fade in the class list means ScrollbarVisibilityController._isNeeded is true, i.e. xterm itself considers the scrollbar necessary.
Expected: no scrollbar (sliderHeight === scrollbarHeight, no fade), as in before.
Cause
Viewport._sync() builds the scroll dimensions from two sources that can disagree:
this._scrollableElement.setScrollDimensions({
height: this._renderService.dimensions.css.canvas.height,
scrollHeight: this._renderService.dimensions.css.cell.height * this._bufferService.buffer.lines.length
});
On resize:
BufferService.resize() runs immediately, so buffer.lines.length is the new row count, and onResize fires → Viewport.queueSync() → addRefreshCallback (next frame).
RenderService.handleResize() sees _isPaused and defers the renderer's resize into _pausedResizeTask, so dimensions.css.canvas.height is still the old height.
- The queued
_sync() runs on the next animation frame — before the IntersectionObserver delivers isIntersecting: true at the end of that frame — and computes height from the old canvas over scrollHeight from the new line count.
_handleIntersectionChange() later flushes _pausedResizeTask, so the renderer's dimensions become correct, but nothing re-syncs the viewport.
The three things that call _sync() are bufferService.onResize, buffers.onBufferActivate and bufferService.onScroll. In the alternate buffer a full-screen TUI repaints in place and never scrolls, so onScroll never fires again — the wrong scroll range is permanent until the next resize. In the normal buffer the next output usually re-syncs it, which is why this mostly shows up under a full-screen application.
Impact
Downstream of the scrollbar itself, the phantom range is also driven by the wheel and by dragging: ScrollableElement handles the wheel whenever the core mouse protocol has no WHEEL bit, so the slider moves independently of what the application does, and dragging it fires onRequestScrollLines that BufferService.scrollLines() discards (ydisp === 0). To a user that reads as "the scrollbar is stuck / jumps / does nothing" — which is how we found it (receptron/mulmoterminal#1762, reported against a Claude Code session in a pane that had just been enlarged).
Suggested direction
Re-sync the viewport when the deferred resize is flushed. _handleIntersectionChange() already does
if (!this._isPaused && this._needsFullRefresh) {
this._pausedResizeTask.flush();
this.refreshRows(0, this._rowCount - 1);
this._needsFullRefresh = false;
}
but the Viewport has no way to learn that its dimensions were stale when it last synced. Firing onDimensionsChange after the flush (or having Viewport re-sync on it) would close it. Making _sync() read both terms from the same source would remove the class of bug rather than this instance.
We worked around it host-side by holding the resize until the terminal is back on screen, which avoids the paused window entirely — but that is only available to embedders who control every resize path.
Details
Steps to reproduce
A
resize()that lands while the renderer is paused (the terminal is off screen, soRenderService._isPausedistrue) leavesViewport's scroll dimensions built from two inconsistent sources, and in the alternate buffer nothing ever recomputes them.Save as
repro.htmlnext toxterm.js/xterm.cssand open it, then runawait __run()in the console:No addons, no fit addon — plain
term.resize().The 60 ms detached window matters: it is what lets the IntersectionObserver deliver
isIntersecting: falsebefore the resize. With0the bug does not occur, because the pause flag has not been set yet.Result
A 54-row alternate buffer holding exactly 54 lines has nothing to scroll, yet the scrollbar reports a scrollable range of ~864 px inside a 544 px viewport.
fadein the class list meansScrollbarVisibilityController._isNeededistrue, i.e. xterm itself considers the scrollbar necessary.Expected: no scrollbar (
sliderHeight === scrollbarHeight, nofade), as inbefore.Cause
Viewport._sync()builds the scroll dimensions from two sources that can disagree:On resize:
BufferService.resize()runs immediately, sobuffer.lines.lengthis the new row count, andonResizefires →Viewport.queueSync()→addRefreshCallback(next frame).RenderService.handleResize()sees_isPausedand defers the renderer's resize into_pausedResizeTask, sodimensions.css.canvas.heightis still the old height._sync()runs on the next animation frame — before the IntersectionObserver deliversisIntersecting: trueat the end of that frame — and computesheightfrom the old canvas overscrollHeightfrom the new line count._handleIntersectionChange()later flushes_pausedResizeTask, so the renderer's dimensions become correct, but nothing re-syncs the viewport.The three things that call
_sync()arebufferService.onResize,buffers.onBufferActivateandbufferService.onScroll. In the alternate buffer a full-screen TUI repaints in place and never scrolls, soonScrollnever fires again — the wrong scroll range is permanent until the next resize. In the normal buffer the next output usually re-syncs it, which is why this mostly shows up under a full-screen application.Impact
Downstream of the scrollbar itself, the phantom range is also driven by the wheel and by dragging:
ScrollableElementhandles the wheel whenever the core mouse protocol has noWHEELbit, so the slider moves independently of what the application does, and dragging it firesonRequestScrollLinesthatBufferService.scrollLines()discards (ydisp === 0). To a user that reads as "the scrollbar is stuck / jumps / does nothing" — which is how we found it (receptron/mulmoterminal#1762, reported against a Claude Code session in a pane that had just been enlarged).Suggested direction
Re-sync the viewport when the deferred resize is flushed.
_handleIntersectionChange()already doesbut the
Viewporthas no way to learn that its dimensions were stale when it last synced. FiringonDimensionsChangeafter the flush (or havingViewportre-sync on it) would close it. Making_sync()read both terms from the same source would remove the class of bug rather than this instance.We worked around it host-side by holding the resize until the terminal is back on screen, which avoids the paused window entirely — but that is only available to embedders who control every resize path.