From bf15f535467c6e8613d8c15935045b0e62263611 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:21:47 +0000 Subject: [PATCH] fix: prevent terminal resize error loops --- .../__tests__/use-terminal.client.test.tsx | 134 ++++++++++++++++++ .../task/[taskId]/hooks/use-terminal.ts | 23 ++- .../CloudAnalyticsProvider.client.test.tsx | 1 + .../layout/CloudAnalyticsProvider.tsx | 2 + 4 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/hooks/__tests__/use-terminal.client.test.tsx diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/__tests__/use-terminal.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/__tests__/use-terminal.client.test.tsx new file mode 100644 index 000000000..e69a6844a --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/__tests__/use-terminal.client.test.tsx @@ -0,0 +1,134 @@ +import { useRef } from 'react'; +import { act, render } from '@testing-library/react'; + +const { fitMock } = vi.hoisted(() => ({ + fitMock: vi.fn(), +})); + +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + cols = 80; + rows = 24; + buffer = { active: { length: 0 } }; + + loadAddon() {} + open() {} + write() {} + dispose() {} + clear() {} + focus() {} + onData() { + return { dispose: vi.fn() }; + } + }, +})); + +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: class { + fit = fitMock; + }, +})); + +vi.mock('@xterm/addon-web-links', () => ({ + WebLinksAddon: class {}, +})); + +import { useTerminal } from '../use-terminal'; + +class WebSocketMock { + static OPEN = 1; + readyState = WebSocketMock.OPEN; + onopen: (() => void) | null = null; + onmessage: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + + close() {} + send() {} +} + +describe('useTerminal', () => { + let resizeCallback: ResizeObserverCallback; + let nextFrameId: number; + let frameCallbacks: Map; + + beforeEach(() => { + fitMock.mockClear(); + nextFrameId = 1; + frameCallbacks = new Map(); + + vi.stubGlobal('WebSocket', WebSocketMock); + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + const id = nextFrameId++; + frameCallbacks.set(id, callback); + return id; + }), + ); + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((id: number) => frameCallbacks.delete(id)), + ); + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + + observe() {} + disconnect() {} + unobserve() {} + }, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function flushAnimationFrames() { + const callbacks = [...frameCallbacks.values()]; + frameCallbacks.clear(); + callbacks.forEach((callback) => callback(performance.now())); + } + + function TerminalHarness() { + const containerRef = useRef(null); + + useTerminal(containerRef, () => 'ws://sandbox.test/ws/terminal'); + + return ( +
{ + containerRef.current = node; + if (node) { + Object.defineProperties(node, { + offsetWidth: { configurable: true, value: 800 }, + offsetHeight: { configurable: true, value: 600 }, + }); + } + }} + /> + ); + } + + it('defers and coalesces terminal fits triggered by ResizeObserver', () => { + render(); + + act(() => flushAnimationFrames()); + expect(fitMock).toHaveBeenCalledTimes(1); + + act(() => { + resizeCallback([], {} as ResizeObserver); + resizeCallback([], {} as ResizeObserver); + }); + + expect(fitMock).toHaveBeenCalledTimes(1); + expect(frameCallbacks).toHaveLength(1); + + act(() => flushAnimationFrames()); + expect(fitMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-terminal.ts b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-terminal.ts index df4c84ecd..30ba03ebf 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-terminal.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-terminal.ts @@ -98,6 +98,7 @@ export function useTerminal( let opened = false; let intentionalClose = false; let initialInputSent = false; + let resizeFrame: number | null = null; // Helper to fit the terminal and notify the server of the new size. function fitAndResize() { @@ -212,7 +213,6 @@ export function useTerminal( opened = true; terminal.open(container); - fitAddon.fit(); connect(); } @@ -231,8 +231,22 @@ export function useTerminal( // Resize handling — also triggers deferred open when container becomes visible. const resizeObserver = new ResizeObserver(() => { - openIfReady(); - fitAndResize(); + if (resizeFrame !== null) { + return; + } + + // Fitting xterm mutates layout. Defer it outside the ResizeObserver + // callback and coalesce notifications to avoid observer feedback loops. + resizeFrame = requestAnimationFrame(() => { + resizeFrame = null; + + if (!opened) { + openIfReady(); + return; + } + + fitAndResize(); + }); }); resizeObserver.observe(container); @@ -241,6 +255,9 @@ export function useTerminal( intentionalClose = true; connectRef.current = null; cancelAnimationFrame(initialOpenFrame); + if (resizeFrame !== null) { + cancelAnimationFrame(resizeFrame); + } resizeObserver.disconnect(); inputDisposable.dispose(); diff --git a/apps/web/src/components/layout/CloudAnalyticsProvider.client.test.tsx b/apps/web/src/components/layout/CloudAnalyticsProvider.client.test.tsx index 48d7fdcd9..8e4a41420 100644 --- a/apps/web/src/components/layout/CloudAnalyticsProvider.client.test.tsx +++ b/apps/web/src/components/layout/CloudAnalyticsProvider.client.test.tsx @@ -56,6 +56,7 @@ describe('CloudAnalyticsProvider', () => { expect(window.posthog?._i).toContainEqual([ 'posthog-project', expect.objectContaining({ + capture_exceptions: true, defaults: '2026-05-30', session_recording: { maskAllInputs: true }, }), diff --git a/apps/web/src/components/layout/CloudAnalyticsProvider.tsx b/apps/web/src/components/layout/CloudAnalyticsProvider.tsx index 616005e42..89f95dcdd 100644 --- a/apps/web/src/components/layout/CloudAnalyticsProvider.tsx +++ b/apps/web/src/components/layout/CloudAnalyticsProvider.tsx @@ -8,6 +8,7 @@ const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'; type PostHogOptions = { api_host: string; defaults: '2026-05-30'; + capture_exceptions: boolean; disable_session_recording: boolean; session_recording: { maskAllInputs: boolean; @@ -81,6 +82,7 @@ export function CloudAnalyticsProvider({ { api_host: resolvedPosthogHost, defaults: '2026-05-30', + capture_exceptions: true, disable_session_recording: false, session_recording: { maskAllInputs: true }, },