Skip to content

Commit a1681f7

Browse files
committed
fix(web): restore debounced terminal resize after hydration
1 parent 5a8336c commit a1681f7

3 files changed

Lines changed: 233 additions & 1 deletion

File tree

e2e/specs/session-hydrate-refresh.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ const BACKEND_HTTP_URL = `http://${HOST}:${SERVER_PORT}`;
1212
const BASE_URL = `http://${HOST}:${WEB_PORT}`;
1313
const INTERRUPTED_SESSION_ID = "sess-hydrate-interrupted";
1414
const UNAVAILABLE_SESSION_ID = "sess-hydrate-unavailable";
15+
type TerminalTraceEntry = {
16+
terminalId?: string;
17+
event?: string;
18+
};
1519
let sandboxDir: string;
1620
let workspaceDir: string;
1721
let dbPath: string;
@@ -192,6 +196,65 @@ test.describe("session hydrate refresh acceptance", () => {
192196
await expect(unavailableTextarea).toHaveAttribute("readonly", "");
193197
});
194198

199+
test("desktop terminals refit once per terminal after rapid viewport resize", async ({
200+
page,
201+
}) => {
202+
const traces: TerminalTraceEntry[] = [];
203+
204+
page.on("console", (message) => {
205+
if (message.type() !== "debug") {
206+
return;
207+
}
208+
209+
void Promise.all(message.args().map((arg) => arg.jsonValue().catch(() => undefined))).then(
210+
(args) => {
211+
if (args[0] !== "[terminal-trace]") {
212+
return;
213+
}
214+
215+
const entry = args[1];
216+
if (entry && typeof entry === "object") {
217+
traces.push(entry as TerminalTraceEntry);
218+
}
219+
}
220+
);
221+
});
222+
223+
await page.addInitScript(() => {
224+
window.localStorage.setItem("coderStudio.terminalTrace", "1");
225+
});
226+
227+
await page.goto("/workspace");
228+
await expect(page.getByTestId("workspace-resolving-shell")).toHaveCount(0, { timeout: 20000 });
229+
await expect(page.locator(".session-card.agent-pane")).toHaveCount(2, { timeout: 20000 });
230+
231+
await expect
232+
.poll(() => new Set(traces.map((entry) => entry.terminalId).filter(Boolean)).size)
233+
.toBe(2);
234+
235+
await page.waitForTimeout(300);
236+
237+
const terminalCount = new Set(traces.map((entry) => entry.terminalId).filter(Boolean)).size;
238+
const initialFitCount = traces.filter((entry) => entry.event === "fit").length;
239+
const initialObserverCount = traces.filter((entry) => entry.event === "resize-observer").length;
240+
241+
await page.setViewportSize({ width: 1180, height: 800 });
242+
await page.setViewportSize({ width: 1020, height: 800 });
243+
244+
await expect
245+
.poll(() => traces.filter((entry) => entry.event === "resize-observer").length)
246+
.toBeGreaterThan(initialObserverCount);
247+
248+
await expect
249+
.poll(() => traces.filter((entry) => entry.event === "fit").length)
250+
.toBe(initialFitCount + terminalCount);
251+
252+
await page.waitForTimeout(250);
253+
expect(traces.filter((entry) => entry.event === "fit")).toHaveLength(
254+
initialFitCount + terminalCount
255+
);
256+
});
257+
195258
test("mobile restores the server-backed active session after refresh", async ({ browser }) => {
196259
const context = await browser.newContext({
197260
viewport: { width: 430, height: 932 },

packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4174,6 +4174,175 @@ describe("XtermHost", () => {
41744174
global.cancelAnimationFrame = originalCancelAnimationFrame;
41754175
});
41764176

4177+
it("attaches the resize observer after desktop hydration is granted", async () => {
4178+
const store = createStore();
4179+
const sendCommand = vi.fn().mockImplementation((op: string) => {
4180+
if (op === "terminal.snapshot") {
4181+
return Promise.resolve({ status: "unsupported" });
4182+
}
4183+
4184+
if (op === "terminal.replay") {
4185+
return Promise.resolve({ status: "ok", seq: 0 });
4186+
}
4187+
4188+
return Promise.resolve({ status: "ok" });
4189+
});
4190+
const subscribe = vi.fn(() => vi.fn());
4191+
const observe = vi.fn();
4192+
const disconnect = vi.fn();
4193+
const originalResizeObserver = global.ResizeObserver;
4194+
const originalRequestAnimationFrame = global.requestAnimationFrame;
4195+
const originalCancelAnimationFrame = global.cancelAnimationFrame;
4196+
4197+
hydrationCoordinatorMocks.autoGrant = false;
4198+
mockTerminal.cols = 120;
4199+
mockTerminal.rows = 32;
4200+
4201+
class ResizeObserverMock {
4202+
observe = observe;
4203+
unobserve = vi.fn();
4204+
disconnect = disconnect;
4205+
4206+
constructor(_callback: ResizeObserverCallback) {}
4207+
}
4208+
4209+
global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;
4210+
4211+
global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
4212+
callback(16);
4213+
return 1;
4214+
}) as typeof requestAnimationFrame;
4215+
global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame;
4216+
4217+
store.set(wsClientAtom, {
4218+
sendCommand,
4219+
subscribe,
4220+
} as never);
4221+
4222+
render(
4223+
<Provider store={store}>
4224+
<XtermHost terminalId="hydration-resize-terminal" workspaceId="test-workspace" />
4225+
</Provider>
4226+
);
4227+
4228+
expect(mockTerminal.open).not.toHaveBeenCalled();
4229+
expect(observe).not.toHaveBeenCalled();
4230+
4231+
await act(async () => {
4232+
hydrationCoordinatorMocks.resolveGranted();
4233+
await Promise.resolve();
4234+
await Promise.resolve();
4235+
await Promise.resolve();
4236+
});
4237+
4238+
await waitFor(() => {
4239+
expect(mockTerminal.open).toHaveBeenCalledTimes(1);
4240+
});
4241+
4242+
expect(observe).toHaveBeenCalledTimes(1);
4243+
expect(disconnect).not.toHaveBeenCalled();
4244+
4245+
global.ResizeObserver = originalResizeObserver;
4246+
global.requestAnimationFrame = originalRequestAnimationFrame;
4247+
global.cancelAnimationFrame = originalCancelAnimationFrame;
4248+
});
4249+
4250+
it("debounces resize-observer fits until resize settles", async () => {
4251+
const store = createStore();
4252+
const fontsReady = new Promise<void>(() => {});
4253+
Object.defineProperty(document, "fonts", {
4254+
configurable: true,
4255+
value: {
4256+
ready: fontsReady,
4257+
},
4258+
});
4259+
const sendCommand = vi.fn().mockImplementation((op: string) => {
4260+
if (op === "terminal.snapshot") {
4261+
return Promise.resolve({ status: "unsupported" });
4262+
}
4263+
4264+
if (op === "terminal.replay") {
4265+
return Promise.resolve({ status: "ok", seq: 0 });
4266+
}
4267+
4268+
return Promise.resolve({ status: "ok" });
4269+
});
4270+
const subscribe = vi.fn(() => vi.fn());
4271+
const originalResizeObserver = global.ResizeObserver;
4272+
const originalRequestAnimationFrame = global.requestAnimationFrame;
4273+
const originalCancelAnimationFrame = global.cancelAnimationFrame;
4274+
let resizeObserverCallback: ResizeObserverCallback | null = null;
4275+
4276+
hydrationCoordinatorMocks.autoGrant = false;
4277+
mockTerminal.cols = 120;
4278+
mockTerminal.rows = 32;
4279+
4280+
class ResizeObserverMock {
4281+
observe = vi.fn();
4282+
unobserve = vi.fn();
4283+
disconnect = vi.fn();
4284+
4285+
constructor(callback: ResizeObserverCallback) {
4286+
resizeObserverCallback = callback;
4287+
}
4288+
}
4289+
4290+
global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;
4291+
4292+
global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
4293+
callback(16);
4294+
return 1;
4295+
}) as typeof requestAnimationFrame;
4296+
global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame;
4297+
4298+
store.set(wsClientAtom, {
4299+
sendCommand,
4300+
subscribe,
4301+
} as never);
4302+
4303+
render(
4304+
<Provider store={store}>
4305+
<XtermHost terminalId="debounced-resize-terminal" workspaceId="test-workspace" />
4306+
</Provider>
4307+
);
4308+
4309+
await act(async () => {
4310+
hydrationCoordinatorMocks.resolveGranted();
4311+
await Promise.resolve();
4312+
await Promise.resolve();
4313+
await Promise.resolve();
4314+
});
4315+
4316+
expect(resizeObserverCallback).toBeTypeOf("function");
4317+
4318+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
4319+
4320+
mockFitAddon.fit.mockClear();
4321+
4322+
act(() => {
4323+
resizeObserverCallback?.([], {} as ResizeObserver);
4324+
resizeObserverCallback?.([], {} as ResizeObserver);
4325+
resizeObserverCallback?.([], {} as ResizeObserver);
4326+
});
4327+
4328+
act(() => {
4329+
vi.advanceTimersByTime(149);
4330+
});
4331+
4332+
expect(mockFitAddon.fit).not.toHaveBeenCalled();
4333+
4334+
act(() => {
4335+
vi.advanceTimersByTime(1);
4336+
});
4337+
4338+
expect(mockFitAddon.fit).toHaveBeenCalledTimes(1);
4339+
4340+
global.ResizeObserver = originalResizeObserver;
4341+
global.requestAnimationFrame = originalRequestAnimationFrame;
4342+
global.cancelAnimationFrame = originalCancelAnimationFrame;
4343+
vi.useRealTimers();
4344+
});
4345+
41774346
it("waits for websocket connection before initial resize sync and snapshot recovery", async () => {
41784347
const store = createStore();
41794348
const snapshotChunk = new TextEncoder().encode("snapshot after connect\n");

packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1612,7 +1612,7 @@ export function XtermHost({
16121612
resizeDebounceRef.current = null;
16131613
}
16141614
};
1615-
}, [scheduleFit, terminalId]);
1615+
}, [hydrationState.kind, scheduleFit, terminalId]);
16161616

16171617
/**
16181618
* Focus terminal when it becomes active

0 commit comments

Comments
 (0)