Fix worktree sidebar virtual sticky ranges (#2780)

This commit is contained in:
Neil 2026-05-30 12:37:34 -07:00 committed by GitHub
parent 99e527352c
commit 0476ad0b59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 59 additions and 28 deletions

View File

@ -1,7 +1,6 @@
/* eslint-disable max-lines */
import React, { useMemo, useCallback, useRef, useState, useEffect, useLayoutEffect } from 'react'
import {
defaultRangeExtractor,
measureElement as measureVirtualElementSize,
useVirtualizer
} from '@tanstack/react-virtual'
@ -77,9 +76,8 @@ import {
} from './worktree-list-groups'
import {
estimateRenderRowSize,
getActiveStickyHeaderIndex,
extractWorktreeVirtualRowIndexes,
getActiveStickyHeaderIndexForScroll,
getPreviousStickyHeaderIndex,
getStickyHeaderIndexes,
getVirtualRowTransform,
shouldUseHeaderTopSpacing,
@ -1013,30 +1011,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
activeStickyHeaderIndexRef.current
),
measureElement: measureCurrentVirtualRowElement,
rangeExtractor: useCallback((range: Range) => {
stickyRangeStartIndexRef.current = range.startIndex
const activeStickyHeaderIndex = getActiveStickyHeaderIndex(
stickyHeaderIndexesRef.current,
range.startIndex
)
if (activeStickyHeaderIndex === null) {
return defaultRangeExtractor(range)
}
// Why: this mirrors TanStack Virtual's sticky example — the active
// section header remains a real virtual row even after it scrolls out.
const previousStickyHeaderIndex = getPreviousStickyHeaderIndex(
stickyHeaderIndexesRef.current,
activeStickyHeaderIndex
)
return Array.from(
new Set([
activeStickyHeaderIndex,
...(previousStickyHeaderIndex === null ? [] : [previousStickyHeaderIndex]),
...defaultRangeExtractor(range)
])
).sort((a, b) => a - b)
}, []),
// Why: TanStack memoizes range extraction by function identity. Header
// indexes must be deps so grouping/filtering cannot leave stale sticky
// slots rendering ordinary worktree rows.
rangeExtractor: useCallback(
(range: Range) => {
stickyRangeStartIndexRef.current = range.startIndex
return extractWorktreeVirtualRowIndexes({ range, stickyHeaderIndexes })
},
[stickyHeaderIndexes]
),
overscan: 10,
gap: 6,
// Why: the active sticky group header is rendered inside the virtual list,

View File

@ -11,6 +11,7 @@ import {
shouldAdjustWorktreeSidebarMeasuredRowScroll
} from './WorktreeList'
import {
extractWorktreeVirtualRowIndexes,
estimateRenderRowSize,
GROUP_HEADER_ROW_HEIGHT,
getActiveStickyHeaderIndexForScroll
@ -213,6 +214,26 @@ describe('getScrollTopToRevealBounds', () => {
})
})
describe('extractWorktreeVirtualRowIndexes', () => {
it('extracts the active and previous sticky headers with the visible range', () => {
expect(
extractWorktreeVirtualRowIndexes({
range: { startIndex: 8, endIndex: 10, overscan: 1, count: 20 },
stickyHeaderIndexes: [0, 5, 9]
})
).toEqual([0, 5, 7, 8, 9, 10, 11])
})
it('falls back to the default range when no sticky header is active', () => {
expect(
extractWorktreeVirtualRowIndexes({
range: { startIndex: 2, endIndex: 3, overscan: 1, count: 10 },
stickyHeaderIndexes: [5]
})
).toEqual([1, 2, 3, 4])
})
})
describe('estimateRenderRowSize', () => {
it('keeps secondary group header size stable while it is the active sticky header', () => {
const rows = [makeHeaderRow('first'), makeHeaderRow('second')]

View File

@ -1,4 +1,5 @@
import type { VirtualItem } from '@tanstack/react-virtual'
import { defaultRangeExtractor } from '@tanstack/react-virtual'
import type { Range, VirtualItem } from '@tanstack/react-virtual'
import type { Row } from './worktree-list-groups'
import { PINNED_GROUP_KEY } from './worktree-list-groups'
@ -86,6 +87,31 @@ export function getPreviousStickyHeaderIndex(
return stickyHeaderIndexes[currentPosition - 1] ?? null
}
export function extractWorktreeVirtualRowIndexes(args: {
range: Range
stickyHeaderIndexes: readonly number[]
}): number[] {
const activeStickyHeaderIndex = getActiveStickyHeaderIndex(
args.stickyHeaderIndexes,
args.range.startIndex
)
if (activeStickyHeaderIndex === null) {
return defaultRangeExtractor(args.range)
}
const previousStickyHeaderIndex = getPreviousStickyHeaderIndex(
args.stickyHeaderIndexes,
activeStickyHeaderIndex
)
return Array.from(
new Set([
activeStickyHeaderIndex,
...(previousStickyHeaderIndex === null ? [] : [previousStickyHeaderIndex]),
...defaultRangeExtractor(args.range)
])
).sort((a, b) => a - b)
}
export function getActiveStickyHeaderIndexForScroll(args: {
rangeStartIndex: number
scrollOffset: number