tenseleyflow/claudex / 534d88d

Browse files

perf: restore webgl renderer + raf-debounce resize cascade

Authored by espadonne
SHA
534d88dfabf28a119054154020773abbf6e29318
Parents
73c634b
Tree
dfdc2a0

1 changed file

StatusFile+-
M src/components/TerminalPane.tsx 45 24
src/components/TerminalPane.tsxmodified
@@ -22,7 +22,6 @@ import { useEffect, useRef } from "react";
2222
 
2323
 import {
2424
   getPtyBuffer,
25
-  logFrontend,
2625
   onPtyData,
2726
   resizePty,
2827
   spawnPty,
@@ -34,21 +33,18 @@ import { useSessionStore } from "@/lib/store/sessions";
3433
 import { FitAddon } from "@xterm/addon-fit";
3534
 import { Unicode11Addon } from "@xterm/addon-unicode11";
3635
 import { WebLinksAddon } from "@xterm/addon-web-links";
36
+import { WebglAddon } from "@xterm/addon-webgl";
3737
 import { Terminal } from "@xterm/xterm";
3838
 
39
-// NOTE: WebglAddon is intentionally NOT loaded. The macOS webview
40
-// has a very low limit on live WebGL contexts (~8) and rapid
41
-// session switching blows past that limit every few clicks,
42
-// stalling the main thread for seconds while the GPU driver
43
-// tears contexts down. The default DOM renderer is fast enough
44
-// for our single-terminal use case and has zero init cost per
45
-// mount. Revisit if we ever want to render multi-pane tmux.
46
-
39
+// Dev-only trace. Writes to the devtools console; does NOT forward
40
+// to Rust tracing because each logFrontend call is an IPC round
41
+// trip on the UI thread, and per-mount chatter is exactly the
42
+// kind of noise that piles up on rapid interactions.
4743
 const trace = (msg: string, extra?: Record<string, unknown>) => {
48
-  const payload = extra ? `${msg} ${JSON.stringify(extra)}` : msg;
49
-  // eslint-disable-next-line no-console
50
-  console.debug("[TerminalPane]", payload);
51
-  void logFrontend("debug", "TerminalPane", payload);
44
+  if (import.meta.env.DEV) {
45
+    // eslint-disable-next-line no-console
46
+    console.debug("[TerminalPane]", msg, extra ?? "");
47
+  }
5248
 };
5349
 
5450
 /** Module-level dedup lock for concurrent spawn attempts on the
@@ -217,14 +213,30 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
217213
     try {
218214
       term.unicode.activeVersion = "11";
219215
     } catch {
220
-      // Older xterm builds may not have unicode registered — ignore.
216
+      /* unicode not registered — ignore */
217
+    }
218
+
219
+    // WebGL renderer. Must be loaded AFTER open(). The DOM renderer
220
+    // fallback creates/updates a span per terminal cell on every
221
+    // redraw, which chokes the main thread during claude's TUI
222
+    // spinner animations and token streaming — that's what was
223
+    // producing the mouse-move beachballs. Earlier we removed this
224
+    // because React StrictMode's double-invoke created two WebGL
225
+    // contexts per mount and blew past the webview's context
226
+    // cap; with StrictMode off, exactly one context per mount,
227
+    // disposed cleanly on unmount.
228
+    try {
229
+      term.loadAddon(new WebglAddon());
230
+    } catch (err) {
231
+      trace("webgl addon failed, falling back to DOM renderer", {
232
+        error: String(err),
233
+      });
221234
     }
222235
 
223236
     try {
224237
       fit.fit();
225238
     } catch {
226
-      // No layout yet — ResizeObserver below will retry on first
227
-      // real box update.
239
+      /* no layout yet — ResizeObserver below will retry */
228240
     }
229241
 
230242
     let ptyId: string | null = null;
@@ -304,21 +316,30 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
304316
 
305317
     void attach();
306318
 
307
-    // Keep the terminal in sync with its container's box. ResizeObserver
308
-    // fires whenever the parent pane grows or shrinks (window resize,
309
-    // splitter drag, etc.).
319
+    // rAF-debounce the resize cascade. ResizeObserver can fire
320
+    // many times per frame during a panel drag; xterm's fit.fit()
321
+    // is a moderately expensive layout pass and each successful
322
+    // fit emits an `onResize` event which triggers a `resizePty`
323
+    // IPC call → Rust ioctl. Coalescing to at most once per frame
324
+    // is free correctness AND a big main-thread win under drag.
325
+    let rafHandle: number | null = null;
310326
     const ro = new ResizeObserver(() => {
311
-      try {
312
-        fit.fit();
313
-      } catch {
314
-        // Container has no layout — harmless to ignore.
315
-      }
327
+      if (rafHandle !== null) return;
328
+      rafHandle = requestAnimationFrame(() => {
329
+        rafHandle = null;
330
+        try {
331
+          fit.fit();
332
+        } catch {
333
+          /* container has no layout — retry on next resize */
334
+        }
335
+      });
316336
     });
317337
     ro.observe(container);
318338
 
319339
     return () => {
320340
       cancelled = true;
321341
       ro.disconnect();
342
+      if (rafHandle !== null) cancelAnimationFrame(rafHandle);
322343
       if (unlistenData) unlistenData();
323344
       for (const d of disposeListeners) d();
324345
       disposeListeners = [];