Virtualize available participants list, memoize draft room props

Adds @tanstack/react-virtual to replace separate mobile/desktop lists
with a single unified virtual scroll loop. Also memoizes miniDraftGrid
and availableParticipantsSectionProps, and switches pick lookup from
Array.find to a Map for O(1) access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-17 16:06:48 -07:00
parent 27ee876fd1
commit e3bc3c1ce2
5 changed files with 286 additions and 236 deletions

View file

@ -3,6 +3,29 @@ import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection"; import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({
count,
estimateSize,
}: {
count: number;
estimateSize: () => number;
}) => {
const size = estimateSize();
const items = Array.from({ length: count }, (_, i) => ({
index: i,
start: i * size,
size,
key: i,
}));
return {
getTotalSize: () => count * size,
getVirtualItems: () => items,
measureElement: vi.fn(),
};
},
}));
const defaultProps = { const defaultProps = {
participants: [ participants: [
{ id: "1", name: "Player A", sport: { id: "s1", name: "NFL" } }, { id: "1", name: "Player A", sport: { id: "s1", name: "NFL" } },

View file

@ -1,4 +1,5 @@
import { memo, useCallback, useMemo } from "react"; import { memo, useCallback, useMemo, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Checkbox } from "~/components/ui/checkbox"; import { Checkbox } from "~/components/ui/checkbox";
@ -271,6 +272,19 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
const hasActiveFilters = sportFilters.length > 0 || hideCompletedSports; const hasActiveFilters = sportFilters.length > 0 || hideCompletedSports;
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: participants.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 5,
getItemKey: (index) => participants[index]?.id ?? index,
});
const desktopGridClass = hasTeam
? "grid-cols-[60px_60px_1fr_140px]"
: "grid-cols-[60px_60px_1fr]";
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{miniDraftGrid && ( {miniDraftGrid && (
@ -280,7 +294,6 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
)} )}
<div className="px-4 pt-4 pb-2 flex-shrink-0"> <div className="px-4 pt-4 pb-2 flex-shrink-0">
<div className="space-y-2"> <div className="space-y-2">
{/* Search Input */}
<input <input
type="text" type="text"
placeholder="Search participants..." placeholder="Search participants..."
@ -290,9 +303,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
/> />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{/* Sport Filter Multi-Select */}
<div className="flex-1 min-w-[120px]"> <div className="flex-1 min-w-[120px]">
{/* Mobile: bottom sheet */}
<div className="md:hidden"> <div className="md:hidden">
<Sheet> <Sheet>
<SheetTrigger asChild> <SheetTrigger asChild>
@ -334,7 +345,6 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
</Sheet> </Sheet>
</div> </div>
{/* Desktop: popover */}
<div className="hidden md:block"> <div className="hidden md:block">
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -374,7 +384,6 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
</div> </div>
</div> </div>
{/* Show/Hide Drafted Toggle */}
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md"> <label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
<Checkbox <Checkbox
checked={!hideDrafted} checked={!hideDrafted}
@ -383,7 +392,6 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
<span>Show Drafted</span> <span>Show Drafted</span>
</label> </label>
{/* Show/Hide Ineligible Toggle - only show if user has a team */}
{hasTeam && eligibility && ( {hasTeam && eligibility && (
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md"> <label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
<Checkbox <Checkbox
@ -398,169 +406,65 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
</div> </div>
</div> </div>
{/* Mobile Card List - visible on mobile only */} <div className={`hidden md:grid ${desktopGridClass} bg-muted border-b text-sm font-semibold flex-shrink-0 px-4`}>
<div className="flex md:hidden flex-1 overflow-y-auto flex-col gap-2 px-4 py-2"> <span className="text-center p-3 px-0">OVR</span>
<span className="text-center p-3 px-0">SPR</span>
<span className="text-left p-3">Participant</span>
</div>
<div ref={parentRef} className="flex-1 overflow-y-auto">
{participants.length === 0 ? ( {participants.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm"> <div className="text-center py-8 text-muted-foreground text-sm">
{emptyMessage} {emptyMessage}
</div> </div>
) : ( ) : (
participants.map((participant) => { <div
const { isDrafted, isInQueue, isEligible, ineligibleReason } = style={{
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility); height: virtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const participant = participants[virtualItem.index];
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
const rank = participantRanks.get(participant.id);
return ( return (
<div <div
key={participant.id} key={virtualItem.key}
className={`bg-card border rounded-lg p-3 flex items-start justify-between gap-3 transition-colors ${ data-index={virtualItem.index}
isDrafted ref={virtualizer.measureElement}
? "bg-muted/50 opacity-60" style={{
: !isEligible position: "absolute",
? "bg-destructive/10 opacity-75" top: virtualItem.start,
: "" left: 0,
}`} right: 0,
> }}
<div className="flex flex-col gap-1 min-w-0"> >
<span <div className="md:hidden px-4 py-1">
className={`font-semibold text-sm truncate ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`} <div
> className={`bg-card border rounded-lg p-3 flex items-start justify-between gap-3 transition-colors ${
{participant.name}
</span>
<div className="flex flex-wrap gap-1 items-center">
<Badge variant="outline" className="text-xs">
{participant.sport.name}
</Badge>
<span className="text-xs text-muted-foreground tabular-nums">
OVR {participantRanks.get(participant.id)?.overallRank} · SPR {participantRanks.get(participant.id)?.sportRank}
</span>
{isDrafted && (
<Badge variant="secondary" className="text-xs">
Drafted
</Badge>
)}
{!isDrafted && !isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason}
>
Ineligible
</Badge>
)}
{isInQueue && !isDrafted && isEligible && (
<Badge variant="default" className="text-xs">
Queued
</Badge>
)}
</div>
</div>
{hasTeam && !isDrafted && (
<div className="flex gap-2 flex-shrink-0">
{!isInQueue ? (
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px]"
onClick={() => onAddToQueue(participant.id)}
title={!isEligible ? ineligibleReason : "Add to queue"}
disabled={!isEligible}
>
<ListPlus className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px]"
onClick={() => {
const queueId = queueMap.get(participant.id);
if (queueId) onRemoveFromQueue(queueId);
}}
title="Remove from queue"
>
<ListX className="h-4 w-4" />
</Button>
)}
<Button
variant="default"
size="sm"
className="min-h-[44px]"
onClick={() => onMakePick(participant.id)}
disabled={!canPick || !isEligible}
title={
!canPick
? "Not your turn"
: !isEligible
? ineligibleReason
: undefined
}
>
Draft
</Button>
</div>
)}
</div>
);
})
)}
</div>
{/* Desktop Table - hidden on mobile */}
<div className="hidden md:flex flex-1 overflow-hidden">
<div className="h-full w-full overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-center p-3 px-0 font-semibold">OVR</th>
<th className="text-center p-3 px-0 font-semibold">SPR</th>
<th className="text-left p-3 font-semibold">Participant</th>
<th className="text-left p-3 font-semibold">Sport</th>
{hasTeam && (
<th className="text-right p-3 font-semibold">Queue</th>
)}
</tr>
</thead>
<tbody>
{participants.length === 0 ? (
<tr>
<td
colSpan={hasTeam ? 5 : 4}
className="text-center py-8 text-muted-foreground"
>
{emptyMessage}
</td>
</tr>
) : (
participants.map((participant) => {
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
return (
<tr
key={participant.id}
className={`border-t transition-colors ${
isDrafted isDrafted
? "bg-muted/50 opacity-60" ? "bg-muted/50 opacity-60"
: !isEligible : !isEligible
? "bg-destructive/10 opacity-75" ? "bg-destructive/10 opacity-75"
: "hover:bg-muted/50" : ""
}`} }`}
title={
!isEligible && !isDrafted ? ineligibleReason : undefined
}
> >
<td className="p-3 px-0 text-center text-muted-foreground tabular-nums"> <div className="flex flex-col gap-1 min-w-0">
{participantRanks.get(participant.id)?.overallRank} <span
</td> className={`font-semibold text-sm truncate ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
<td className="p-3 px-0 text-center text-muted-foreground tabular-nums"> >
{participantRanks.get(participant.id)?.sportRank} {participant.name}
</td> </span>
<td className="p-3"> <div className="flex flex-wrap gap-1 items-center">
<div className="flex items-center gap-2"> <Badge variant="outline" className="text-xs">
<span {participant.sport.name}
className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`} </Badge>
> <span className="text-xs text-muted-foreground tabular-nums">
{participant.name} OVR {rank?.overallRank} · SPR {rank?.sportRank}
</span> </span>
{isDrafted && ( {isDrafted && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
@ -582,71 +486,162 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
</Badge> </Badge>
)} )}
</div> </div>
</td> </div>
<td className="p-3 text-muted-foreground"> {hasTeam && !isDrafted && (
{participant.sport.name} <div className="flex gap-2 flex-shrink-0">
</td> {!isInQueue ? (
{hasTeam && ( <Button
<td className="p-3 text-right"> variant="ghost"
<div className="flex gap-2 justify-end items-center"> size="sm"
{!isDrafted && ( className="min-h-[44px] min-w-[44px]"
<> onClick={() => onAddToQueue(participant.id)}
{/* Queue icon button */} title={!isEligible ? ineligibleReason : "Add to queue"}
{!isInQueue ? ( disabled={!isEligible}
<Button >
variant="ghost" <ListPlus className="h-4 w-4" />
size="sm" </Button>
onClick={() => onAddToQueue(participant.id)} ) : (
title={ <Button
!isEligible variant="ghost"
? ineligibleReason size="sm"
: "Add to queue" className="min-h-[44px] min-w-[44px]"
} onClick={() => {
disabled={!isEligible} const queueId = queueMap.get(participant.id);
> if (queueId) onRemoveFromQueue(queueId);
<ListPlus className="h-4 w-4" /> }}
</Button> title="Remove from queue"
) : ( >
<Button <ListX className="h-4 w-4" />
variant="ghost" </Button>
size="sm" )}
onClick={() => { <Button
const queueId = queueMap.get(participant.id); variant="default"
if (queueId) onRemoveFromQueue(queueId); size="sm"
}} className="min-h-[44px]"
title="Remove from queue" onClick={() => onMakePick(participant.id)}
> disabled={!canPick || !isEligible}
<ListX className="h-4 w-4" /> title={
</Button> !canPick
)} ? "Not your turn"
{/* Draft button - always visible, disabled when not your turn */} : !isEligible
<Button ? ineligibleReason
variant="default" : undefined
size="sm" }
onClick={() => onMakePick(participant.id)} >
disabled={!canPick || !isEligible} Draft
title={ </Button>
!canPick </div>
? "Not your turn"
: !isEligible
? ineligibleReason
: undefined
}
>
Draft
</Button>
</>
)}
</div>
</td>
)} )}
</tr> </div>
); </div>
})
)} <div
</tbody> className={`hidden md:grid ${desktopGridClass} items-center px-4 transition-colors border-t ${
</table> isDrafted
</div> ? "bg-muted/50 opacity-60"
: !isEligible
? "bg-destructive/10 opacity-75"
: "hover:bg-muted/50"
}`}
title={
!isEligible && !isDrafted ? ineligibleReason : undefined
}
>
<span className="text-center text-muted-foreground tabular-nums p-3 px-0">
{rank?.overallRank}
</span>
<span className="text-center text-muted-foreground tabular-nums p-3 px-0">
{rank?.sportRank}
</span>
<div className="p-3">
<div className="flex items-center gap-2">
<span
className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
>
{participant.name}
</span>
<Badge variant="outline" className="text-xs">
{participant.sport.name}
</Badge>
{isDrafted && (
<Badge variant="secondary" className="text-xs">
Drafted
</Badge>
)}
{!isDrafted && !isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason}
>
Ineligible
</Badge>
)}
{isInQueue && !isDrafted && isEligible && (
<Badge variant="default" className="text-xs">
Queued
</Badge>
)}
</div>
</div>
{hasTeam && (
<div className="p-3">
<div className="flex gap-2 justify-end items-center">
{!isDrafted && (
<>
{!isInQueue ? (
<Button
variant="ghost"
size="sm"
onClick={() => onAddToQueue(participant.id)}
title={
!isEligible
? ineligibleReason
: "Add to queue"
}
disabled={!isEligible}
>
<ListPlus className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => {
const queueId = queueMap.get(participant.id);
if (queueId) onRemoveFromQueue(queueId);
}}
title="Remove from queue"
>
<ListX className="h-4 w-4" />
</Button>
)}
<Button
variant="default"
size="sm"
onClick={() => onMakePick(participant.id)}
disabled={!canPick || !isEligible}
title={
!canPick
? "Not your turn"
: !isEligible
? ineligibleReason
: undefined
}
>
Draft
</Button>
</>
)}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div> </div>
</div> </div>
); );

View file

@ -1395,6 +1395,7 @@ export default function DraftRoom() {
const draftGrid = useMemo(() => { const draftGrid = useMemo(() => {
const teamCount = draftSlots.length; const teamCount = draftSlots.length;
const rounds = season.draftRounds; const rounds = season.draftRounds;
const pickMap = new Map(picks.map((p) => [p.pickNumber, p]));
const grid: Array< const grid: Array<
Array<{ Array<{
pickNumber: number; pickNumber: number;
@ -1415,7 +1416,7 @@ export default function DraftRoom() {
const pickNumber = (round - 1) * teamCount + pickInRound; const pickNumber = (round - 1) * teamCount + pickInRound;
const teamId = draftSlots[teamIndex]?.team.id; const teamId = draftSlots[teamIndex]?.team.id;
const pick = picks.find((p) => p.pickNumber === pickNumber); const pick = pickMap.get(pickNumber);
roundPicks.push({ pickNumber, round, pickInRound, teamId, pick }); roundPicks.push({ pickNumber, round, pickInRound, teamId, pick });
} }
@ -1502,20 +1503,22 @@ export default function DraftRoom() {
} }
}, [userDraftedSportNames]); }, [userDraftedSportNames]);
const availableParticipantsSectionProps = { const miniDraftGrid = useMemo(() => ({
draftSlots,
draftGrid,
currentPick,
currentRound,
ownerMap,
onForceAutopick: isCommissioner ? handleForceAutopick : undefined,
onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined,
onReplacePick: isCommissioner ? handleReplacePickOpen : undefined,
onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined,
}), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, isCommissioner, handleForceAutopick, handleForceManualPickOpen, handleReplacePickOpen, handleRollbackToPick, isDraftComplete]);
const availableParticipantsSectionProps = useMemo(() => ({
participants: filteredParticipants, participants: filteredParticipants,
participantRanks, participantRanks,
miniDraftGrid: { miniDraftGrid,
draftSlots,
draftGrid,
currentPick,
currentRound,
ownerMap,
onForceAutopick: isCommissioner ? handleForceAutopick : undefined,
onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined,
onReplacePick: isCommissioner ? handleReplacePickOpen : undefined,
onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined,
},
searchQuery, searchQuery,
sportFilters, sportFilters,
hideDrafted, hideDrafted,
@ -1536,7 +1539,7 @@ export default function DraftRoom() {
onMakePick: handleMakePick, onMakePick: handleMakePick,
onAddToQueue: handleAddToQueue, onAddToQueue: handleAddToQueue,
onRemoveFromQueue: handleRemoveFromQueue, onRemoveFromQueue: handleRemoveFromQueue,
}; }), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue]);
const draftGridSectionProps = { const draftGridSectionProps = {
draftSlots, draftSlots,

28
package-lock.json generated
View file

@ -29,6 +29,7 @@
"@react-router/express": "^7.7.1", "@react-router/express": "^7.7.1",
"@react-router/node": "^7.7.1", "@react-router/node": "^7.7.1",
"@sentry/react-router": "^10.43.0", "@sentry/react-router": "^10.43.0",
"@tanstack/react-virtual": "^3.13.24",
"@types/nprogress": "^0.2.3", "@types/nprogress": "^0.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -6491,6 +6492,33 @@
"vite": "^5.2.0 || ^6 || ^7" "vite": "^5.2.0 || ^6 || ^7"
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.13.24",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.14.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/cypress": { "node_modules/@testing-library/cypress": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.1.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.1.0.tgz",

View file

@ -51,6 +51,7 @@
"@react-router/express": "^7.7.1", "@react-router/express": "^7.7.1",
"@react-router/node": "^7.7.1", "@react-router/node": "^7.7.1",
"@sentry/react-router": "^10.43.0", "@sentry/react-router": "^10.43.0",
"@tanstack/react-virtual": "^3.13.24",
"@types/nprogress": "^0.2.3", "@types/nprogress": "^0.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",