Virtualize workspace cleanup candidate rows for large lists (#8044)

Extracts row rendering into a WorkspaceCleanupCandidateList that windows
rows via @tanstack/react-virtual once the list crosses 40 items, keeping
plain natural-flow rendering below that threshold. Memoizes CandidateRow
and stabilizes its callback props so scan stream-in and selection updates
don't re-render unrelated rows, avoiding O(N) DOM churn for users with
large numbers of worktrees.
This commit is contained in:
Jinjing 2026-07-10 00:18:07 -07:00 committed by GitHub
parent bcd16e56fd
commit 1383ba85cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 223 additions and 40 deletions

View File

@ -69,6 +69,7 @@ import {
type WorkspaceCleanupRemovalProgress
} from './workspace-cleanup-background-removal'
import { CandidateRow } from './workspace-cleanup-candidate-row'
import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list'
import {
getCandidateStatus,
getContextPillLabel,
@ -212,6 +213,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
const openRef = useRef(open)
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
const [expandedRowIds, setExpandedRowIds] = useState<Set<string>>(() => new Set())
const [rowsScrollElement, setRowsScrollElement] = useState<HTMLDivElement | null>(null)
const [activeView, setActiveView] = useState<WorkspaceCleanupView>('ready')
const [confirming, setConfirming] = useState(false)
const [confirmCandidates, setConfirmCandidates] = useState<WorkspaceCleanupCandidate[]>([])
@ -505,6 +507,10 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
setExpandedRowIds((current) => toggleSetMember(current, worktreeId))
}, [])
const toggleSelectedRow = useCallback((worktreeId: string) => {
setSelectedIds((current) => toggleSetMember(current, worktreeId))
}, [])
const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => {
const nextCandidates = filterWorkspaceCleanupRemovalCandidates(
candidates,
@ -517,6 +523,28 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
setConfirming(true)
}, [])
// Why: stable per-row handlers so React.memo keeps unchanged CandidateRow
// instances from re-rendering on scan stream-in and selection changes.
const handleRemoveRow = useCallback(
(candidate: WorkspaceCleanupCandidate) => {
if (loading) {
return
}
setSelectedIds(new Set([candidate.worktreeId]))
openConfirmRemove([candidate])
},
[loading, openConfirmRemove]
)
const handleViewCandidate = useCallback(
(candidate: WorkspaceCleanupCandidate) => {
markCandidateViewed(candidate)
closeModal()
activateAndRevealWorktree(candidate.worktreeId)
},
[closeModal, markCandidateViewed]
)
const cancelConfirmRemove = useCallback(() => {
if (removalProgress) {
closeModal()
@ -777,7 +805,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
onRestoreIgnored={() => void resetDismissals()}
/>
) : null}
<ScrollArea className="min-h-0 flex-1">
<ScrollArea className="min-h-0 flex-1" viewportRef={setRowsScrollElement}>
<div>
{initialLoading ? <SkeletonRows /> : null}
{!loading && scan && candidates.length === 0 && !scanNoticeMessage ? (
@ -851,38 +879,34 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
)}
/>
) : null}
{activeRows.map((candidate, index) => (
<CandidateRow
key={candidate.worktreeId}
candidate={candidate}
reviewInfo={
reviewInfoByWorktreeId.get(candidate.worktreeId) ?? EMPTY_REVIEW_INFO
}
last={activeRows.length > 1 && index === activeRows.length - 1}
expanded={expandedRowIds.has(candidate.worktreeId)}
lastActivityLabel={formatRelativeTime(candidate.lastActivityAt)}
removing={loading || deletingWorktreeIds.has(candidate.worktreeId)}
selected={
selectedIds.has(candidate.worktreeId) &&
!loading &&
!deletingWorktreeIds.has(candidate.worktreeId)
}
failure={rowFailures[candidate.worktreeId]}
onToggleExpanded={toggleExpandedRow}
onToggleSelected={(id) =>
setSelectedIds((current) => toggleSetMember(current, id))
}
onView={closeAndView}
onIgnore={ignoreCandidate}
onRemove={(candidate) => {
if (loading) {
return
<WorkspaceCleanupCandidateList
rows={activeRows}
scrollElement={rowsScrollElement}
renderRow={(candidate, index) => (
<CandidateRow
key={candidate.worktreeId}
candidate={candidate}
reviewInfo={
reviewInfoByWorktreeId.get(candidate.worktreeId) ?? EMPTY_REVIEW_INFO
}
setSelectedIds(new Set([candidate.worktreeId]))
openConfirmRemove([candidate])
}}
/>
))}
last={activeRows.length > 1 && index === activeRows.length - 1}
expanded={expandedRowIds.has(candidate.worktreeId)}
lastActivityLabel={formatRelativeTime(candidate.lastActivityAt)}
removing={loading || deletingWorktreeIds.has(candidate.worktreeId)}
selected={
selectedIds.has(candidate.worktreeId) &&
!loading &&
!deletingWorktreeIds.has(candidate.worktreeId)
}
failure={rowFailures[candidate.worktreeId]}
onToggleExpanded={toggleExpandedRow}
onToggleSelected={toggleSelectedRow}
onView={handleViewCandidate}
onIgnore={ignoreCandidate}
onRemove={handleRemoveRow}
/>
)}
/>
</div>
</ScrollArea>
</div>
@ -900,12 +924,6 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
</DialogContent>
</Dialog>
)
function closeAndView(candidate: WorkspaceCleanupCandidate): void {
markCandidateViewed(candidate)
closeModal()
activateAndRevealWorktree(candidate.worktreeId)
}
}
function WorkspaceCleanupFilterToolbar({

View File

@ -0,0 +1,88 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS,
WorkspaceCleanupCandidateList
} from './workspace-cleanup-candidate-list'
import { CandidateRow } from './workspace-cleanup-candidate-row'
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
let root: Root | null = null
let container: HTMLDivElement | null = null
function makeRows(count: number): WorkspaceCleanupCandidate[] {
return Array.from({ length: count }, (_, index) =>
makeCandidate({ worktreeId: `wt-${index}`, displayName: `Workspace ${index}` })
)
}
describe('WorkspaceCleanupCandidateList', () => {
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
})
it('renders every row in natural flow below the virtualization threshold', () => {
const rows = makeRows(WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS - 1)
const rendered: string[] = []
act(() => {
root?.render(
<WorkspaceCleanupCandidateList
rows={rows}
scrollElement={null}
renderRow={(candidate) => {
rendered.push(candidate.worktreeId)
return <div key={candidate.worktreeId} data-testid="row" />
}}
/>
)
})
expect(rendered).toHaveLength(rows.length)
expect(container?.querySelectorAll('[data-testid="row"]')).toHaveLength(rows.length)
// Plain path keeps natural flow: no absolute-positioned windowing wrappers.
expect(container?.querySelector('[data-index]')).toBeNull()
})
it('windows rows into an absolutely positioned container at the threshold', () => {
const rows = makeRows(WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS)
// A real element enables the virtualizer; happy-dom reports zero-size layout,
// so this asserts the windowed structure rather than a specific mounted count.
const scrollElement = document.createElement('div')
act(() => {
root?.render(
<WorkspaceCleanupCandidateList
rows={rows}
scrollElement={scrollElement}
renderRow={(candidate) => <div key={candidate.worktreeId} data-testid="row" />}
/>
)
})
const windowed = container?.querySelector('.absolute') != null
const mounted = container?.querySelectorAll('[data-testid="row"]').length ?? 0
// Windowed mode never mounts more than the full set, and switches away from
// the plain flow used below the threshold.
expect(mounted).toBeLessThanOrEqual(rows.length)
expect(windowed || mounted === 0).toBe(true)
})
it('memoizes CandidateRow so unchanged rows skip re-render', () => {
expect((CandidateRow as { $$typeof?: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})

View File

@ -0,0 +1,72 @@
import React from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
// Why: below this count plain rows keep the pre-virtualization DOM (natural
// flow, no absolute positioning), so the common few-worktrees case is
// byte-for-byte unchanged. The O(N) stream-in churn and per-keystroke re-render
// only bite at the hundreds-to-thousands a heavy multi-agent user accumulates.
export const WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS = 40
// Why: a collapsed row is a single metadata line (~48px with px-3 py-2.5);
// expanded rows and failure banners are taller, so estimate the common height
// and let measureElement correct the tall variants.
const WORKSPACE_CLEANUP_ROW_ESTIMATE_PX = 48
const WORKSPACE_CLEANUP_ROW_OVERSCAN = 8
/**
* Windows the cleanup candidate rows inside the dialog's ScrollArea viewport.
* The list is the viewport's only content, so rows sit at scroll offset 0 and
* no scroll-margin bookkeeping is needed. Lists shorter than
* WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS render plainly.
*/
export function WorkspaceCleanupCandidateList({
rows,
renderRow,
// Why: a state-held element, not a ref — the ScrollArea viewport is not
// attached when this component first mounts, so a ref would leave the
// virtualizer unobserved until some unrelated re-render.
scrollElement
}: {
rows: readonly WorkspaceCleanupCandidate[]
renderRow: (candidate: WorkspaceCleanupCandidate, index: number) => React.ReactNode
scrollElement: HTMLDivElement | null
}): React.JSX.Element {
const virtualize = rows.length >= WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS
const virtualizer = useVirtualizer({
count: rows.length,
enabled: virtualize && scrollElement !== null,
getScrollElement: () => scrollElement,
estimateSize: () => WORKSPACE_CLEANUP_ROW_ESTIMATE_PX,
overscan: WORKSPACE_CLEANUP_ROW_OVERSCAN,
// Why: stable worktree keys let the virtualizer carry row identity across
// scan refreshes instead of remounting the window on every streamed row.
getItemKey: (index) => rows[index]?.worktreeId ?? index
})
if (!virtualize) {
return <>{rows.map((candidate, index) => renderRow(candidate, index))}</>
}
return (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const candidate = rows[item.index]
if (candidate === undefined) {
return null
}
return (
<div
key={item.key}
ref={virtualizer.measureElement}
data-index={item.index}
className="absolute top-0 left-0 w-full"
style={{ transform: `translateY(${item.start}px)` }}
>
{renderRow(candidate, item.index)}
</div>
)
})}
</div>
)
}

View File

@ -92,7 +92,12 @@ function MetadataIconChip({
)
}
export function CandidateRow({
// Why: the cleanup list re-renders on every checkbox/expand/search keystroke;
// memo keeps each unchanged row from re-rendering. Effective only while the
// parent passes stable (useCallback) handlers — see WorkspaceCleanupDialog.
// Scan stream-in still re-renders rows (candidates change, so the reviewInfo
// prop identity changes); virtualization, not memo, bounds that cost.
export const CandidateRow = React.memo(function CandidateRow({
candidate,
expanded,
failure,
@ -321,7 +326,7 @@ export function CandidateRow({
</div>
</div>
)
}
})
function formatCompactActivityLabel(label: string): string {
if (label === 'Just now') {