fix: wait for container dimensions before xterm init

- xterm docs require parent to have dimensions when open() is called
- Added ResizeObserver to wait for non-zero dimensions before initializing
- Added container ResizeObserver to handle resizes (header hide, keyboard)
- Fixed cleanup to properly disconnect observers and handle uninitialized ws
This commit is contained in:
Fusion
2026-05-24 15:16:11 +02:00
parent b638dccd36
commit 07e7c6ea0f
+92 -26
View File
@@ -56,6 +56,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
});
const lastPingRef = useRef<number>(0);
const heartbeatCheckRef = useRef<number | null>(null);
const containerResizeObserverRef = useRef<ResizeObserver | null>(null);
const calculateFontSize = useCallback(() => {
if (!isMobile) return fontSize;
@@ -201,17 +202,20 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.open(terminalRef.current);
// Wait for container to have non-zero dimensions before opening xterm
// xterm docs: "parent must be visible (have dimensions) when open is called"
const container = terminalRef.current;
let ws: WebSocket;
// Connect WebSocket
const ws = connectWebSocket();
const initTerminal = () => {
term.open(container);
// Fit after layout settles - use rAF + timeout to ensure DOM is ready
requestAnimationFrame(() => {
// Connect WebSocket
ws = connectWebSocket();
// Fit after layout settles
requestAnimationFrame(() => {
fitAddon.fit();
// Force a second fit after layout fully settles
setTimeout(() => {
requestAnimationFrame(() => {
fitAddon.fit();
const { cols, rows } = term;
if (ws.readyState === WebSocket.OPEN) {
@@ -223,24 +227,58 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
})
);
}
}, 500);
// Fit after mobile header auto-hides (3s delay + 0.3s transition)
setTimeout(() => {
fitAddon.fit();
const { cols, rows } = term;
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
cols,
rows,
})
);
}
}, 4000);
// Force a second fit after layout fully settles
setTimeout(() => {
fitAddon.fit();
const { cols, rows } = term;
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
cols,
rows,
})
);
}
}, 500);
// Fit after mobile header auto-hides (3s delay + 0.3s transition)
setTimeout(() => {
fitAddon.fit();
const { cols, rows } = term;
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
cols,
rows,
})
);
}
}, 4000);
});
});
});
};
// Check if container already has dimensions
const rect = container.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
initTerminal();
} else {
// Wait for container to be laid out
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) {
resizeObserver.disconnect();
initTerminal();
break;
}
}
});
resizeObserver.observe(container);
}
// Handle terminal input
term.onData((data) => {
@@ -281,6 +319,28 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
window.addEventListener("resize", handleResize);
// Watch container size changes (e.g. mobile header auto-hide, keyboard)
const containerResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) {
fitAddon.fit();
const { cols, rows } = term;
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
cols,
rows,
})
);
}
}
}
});
containerResizeObserver.observe(container);
containerResizeObserverRef.current = containerResizeObserver;
// Notify parent about terminal readiness
if (onTerminalReadyRef.current) {
const sendData = (data: string) => {
@@ -299,7 +359,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
// Visibility API for reconnection
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && ws.readyState !== WebSocket.OPEN) {
if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) {
reconnectAttemptsRef.current = 0;
connectWebSocket();
}
@@ -310,7 +370,13 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
clearTimeout(resizeTimeout);
window.removeEventListener("resize", handleResize);
document.removeEventListener("visibilitychange", handleVisibilityChange);
ws.close();
if (containerResizeObserverRef.current) {
containerResizeObserverRef.current.disconnect();
containerResizeObserverRef.current = null;
}
if (ws) {
ws.close();
}
term.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps