Fix web terminal scroll lag (#1622)

* fix(app): eliminate terminal scroll lag by removing MutationObserver and throttling scroll events

The MutationObserver with subtree:true was firing React setState on every
xterm DOM mutation (character output, cursor blink, scroll line rendering),
causing cascading React re-renders that blocked the main thread and made
the mouse wheel unresponsive on web.

Changes:
- Remove MutationObserver (redundant with ResizeObserver for viewport sizing)
- Throttle scroll handler with requestAnimationFrame to batch setState calls
- Early-return in updateViewportMetrics when metrics unchanged since last emit
- Cancel pending rAF on cleanup to avoid stale updates

Co-Authored-By: TommyLike <tommylikehu@gmail.com>

* Fix terminal scrollbar metric dedupe state

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Hu Sheng
2026-06-26 21:45:31 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent d6b946720e
commit 8228bafc5d
@@ -252,6 +252,7 @@ export default function TerminalEmulator({
const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastObservedOffsetRef = useRef<number | null>(null); const lastObservedOffsetRef = useRef<number | null>(null);
const lastMetricsRef = useRef({ offset: 0, viewportSize: 0, contentSize: 0 });
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]); const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
const xtermThemeRef = useRef(xtermTheme); const xtermThemeRef = useRef(xtermTheme);
xtermThemeRef.current = xtermTheme; xtermThemeRef.current = xtermTheme;
@@ -290,6 +291,19 @@ export default function TerminalEmulator({
const [isScrollActive, setIsScrollActive] = useState(false); const [isScrollActive, setIsScrollActive] = useState(false);
const [isDropActive, setIsDropActive] = useState(false); const [isDropActive, setIsDropActive] = useState(false);
const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updateViewportMetricsState = useCallback((metrics: ViewportMetrics) => {
const lastMetrics = lastMetricsRef.current;
if (
metrics.offset === lastMetrics.offset &&
metrics.viewportSize === lastMetrics.viewportSize &&
metrics.contentSize === lastMetrics.contentSize
) {
return;
}
lastMetricsRef.current = metrics;
setViewportMetrics(metrics);
}, []);
const domBridgeRef = useRef<DOMImperativeFactory | null>(null); const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
useDOMImperativeHandle( useDOMImperativeHandle(
@@ -575,24 +589,30 @@ export default function TerminalEmulator({
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport"); const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
if (!viewportElement) { if (!viewportElement) {
viewportRef.current = null; viewportRef.current = null;
setViewportMetrics({ offset: 0, viewportSize: 0, contentSize: 0 }); updateViewportMetricsState({ offset: 0, viewportSize: 0, contentSize: 0 });
return () => {}; return () => {};
} }
viewportRef.current = viewportElement; viewportRef.current = viewportElement;
const updateViewportMetrics = () => { const updateViewportMetrics = () => {
setViewportMetrics({ const offset = Math.max(0, viewportElement.scrollTop);
offset: Math.max(0, viewportElement.scrollTop), const viewportSize = Math.max(0, viewportElement.clientHeight);
viewportSize: Math.max(0, viewportElement.clientHeight), const contentSize = Math.max(0, viewportElement.scrollHeight);
contentSize: Math.max(0, viewportElement.scrollHeight), updateViewportMetricsState({ offset, viewportSize, contentSize });
});
}; };
updateViewportMetrics(); updateViewportMetrics();
let scrollRafId: number | null = null;
const handleViewportScroll = () => { const handleViewportScroll = () => {
updateViewportMetrics(); if (scrollRafId !== null) {
return;
}
scrollRafId = requestAnimationFrame(() => {
scrollRafId = null;
updateViewportMetrics();
});
}; };
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
@@ -604,27 +624,20 @@ export default function TerminalEmulator({
resizeObserver.observe(scrollAreaElement); resizeObserver.observe(scrollAreaElement);
} }
const mutationObserver = new MutationObserver(() => {
updateViewportMetrics();
});
mutationObserver.observe(host, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style", "class"],
});
viewportElement.addEventListener("scroll", handleViewportScroll, { passive: true }); viewportElement.addEventListener("scroll", handleViewportScroll, { passive: true });
return () => { return () => {
if (scrollRafId !== null) {
cancelAnimationFrame(scrollRafId);
scrollRafId = null;
}
viewportElement.removeEventListener("scroll", handleViewportScroll); viewportElement.removeEventListener("scroll", handleViewportScroll);
resizeObserver.disconnect(); resizeObserver.disconnect();
mutationObserver.disconnect();
if (viewportRef.current === viewportElement) { if (viewportRef.current === viewportElement) {
viewportRef.current = null; viewportRef.current = null;
} }
}; };
}, [streamKey]); }, [streamKey, updateViewportMetricsState]);
useEffect(() => { useEffect(() => {
const maxScrollOffset = Math.max(0, viewportMetrics.contentSize - viewportMetrics.viewportSize); const maxScrollOffset = Math.max(0, viewportMetrics.contentSize - viewportMetrics.viewportSize);
@@ -700,7 +713,7 @@ export default function TerminalEmulator({
return; return;
} }
viewportElement.scrollTop = nextOffset; viewportElement.scrollTop = nextOffset;
setViewportMetrics({ updateViewportMetricsState({
offset: nextOffset, offset: nextOffset,
viewportSize: Math.max(0, viewportElement.clientHeight), viewportSize: Math.max(0, viewportElement.clientHeight),
contentSize: Math.max(0, viewportElement.scrollHeight), contentSize: Math.max(0, viewportElement.scrollHeight),
@@ -720,7 +733,12 @@ export default function TerminalEmulator({
window.removeEventListener("pointerup", stopDragging); window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging); window.removeEventListener("pointercancel", stopDragging);
}; };
}, [isDraggingScrollbar, scrollbarGeometry.maxHandleOffset, scrollbarGeometry.maxScrollOffset]); }, [
isDraggingScrollbar,
scrollbarGeometry.maxHandleOffset,
scrollbarGeometry.maxScrollOffset,
updateViewportMetricsState,
]);
const handleVisible = const handleVisible =
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered); scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);