From 092fd6cde3c5dcb70a13106ea399b330cbb3fd7c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:48:47 -0700 Subject: [PATCH] Prune stale worktree virtual row cache (#6117) Co-authored-by: Orca --- .../src/components/sidebar/WorktreeList.tsx | 15 +++++- .../worktree-list-virtual-rows.test.ts | 47 +++++++++++++++++++ .../sidebar/worktree-list-virtual-rows.ts | 25 ++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 3764ee8ad..a46ed4c2d 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -87,6 +87,7 @@ import { getActiveStickyIndexesForScroll, getStickyHeaderIndexes, getVirtualRowTransform, + pruneStaleVirtualRowElementCache, shouldUseHeaderTopSpacing, type RenderRow } from './worktree-list-virtual-rows' @@ -1941,6 +1942,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp () => renderRows.map(getRenderRowKey).join('\n'), [renderRows] ) + const activeRenderRowKeys = useMemo(() => new Set(renderRows.map(getRenderRowKey)), [renderRows]) const totalSize = virtualizer.getTotalSize() const virtualItems = virtualizer.getVirtualItems() const activeStickyIndexes = getActiveStickyIndexesForScroll({ @@ -1976,6 +1978,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) useLayoutEffect(() => { + pruneStaleVirtualRowElementCache({ + activeRowKeys: activeRenderRowKeys, + virtualizer + }) // Why: after delete/collapse, TanStack may briefly retain the removed row's // cached element. Measuring that disconnected node reports 0px and corrupts // the next row's slot, so measure only elements whose DOM key still matches @@ -1983,7 +1989,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp measureMountedRows() const frameId = window.requestAnimationFrame(measureMountedRows) return () => window.cancelAnimationFrame(frameId) - }, [prCacheLen, issueCacheLen, measureMountedRows, renderRowKeySignature]) + }, [ + activeRenderRowKeys, + prCacheLen, + issueCacheLen, + measureMountedRows, + renderRowKeySignature, + virtualizer + ]) useVirtualizedScrollAnchor({ anchorRef: scrollAnchorRef, diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts index e0449b6ee..cb742a126 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts @@ -5,6 +5,7 @@ import { extractWorktreeVirtualRowIndexes, getActiveStickyIndexesForScroll, getStickyHeaderIndexes, + pruneStaleVirtualRowElementCache, type RenderRow } from './worktree-list-virtual-rows' @@ -144,3 +145,49 @@ describe('extractWorktreeVirtualRowIndexes', () => { expect(indexes).toContain(0) }) }) + +describe('pruneStaleVirtualRowElementCache', () => { + it('removes stale measured row elements before they retain old WorktreeCard scopes', () => { + const activeElement = { + isConnected: true, + getAttribute: (name: string) => + name === 'data-worktree-virtual-row-key' ? 'wt:active' : null + } as Element + const staleElement = { + isConnected: false, + getAttribute: (name: string) => (name === 'data-worktree-virtual-row-key' ? 'wt:stale' : null) + } as Element + const connectedStaleElement = { + isConnected: true, + getAttribute: (name: string) => + name === 'data-worktree-virtual-row-key' ? 'wt:connected-stale' : null + } as Element + const retainedScope = { + defaultHostId: 'runtime:env-1', + handlerName: 'handleOpenReviewInOrca' + } + Object.assign(staleElement, { __retainedWorktreeCardScopeForTest: retainedScope }) + + const virtualizer = { + elementsCache: new Map([ + ['wt:active', activeElement], + ['wt:stale', staleElement], + ['wt:connected-stale', connectedStaleElement] + ]), + measureElement: (element: Element | null) => { + if (element) { + throw new Error('stale cache pruning should not remeasure rows') + } + } + } + + pruneStaleVirtualRowElementCache({ + activeRowKeys: new Set(['wt:active']), + virtualizer + }) + + expect(virtualizer.elementsCache.get('wt:active')).toBe(activeElement) + expect(virtualizer.elementsCache.has('wt:stale')).toBe(false) + expect(virtualizer.elementsCache.get('wt:connected-stale')).toBe(connectedStaleElement) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index e98b9c1ee..93daaf427 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -76,6 +76,31 @@ export function getVirtualRowTransform(start: number): string { return `translateY(${start}px)` } +type VirtualRowElementCache = { + elementsCache: Map + measureElement: (node: TElement | null) => void +} + +export function pruneStaleVirtualRowElementCache({ + activeRowKeys, + virtualizer +}: { + activeRowKeys: ReadonlySet + virtualizer: VirtualRowElementCache +}): void { + virtualizer.measureElement(null) + for (const [key, element] of virtualizer.elementsCache) { + const rowKey = String(key) + if (activeRowKeys.has(rowKey) || element.isConnected) { + continue + } + // Why: measured row nodes retain their React fiber tree. Once TanStack's + // public null-measure cleanup has run, drop any disconnected stale key left + // behind so old WorktreeCard scopes do not survive runtime-host row churn. + virtualizer.elementsCache.delete(key) + } +} + export function getStickyHeaderIndexes(rows: readonly RenderRow[]): number[] { const indexes: number[] = [] rows.forEach((row, index) => {