fix(dashboard): bind xterm onData once to prevent input doubling
Move the xterm.onData -> sendInput wiring (and the window resize listener) from a separate post-init effect into initTerminal itself, so they share the xterm instance's lifetime. Under StrictMode + Vite Fast Refresh the separate effect could re-run and attach a second listener to the same live xterm instance, producing per-character input doubling (every keystroke -> two pty.write calls -> shell echoes "aabbcc"). The handler now reads sendInput via a ref, so function-identity changes no longer require re-binding. Resize listener is removed at every xterm disposal site (tab switch, modal close, session-invalid replace, reinitialize). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -243,6 +243,14 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
|||||||
const hasInitialCommandRun = useRef<string | false>(false);
|
const hasInitialCommandRun = useRef<string | false>(false);
|
||||||
const xtermInitializedRef = useRef<string | false>(false);
|
const xtermInitializedRef = useRef<string | false>(false);
|
||||||
const resizeRef = useRef<((cols: number, rows: number) => void) | null>(null);
|
const resizeRef = useRef<((cols: number, rows: number) => void) | null>(null);
|
||||||
|
// Latest sendInput, kept in a ref so the xterm.onData listener bound at
|
||||||
|
// init time always calls the current function without needing to re-bind
|
||||||
|
// (which under StrictMode/Vite Fast Refresh could leak a stale listener
|
||||||
|
// on the same xterm instance and cause per-character input doubling).
|
||||||
|
const sendInputRef = useRef<(data: string) => void>(() => {});
|
||||||
|
// Window resize listener tied to the live xterm instance — tracked here so
|
||||||
|
// it can be removed in step with xterm disposal (modal close, tab switch).
|
||||||
|
const windowResizeListenerRef = useRef<(() => void) | null>(null);
|
||||||
const keyboardOverlapRef = useRef(0);
|
const keyboardOverlapRef = useRef(0);
|
||||||
const fontSizeRef = useRef(fontSize);
|
const fontSizeRef = useRef(fontSize);
|
||||||
/** Tracks a pending requestAnimationFrame for deferred xterm re-fit. */
|
/** Tracks a pending requestAnimationFrame for deferred xterm re-fit. */
|
||||||
@@ -389,6 +397,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
|||||||
// Keep a ref to resize so the viewport-change effect can call it
|
// Keep a ref to resize so the viewport-change effect can call it
|
||||||
// without needing resize as a dependency (avoids ordering issues).
|
// without needing resize as a dependency (avoids ordering issues).
|
||||||
resizeRef.current = resize;
|
resizeRef.current = resize;
|
||||||
|
sendInputRef.current = sendInput;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
@@ -444,6 +453,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
|||||||
xtermRef.current = null;
|
xtermRef.current = null;
|
||||||
fitAddonRef.current = null;
|
fitAddonRef.current = null;
|
||||||
xtermInitializedRef.current = false;
|
xtermInitializedRef.current = false;
|
||||||
|
if (windowResizeListenerRef.current) {
|
||||||
|
window.removeEventListener("resize", windowResizeListenerRef.current);
|
||||||
|
windowResizeListenerRef.current = null;
|
||||||
|
}
|
||||||
setXtermReady(false);
|
setXtermReady(false);
|
||||||
setXtermInitError(null);
|
setXtermInitError(null);
|
||||||
}
|
}
|
||||||
@@ -586,6 +599,43 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire user input forwarding (xterm → server) once, here, while we
|
||||||
|
// still hold a live reference to the freshly-created xterm. Doing
|
||||||
|
// this in a separate effect is fragile under StrictMode/Vite Fast
|
||||||
|
// Refresh: the effect can re-run and attach a second listener to the
|
||||||
|
// same xterm instance, which produces per-character input doubling
|
||||||
|
// (every keystroke calls sendInput twice → server pty.write twice →
|
||||||
|
// shell echoes the doubled byte → "aabbcc" on screen). Binding here
|
||||||
|
// ties the listener's lifetime to the xterm; xterm.dispose() removes
|
||||||
|
// it. The handler reads sendInput via a ref so updates to that
|
||||||
|
// function don't require re-binding.
|
||||||
|
terminal.onData((data) => {
|
||||||
|
if (xtermInitializedRef.current !== currentSessionId) return;
|
||||||
|
sendInputRef.current(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Window resize listener bound to this xterm. Tracked in a ref so it
|
||||||
|
// can be removed when xterm is disposed (modal close, tab switch).
|
||||||
|
const resizeHandler = () => {
|
||||||
|
if (xtermInitializedRef.current !== currentSessionId) return;
|
||||||
|
if (fitAddonRef.current && xtermRef.current) {
|
||||||
|
try {
|
||||||
|
(fitAddonRef.current as InstanceType<typeof FitAddon>).fit();
|
||||||
|
const { cols, rows } = xtermRef.current;
|
||||||
|
resizeRef.current?.(cols, rows);
|
||||||
|
} catch {
|
||||||
|
// Ignore fit errors during viewport transitions.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", resizeHandler);
|
||||||
|
// Replace any stale listener (defensive: e.g. a previous xterm whose
|
||||||
|
// disposal path didn't clear this ref). Removes before re-registering.
|
||||||
|
if (windowResizeListenerRef.current) {
|
||||||
|
window.removeEventListener("resize", windowResizeListenerRef.current);
|
||||||
|
}
|
||||||
|
windowResizeListenerRef.current = resizeHandler;
|
||||||
|
|
||||||
// Signal that xterm is ready so lifecycle effects can subscribe.
|
// Signal that xterm is ready so lifecycle effects can subscribe.
|
||||||
setXtermReady(true);
|
setXtermReady(true);
|
||||||
// Clear any prior xterm init error
|
// Clear any prior xterm init error
|
||||||
@@ -613,46 +663,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
|||||||
};
|
};
|
||||||
}, [fitAndResizeForSession, isOpen, isReady, activeTab?.sessionId, projectId]);
|
}, [fitAndResizeForSession, isOpen, isReady, activeTab?.sessionId, projectId]);
|
||||||
|
|
||||||
// Keep user input forwarding and resize publishing attached to the current
|
// (Input forwarding + window resize listener are wired inside initTerminal
|
||||||
// xterm instance/session. This prevents unrelated rerenders (tab title or
|
// so they share the xterm instance's lifetime — see comment there.)
|
||||||
// status updates) from silently dropping onData -> sendInput wiring.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen || !xtermReady || !activeTab?.sessionId || !xtermRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectedSessionId = activeTab.sessionId;
|
|
||||||
const terminal = xtermRef.current;
|
|
||||||
|
|
||||||
const dataHandler = terminal.onData((data) => {
|
|
||||||
if (xtermInitializedRef.current !== expectedSessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendInput(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
const resizeHandler = () => {
|
|
||||||
if (xtermInitializedRef.current !== expectedSessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (fitAddonRef.current && xtermRef.current) {
|
|
||||||
try {
|
|
||||||
(fitAddonRef.current as InstanceType<typeof FitAddon>).fit();
|
|
||||||
const { cols, rows } = xtermRef.current;
|
|
||||||
resize(cols, rows);
|
|
||||||
} catch {
|
|
||||||
// Ignore fit errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("resize", resizeHandler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
dataHandler.dispose();
|
|
||||||
window.removeEventListener("resize", resizeHandler);
|
|
||||||
};
|
|
||||||
}, [isOpen, xtermReady, activeTab?.sessionId, sendInput, resize]);
|
|
||||||
|
|
||||||
// Cleanup xterm when modal closes
|
// Cleanup xterm when modal closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -665,6 +677,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
|||||||
}
|
}
|
||||||
fitAddonRef.current = null;
|
fitAddonRef.current = null;
|
||||||
xtermInitializedRef.current = false;
|
xtermInitializedRef.current = false;
|
||||||
|
if (windowResizeListenerRef.current) {
|
||||||
|
window.removeEventListener("resize", windowResizeListenerRef.current);
|
||||||
|
windowResizeListenerRef.current = null;
|
||||||
|
}
|
||||||
setXtermReady(false);
|
setXtermReady(false);
|
||||||
setXtermInitError(null);
|
setXtermInitError(null);
|
||||||
hasInitialCommandRun.current = false;
|
hasInitialCommandRun.current = false;
|
||||||
@@ -893,6 +909,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
|||||||
}
|
}
|
||||||
fitAddonRef.current = null;
|
fitAddonRef.current = null;
|
||||||
xtermInitializedRef.current = false;
|
xtermInitializedRef.current = false;
|
||||||
|
if (windowResizeListenerRef.current) {
|
||||||
|
window.removeEventListener("resize", windowResizeListenerRef.current);
|
||||||
|
windowResizeListenerRef.current = null;
|
||||||
|
}
|
||||||
setXtermReady(false);
|
setXtermReady(false);
|
||||||
setXtermInitError(null);
|
setXtermInitError(null);
|
||||||
|
|
||||||
@@ -941,6 +961,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
|||||||
}
|
}
|
||||||
fitAddonRef.current = null;
|
fitAddonRef.current = null;
|
||||||
xtermInitializedRef.current = false;
|
xtermInitializedRef.current = false;
|
||||||
|
if (windowResizeListenerRef.current) {
|
||||||
|
window.removeEventListener("resize", windowResizeListenerRef.current);
|
||||||
|
windowResizeListenerRef.current = null;
|
||||||
|
}
|
||||||
// Clear error state and reset readiness so the init effect re-runs
|
// Clear error state and reset readiness so the init effect re-runs
|
||||||
setXtermInitError(null);
|
setXtermInitError(null);
|
||||||
setXtermReady(false);
|
setXtermReady(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user