diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index a99268aba81..88e0c278536 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -10,7 +10,6 @@ import { shouldMountBackgroundWorktreeTab } from '../terminal/background-termina import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut' import { TerminalOverlaySlot } from './TerminalOverlaySlot' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' -import { useWorktreeParkTerminalAssignments } from './use-worktree-park-terminal-assignments' type TerminalOverlayAssignment = { unifiedTabId: string @@ -100,12 +99,10 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ } return entries }, [groupActiveTabById, unifiedTabs]) - const parkingAssignments = useWorktreeParkTerminalAssignments(assignments, coldParkTerminalPanes) - const parkedTerminalTabIds = useTerminalTabColdParking({ worktreeId, terminalTabs, - assignments: parkingAssignments, + assignments, isWorktreeActive, coldParkTerminalPanes, isForceParked, diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx index 0902e9ad7e6..d689bef8dc4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx @@ -1,5 +1,6 @@ /** @vitest-environment happy-dom */ import { act, StrictMode } from 'react' +import type * as ReactModule from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types' @@ -8,12 +9,24 @@ import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types' const harness = vi.hoisted(() => ({ worktreeId: 'repo::/cold-park-pre-gate', + discardMemoCaches: false, coverageCalls: 0, syncCalls: 0, slotRenders: 0, renderedParkedSets: [] as string[][] })) +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useMemo: ((calculate, dependencies) => + harness.discardMemoCaches + ? calculate() + : actual.useMemo(calculate, dependencies)) as typeof actual.useMemo + } +}) + vi.mock('../../store', async () => { const { create } = await import('zustand') const useAppStore = create(() => ({ @@ -164,6 +177,7 @@ describe('TerminalPaneOverlayLayer cold-park pre-gate loop', () => { let root: Root | undefined beforeEach(() => { + harness.discardMemoCaches = false harness.coverageCalls = 0 harness.syncCalls = 0 harness.slotRenders = 0 @@ -310,4 +324,17 @@ describe('TerminalPaneOverlayLayer cold-park pre-gate loop', () => { expect(renderProductionLayer(root)).toBeNull() expect(harness.renderedParkedSets.at(-1)).toEqual([...TAB_IDS]) }) + + it('settles equivalent publications after memo cache discard', () => { + harness.discardMemoCaches = true + root = createRoot(container) + + const thrown = renderProductionLayer(root) + + expect(thrown).toBeNull() + expect(harness.syncCalls).toBeLessThan(10) + expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual( + new Set([TAB_IDS.join(',')]) + ) + }) }) diff --git a/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts new file mode 100644 index 00000000000..91987c63be1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef } from 'react' +import type { TerminalTab } from '../../../../shared/types' +import { syncParkedTerminalTabWatchers } from './terminal-parked-tab-watchers' + +type TerminalParkingAssignment = { + groupId: string + isActiveInGroup: boolean +} + +export function getTerminalParkingInputsKey(terminalTabs: readonly TerminalTab[]): string { + return JSON.stringify(terminalTabs.map((tab) => [tab.id, tab.ptyId, tab.pendingActivationSpawn])) +} + +export function getTerminalParkingAssignmentsKey( + assignments: ReadonlyMap +): string { + return JSON.stringify( + Array.from(assignments, ([tabId, assignment]) => [ + tabId, + assignment.groupId, + assignment.isActiveInGroup + ]) + ) +} + +function getWatcherSynchronizationKey(args: { + worktreeId: string + inputsKey: string + assignmentsKey: string + parkedTabIds: ReadonlySet + activationDeferredMountTabIds?: ReadonlySet | null +}): string { + return JSON.stringify([ + args.worktreeId, + args.inputsKey, + args.assignmentsKey, + Array.from(args.parkedTabIds), + Array.from(args.activationDeferredMountTabIds ?? []).sort() + ]) +} + +export function useParkedTerminalWatcherSynchronization(args: { + worktreeId: string + terminalTabs: readonly TerminalTab[] + inputsKey: string + assignmentsKey: string + parkedTabIds: ReadonlySet + activationDeferredMountTabIds?: ReadonlySet | null +}): void { + const { worktreeId, terminalTabs, parkedTabIds, activationDeferredMountTabIds } = args + const synchronizationKey = getWatcherSynchronizationKey(args) + const synchronizationKeyRef = useRef(null) + + useEffect(() => { + if (synchronizationKeyRef.current === synchronizationKey) { + return + } + synchronizationKeyRef.current = synchronizationKey + syncParkedTerminalTabWatchers({ + worktreeId, + tabs: terminalTabs, + parkedTabIds, + // Why: activation-deferred tabs have no prior pane-owned title slot. + restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined + }) + }, [activationDeferredMountTabIds, parkedTabIds, synchronizationKey, terminalTabs, worktreeId]) +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 75177ee15ec..98b2680c5a8 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -33,10 +33,13 @@ import { import { selectSleepingRecordParkExemptTabIds } from './sleeping-record-park-exemption' import { canWatcherCoverParkedTerminalTab, - disposeParkedTerminalWatchersForWorktree, - syncParkedTerminalTabWatchers + disposeParkedTerminalWatchersForWorktree } from './terminal-parked-tab-watchers' -import { useWorktreeParkTerminalTabs } from './use-worktree-park-terminal-tabs' +import { + getTerminalParkingAssignmentsKey, + getTerminalParkingInputsKey, + useParkedTerminalWatcherSynchronization +} from './use-parked-terminal-watcher-synchronization' type TerminalOverlayTabAssignment = { groupId: string @@ -78,7 +81,7 @@ export function useTerminalTabColdParking(args: { }): ReadonlySet { const { worktreeId, - terminalTabs: publishedTerminalTabs, + terminalTabs, assignments, isWorktreeActive, coldParkTerminalPanes, @@ -87,7 +90,14 @@ export function useTerminalTabColdParking(args: { activityTerminalPortals, activationDeferredMountTabIds } = args - const terminalTabs = useWorktreeParkTerminalTabs(publishedTerminalTabs, coldParkTerminalPanes) + const terminalParkingInputsKey = getTerminalParkingInputsKey(terminalTabs) + const terminalParkingAssignmentsKey = getTerminalParkingAssignmentsKey(assignments) + const terminalParkingTabsDependency = coldParkTerminalPanes + ? terminalParkingInputsKey + : terminalTabs + const terminalParkingAssignmentsDependency = coldParkTerminalPanes + ? terminalParkingAssignmentsKey + : assignments const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) const terminalParkingEnabled = useAppStore( (state) => state.settings?.terminalHiddenViewParking !== false @@ -249,17 +259,18 @@ export function useTerminalTabColdParking(args: { timers.set(tabId, timer) } } + // eslint-disable-next-line react-hooks/exhaustive-deps -- semantic keys own the tab and assignment dependencies. }, [ activityTerminalPortals, - assignments, isWorktreeActive, pendingStartupByTabId, - pairedRuntimeParkingEnvironmentIds, + runtimeStatusByEnvironmentId, shouldMeasureHiddenWorktree, terminalParkingEnabled, terminalSshParkingEnabled, terminalTabParkingRevision, - terminalTabs, + terminalParkingAssignmentsDependency, + terminalParkingTabsDependency, worktreeId ]) @@ -359,16 +370,14 @@ export function useTerminalTabColdParking(args: { // panes — watcher disposal therefore lands before any PTY data IPC can // reach a freshly remounted pane, and watcher start lands after the parked // pane's unmount capture. - useEffect(() => { - syncParkedTerminalTabWatchers({ - worktreeId, - tabs: terminalTabs, - parkedTabIds: parkedTerminalTabIds, - // Why: activation-deferred tabs have no prior pane-owned title slot; - // pull main's title-only snapshot when their watcher starts. - restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined - }) - }, [activationDeferredMountTabIds, parkedTerminalTabIds, terminalTabs, worktreeId]) + useParkedTerminalWatcherSynchronization({ + worktreeId, + terminalTabs, + assignmentsKey: terminalParkingAssignmentsKey, + inputsKey: terminalParkingInputsKey, + parkedTabIds: parkedTerminalTabIds, + activationDeferredMountTabIds + }) useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId]) diff --git a/src/renderer/src/components/terminal-pane/use-worktree-park-terminal-assignments.ts b/src/renderer/src/components/terminal-pane/use-worktree-park-terminal-assignments.ts deleted file mode 100644 index becc47e2510..00000000000 --- a/src/renderer/src/components/terminal-pane/use-worktree-park-terminal-assignments.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMemo } from 'react' - -type WorktreeParkTerminalAssignment = { - unifiedTabId: string - groupId: string - isActiveInGroup: boolean -} - -function getTerminalParkingAssignmentsKey( - assignments: ReadonlyMap -): string { - return JSON.stringify( - Array.from(assignments, ([tabId, assignment]) => [ - tabId, - assignment.unifiedTabId, - assignment.groupId, - assignment.isActiveInGroup - ]) - ) -} - -export function useWorktreeParkTerminalAssignments( - assignments: ReadonlyMap, - coldParkTerminalPanes: boolean -): ReadonlyMap { - const parkingAssignmentsKey = coldParkTerminalPanes - ? getTerminalParkingAssignmentsKey(assignments) - : assignments - // eslint-disable-next-line react-hooks/exhaustive-deps -- decorative unified labels must not retrigger dominated pre-gate work. - return useMemo(() => assignments, [parkingAssignmentsKey]) -} diff --git a/src/renderer/src/components/terminal-pane/use-worktree-park-terminal-tabs.ts b/src/renderer/src/components/terminal-pane/use-worktree-park-terminal-tabs.ts deleted file mode 100644 index 4ac6a0569a6..00000000000 --- a/src/renderer/src/components/terminal-pane/use-worktree-park-terminal-tabs.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useMemo } from 'react' -import type { TerminalTab } from '../../../../shared/types' - -function getTerminalParkingInputsKey(terminalTabs: readonly TerminalTab[]): string { - return JSON.stringify(terminalTabs.map((tab) => [tab.id, tab.ptyId, tab.pendingActivationSpawn])) -} - -export function useWorktreeParkTerminalTabs( - terminalTabs: readonly TerminalTab[], - coldParkTerminalPanes: boolean -): readonly TerminalTab[] { - const parkingInputsKey = coldParkTerminalPanes - ? getTerminalParkingInputsKey(terminalTabs) - : terminalTabs - // eslint-disable-next-line react-hooks/exhaustive-deps -- decorative tab publications must not retrigger dominated pre-gate work. - return useMemo(() => terminalTabs, [parkingInputsKey]) -}