(columns: T[])
| 60 | * rather than NaN. |
| 61 | */ |
| 62 | export function useColumnWidths<T extends ColumnWithWidth>(columns: T[]) { |
| 63 | const [widths, setWidths] = useState<number[]>(() => |
| 64 | columns.map((c) => c.defaultWidth) |
| 65 | ); |
| 66 | const dragRef = useRef<{ |
| 67 | colIndex: number; |
| 68 | startX: number; |
| 69 | startWidth: number; |
| 70 | minWidth: number; |
| 71 | } | null>(null); |
| 72 | // Holds the teardown function for an active drag so an unmount-mid-drag |
| 73 | // (route change, modal close, etc.) tears down document-level listeners |
| 74 | // and resets the body cursor/userSelect overrides. |
| 75 | const dragCleanupRef = useRef<() => void>(() => {}); |
| 76 | // Closures should capture state eagerly via refs, not via React deps. |
| 77 | // Putting `widths` in onResizeStart's dep array would rebind the callback |
| 78 | // on every mousemove (since each tick calls setWidths), which would |
| 79 | // re-render every header consumer mid-drag. Mirror the latest values |
| 80 | // into refs via useLayoutEffect (runs synchronously after commit, before |
| 81 | // paint and before any subsequent event handler can fire) so onResizeStart |
| 82 | // stays referentially stable AND avoids render-phase ref writes that |
| 83 | // React 19 concurrent mode could expose from a discarded render. |
| 84 | const widthsRef = useRef(widths); |
| 85 | const columnsRef = useRef(columns); |
| 86 | useLayoutEffect(() => { |
| 87 | widthsRef.current = widths; |
| 88 | columnsRef.current = columns; |
| 89 | }); |
| 90 | |
| 91 | const onResizeStart = useCallback((colIndex: number, e: React.MouseEvent) => { |
| 92 | e.preventDefault(); |
| 93 | e.stopPropagation(); |
| 94 | // Defensively tear down any in-flight drag before starting a new one. |
| 95 | // A second onResizeStart without an intervening mouseup (rapid |
| 96 | // sequential mousedowns, missed mouseup outside the document, |
| 97 | // multi-touch trackpad, programmatic dispatch) would otherwise leak |
| 98 | // the prior drag's document listeners and orphan its cleanup. |
| 99 | dragCleanupRef.current(); |
| 100 | // Defensive: the user clicked a header that was just rendered, so the |
| 101 | // column at this index should be defined. Guard against the edge case |
| 102 | // where columnsRef has been synced to a shorter array via a commit |
| 103 | // that's interleaved with this click. |
| 104 | const col = columnsRef.current[colIndex]; |
| 105 | if (!col) return; |
| 106 | dragRef.current = { |
| 107 | colIndex, |
| 108 | startX: e.clientX, |
| 109 | // Fall back to defaultWidth if widths state hasn't grown to include |
| 110 | // a column added after mount (widths state is sized at first render |
| 111 | // and never auto-extends). Without this, startWidth would be |
| 112 | // undefined and the drag's newWidth math would produce NaN. |
| 113 | startWidth: widthsRef.current[colIndex] ?? col.defaultWidth, |
| 114 | minWidth: col.minWidth ?? 40, |
| 115 | }; |
| 116 | |
| 117 | const onMouseMove = (ev: MouseEvent) => { |
| 118 | if (!dragRef.current) return; |
| 119 | // Read only the snapshot — see "Eager-capture contract" in JSDoc. |
no outgoing calls