Skip to content

Commit 9ed3184

Browse files
committed
perf: implement virtual scrolling for kanban columns
Use @tanstack/react-virtual to only render visible bug cards in each column. This significantly improves performance when columns contain many bugs (100+) by reducing DOM nodes and layout calculations. - Add useVirtualizer to Column component - Use absolute positioning with transforms for virtual items - Add mock for @tanstack/react-virtual in tests
1 parent 18ac898 commit 9ed3184

File tree

3 files changed

+94
-28
lines changed

3 files changed

+94
-28
lines changed

src/components/Board/Board.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@ afterAll(() => {
1515
vi.useRealTimers()
1616
})
1717

18+
// Mock @tanstack/react-virtual to render all items in tests
19+
vi.mock('@tanstack/react-virtual', () => ({
20+
useVirtualizer: ({ count }: { count: number }) => ({
21+
getVirtualItems: () =>
22+
Array.from({ length: count }, (_, index) => ({
23+
index,
24+
start: index * 160,
25+
size: 160,
26+
key: index,
27+
})),
28+
getTotalSize: () => count * 160,
29+
}),
30+
}))
31+
1832
// Mock framer-motion
1933
vi.mock('framer-motion', () => ({
2034
motion: {

src/components/Board/Column.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ function renderWithUser(ui: React.ReactElement) {
1313
}
1414
}
1515

16+
// Mock @tanstack/react-virtual to render all items in tests
17+
vi.mock('@tanstack/react-virtual', () => ({
18+
useVirtualizer: ({ count }: { count: number }) => ({
19+
getVirtualItems: () =>
20+
Array.from({ length: count }, (_, index) => ({
21+
index,
22+
start: index * 160,
23+
size: 160,
24+
key: index,
25+
})),
26+
getTotalSize: () => count * 160,
27+
}),
28+
}))
29+
1630
// Mock framer-motion
1731
vi.mock('framer-motion', () => ({
1832
motion: {

src/components/Board/Column.tsx

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { useMemo, useState } from 'react'
1+
import { useMemo, useState, useRef } from 'react'
22
import { useDroppable } from '@dnd-kit/core'
3+
import { useVirtualizer } from '@tanstack/react-virtual'
34
import { Card } from './Card'
45
import { ColumnInfoPopover } from './ColumnInfoPopover'
56
import type { BugzillaBug } from '@/lib/bugzilla/types'
@@ -8,6 +9,11 @@ import type { Assignee } from '@/hooks/use-board-assignees'
89
import type { QeVerifyStatus } from '@/lib/bugzilla/qe-verify'
910
import { COLUMN_NAMES } from '@/types'
1011

12+
// Estimated height of each card in pixels (used for virtual scrolling)
13+
const ESTIMATED_CARD_HEIGHT = 160
14+
// Gap between cards in pixels
15+
const CARD_GAP = 12
16+
1117
const NOBODY_EMAIL = 'nobody@mozilla.org'
1218

1319
function calculateTotalPoints(bugs: BugzillaBug[]): number {
@@ -126,6 +132,15 @@ export function Column({
126132
return allAssignees.filter((assignee) => assignee.email !== NOBODY_EMAIL)
127133
}, [allAssignees, column])
128134

135+
// Virtual scrolling for large bug lists
136+
const scrollContainerRef = useRef<HTMLDivElement>(null)
137+
const virtualizer = useVirtualizer({
138+
count: bugs.length,
139+
getScrollElement: () => scrollContainerRef.current,
140+
estimateSize: () => ESTIMATED_CARD_HEIGHT,
141+
gap: CARD_GAP,
142+
})
143+
129144
// Determine column styling based on state
130145
const getColumnClassName = () => {
131146
const base = 'flex min-h-[500px] w-72 flex-shrink-0 flex-col rounded-lg p-4 transition-colors'
@@ -227,34 +242,57 @@ export function Column({
227242
</div>
228243
)}
229244

230-
{/* Bug Cards */}
245+
{/* Bug Cards with Virtual Scrolling */}
231246
{!isLoading && bugs.length > 0 && (
232-
<div className="flex flex-1 flex-col gap-3 overflow-y-auto">
233-
{bugs.map((bug, index) => (
234-
<Card
235-
key={bug.id}
236-
bug={bug}
237-
isStaged={stagedBugIds.has(bug.id)}
238-
isAssigneeStaged={stagedAssigneeBugIds?.has(bug.id)}
239-
stagedAssignee={stagedAssignees?.get(bug.id)}
240-
isPointsStaged={stagedPointsBugIds?.has(bug.id)}
241-
stagedPoints={stagedPoints?.get(bug.id)}
242-
isPriorityStaged={stagedPriorityBugIds?.has(bug.id)}
243-
stagedPriority={stagedPriorities?.get(bug.id)}
244-
isSeverityStaged={stagedSeverityBugIds?.has(bug.id)}
245-
stagedSeverity={stagedSeverities?.get(bug.id)}
246-
isQeVerifyStaged={stagedQeVerifyBugIds?.has(bug.id)}
247-
stagedQeVerify={stagedQeVerifies?.get(bug.id)}
248-
isSelected={selectedIndex === index}
249-
isGrabbed={selectedIndex === index && isGrabbing}
250-
allAssignees={filteredAssignees}
251-
onAssigneeChange={onAssigneeChange}
252-
onPointsChange={onPointsChange}
253-
onPriorityChange={onPriorityChange}
254-
onSeverityChange={onSeverityChange}
255-
onQeVerifyChange={onQeVerifyChange}
256-
/>
257-
))}
247+
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
248+
<div
249+
style={{
250+
height: `${virtualizer.getTotalSize().toString()}px`,
251+
width: '100%',
252+
position: 'relative',
253+
}}
254+
>
255+
{virtualizer.getVirtualItems().map((virtualItem) => {
256+
const bug = bugs[virtualItem.index]
257+
if (!bug) return null
258+
const index = virtualItem.index
259+
return (
260+
<div
261+
key={bug.id}
262+
style={{
263+
position: 'absolute',
264+
top: 0,
265+
left: 0,
266+
width: '100%',
267+
transform: `translateY(${virtualItem.start.toString()}px)`,
268+
}}
269+
>
270+
<Card
271+
bug={bug}
272+
isStaged={stagedBugIds.has(bug.id)}
273+
isAssigneeStaged={stagedAssigneeBugIds?.has(bug.id)}
274+
stagedAssignee={stagedAssignees?.get(bug.id)}
275+
isPointsStaged={stagedPointsBugIds?.has(bug.id)}
276+
stagedPoints={stagedPoints?.get(bug.id)}
277+
isPriorityStaged={stagedPriorityBugIds?.has(bug.id)}
278+
stagedPriority={stagedPriorities?.get(bug.id)}
279+
isSeverityStaged={stagedSeverityBugIds?.has(bug.id)}
280+
stagedSeverity={stagedSeverities?.get(bug.id)}
281+
isQeVerifyStaged={stagedQeVerifyBugIds?.has(bug.id)}
282+
stagedQeVerify={stagedQeVerifies?.get(bug.id)}
283+
isSelected={selectedIndex === index}
284+
isGrabbed={selectedIndex === index && isGrabbing}
285+
allAssignees={filteredAssignees}
286+
onAssigneeChange={onAssigneeChange}
287+
onPointsChange={onPointsChange}
288+
onPriorityChange={onPriorityChange}
289+
onSeverityChange={onSeverityChange}
290+
onQeVerifyChange={onQeVerifyChange}
291+
/>
292+
</div>
293+
)
294+
})}
295+
</div>
258296
</div>
259297
)}
260298
</div>

0 commit comments

Comments
 (0)