Skip to content

Commit eb85eab

Browse files
committed
fix terminal fit after desktop refresh
1 parent 057dea1 commit eb85eab

2 files changed

Lines changed: 134 additions & 8 deletions

File tree

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

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ describe("XtermHost", () => {
211211
Math.min(mockTerminal.buffer.active.baseY, nextViewportY)
212212
);
213213
});
214+
Object.defineProperty(document, "fonts", {
215+
configurable: true,
216+
value: {
217+
ready: Promise.resolve(),
218+
},
219+
});
214220
});
215221

216222
afterEach(() => {
@@ -1411,7 +1417,8 @@ describe("XtermHost", () => {
14111417
</Provider>
14121418
);
14131419

1414-
const firstOnDataCallback = mockTerminal.onData.mock.calls[0]?.[0]; await act(async () => {
1420+
const firstOnDataCallback = mockTerminal.onData.mock.calls[0]?.[0];
1421+
await act(async () => {
14151422
await firstOnDataCallback?.("f");
14161423
await firstOnDataCallback?.("o");
14171424
await firstOnDataCallback?.("o");
@@ -1532,7 +1539,8 @@ describe("XtermHost", () => {
15321539
);
15331540

15341541
const onDataCallback = mockTerminal.onData.mock.calls[0]?.[0];
1535-
expect(onDataCallback).toBeTypeOf("function"); await onDataCallback?.("f");
1542+
expect(onDataCallback).toBeTypeOf("function");
1543+
await onDataCallback?.("f");
15361544
await onDataCallback?.("i");
15371545
await onDataCallback?.("x");
15381546
await user.click(screen.getByRole("button", { name: "Ctrl" }));
@@ -1569,7 +1577,8 @@ describe("XtermHost", () => {
15691577
);
15701578

15711579
const onDataCallback = mockTerminal.onData.mock.calls[0]?.[0];
1572-
expect(onDataCallback).toBeTypeOf("function"); await onDataCallback?.("f");
1580+
expect(onDataCallback).toBeTypeOf("function");
1581+
await onDataCallback?.("f");
15731582
await onDataCallback?.("i");
15741583
await onDataCallback?.("x");
15751584
await user.click(screen.getByRole("button", { name: "Ctrl" }));
@@ -1606,7 +1615,8 @@ describe("XtermHost", () => {
16061615
);
16071616

16081617
const onDataCallback = mockTerminal.onData.mock.calls[0]?.[0];
1609-
expect(onDataCallback).toBeTypeOf("function"); await onDataCallback?.("f");
1618+
expect(onDataCallback).toBeTypeOf("function");
1619+
await onDataCallback?.("f");
16101620
await onDataCallback?.("o");
16111621
await onDataCallback?.("o");
16121622
await onDataCallback?.(" ");
@@ -1646,7 +1656,8 @@ describe("XtermHost", () => {
16461656
);
16471657

16481658
const onDataCallback = mockTerminal.onData.mock.calls[0]?.[0];
1649-
expect(onDataCallback).toBeTypeOf("function"); await onDataCallback?.("n");
1659+
expect(onDataCallback).toBeTypeOf("function");
1660+
await onDataCallback?.("n");
16501661
await onDataCallback?.("p");
16511662
await onDataCallback?.("m");
16521663
await user.click(screen.getByRole("button", { name: "Ctrl" }));
@@ -3378,9 +3389,9 @@ describe("XtermHost", () => {
33783389
});
33793390

33803391
await waitFor(() => {
3381-
expect(
3382-
mockTerminal.write.mock.calls.some(([written]) => written === reconnectSnapshot)
3383-
).toBe(true);
3392+
expect(mockTerminal.write.mock.calls.some(([written]) => written === reconnectSnapshot)).toBe(
3393+
true
3394+
);
33843395
});
33853396
expect(snapshotCount).toBe(2);
33863397
expect(sendCommand.mock.calls.some(([op]) => op === "terminal.replay")).toBe(false);
@@ -4079,6 +4090,90 @@ describe("XtermHost", () => {
40794090
global.cancelAnimationFrame = originalCancelAnimationFrame;
40804091
});
40814092

4093+
it("waits for fonts to settle before the initial replay sync", async () => {
4094+
const store = createStore();
4095+
let resolveFontsReady: (() => void) | undefined;
4096+
const fontsReady = new Promise<void>((resolve) => {
4097+
resolveFontsReady = resolve;
4098+
});
4099+
Object.defineProperty(document, "fonts", {
4100+
configurable: true,
4101+
value: {
4102+
ready: fontsReady,
4103+
},
4104+
});
4105+
4106+
const dispatchCommand = vi.fn().mockImplementation((op: string) => {
4107+
if (op === "terminal.replay") {
4108+
return Promise.resolve({ ok: true, data: { status: "ok", seq: 200 } });
4109+
}
4110+
4111+
return Promise.resolve({ ok: true, data: { status: "ok" } });
4112+
});
4113+
const subscribe = vi.fn(() => vi.fn());
4114+
const rafCallbacks: FrameRequestCallback[] = [];
4115+
const originalRequestAnimationFrame = global.requestAnimationFrame;
4116+
const originalCancelAnimationFrame = global.cancelAnimationFrame;
4117+
4118+
mockTerminal.cols = 132;
4119+
mockTerminal.rows = 36;
4120+
4121+
global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
4122+
rafCallbacks.push(callback);
4123+
return rafCallbacks.length;
4124+
}) as typeof requestAnimationFrame;
4125+
global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame;
4126+
4127+
store.set(wsClientAtom, {
4128+
sendCommand: dispatchCommand,
4129+
subscribe,
4130+
getStatus: vi.fn(() => "connected"),
4131+
onStatus: vi.fn(() => () => {}),
4132+
} as never);
4133+
4134+
render(
4135+
<Provider store={store}>
4136+
<XtermHost terminalId="font-ready-terminal" workspaceId="test-workspace" />
4137+
</Provider>
4138+
);
4139+
4140+
await act(async () => {
4141+
const callback = rafCallbacks.shift();
4142+
callback?.(16);
4143+
await Promise.resolve();
4144+
await Promise.resolve();
4145+
await Promise.resolve();
4146+
});
4147+
4148+
expect(dispatchCommand).not.toHaveBeenCalledWith("terminal.replay", {
4149+
terminalId: "font-ready-terminal",
4150+
lastSeq: 0,
4151+
});
4152+
expect(mockFitAddon.fit).toHaveBeenCalledTimes(1);
4153+
4154+
await act(async () => {
4155+
resolveFontsReady?.();
4156+
await Promise.resolve();
4157+
});
4158+
4159+
await act(async () => {
4160+
const callback = rafCallbacks.shift();
4161+
callback?.(32);
4162+
await Promise.resolve();
4163+
await Promise.resolve();
4164+
await Promise.resolve();
4165+
});
4166+
4167+
await waitFor(() => {
4168+
expect(mockFitAddon.fit).toHaveBeenCalledTimes(2);
4169+
expectResizeCall(dispatchCommand, "font-ready-terminal", 132, 36);
4170+
expectReplayCall(dispatchCommand, "font-ready-terminal", 0);
4171+
});
4172+
4173+
global.requestAnimationFrame = originalRequestAnimationFrame;
4174+
global.cancelAnimationFrame = originalCancelAnimationFrame;
4175+
});
4176+
40824177
it("waits for websocket connection before initial resize sync and snapshot recovery", async () => {
40834178
const store = createStore();
40844179
const snapshotChunk = new TextEncoder().encode("snapshot after connect\n");

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,22 @@ function isTerminalTraceEnabled() {
302302
}
303303
}
304304

305+
function waitForDocumentFontsReady(): Promise<void> {
306+
if (typeof document === "undefined") {
307+
return Promise.resolve();
308+
}
309+
310+
const fontSet = document.fonts;
311+
if (!fontSet?.ready) {
312+
return Promise.resolve();
313+
}
314+
315+
return fontSet.ready.then(
316+
() => undefined,
317+
() => undefined
318+
);
319+
}
320+
305321
function countOccurrences(text: string, needle: string): number {
306322
return text.split(needle).length - 1;
307323
}
@@ -1070,6 +1086,21 @@ export function XtermHost({
10701086
fitAddonRef.current = fitAddon;
10711087
scheduleFit();
10721088
const initialFitReady = waitForNextFit();
1089+
if (viewport !== "mobile") {
1090+
void initialFitReady
1091+
.then(async () => {
1092+
await waitForDocumentFontsReady();
1093+
if (disposed || !mountedRef.current) {
1094+
return;
1095+
}
1096+
1097+
scheduleFit();
1098+
})
1099+
.catch(() => {
1100+
// Keep the initial terminal boot resilient even if the Font Loading
1101+
// API is unavailable or a browser rejects the readiness promise.
1102+
});
1103+
}
10731104

10741105
const waitForConnected = async () => {
10751106
if (!wsClient) {

0 commit comments

Comments
 (0)