Primary fix: setTeamTimers now bails out with `return prev` when the
value hasn't changed, preventing a full DraftRoom re-render on every
1-second timer tick (was 33% of profiler samples).
Memoization: wrap AvailableParticipantsSection, TeamsDraftedGrid,
QueueSection, SidebarRecentPicks, and DraftGridSection in React.memo
so timer ticks don't cascade into heavy components that don't use
timer state.
Stable refs: wrap nine action handlers in useCallback and extract two
inline arrow functions from props objects so memo() comparisons
actually bail out. Memoize the { numFlexPicks } object passed to
TeamsDraftedGrid.
socketVersion: expose an incrementing counter from useDraftSocket so
the socket handler effect re-registers on socket recreation.
Async cleanup: add abort flag + in-flight guard to the visibilitychange
JWT refresh handler to prevent concurrent executions and stale state
updates after unmount. Add abort flag to useDraftNotifications
permissions.query() to prevent dangling onchange if unmounted
mid-promise.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
223 lines
6.1 KiB
TypeScript
223 lines
6.1 KiB
TypeScript
import { memo } from "react";
|
||
import { Button } from "~/components/ui/button";
|
||
import { Badge } from "~/components/ui/badge";
|
||
import { AutodraftSettings } from "~/components/AutodraftSettings";
|
||
import {
|
||
DndContext,
|
||
closestCenter,
|
||
KeyboardSensor,
|
||
PointerSensor,
|
||
useSensor,
|
||
useSensors,
|
||
} from "@dnd-kit/core";
|
||
import type { DragEndEvent } from "@dnd-kit/core";
|
||
import {
|
||
arrayMove,
|
||
SortableContext,
|
||
sortableKeyboardCoordinates,
|
||
useSortable,
|
||
verticalListSortingStrategy,
|
||
} from "@dnd-kit/sortable";
|
||
import { CSS } from "@dnd-kit/utilities";
|
||
|
||
interface QueueSectionProps {
|
||
queue: Array<{
|
||
id: string;
|
||
participantId: string;
|
||
}>;
|
||
availableParticipants: Array<{
|
||
id: string;
|
||
name: string;
|
||
sport: { name: string };
|
||
}>;
|
||
seasonId: string;
|
||
teamId: string;
|
||
isMyTurn: boolean;
|
||
canPick: boolean;
|
||
userAutodraft: {
|
||
isEnabled: boolean;
|
||
mode: "next_pick" | "while_on";
|
||
queueOnly: boolean;
|
||
};
|
||
onRemoveFromQueue: (queueId: string) => void;
|
||
onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => void;
|
||
onReorder: (participantIds: string[]) => void;
|
||
onMakePick?: (participantId: string) => void;
|
||
}
|
||
|
||
// Sortable queue item component
|
||
function SortableQueueItem({
|
||
item,
|
||
index,
|
||
participantName,
|
||
sportName,
|
||
canPick,
|
||
onRemove,
|
||
onDraft,
|
||
}: {
|
||
item: { id: string; participantId: string };
|
||
index: number;
|
||
participantName: string;
|
||
sportName?: string;
|
||
canPick: boolean;
|
||
onRemove: () => void;
|
||
onDraft?: () => void;
|
||
}) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id: item.id });
|
||
|
||
const style = {
|
||
transform: CSS.Transform.toString(transform),
|
||
transition,
|
||
opacity: isDragging ? 0.5 : 1,
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={style}
|
||
className={`flex items-center justify-between p-2 rounded-lg touch-none ${
|
||
canPick ? "bg-electric/10 border border-electric/40" : "bg-muted"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2 flex-1 min-w-0" {...attributes} {...listeners}>
|
||
<div className="cursor-grab active:cursor-grabbing flex-shrink-0">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
className="text-muted-foreground"
|
||
>
|
||
<line x1="5" y1="9" x2="19" y2="9"></line>
|
||
<line x1="5" y1="15" x2="19" y2="15"></line>
|
||
</svg>
|
||
</div>
|
||
<Badge variant="default" className="text-xs flex-shrink-0">{index + 1}</Badge>
|
||
<div className="min-w-0">
|
||
<p className="font-semibold text-sm truncate">{participantName}</p>
|
||
{sportName && <p className="text-xs text-muted-foreground">{sportName}</p>}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||
{canPick && onDraft && (
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
className="h-7 text-xs bg-electric text-background hover:bg-electric/90"
|
||
onClick={onDraft}
|
||
>
|
||
Draft
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||
onClick={onRemove}
|
||
title="Remove from queue"
|
||
>
|
||
<span className="text-lg">×</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export const QueueSection = memo(function QueueSection({
|
||
queue,
|
||
availableParticipants,
|
||
seasonId,
|
||
teamId,
|
||
isMyTurn,
|
||
canPick,
|
||
userAutodraft,
|
||
onRemoveFromQueue,
|
||
onAutodraftUpdate,
|
||
onReorder,
|
||
onMakePick,
|
||
}: QueueSectionProps) {
|
||
const sensors = useSensors(
|
||
useSensor(PointerSensor),
|
||
useSensor(KeyboardSensor, {
|
||
coordinateGetter: sortableKeyboardCoordinates,
|
||
})
|
||
);
|
||
|
||
const handleDragEnd = (event: DragEndEvent) => {
|
||
const { active, over } = event;
|
||
|
||
if (over && active.id !== over.id) {
|
||
const oldIndex = queue.findIndex((item) => item.id === active.id);
|
||
const newIndex = queue.findIndex((item) => item.id === over.id);
|
||
|
||
const reorderedQueue = arrayMove(queue, oldIndex, newIndex);
|
||
const participantIds = reorderedQueue.map((item) => item.participantId);
|
||
onReorder(participantIds);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="p-4">
|
||
{/* Queue List */}
|
||
{queue.length === 0 ? (
|
||
<p className="text-muted-foreground text-sm text-center py-8">
|
||
Click participants in Available to add to your queue
|
||
</p>
|
||
) : (
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={handleDragEnd}
|
||
>
|
||
<SortableContext
|
||
items={queue.map((item) => item.id)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
<div className="space-y-1.5 mb-4">
|
||
{queue.map((item, index) => {
|
||
const participant = availableParticipants.find(
|
||
(p) => p.id === item.participantId
|
||
);
|
||
return (
|
||
<SortableQueueItem
|
||
key={item.id}
|
||
item={item}
|
||
index={index}
|
||
participantName={participant?.name || "Unknown"}
|
||
sportName={participant?.sport.name}
|
||
canPick={canPick}
|
||
onRemove={() => onRemoveFromQueue(item.id)}
|
||
onDraft={onMakePick ? () => onMakePick(item.participantId) : undefined}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
)}
|
||
|
||
{/* Autodraft Settings */}
|
||
<AutodraftSettings
|
||
seasonId={seasonId}
|
||
teamId={teamId}
|
||
isEnabled={userAutodraft.isEnabled}
|
||
mode={userAutodraft.mode}
|
||
queueOnly={userAutodraft.queueOnly}
|
||
isMyTurn={isMyTurn}
|
||
onUpdate={onAutodraftUpdate}
|
||
/>
|
||
</div>
|
||
);
|
||
});
|