authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 09:54:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 09:54:17-07:00
log5eab021a5185e492b6b021f83564143d5263e8a6
treebfa9fad171e5c383503197d878a80d313418b9a7
parent9eb4d71681dc7d002082db2f8b4bacf7614412f4
signature Signed by PGP key B5690EEEBB952194

perf(web): stop rendering hidden terminals (#9718)

Stop post-construction terminal snapshots, canvas paint, and cursor timers while a drawer or right panel is hidden. Keep parsing output and answering VT queries, then render current state once on reveal. Cover delayed WASM initialization, selection behavior, zero-size mounts, and reveal with real-core headless tests. Keep the existing startup background fill. Continue the zero-size guard from source commit eb5b68103506b1bb5bd81c3b9e27f11e349743c9 in the terminal streaming contribution. Created with GPT-6 Astra (preview) in Codex. Co-Authored-By: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

4 files changed, 401 insertions(+), 16 deletions(-)

apps/web/src/components/ChatView.tsx+4
......@@ -1154,6 +1154,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
11541154});
11551155
11561156interface PersistentThreadTerminalPanelProps {
1157 visible: boolean;
11571158 threadRef: ScopedThreadRef;
11581159 surface: Extract<RightPanelSurface, { kind: "terminal" }>;
11591160 launchContext: PersistentTerminalLaunchContext | null;
......@@ -1172,6 +1173,7 @@ interface PersistentThreadTerminalPanelProps {
11721173}
11731174
11741175const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({
1176 visible,
11751177 threadRef,
11761178 surface,
11771179 launchContext,
......@@ -1287,6 +1289,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane
12871289 return (
12881290 <ThreadTerminalDrawer
12891291 mode="panel"
1292 visible={visible}
12901293 threadRef={threadRef}
12911294 threadId={threadRef.threadId}
12921295 cwd={cwd}
......@@ -7549,6 +7552,7 @@ export default function ChatView(props: ChatViewProps) {
75497552 </Suspense>
75507553 ) : renderedRightPanelSurface?.kind === "terminal" ? (
75517554 <PersistentThreadTerminalPanel
7555 visible={rightPanelOpen}
75527556 threadRef={activeThreadRef}
75537557 surface={renderedRightPanelSurface}
75547558 launchContext={activeTerminalLaunchContext ?? null}
apps/web/src/components/ThreadTerminalDrawer.tsx+21-5
......@@ -33,6 +33,7 @@ import {
3333 useCallback,
3434 useEffect,
3535 useEffectEvent,
36 useLayoutEffect,
3637 useMemo,
3738 useRef,
3839 useState,
......@@ -295,6 +296,7 @@ interface TerminalViewportProps {
295296 onAddTerminalContext: (selection: TerminalContextSelection) => void;
296297 focusRequestId: number;
297298 autoFocus: boolean;
299 visible: boolean;
298300 resizeEpoch: number;
299301 drawerHeight: number;
300302 keybindings: ResolvedKeybindingsConfig;
......@@ -319,12 +321,14 @@ export function TerminalViewport({
319321 onAddTerminalContext,
320322 focusRequestId,
321323 autoFocus,
324 visible,
322325 resizeEpoch,
323326 drawerHeight,
324327 keybindings,
325328}: TerminalViewportProps) {
326329 const containerRef = useRef<HTMLDivElement>(null);
327330 const terminalRef = useRef<GhosttyTerminalSurface | null>(null);
331 const visibleRef = useRef(visible);
328332 const environmentId = threadRef.environmentId;
329333 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
330334 const openInPreferredEditor = useOpenInPreferredEditor(
......@@ -434,6 +438,11 @@ export function TerminalViewport({
434438 keybindingsRef.current = keybindings;
435439 }, [keybindings]);
436440
441 useLayoutEffect(() => {
442 visibleRef.current = visible;
443 terminalRef.current?.setVisible(visible);
444 }, [visible]);
445
437446 useEffect(() => {
438447 const current = terminalFontRef.current;
439448 if (current.family === terminalFontFamily && current.size === terminalFontSize) return;
......@@ -457,6 +466,9 @@ export function TerminalViewport({
457466 const terminalOptions: GhosttyTerminalSurfaceOptions = {
458467 theme: terminalThemeFromApp(mount),
459468 font: terminalFontOptions(setupFont.family, setupFont.size),
469 get visible() {
470 return visibleRef.current;
471 },
460472 onData: (data) => handleData(data),
461473 onResize: (cols, rows) => void resizeTerminal(cols, rows),
462474 onSelectionChange: () => handleSelectionChange(),
......@@ -474,6 +486,7 @@ export function TerminalViewport({
474486 terminal.dispose();
475487 return null;
476488 }
489 terminal.setVisible(visibleRef.current);
477490 // The theme observer is not installed yet, so re-read the theme in case
478491 // the app toggled light/dark while the WASM surface was loading.
479492 terminal.setTheme(terminalThemeFromApp(mount));
......@@ -503,7 +516,7 @@ export function TerminalViewport({
503516 // never started, so only "exited" triggers the message — as with xterm.)
504517 synchronizedStatusRef.current = "closed";
505518 synchronizeTerminalStatus(terminal, latestSession.status);
506 if (autoFocus) window.requestAnimationFrame(() => terminal.focus());
519 if (autoFocus && visibleRef.current) window.requestAnimationFrame(() => terminal.focus());
507520
508521 const dismissSelectionAction = (supersede = false) => {
509522 const ownsMenu =
......@@ -891,7 +904,7 @@ export function TerminalViewport({
891904 writeSystemMessage(terminal, current.error);
892905 }
893906
894 if (previous.version === 0 && autoFocus) {
907 if (previous.version === 0 && autoFocus && visibleRef.current) {
895908 window.requestAnimationFrame(() => {
896909 terminal.focus();
897910 });
......@@ -900,7 +913,7 @@ export function TerminalViewport({
900913 }, [autoFocus, terminalOutput, terminalError, terminalStatus, terminalVersion]);
901914
902915 useEffect(() => {
903 if (!autoFocus) return;
916 if (!autoFocus || !visible) return;
904917 const terminal = terminalRef.current;
905918 if (!terminal) return;
906919 const frame = window.requestAnimationFrame(() => {
......@@ -909,15 +922,16 @@ export function TerminalViewport({
909922 return () => {
910923 window.cancelAnimationFrame(frame);
911924 };
912 }, [autoFocus, focusRequestId]);
925 }, [autoFocus, focusRequestId, visible]);
913926
914927 useEffect(() => {
915928 const terminal = terminalRef.current;
916 if (!terminal) return;
929 if (!terminal || !visibleRef.current) return;
917930 const wasAtBottom = terminal.isAtBottom();
918931 // The surface reports grid changes through onResize, which is the single
919932 // channel for PTY resize RPCs; fitting here only refreshes the layout.
920933 const frame = window.requestAnimationFrame(() => {
934 if (!visibleRef.current) return;
921935 terminal.fit();
922936 if (wasAtBottom) {
923937 terminal.scrollToBottom();
......@@ -1493,6 +1507,7 @@ export default function ThreadTerminalDrawer({
14931507 onAddTerminalContext={onAddTerminalContext}
14941508 focusRequestId={focusRequestId}
14951509 autoFocus={terminalId === resolvedActiveTerminalId}
1510 visible={visible}
14961511 resizeEpoch={resizeEpoch}
14971512 drawerHeight={drawerHeight}
14981513 keybindings={keybindings}
......@@ -1522,6 +1537,7 @@ export default function ThreadTerminalDrawer({
15221537 onAddTerminalContext={onAddTerminalContext}
15231538 focusRequestId={focusRequestId}
15241539 autoFocus
1540 visible={visible}
15251541 resizeEpoch={resizeEpoch}
15261542 drawerHeight={drawerHeight}
15271543 keybindings={keybindings}
apps/web/src/terminal/ghostty/surface.test.ts+322-2
......@@ -1,6 +1,6 @@
1import { describe, expect, it, vi } from "vite-plus/test";
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
22
3import type { GhosttyCell, GhosttyRow } from "./core";
3import { GhosttyTerminalCore, type GhosttyCell, type GhosttyRow } from "./core";
44import {
55 DEFAULT_TERMINAL_FONT_FAMILY,
66 DEFAULT_TERMINAL_FONT_SIZE,
......@@ -31,8 +31,328 @@ import {
3131 terminalFontSize,
3232 terminalWheelArrowData,
3333 terminalWheelDeltaRows,
34 GhosttyTerminalSurface,
35 type GhosttyTerminalSurfaceOptions,
3436} from "./surface";
3537
38vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({
39 default: (await import("./vendor/ghostty-vt.wasm?inline")).default,
40}));
41vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({
42 default: (await import("./vendor/ghostty-write-pty.wasm?inline")).default,
43}));
44
45describe("GhosttyTerminalSurface visibility", () => {
46 const surfaces = new Set<GhosttyTerminalSurface>();
47
48 // Keep the real surface, renderer, and WASM core. Only browser layout and
49 // scheduling are replaced so tests can count work while the terminal is hidden.
50 function createHarness() {
51 vi.useFakeTimers();
52 const frames = new Map<number, FrameRequestCallback>();
53 const resizeCallbacks = new Set<() => void>();
54 const paint = vi.fn((_operation: string, _args: ReadonlyArray<unknown>) => {});
55 let frameId = 0;
56 const requestFrame = vi.fn((callback: FrameRequestCallback) => {
57 frames.set(++frameId, callback);
58 return frameId;
59 });
60
61 class TerminalTestElement extends EventTarget {
62 style: Record<string, string> = {};
63 parentElement: TerminalTestElement | null = null;
64 clientWidth = 168;
65 clientHeight = 104;
66 width = 300;
67 height = 150;
68 value = "";
69 private readonly captures = new Set<number>();
70
71 setAttribute() {}
72 append(...children: TerminalTestElement[]) {
73 for (const child of children) child.parentElement = this;
74 }
75 replaceChildren(...children: TerminalTestElement[]) {
76 this.append(...children);
77 }
78 remove() {
79 this.parentElement = null;
80 }
81 getContext() {
82 return context;
83 }
84 focus() {
85 this.dispatchEvent(new Event("focus"));
86 }
87 setPointerCapture(pointerId: number) {
88 this.captures.add(pointerId);
89 }
90 hasPointerCapture(pointerId: number) {
91 return this.captures.has(pointerId);
92 }
93 releasePointerCapture(pointerId: number) {
94 this.captures.delete(pointerId);
95 }
96 getBoundingClientRect() {
97 return { left: 0, top: 0, right: 168, bottom: 104, width: 168, height: 104 };
98 }
99 }
100
101 const canvas = new TerminalTestElement();
102 const mount = new TerminalTestElement();
103 const context = {
104 canvas,
105 beginPath() {},
106 clip() {},
107 rect() {},
108 resetTransform() {},
109 restore() {},
110 save() {},
111 setTransform() {},
112 fillRect: (...args: number[]) => paint("fillRect", args),
113 strokeRect: (...args: number[]) => paint("strokeRect", args),
114 fillText: (...args: [string, number, number, number?]) => paint("fillText", args),
115 measureText: (text: string) => ({
116 width: text.length * 8,
117 actualBoundingBoxAscent: 9,
118 actualBoundingBoxDescent: 3,
119 }),
120 };
121 vi.stubGlobal("document", {
122 createElement: (tag: string) => (tag === "canvas" ? canvas : new TerminalTestElement()),
123 fonts: Object.assign(new EventTarget(), { load: async () => [], add() {} }),
124 });
125 vi.stubGlobal(
126 "window",
127 Object.assign(new EventTarget(), {
128 devicePixelRatio: 1,
129 requestAnimationFrame: requestFrame,
130 cancelAnimationFrame: (id: number) => frames.delete(id),
131 setTimeout,
132 clearTimeout,
133 setInterval,
134 clearInterval,
135 matchMedia: () => Object.assign(new EventTarget(), { matches: false }),
136 }),
137 );
138 vi.stubGlobal(
139 "ResizeObserver",
140 class {
141 constructor(private readonly callback: () => void) {
142 resizeCallbacks.add(callback);
143 }
144 observe() {}
145 disconnect() {
146 resizeCallbacks.delete(this.callback);
147 }
148 },
149 );
150 const snapshot = vi.spyOn(GhosttyTerminalCore.prototype, "snapshot");
151 const onData = vi.fn<(data: string) => void>();
152
153 return {
154 mount,
155 frames,
156 paint,
157 requestFrame,
158 snapshot,
159 onData,
160 get renderedSnapshot() {
161 const result = snapshot.mock.results.at(-1);
162 if (result?.type !== "return") throw new Error("No terminal snapshot was rendered");
163 return result.value;
164 },
165 flushFrame() {
166 const queued = [...frames.values()];
167 frames.clear();
168 for (const callback of queued) callback(0);
169 },
170 resize() {
171 for (const callback of resizeCallbacks) callback();
172 },
173 pointer(type: string, clientX: number, buttons: number) {
174 canvas.dispatchEvent(
175 Object.assign(new Event(type, { cancelable: true }), {
176 clientX,
177 clientY: 5,
178 pointerId: 1,
179 button: 0,
180 buttons,
181 }),
182 );
183 },
184 async create(options: Partial<GhosttyTerminalSurfaceOptions> = {}) {
185 const surface = await GhosttyTerminalSurface.create(mount as unknown as HTMLElement, {
186 theme: {
187 foreground: { r: 255, g: 255, b: 255 },
188 background: { r: 0, g: 0, b: 0 },
189 cursor: { r: 255, g: 255, b: 255 },
190 },
191 onData,
192 onResize() {},
193 onSelectionChange() {},
194 beforeKey: () => false,
195 onLinkActivate() {},
196 ...options,
197 get visible() {
198 return options.visible ?? true;
199 },
200 });
201 surfaces.add(surface);
202 return surface;
203 },
204 };
205 }
206
207 afterEach(() => {
208 for (const surface of surfaces) surface.dispose();
209 surfaces.clear();
210 vi.useRealTimers();
211 vi.unstubAllGlobals();
212 vi.restoreAllMocks();
213 });
214
215 it("stops hidden snapshots and paint while preserving live VT replies and the next cursor", async () => {
216 const harness = createHarness();
217 const surface = await harness.create();
218 surface.focus();
219 surface.write("ready\x1b[1 q");
220 harness.flushFrame();
221 vi.advanceTimersByTime(500);
222 harness.flushFrame();
223 surface.write("queued");
224 expect(harness.frames.size).toBe(1);
225 surface.setVisible(false);
226 expect(harness.frames.size).toBe(0);
227 expect(vi.getTimerCount()).toBe(0);
228 harness.snapshot.mockClear();
229 harness.paint.mockClear();
230 harness.requestFrame.mockClear();
231
232 surface.write("\x1b[2J\x1b[H\x1b[3");
233 surface.write("1mhidden");
234 surface.write("界🙂\x1b[0m\x1b[5n\x1b[6n");
235 surface.fit();
236 vi.advanceTimersByTime(2_000);
237 harness.flushFrame();
238
239 expect(harness.onData.mock.calls).toEqual([["\x1b[0n"], ["\x1b[1;11R"]]);
240 expect(harness.snapshot).not.toHaveBeenCalled();
241 expect(harness.paint).not.toHaveBeenCalled();
242 expect(harness.requestFrame).not.toHaveBeenCalled();
243
244 surface.setVisible(true);
245 expect(harness.snapshot).toHaveBeenCalledTimes(1);
246 expect(harness.renderedSnapshot).toMatchObject({ cursorX: 10, cursorY: 0 });
247 expect(harness.renderedSnapshot.rowData[0]?.text).toContain("hidden");
248 expect(harness.paint.mock.calls).toContainEqual(["fillRect", [84, 4, 8, 16]]);
249 expect(harness.frames.size).toBe(0);
250 });
251
252 it("keeps the selection on reveal and applies a hidden selection clear", async () => {
253 const harness = createHarness();
254 const surface = await harness.create();
255 surface.write("hello world");
256 harness.flushFrame();
257 harness.pointer("pointerdown", 5, 1);
258 harness.pointer("pointermove", 37, 1);
259 harness.pointer("pointerup", 37, 0);
260 harness.flushFrame();
261 expect(surface.getSelection()).toBe("hello");
262 const position = surface.getSelectionPosition();
263
264 surface.setVisible(false);
265 harness.snapshot.mockClear();
266 surface.setVisible(true);
267 expect(harness.snapshot).toHaveBeenCalledTimes(1);
268 expect(surface.getSelectionPosition()).toEqual(position);
269 expect(
270 harness.renderedSnapshot.rowData[0]?.cells.slice(0, 5).every((cell) => cell.selected),
271 ).toBe(true);
272
273 surface.setVisible(false);
274 harness.snapshot.mockClear();
275 surface.clearSelection();
276 surface.write("!");
277 expect(harness.snapshot).not.toHaveBeenCalled();
278 surface.setVisible(true);
279 expect(harness.snapshot).toHaveBeenCalledTimes(1);
280 expect(surface.getSelection()).toBe("");
281 expect(surface.getSelectionPosition()).toBeNull();
282 expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false);
283 });
284
285 it("stops zero-size mounts and repaints when the same size returns", async () => {
286 const harness = createHarness();
287 const surface = await harness.create();
288 surface.focus();
289 surface.write("visible");
290 harness.flushFrame();
291 harness.snapshot.mockClear();
292 harness.paint.mockClear();
293 harness.mount.clientWidth = 0;
294 surface.write("!");
295 harness.flushFrame();
296 harness.requestFrame.mockClear();
297 for (let index = 0; index < 8; index += 1) surface.write("x");
298 vi.advanceTimersByTime(2_000);
299
300 expect(harness.snapshot).not.toHaveBeenCalled();
301 expect(harness.paint).not.toHaveBeenCalled();
302 expect(harness.requestFrame).not.toHaveBeenCalled();
303 harness.mount.clientWidth = 168;
304 harness.resize();
305 expect(harness.snapshot).toHaveBeenCalledTimes(1);
306 expect(harness.renderedSnapshot.rowData[0]?.text).toContain("visible!xxxxxxxx");
307 expect(harness.paint).toHaveBeenCalled();
308 });
309
310 it.each([false, true])(
311 "uses visibility %s if it changes while WASM initializes",
312 async (visible) => {
313 const harness = createHarness();
314 let markCreated!: () => void;
315 const created = new Promise<void>((resolve) => {
316 markCreated = resolve;
317 });
318 let finishLoading!: () => void;
319 const release = new Promise<void>((resolve) => {
320 finishLoading = resolve;
321 });
322 const create = GhosttyTerminalCore.create;
323 vi.spyOn(GhosttyTerminalCore, "create").mockImplementation(async (...args) => {
324 const core = await create(...args);
325 markCreated();
326 await release;
327 return core;
328 });
329 let currentVisible = !visible;
330 const pending = harness.create({
331 get visible() {
332 return currentVisible;
333 },
334 });
335 await created;
336 currentVisible = visible;
337 harness.paint.mockClear();
338 finishLoading();
339 const surface = await pending;
340
341 expect(harness.snapshot).toHaveBeenCalledTimes(visible ? 1 : 0);
342 if (!visible) expect(harness.paint).not.toHaveBeenCalled();
343 surface.write("ready\x1b[5n");
344 expect(harness.onData).toHaveBeenCalledWith("\x1b[0n");
345 if (!visible) {
346 expect(harness.frames.size).toBe(0);
347 surface.setVisible(true);
348 } else {
349 harness.flushFrame();
350 }
351 expect(harness.renderedSnapshot.rowData[0]?.text).toContain("ready");
352 },
353 );
354});
355
36356const cell = (text: string): GhosttyCell => ({
37357 text,
38358 wide: 0,
apps/web/src/terminal/ghostty/surface.ts+54-9
......@@ -532,6 +532,8 @@ export interface GhosttySelectionPosition {
532532export interface GhosttyTerminalSurfaceOptions {
533533 readonly theme: GhosttyTheme;
534534 readonly font?: GhosttyTerminalFont;
535 /** Read after font and WASM loading. Hosts can supply a getter for the latest value. */
536 readonly visible?: boolean;
535537 readonly onData: (data: string) => void;
536538 readonly onResize: (cols: number, rows: number) => void;
537539 readonly onSelectionChange: () => void;
......@@ -556,6 +558,8 @@ export class GhosttyTerminalSurface {
556558 private readonly context: CanvasRenderingContext2D;
557559 private readonly core: GhosttyTerminalCore;
558560 private readonly options: GhosttyTerminalSurfaceOptions;
561 private visible: boolean;
562 private hasSize = false;
559563 private metrics: GhosttyCellMetrics;
560564 private fontFamily: string;
561565 private requestedFontFamily: string | undefined;
......@@ -643,6 +647,7 @@ export class GhosttyTerminalSurface {
643647 this.mouseAnyEventTracking = core.isMouseAnyEventTracking();
644648 this.metrics = metrics;
645649 this.options = options;
650 this.visible = options.visible ?? true;
646651 this.theme = options.theme;
647652 this.fontFamily = fontFamily;
648653 this.requestedFontFamily = options.font?.family;
......@@ -725,10 +730,24 @@ export class GhosttyTerminalSurface {
725730 options,
726731 );
727732 surface.fit();
728 surface.requestRender();
729733 return surface;
730734 }
731735
736 /** Pause canvas work without interrupting output parsing or terminal replies. */
737 setVisible(visible: boolean): void {
738 if (this.disposed || this.visible === visible) return;
739 this.visible = visible;
740 this.cursorOn = true;
741 this.forceFullRender = true;
742 this.scrollbarDirty = true;
743 if (!visible) {
744 this.cancelRender();
745 this.setSelectionAutoscroll(0);
746 return;
747 }
748 this.fit();
749 }
750
732751 write(data: string): void {
733752 if (this.disposed) return;
734753 this.core.write(data);
......@@ -824,10 +843,16 @@ export class GhosttyTerminalSurface {
824843 };
825844
826845 fit(): boolean {
827 if (this.disposed) return false;
846 if (this.disposed || !this.visible) return false;
828847 const width = this.mount.clientWidth;
829848 const height = this.mount.clientHeight;
830 if (width <= 0 || height <= 0) return false;
849 if (width <= 0 || height <= 0) {
850 this.hasSize = false;
851 this.forceFullRender = true;
852 this.cancelRender();
853 return false;
854 }
855 this.hasSize = true;
831856 const ratio = window.devicePixelRatio || 1;
832857 const pixelWidth = Math.max(1, Math.round(width * ratio));
833858 const pixelHeight = Math.max(1, Math.round(height * ratio));
......@@ -864,7 +889,7 @@ export class GhosttyTerminalSurface {
864889 // Rendering synchronously keeps the repaint inside the same frame as the
865890 // layout change: ResizeObserver fires before paint, so the browser never
866891 // composites the old backing store stretched into the new element box.
867 if (shouldRender) this.renderFrame();
892 if (shouldRender || this.forceFullRender) this.renderFrame();
868893 return true;
869894 }
870895
......@@ -883,6 +908,7 @@ export class GhosttyTerminalSurface {
883908 }
884909
885910 focus(): void {
911 if (this.disposed || !this.visible) return;
886912 this.input.focus({ preventScroll: true });
887913 }
888914
......@@ -982,8 +1008,7 @@ export class GhosttyTerminalSurface {
9821008 // the surface unmounts inside the debounce window.
9831009 this.options.onResize(this.cols, this.rows);
9841010 }
985 if (this.frame !== 0) window.cancelAnimationFrame(this.frame);
986 if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer);
1011 this.cancelRender();
9871012 if (this.compositionSuppressionTimer !== null) {
9881013 window.clearTimeout(this.compositionSuppressionTimer);
9891014 }
......@@ -1676,19 +1701,39 @@ export class GhosttyTerminalSurface {
16761701 }
16771702
16781703 private requestRender(): void {
1679 if (this.disposed || this.frame !== 0) return;
1704 if (this.disposed || !this.visible || !this.hasSize || this.frame !== 0) return;
16801705 this.frame = window.requestAnimationFrame(() => {
16811706 this.frame = 0;
16821707 this.renderFrame();
16831708 });
16841709 }
16851710
1711 private cancelRender(): void {
1712 if (this.frame !== 0) {
1713 window.cancelAnimationFrame(this.frame);
1714 this.frame = 0;
1715 }
1716 if (this.cursorTimer !== null) {
1717 window.clearTimeout(this.cursorTimer);
1718 this.cursorTimer = null;
1719 }
1720 }
1721
16861722 private renderFrame(): void {
1687 if (this.disposed) return;
1723 if (this.disposed || !this.visible) return;
16881724 if (this.frame !== 0) {
16891725 window.cancelAnimationFrame(this.frame);
16901726 this.frame = 0;
16911727 }
1728 // Hidden thread drawers stay mounted so switching back is instant, but a
1729 // display:none canvas has nothing to show. Ghostty keeps parsing; the
1730 // ResizeObserver refits and repaints in full once the mount has a size.
1731 if (this.mount.clientWidth === 0 || this.mount.clientHeight === 0) {
1732 this.hasSize = false;
1733 this.forceFullRender = true;
1734 this.cancelRender();
1735 return;
1736 }
16921737 this.snapshot = this.core.snapshot();
16931738 // A cursor that is not blinking right now must be drawn, never caught in an
16941739 // off phase left behind by a blink that has since been turned off.
......@@ -1754,7 +1799,7 @@ export class GhosttyTerminalSurface {
17541799
17551800 private blinkEnabled(): boolean {
17561801 const snapshot = this.snapshot;
1757 if (!snapshot) return false;
1802 if (!snapshot || !this.visible || !this.hasSize) return false;
17581803 return shouldBlinkTerminalCursor({
17591804 focused: this.focused,
17601805 cursorBlinking: snapshot.cursorBlinking,