Skip to content

Viewport keeps a phantom scroll range when a resize lands while the renderer is paused (permanent in the alternate buffer) #6117

Description

@isamu

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:

  1. BufferService.resize() runs immediately, so buffer.lines.length is the new row count, and onResize fires → Viewport.queueSync()addRefreshCallback (next frame).
  2. RenderService.handleResize() sees _isPaused and defers the renderer's resize into _pausedResizeTask, so dimensions.css.canvas.height is still the old height.
  3. 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.
  4. _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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions