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:
parent
bcd16e56fd
commit
1383ba85cf
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Reference in New Issue