diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx index 791b5f75beb..05c6f1302c3 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx @@ -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() + 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() + expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(0) + mocks.state!.groupsByWorktree['wt-1'] = [{ ...group, activeTabId: tabs[199].id }] + view.rerender() + 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 = { diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx index d262e05b43a..1f74340f11a 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx @@ -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} >
- {/* 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 ? ( ) })} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx index 527a05f1460..70409be3d5d 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx @@ -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() + 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() + expect(renderedIds()).toEqual(['page-199']) + view.rerender() + expect(renderedIds()).toEqual([]) + view.rerender() + 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() + 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') diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx index e5c2cc28240..05ea7e49cac 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx @@ -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),