fix(terminal): make park synchronization cache independent

This commit is contained in:
Neil
2026-08-09 19:13:10 -07:00
parent 99c2ed006b
commit 49f0a2f5bd
6 changed files with 122 additions and 70 deletions
@@ -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,
@@ -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<typeof ReactModule>('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(',')])
)
})
})
@@ -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, TerminalParkingAssignment>
): 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<string>
activationDeferredMountTabIds?: ReadonlySet<string> | 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<string>
activationDeferredMountTabIds?: ReadonlySet<string> | null
}): void {
const { worktreeId, terminalTabs, parkedTabIds, activationDeferredMountTabIds } = args
const synchronizationKey = getWatcherSynchronizationKey(args)
const synchronizationKeyRef = useRef<string | null>(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])
}
@@ -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<string> {
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])
@@ -1,31 +0,0 @@
import { useMemo } from 'react'
type WorktreeParkTerminalAssignment = {
unifiedTabId: string
groupId: string
isActiveInGroup: boolean
}
function getTerminalParkingAssignmentsKey(
assignments: ReadonlyMap<string, WorktreeParkTerminalAssignment>
): string {
return JSON.stringify(
Array.from(assignments, ([tabId, assignment]) => [
tabId,
assignment.unifiedTabId,
assignment.groupId,
assignment.isActiveInGroup
])
)
}
export function useWorktreeParkTerminalAssignments(
assignments: ReadonlyMap<string, WorktreeParkTerminalAssignment>,
coldParkTerminalPanes: boolean
): ReadonlyMap<string, WorktreeParkTerminalAssignment> {
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])
}
@@ -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])
}