From 803f060e08d0e1b3a3ec76b90f1a0ef34bcc1586 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 16 May 2026 12:51:30 -0700 Subject: [PATCH] fix sidebar virtual row stale measurement (#2101) --- .../src/components/sidebar/WorktreeList.tsx | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 73bcb8ee5..92bb69e82 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -392,8 +392,42 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const prCacheLen = useAppStore((s) => Object.keys(s.prCache).length) const issueCacheLen = useAppStore((s) => Object.keys(s.issueCache).length) + const renderRowKeySignature = useMemo( + () => renderRows.map(getRenderRowKey).join('\n'), + [renderRows] + ) + const renderRowsRef = useRef(renderRows) + renderRowsRef.current = renderRows const totalSize = virtualizer.getTotalSize() const virtualItems = virtualizer.getVirtualItems() + + const measureMountedRows = useCallback(() => { + const currentRows = renderRowsRef.current + virtualizer.elementsCache.forEach((element) => { + const idx = parseInt(element.getAttribute('data-index') ?? '', 10) + const row = Number.isNaN(idx) ? undefined : currentRows[idx] + const expectedKey = row ? getRenderRowKey(row) : null + if ( + !element.isConnected || + expectedKey === null || + element.getAttribute('data-worktree-virtual-row-key') !== expectedKey + ) { + return + } + virtualizer.measureElement(element) + }) + }, [virtualizer]) + + useLayoutEffect(() => { + // 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 + // the row currently rendered at that index. + measureMountedRows() + const frameId = window.requestAnimationFrame(measureMountedRows) + return () => window.cancelAnimationFrame(frameId) + }, [prCacheLen, issueCacheLen, measureMountedRows, renderRowKeySignature]) + useVirtualizedScrollAnchor({ anchorRef: scrollAnchorRef, getRowKey: getRenderRowKey, @@ -404,16 +438,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp virtualizer }) - useLayoutEffect(() => { - virtualizer.elementsCache.forEach((element) => { - const idx = parseInt(element.getAttribute('data-index') ?? '', 10) - if (Number.isNaN(idx) || idx >= renderRows.length) { - return - } - virtualizer.measureElement(element) - }) - }, [prCacheLen, issueCacheLen, virtualizer, renderRows.length]) - const navigateWorktree = useCallback( (direction: 'up' | 'down') => { // Why: derive the cycling order from an all-expanded layout, not the