diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 4189b0e44a1..010eb226a63 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -2674,11 +2674,10 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ backgroundMountTabIds={backgroundMountTabIds} activationDeferredMountTabIds={activationDeferredMountTabIds} /> + {/* Why: always mounted — its slots host persistent guests that die if the slot leaves the DOM (STA-3228); hidden worktrees park the pane subtree, so targeted background mounts only pay for empty slot divs. */} + {isVisible || backgroundMountTabIds === null ? ( - <> - - - + ) : null} diff --git a/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts b/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts index 9172ddae7f2..c4df770d2eb 100644 --- a/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts +++ b/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts @@ -46,6 +46,41 @@ describe('ensureBrowserPageViewport', () => { it('returns null until the slot viewport root is registered', () => { expect(ensureBrowserPageViewport('page-1', 'workspace-missing')).toBeNull() }) + + it('reuses the cached viewport while the slot root is unchanged', () => { + mountSlotViewport('workspace-1') + const first = ensureBrowserPageViewport('page-1', 'workspace-1') + const second = ensureBrowserPageViewport('page-1', 'workspace-1') + + expect(second).toBe(first) + }) + + it('keeps the parked viewport while the slot root is unregistered', () => { + const root = mountSlotViewport('workspace-1') + const parked = ensureBrowserPageViewport('page-1', 'workspace-1') + root.remove() + registerBrowserOverlaySlotViewport('workspace-1', null) + + expect(ensureBrowserPageViewport('page-1', 'workspace-1')).toBe(parked) + }) + + it('rebuilds under a replacement slot root instead of returning the stranded shell (STA-3228)', () => { + const oldRoot = mountSlotViewport('workspace-1') + const stale = ensureBrowserPageViewport('page-1', 'workspace-1')! + // Worktree overlay unmounts while hidden: slot root leaves the DOM with the shell inside. + oldRoot.remove() + registerBrowserOverlaySlotViewport('workspace-1', null) + const newRoot = mountSlotViewport('workspace-1') + + const rebuilt = ensureBrowserPageViewport('page-1', 'workspace-1') + + expect(rebuilt).not.toBeNull() + expect(rebuilt).not.toBe(stale) + expect(rebuilt!.shell.parentElement).toBe(newRoot) + expect(rebuilt!.shell.isConnected).toBe(true) + expect(stale.shell.isConnected).toBe(false) + expect(getBrowserPageViewportContainer('page-1')).toBe(rebuilt!.container) + }) }) describe('syncBrowserPageChromeInset', () => { diff --git a/src/renderer/src/components/browser-pane/browser-page-viewport.ts b/src/renderer/src/components/browser-pane/browser-page-viewport.ts index f6cae76105a..d0d1ed7bc8d 100644 --- a/src/renderer/src/components/browser-pane/browser-page-viewport.ts +++ b/src/renderer/src/components/browser-pane/browser-page-viewport.ts @@ -65,11 +65,18 @@ export function ensureBrowserPageViewport( browserPageId: string, workspaceTabId: string ): BrowserPageViewport | null { + const root = slotViewportRoots.get(workspaceTabId) const existing = browserPageViewports.get(browserPageId) if (existing) { - return existing + if (!root || existing.shell.parentElement === root) { + return existing + } + // Why: a remounted slot root (overlay unmount while hidden, cross-worktree tab move) strands + // the cached shell in a removed subtree where its guest is already dead; drop it so the caller + // rebuilds against the live root instead of rendering into detached DOM forever (STA-3228). + existing.shell.remove() + browserPageViewports.delete(browserPageId) } - const root = slotViewportRoots.get(workspaceTabId) if (!root) { return null }