Defer inactive browser tabs while retaining their viewport slots

Restore worktrees and tabs on demand instead of mounting the full tree.
Only render active pages and those required by automation, mobile drivers,
or remote viewers. Inactive panes stay deferred with persistent viewport
slots so their webview guests survive chrome unmounts, reducing memory
overhead when opening workspaces with many tabs.
This commit is contained in:
Jinjing
2026-09-07 11:12:19 -07:00
parent 83b1558ecc
commit bd0cb515b9
4 changed files with 93 additions and 19 deletions
@@ -177,13 +177,13 @@ describe('BrowserPaneOverlayLayer', () => {
expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(0)
})
it('keeps inactive browser panes mounted for a visible worktree', () => {
it('defers inactive browser panes while retaining their viewport slots', () => {
const markup = renderOverlay({ isWorktreeActive: true })
expect(markup).toContain('data-browser-pane-id="browser-a"')
expect(markup).toContain('data-browser-pane-active="true"')
expect(markup).toContain('data-browser-pane-id="browser-b"')
expect(markup).toContain('data-browser-pane-active="false"')
expect(markup).not.toContain('data-browser-pane-id="browser-b"')
expect(markup).toContain('data-browser-overlay-tab-id="browser-b"')
})
it('marks the active browser pane focused when its own group holds focus', () => {
@@ -195,6 +195,35 @@ describe('BrowserPaneOverlayLayer', () => {
)
})
it('restores 200 tabs on demand and preserves viewport roots across parking and selection', () => {
const browsers = Array.from({ length: 200 }, (_, index) =>
createBrowserTab(`browser-${index}`, [`page-${index}`])
)
const tabs = browsers.map((browser, index) =>
createUnifiedBrowserTab(`tab-${index}`, browser.id, index)
)
mocks.state!.browserTabsByWorktree['wt-1'] = browsers
mocks.state!.unifiedTabsByWorktree['wt-1'] = tabs
const group = mocks.state!.groupsByWorktree['wt-1'][0]
mocks.state!.groupsByWorktree['wt-1'] = [
{ ...group, activeTabId: tabs[0].id, tabOrder: tabs.map((tab) => tab.id) }
]
const view = render(<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive />)
const slot = view.container.querySelector('[data-browser-overlay-tab-id="browser-0"]')!
const viewport = slot.firstElementChild
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(1)
expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(200)
view.rerender(<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive={false} />)
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(0)
mocks.state!.groupsByWorktree['wt-1'] = [{ ...group, activeTabId: tabs[199].id }]
view.rerender(<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive />)
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(1)
expect(view.container.querySelector('[data-browser-pane-id="browser-199"]')).not.toBeNull()
expect(slot.firstElementChild).toBe(viewport)
expect(viewport!.isConnected).toBe(true)
})
it('keeps an active browser pane unfocused when another split holds focus (#11348)', () => {
mocks.state = createState()
mocks.state.groupsByWorktree = {
@@ -34,7 +34,6 @@ type BrowserOverlaySlotProps = {
chromeShortcutScope: BrowserChromeShortcutScope
// Why: overlay is a sibling of the group layout, so pane focus doesn't bubble to TabGroupPanel; re-sync it here or split-view clicks leave activeGroupIdByWorktree stale.
onFocusOwningGroup: ((groupId: string) => void) | undefined
isWorktreeActive: boolean
}
// Why: memoize each slot so unrelated worktree mutations don't cascade a re-render into every BrowserPane subtree.
@@ -43,8 +42,7 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
groupId,
isActive,
chromeShortcutScope,
onFocusOwningGroup,
isWorktreeActive
onFocusOwningGroup
}: BrowserOverlaySlotProps): React.JSX.Element {
// Why: persistent page viewports (webview guests) live under this root so they survive BrowserPane chrome unmounts without reparenting.
const setSlotViewportRef = useCallback(
@@ -60,8 +58,8 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
: [browserTab.activePageId ?? browserTab.id]
const needsGuestPaint = useBrowserGuestPaintRetention(browserPageIds)
const isPaintable = isActive || needsGuestPaint
// Why: hidden worktrees keep lightweight overlay slots, but park their webviews unless a remote controller or viewer needs the guest.
const shouldMountPane = isWorktreeActive || needsGuestPaint
// Why: restoring a workspace must not create guests for every inactive tab.
const shouldMountPane = isActive || needsGuestPaint
// Why: CSS anchor positioning pins the overlay to its owning group's body — a tab move only swaps positionAnchor, no measurement/state.
// Orphan branch (no anchorName) stays display:none until the tab is reassigned or destroyed.
const style: React.CSSProperties = useMemo(
@@ -104,7 +102,7 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
onFocusCapture={handleFocus}
>
<div ref={setSlotViewportRef} className="absolute inset-0 flex min-h-0 flex-col" />
{/* Why: hidden worktrees park the heavy pane subtree; visible ones keep stable slots so reparenting can't destroy the webview guest. */}
{/* Persistent viewport slots keep live guests intact when inactive chrome unmounts. */}
{shouldMountPane ? (
<BrowserPane
browserTab={browserTab}
@@ -192,7 +190,6 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({
isActive={isActive}
chromeShortcutScope={chromeShortcutScope}
onFocusOwningGroup={focusOwningGroup}
isWorktreeActive={isWorktreeActive}
/>
)
})}
@@ -149,14 +149,49 @@ describe('browser workspace pane retention props', () => {
hydrateBrowserRemoteViewerPages([])
})
it('loads only the selected page out of 200 after mounting and remounting', () => {
const pages = Array.from({ length: 200 }, (_, index) => createPage(`page-${index}`))
mocks.state!.browserPagesByWorkspace[WORKSPACE_ID] = pages
const workspace = { ...createWorkspace(), activePageId: pages[0].id }
const view = render(<BrowserPane browserTab={workspace} isActive />)
const renderedIds = (): (string | null)[] =>
[...view.container.querySelectorAll('[data-browser-page-id]')].map((node) =>
node.getAttribute('data-browser-page-id')
)
expect(renderedIds()).toEqual(['page-0'])
view.rerender(<BrowserPane browserTab={{ ...workspace, activePageId: 'page-199' }} isActive />)
expect(renderedIds()).toEqual(['page-199'])
view.rerender(<BrowserPane browserTab={workspace} isActive={false} />)
expect(renderedIds()).toEqual([])
view.rerender(<BrowserPane key="restored" browserTab={workspace} isActive />)
expect(renderedIds()).toEqual(['page-0'])
})
it.each(['automation', 'mobile', 'viewer'])('loads an inactive page for %s only', (consumer) => {
const token = consumer === 'automation' ? acquireBrowserAutomationVisibility('page-b') : null
if (consumer === 'mobile') {
hydrateBrowserDrivers([
{ browserPageId: 'page-b', driver: { kind: 'mobile', clientId: 'phone-1' } }
])
}
if (consumer === 'viewer') {
hydrateBrowserRemoteViewerPages(['page-b'])
}
try {
const view = render(<BrowserPane browserTab={createWorkspace()} isActive={false} />)
expect(view.container.querySelector('[data-browser-page-id="page-a"]')).toBeNull()
expect(view.container.querySelector('[data-browser-page-id="page-b"]')).not.toBeNull()
} finally {
if (token) {
releaseBrowserAutomationVisibility(token)
}
}
})
it('threads all three retention terms to the page that owns them', () => {
renderWorkspacePane()
expect(propsFor('page-b')).toEqual({
id: 'page-b',
isAutomationVisible: false,
isMobileDriven: false,
isRemotelyViewed: false
})
expect(mocks.pageProps.some((props) => props.id === 'page-b')).toBe(false)
cleanup()
const token = acquireBrowserAutomationVisibility('page-b')
@@ -55,13 +55,26 @@ export default function BrowserPane({
const automationVisiblePageIds = useBrowserAutomationVisiblePageIds(browserPageIds)
const mobileDrivenPageIds = useBrowserMobileDrivenPageIds(browserPageIds)
const remotelyViewedPageIds = useBrowserRemotelyViewedPageIds(browserPageIds)
// Why: inactive webviews must stay mounted in their original DOM parent; unmounting/reparenting loses form text and SPA state.
// Why: dormant pages load on demand; live guests survive chrome unmounts in persistent viewports.
const renderedBrowserPages = useMemo(
() =>
browserPages.filter(
(page) => !getBrowserPageRuntimeEnvironmentId(page, activeRuntimeEnvironmentId)
(page) =>
!getBrowserPageRuntimeEnvironmentId(page, activeRuntimeEnvironmentId) &&
((isActive && page.id === activeBrowserPageId) ||
automationVisiblePageIds.has(page.id) ||
mobileDrivenPageIds.has(page.id) ||
remotelyViewedPageIds.has(page.id))
),
[browserPages, activeRuntimeEnvironmentId]
[
browserPages,
activeRuntimeEnvironmentId,
isActive,
activeBrowserPageId,
automationVisiblePageIds,
mobileDrivenPageIds,
remotelyViewedPageIds
]
)
const renderedBrowserPageIds = useMemo(
() => renderedBrowserPages.map((page) => page.id),