fix(browser): keep browser tabs rendering across worktree switches (STA-3228)

Switching away from a worktree that had a targeted background mount
unmounted BrowserPaneOverlayLayer, pulling the persistent <webview>
slots out of the DOM and killing their guests; the stale viewport cache
then kept rendering into the removed subtree, so the tab stayed blank
forever and reload threw. Keep the overlay mounted for hidden worktrees
(slots park their panes, so this stays cheap) and rebuild cached
viewports whose slot root remounted.
This commit is contained in:
Brennan Benson
2026-08-02 13:28:13 -07:00
parent 99b94a38eb
commit 0909e66cd8
3 changed files with 47 additions and 6 deletions
+3 -4
View File
@@ -2674,11 +2674,10 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
backgroundMountTabIds={backgroundMountTabIds}
activationDeferredMountTabIds={activationDeferredMountTabIds}
/>
{/* Why: always mounted — its slots host persistent <webview> 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. */}
<BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
{isVisible || backgroundMountTabIds === null ? (
<>
<BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
<EmulatorPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
</>
<EmulatorPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
) : null}
<AiVaultSessionDropLayer worktreeId={worktreeId} enabled={isVisible} />
</div>
@@ -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', () => {
@@ -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
}