Prune stale worktree virtual row cache (#6117)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-22 20:48:47 -07:00 committed by GitHub
parent bf2a6c1040
commit 092fd6cde3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 86 additions and 1 deletions

View File

@ -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,

View File

@ -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<string, Element>([
['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)
})
})

View File

@ -76,6 +76,31 @@ export function getVirtualRowTransform(start: number): string {
return `translateY(${start}px)`
}
type VirtualRowElementCache<TElement extends Element> = {
elementsCache: Map<unknown, TElement>
measureElement: (node: TElement | null) => void
}
export function pruneStaleVirtualRowElementCache<TElement extends Element>({
activeRowKeys,
virtualizer
}: {
activeRowKeys: ReadonlySet<string>
virtualizer: VirtualRowElementCache<TElement>
}): 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) => {