Fix resize artifacts: rAF-throttle drag + force xterm repaint

Two related fixes for stale glyphs / visual artifacts while dragging
a gutter:

- Gutter.tsx: pointermove now writes the new ratio into a ref and
  schedules a single requestAnimationFrame flush per frame. Without
  this, setTree fires 60+ times per second during a drag and React
  + ResizeObserver + xterm's DOM renderer get out of sync. The
  pointerup handler flushes any pending ratio so the final position
  always lands.

- XtermPane.tsx: the ResizeObserver callback now also rAF-coalesces
  AND calls term.refresh(0, term.rows - 1) after fit.fit(). xterm's
  DOM renderer doesn't reliably repaint freed-up rows after a
  shrink, so the explicit refresh wipes any stale glyphs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 21:40:16 +01:00
parent a4cd82440b
commit 94bdb884ad
2 changed files with 48 additions and 11 deletions

View file

@ -24,6 +24,20 @@ export default function Gutter({
}) {
const [dragging, setDragging] = useState(false);
const draggingRef = useRef(false);
// rAF-throttle the ratio updates so we don't fire React + ResizeObserver
// 60+ times per second during a drag (xterm's DOM renderer can't keep up
// and leaves artifacts).
const pendingRatioRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
const flushPending = useCallback(() => {
rafRef.current = null;
const r = pendingRatioRef.current;
if (r != null) {
pendingRatioRef.current = null;
onRatioChange(info.splitId, r);
}
}, [info.splitId, onRatioChange]);
const onPointerDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
@ -46,9 +60,12 @@ export default function Gutter({
? (xFrac - pb.left) / pb.width
: (yFrac - pb.top) / pb.height;
const ratio = Math.max(0.05, Math.min(0.95, rawRatio));
onRatioChange(info.splitId, ratio);
pendingRatioRef.current = ratio;
if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(flushPending);
}
},
[containerRef, info, onRatioChange],
[containerRef, info, flushPending],
);
const onPointerUp = useCallback((e: PointerEvent<HTMLDivElement>) => {
@ -56,7 +73,16 @@ export default function Gutter({
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
draggingRef.current = false;
setDragging(false);
}, []);
// Make sure the final ratio lands even if the rAF hadn't fired.
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (pendingRatioRef.current != null) {
onRatioChange(info.splitId, pendingRatioRef.current);
pendingRatioRef.current = null;
}
}, [info.splitId, onRatioChange]);
const isH = info.orientation === "h";
// Visible 4px line, but the draggable hitbox is wider for grabbability.