From c209001df8a09f906aee766544daf61f3bba9057 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 8 Jan 2026 13:11:07 -0800 Subject: [PATCH] fix(desktop): optimize terminal rendering to prevent re-render loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap Terminal and TabPane components in React.memo to prevent cascading re-renders - Use granular Zustand selectors in Terminal to avoid subscribing to the full pane object - Break circular dependency where Terminal updates cwd → store notifies → Terminal re-renders - Use stable callback pattern for tRPC subscription to prevent re-initialization This fixes dropped frames and slowness when typing in the terminal UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../TabsContent/TabView/TabPane.tsx | 6 +-- .../ContentView/TabsContent/TabView/index.tsx | 25 ++++------ .../TabsContent/Terminal/Terminal.tsx | 48 ++++++++++++------- 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx index 8e82a2d5289..acbfe88689d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { memo, useEffect, useRef } from "react"; import type { MosaicBranch } from "react-mosaic-component"; import { registerPaneRef, @@ -41,7 +41,7 @@ interface TabPaneProps { onMoveToNewTab: () => void; } -export function TabPane({ +export const TabPane = memo(function TabPane({ paneId, path, isActive, @@ -128,4 +128,4 @@ export function TabPane({ ); -} +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index 19e8d8e1bf4..50b929fd648 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -34,7 +34,6 @@ export function TabView({ tab }: TabViewProps) { const movePaneToTab = useTabsStore((s) => s.movePaneToTab); const movePaneToNewTab = useTabsStore((s) => s.movePaneToNewTab); const allTabs = useTabsStore((s) => s.tabs); - const allPanes = useTabsStore((s) => s.panes); // Get worktree path for file viewer panes const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); @@ -45,24 +44,16 @@ export function TabView({ tab }: TabViewProps) { (t) => t.workspaceId === tab.workspaceId, ); - // Extract pane IDs from layout - const layoutPaneIds = useMemo( - () => extractPaneIdsFromLayout(tab.layout), - [tab.layout], + // Get all panes belonging to this tab + const allPanes = useTabsStore((s) => s.panes); + const tabPanes = useMemo( + () => + Object.fromEntries( + Object.entries(allPanes).filter(([_, pane]) => pane.tabId === tab.id), + ), + [allPanes, tab.id], ); - // Memoize the filtered panes to avoid creating new objects on every render - const tabPanes = useMemo(() => { - const result: Record = {}; - for (const paneId of layoutPaneIds) { - const pane = allPanes[paneId]; - if (pane?.tabId === tab.id) { - result[paneId] = { tabId: pane.tabId, type: pane.type }; - } - } - return result; - }, [layoutPaneIds, allPanes, tab.id]); - const validPaneIds = new Set(Object.keys(tabPanes)); const cleanedLayout = cleanLayout(tab.layout, validPaneIds); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 08525586f98..b5aedcfde3c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -5,7 +5,7 @@ import type { SerializeAddon } from "@xterm/addon-serialize"; import type { Terminal as XTerm } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import debounce from "lodash/debounce"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import { trpc } from "renderer/lib/trpc"; import { trpcClient } from "renderer/lib/trpc-client"; import { useAppHotkey } from "renderer/stores/hotkeys"; @@ -27,14 +27,19 @@ import { TerminalSearch } from "./TerminalSearch"; import type { TerminalProps, TerminalStreamEvent } from "./types"; import { shellEscapePaths } from "./utils"; -export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { +export const Terminal = memo(function Terminal({ + tabId, + workspaceId, +}: TerminalProps) { const paneId = tabId; - // Use granular selectors to avoid re-renders when other panes change - const pane = useTabsStore((s) => s.panes[paneId]); - const paneInitialCommands = pane?.initialCommands; - const paneInitialCwd = pane?.initialCwd; + // Use granular selectors to avoid re-renders when pane metadata (cwd, status) changes + // Only subscribe to the initial data we need (which doesn't change after creation) + const paneInitialCommands = useTabsStore( + (s) => s.panes[paneId]?.initialCommands, + ); + const paneInitialCwd = useTabsStore((s) => s.panes[paneId]?.initialCwd); + const parentTabId = useTabsStore((s) => s.panes[paneId]?.tabId); const clearPaneInitialData = useTabsStore((s) => s.clearPaneInitialData); - const parentTabId = pane?.tabId; const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); @@ -50,7 +55,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const updatePaneCwd = useTabsStore((s) => s.updatePaneCwd); // Use granular selector - only subscribe to this tab's focused pane const focusedPaneId = useTabsStore( - (s) => s.focusedPaneIds[pane?.tabId ?? ""], + (s) => s.focusedPaneIds[parentTabId ?? ""], ); const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); const setPaneStatus = useTabsStore((s) => s.setPaneStatus); @@ -72,12 +77,13 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { paneInitialCwdRef.current = paneInitialCwd; clearPaneInitialDataRef.current = clearPaneInitialData; - const { data: workspaceCwd } = - trpc.terminal.getWorkspaceCwd.useQuery(workspaceId); + const workspaceCwdQuery = trpc.terminal.getWorkspaceCwd.useQuery(workspaceId); + const workspaceCwd = workspaceCwdQuery.data; // Query terminal link behavior setting - const { data: terminalLinkBehavior } = + const terminalLinkBehaviorQuery = trpc.settings.getTerminalLinkBehavior.useQuery(); + const terminalLinkBehavior = terminalLinkBehaviorQuery.data; // Handler for file link clicks - uses current setting value const handleFileLinkClick = useCallback( @@ -237,7 +243,10 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }, 100), ); - const handleStreamData = (event: TerminalStreamEvent) => { + const handleStreamDataRef = useRef<(event: TerminalStreamEvent) => void>( + () => {}, + ); + handleStreamDataRef.current = (event: TerminalStreamEvent) => { if (!xtermRef.current) { return; } @@ -266,16 +275,21 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { } }; - trpc.terminal.stream.useSubscription(paneId, { - onData: handleStreamData, + // Stable callback that delegates to ref (prevents subscription re-init on every render) + const stableOnData = useCallback((event: TerminalStreamEvent) => { + handleStreamDataRef.current(event); + }, []); + + const _subscription = trpc.terminal.stream.useSubscription(paneId, { + onData: stableOnData, enabled: true, }); // Use ref to avoid triggering full terminal recreation when focus handler changes const handleTerminalFocusRef = useRef(() => {}); handleTerminalFocusRef.current = () => { - if (pane?.tabId) { - setFocusedPane(pane.tabId, paneId); + if (parentTabId) { + setFocusedPane(parentTabId, paneId); } }; @@ -567,4 +581,4 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
); -}; +});