Review
Virtualize cryo-drawer sample rail for freezer tablets
CryoBay techs were lagging when scrolling 8k+ vial slots on the cart-mounted freezer tablets. Drop the full map for @tanstack/react-virtual so only the visible drawer rows mount, keep the 32px compact row chrome, and wire row clicks to the pick queue so a tech can queue a pull without lifting gloves.
ReactTier 3reactvirtualizationlayouttanstack-virtual
Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.
src/features/cryobay/CryoDrawerRail.tsx+42-13
| 1 | 1 | import { useRef } from "react"; | |
| 2 | + | import { useVirtualizer } from "@tanstack/react-virtual"; | |
| 2 | 3 | import type { CryoSlot } from "@/api/cryobay"; | |
| 3 | 4 | ||
| 4 | 5 | type Props = { | |
| 5 | 6 | slots: CryoSlot[]; | |
| 6 | 7 | onQueuePull: (slotId: string) => void; | |
| 7 | 8 | }; | |
| 8 | 9 | ||
| 9 | 10 | export function CryoDrawerRail({ slots, onQueuePull }: Props) { | |
| 11 | + | const parentRef = useRef<HTMLDivElement>(null); | |
| 12 | + | const rowVirtualizer = useVirtualizer({ | |
| 13 | + | count: slots.length, | |
| 14 | + | getScrollElement: () => parentRef.current, | |
| 15 | + | // Compact rail chrome is 32px in Figma — fixed estimate keeps scroll math cheap | |
| 16 | + | estimateSize: () => 32, | |
| 17 | + | overscan: 8, | |
| 18 | + | }); | |
| 10 | 19 | ||
| 11 | 20 | return ( | |
| 12 | - | <div className="cryo-drawer-rail"> | |
| 13 | - | {slots.map((slot) => ( | |
| 14 | - | <button | |
| 15 | - | key={slot.id} | |
| 16 | - | type="button" | |
| 17 | - | className="cryo-slot-row" | |
| 18 | - | onClick={() => onQueuePull(slot.id)} | |
| 19 | - | > | |
| 20 | - | <span className="cryo-slot-addr">{slot.rack}.{slot.box}.{slot.well}</span> | |
| 21 | - | <span className="cryo-slot-note">{slot.aliquotNote}</span> | |
| 22 | - | </button> | |
| 23 | - | ))} | |
| 24 | - | </div> | |
| 21 | + | <div ref={parentRef} className="cryo-drawer-rail" style={{ overflow: "auto", height: "100%" }}> | |
| 22 | + | <div | |
| 23 | + | style={{ | |
| 24 | + | height: `${rowVirtualizer.getTotalSize()}px`, | |
| 25 | + | width: "100%", | |
| 26 | + | position: "relative", | |
| 27 | + | }} | |
| 28 | + | > | |
| 29 | + | {rowVirtualizer.getVirtualItems().map((virtualRow) => { | |
| 30 | + | const slot = slots[virtualRow.index]!; | |
| 31 | + | return ( | |
| 32 | + | <button | |
| 33 | + | key={slot.id} | |
| 34 | + | type="button" | |
| 35 | + | className="cryo-slot-row" | |
| 36 | + | // aliquotNote can wrap to 2–3 lines on the 7" cart tablet | |
| 37 | + | style={{ | |
| 38 | + | position: "absolute", | |
| 39 | + | top: 0, | |
| 40 | + | left: 0, | |
| 41 | + | width: "100%", | |
| 42 | + | height: `${virtualRow.size}px`, | |
| 43 | + | transform: `translateY(${virtualRow.start}px)`, | |
| 44 | + | }} | |
| 45 | + | onClick={() => onQueuePull(slot.id)} | |
| 46 | + | > | |
| 47 | + | <span className="cryo-slot-addr">{slot.rack}.{slot.box}.{slot.well}</span> | |
| 48 | + | <span className="cryo-slot-note">{slot.aliquotNote}</span> | |
| 49 | + | </button> | |
| 50 | + | ); | |
| 51 | + | })} | |
| 52 | + | </div> | |
| 53 | + | </div> | |
| 25 | 54 | ); | |
| 26 | 55 | } |