fix(frontend): stop fit loop committing transient cell-box mis-measurements (#313) (#316)

The onRender convergence loop added in #312 recovered the under-counted
rows from #280, but on a HiDPI display it could lock the Codex
orchestrator terminal at half size with a ghosted composer (#313).

FitAddon derives the grid by dividing the pane box by the renderer's
measured css cell box. During the WebGL atlas warm-up that cell box can
emit a one-frame transient (a doubled box on a 2x display), which halves
the proposed cols/rows. The loop committed that single frame's proposal,
resized the grid to half, then detached after a few "stable" frames — so
nothing re-fired the PTY resize that makes zellij repaint, leaving the
grid stuck at half width and the stale composer un-cleared.

Require a differing proposal to repeat identically across two consecutive
renders before applying it, so a one-frame transient only updates the
pending value and is never committed. Add 600ms/1200ms settle fits as a
session-bounded backstop: by then the atlas and font metrics are warm, so
even if the loop detached at a briefly-stable wrong measurement, a late
re-measure corrects the grid and fires the resize zellij needs to repaint
cleanly. fit() is idempotent, so a correct terminal never reflows.

Fixes #313

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-18 16:09:51 +05:30 committed by GitHub
parent 63d488ba29
commit 93123edd34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 38 additions and 18 deletions

View File

@ -156,7 +156,14 @@ export function XtermTerminal(props: XtermTerminalProps) {
};
const raf = requestAnimationFrame(fitTerminal);
const settleTimers = [window.setTimeout(fitTerminal, 50), window.setTimeout(fitTerminal, 250)];
// 50/250ms catch the common settle; 600/1200ms are a session-bounded
// backstop. By 600ms the WebGL atlas and font metrics are unambiguously
// warm, so even if the convergence loop below detached at a briefly-stable
// wrong measurement, this re-measures the real cell box and corrects —
// which fires the PTY resize that makes zellij repaint cleanly (clearing
// any ghost frame). fit() is idempotent: a no-op when the grid is already
// right, so a correct terminal never reflows.
const settleTimers = [50, 250, 600, 1200].map((ms) => window.setTimeout(fitTerminal, ms));
if (document.fonts?.ready) {
void document.fonts.ready.then(fitTerminal);
}
@ -165,40 +172,53 @@ export function XtermTerminal(props: XtermTerminalProps) {
// Recovery re-fit that does NOT depend on the host box changing size.
//
// FitAddon derives the row count by dividing the pane height by the
// renderer's measured cell box. That box is measured asynchronously: the
// WebGL renderer loads after open() and the monospace font's real metrics
// FitAddon derives the grid by dividing the pane box by the renderer's
// measured cell box. That box is measured asynchronously: the WebGL
// renderer loads after open() and the monospace font's real metrics
// resolve a frame or more later, so the early fits above can divide by a
// too-tall cell height, under-count rows, and clip the grid to the top of
// the pane. The fixed settle window (rAF, timeouts, fonts.ready) may all
// run before the cell box is final, and the ResizeObserver never fires to
// not-yet-final cell box, mis-count cols/rows, and clip the grid inside the
// pane. The fixed settle window (rAF, timeouts, fonts.ready) may all run
// before the cell box is final, and the ResizeObserver never fires to
// correct it because the host's pixel box is a stable height:100%, so a
// short grid would otherwise freeze for the whole session.
// wrong grid would otherwise freeze for the whole session.
//
// onRender fires on every renderer repaint, including the repaint after
// the metrics settle. Each fire re-proposes dimensions from the *current*
// measured cell box and re-fits when they differ, converging the grid to
// the true row count once the cell height is real. proposeDimensions
// returns undefined until the cell box is non-zero, so a fit is never
// accepted from an unmeasured cell. Once the proposal holds for a few
// frames (or a hard re-fit cap is hit) the listener detaches, so
// steady-state content renders cost nothing.
// measured cell box. Crucially we never re-fit straight off a single
// frame's proposal: the WebGL atlas warm-up can emit a one-frame transient
// cell box (e.g. a doubled box on a HiDPI display) that halves the grid,
// and committing it would lock the terminal at half size and detach (the
// #313 ghost). So a differing proposal must REPEAT identically across two
// consecutive renders — proving the measurement settled — before we apply
// it. proposeDimensions returns undefined until the cell box is non-zero,
// so a fit is never accepted from an unmeasured cell. Once the proposal
// holds at the live grid for a few frames (or a hard re-fit cap is hit) the
// listener detaches, so steady-state content renders cost nothing.
const STABLE_FRAMES_TARGET = 3;
const MAX_REFITS = 20;
let stableFrames = 0;
let refits = 0;
let pending: { cols: number; rows: number } | null = null;
const stabilizer = term.onRender(() => {
const proposed = fit.proposeDimensions();
if (!proposed || !proposed.cols || !proposed.rows) return;
if (proposed.cols !== term.cols || proposed.rows !== term.rows) {
if (refits++ >= MAX_REFITS) {
stabilizer.dispose();
stableFrames = 0;
// Only act once the same differing proposal repeats — a single-frame
// transient never gets committed, it just updates `pending`.
if (pending && pending.cols === proposed.cols && pending.rows === proposed.rows) {
pending = null;
if (refits++ >= MAX_REFITS) {
stabilizer.dispose();
return;
}
fitTerminal();
return;
}
stableFrames = 0;
fitTerminal();
pending = { cols: proposed.cols, rows: proposed.rows };
return;
}
pending = null;
if (++stableFrames >= STABLE_FRAMES_TARGET) stabilizer.dispose();
});