| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | * |
| 7 | * @flow |
| 8 | */ |
| 9 | |
| 10 | import type {CommitDataFrontend} from './types'; |
| 11 | |
| 12 | import * as React from 'react'; |
| 13 | import {useEffect, useMemo, useRef, useState} from 'react'; |
| 14 | import AutoSizer from 'react-virtualized-auto-sizer'; |
| 15 | import {FixedSizeList} from 'react-window'; |
| 16 | import SnapshotCommitListItem from './SnapshotCommitListItem'; |
| 17 | import {minBarWidth} from './constants'; |
| 18 | import {formatDuration, formatTime} from './utils'; |
| 19 | import Tooltip from './Tooltip'; |
| 20 | |
| 21 | import styles from './SnapshotCommitList.css'; |
| 22 | |
| 23 | export type ItemData = { |
| 24 | commitTimes: Array<number>, |
| 25 | filteredCommitIndices: Array<number>, |
| 26 | maxDuration: number, |
| 27 | selectedCommitIndex: number | null, |
| 28 | selectedFilteredCommitIndex: number | null, |
| 29 | selectCommitIndex: (index: number) => void, |
| 30 | setHoveredCommitIndex: (index: number) => void, |
| 31 | startCommitDrag: (newDragState: DragState) => void, |
| 32 | totalDurations: Array<number>, |
| 33 | }; |
| 34 | |
| 35 | type Props = { |
| 36 | commitData: $ReadOnlyArray<CommitDataFrontend>, |
| 37 | commitTimes: Array<number>, |
| 38 | filteredCommitIndices: Array<number>, |
| 39 | selectedCommitIndex: number | null, |
| 40 | selectedFilteredCommitIndex: number | null, |
| 41 | selectCommitIndex: (index: number) => void, |
| 42 | totalDurations: Array<number>, |
| 43 | }; |
| 44 | |
| 45 | export default function SnapshotCommitList({ |
| 46 | commitData, |
| 47 | commitTimes, |
| 48 | filteredCommitIndices, |
| 49 | selectedCommitIndex, |
| 50 | selectedFilteredCommitIndex, |
| 51 | selectCommitIndex, |
| 52 | totalDurations, |
| 53 | }: Props): React.Node { |
| 54 | return ( |
| 55 | <AutoSizer> |
| 56 | {({height, width}) => ( |
| 57 | <List |
| 58 | commitData={commitData} |
| 59 | commitTimes={commitTimes} |
| 60 | height={height} |
| 61 | filteredCommitIndices={filteredCommitIndices} |
| 62 | selectedCommitIndex={selectedCommitIndex} |
| 63 | selectedFilteredCommitIndex={selectedFilteredCommitIndex} |
| 64 | selectCommitIndex={selectCommitIndex} |
| 65 | totalDurations={totalDurations} |
| 66 | width={width} |
| 67 | /> |
| 68 | )} |
| 69 | </AutoSizer> |
| 70 | ); |
| 71 | } |
| 72 | |
| 73 | type ListProps = { |
| 74 | commitData: $ReadOnlyArray<CommitDataFrontend>, |
| 75 | commitTimes: Array<number>, |
| 76 | height: number, |
| 77 | filteredCommitIndices: Array<number>, |
| 78 | selectedCommitIndex: number | null, |
| 79 | selectedFilteredCommitIndex: number | null, |
| 80 | selectCommitIndex: (index: number) => void, |
| 81 | totalDurations: Array<number>, |
| 82 | width: number, |
| 83 | }; |
| 84 | |
| 85 | type DragState = { |
| 86 | commitIndex: number, |
| 87 | left: number, |
| 88 | sizeIncrement: number, |
| 89 | }; |
| 90 | |
| 91 | function List({ |
| 92 | commitData, |
| 93 | selectedCommitIndex, |
| 94 | commitTimes, |
| 95 | height, |
| 96 | filteredCommitIndices, |
| 97 | selectedFilteredCommitIndex, |
| 98 | selectCommitIndex, |
| 99 | totalDurations, |
| 100 | width, |
| 101 | }: ListProps) { |
| 102 | // $FlowFixMe[incompatible-use] |
| 103 | const listRef = useRef<FixedSizeList<ItemData> | null>(null); |
| 104 | const divRef = useRef<HTMLDivElement | null>(null); |
| 105 | const prevCommitIndexRef = useRef<number | null>(null); |
| 106 | |
| 107 | // Make sure a newly selected snapshot is fully visible within the list. |
| 108 | useEffect(() => { |
| 109 | if (selectedFilteredCommitIndex !== prevCommitIndexRef.current) { |
| 110 | prevCommitIndexRef.current = selectedFilteredCommitIndex; |
| 111 | if (selectedFilteredCommitIndex !== null && listRef.current !== null) { |
| 112 | listRef.current.scrollToItem(selectedFilteredCommitIndex); |
| 113 | } |
| 114 | } |
| 115 | }, [listRef, selectedFilteredCommitIndex]); |
| 116 | |
| 117 | const itemSize = useMemo( |
| 118 | () => Math.max(minBarWidth, width / filteredCommitIndices.length), |
| 119 | [filteredCommitIndices, width], |
| 120 | ); |
| 121 | const maxDuration = useMemo( |
| 122 | () => totalDurations.reduce((max, duration) => Math.max(max, duration), 0), |
| 123 | [totalDurations], |
| 124 | ); |
| 125 | |
| 126 | const maxCommitIndex = filteredCommitIndices.length - 1; |
| 127 | |
| 128 | const [dragState, setDragState] = useState<DragState | null>(null); |
| 129 | |
| 130 | const handleDragCommit = ({buttons, pageX}: any) => { |
| 131 | if (buttons === 0) { |
| 132 | setDragState(null); |
| 133 | return; |
| 134 | } |
| 135 | |
| 136 | if (dragState !== null) { |
| 137 | const {commitIndex, left, sizeIncrement} = dragState; |
| 138 | |
| 139 | let newCommitIndex = commitIndex; |
| 140 | let newCommitLeft = left; |
| 141 | |
| 142 | if (pageX < newCommitLeft) { |
| 143 | while (pageX < newCommitLeft) { |
| 144 | newCommitLeft -= sizeIncrement; |
| 145 | newCommitIndex -= 1; |
| 146 | } |
| 147 | } else { |
| 148 | let newCommitRectRight = newCommitLeft + sizeIncrement; |
| 149 | while (pageX > newCommitRectRight) { |
| 150 | newCommitRectRight += sizeIncrement; |
| 151 | newCommitIndex += 1; |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | if (newCommitIndex < 0) { |
| 156 | newCommitIndex = 0; |
| 157 | } else if (newCommitIndex > maxCommitIndex) { |
| 158 | newCommitIndex = maxCommitIndex; |
| 159 | } |
| 160 | |
| 161 | selectCommitIndex(newCommitIndex); |
| 162 | } |
| 163 | }; |
| 164 | |
| 165 | useEffect(() => { |
| 166 | if (dragState === null) { |
| 167 | return; |
| 168 | } |
| 169 | |
| 170 | const element = divRef.current; |
| 171 | if (element !== null) { |
| 172 | const ownerDocument = element.ownerDocument; |
| 173 | ownerDocument.addEventListener('mousemove', handleDragCommit); |
| 174 | return () => { |
| 175 | ownerDocument.removeEventListener('mousemove', handleDragCommit); |
| 176 | }; |
| 177 | } |
| 178 | }, [dragState]); |
| 179 | |
| 180 | const [hoveredCommitIndex, setHoveredCommitIndex] = useState<number | null>( |
| 181 | null, |
| 182 | ); |
| 183 | |
| 184 | // Pass required contextual data down to the ListItem renderer. |
| 185 | const itemData = useMemo<ItemData>( |
| 186 | () => ({ |
| 187 | commitTimes, |
| 188 | filteredCommitIndices, |
| 189 | maxDuration, |
| 190 | selectedCommitIndex, |
| 191 | selectedFilteredCommitIndex, |
| 192 | selectCommitIndex, |
| 193 | setHoveredCommitIndex, |
| 194 | startCommitDrag: setDragState, |
| 195 | totalDurations, |
| 196 | }), |
| 197 | [ |
| 198 | commitTimes, |
| 199 | filteredCommitIndices, |
| 200 | maxDuration, |
| 201 | selectedCommitIndex, |
| 202 | selectedFilteredCommitIndex, |
| 203 | selectCommitIndex, |
| 204 | setHoveredCommitIndex, |
| 205 | totalDurations, |
| 206 | ], |
| 207 | ); |
| 208 | |
| 209 | let tooltipLabel = null; |
| 210 | if (hoveredCommitIndex !== null) { |
| 211 | const { |
| 212 | duration, |
| 213 | effectDuration, |
| 214 | passiveEffectDuration, |
| 215 | priorityLevel, |
| 216 | timestamp, |
| 217 | } = commitData[hoveredCommitIndex]; |
| 218 | |
| 219 | // Only some React versions include commit durations. |
| 220 | // Show a richer tooltip only for builds that have that info. |
| 221 | if ( |
| 222 | effectDuration !== null || |
| 223 | passiveEffectDuration !== null || |
| 224 | priorityLevel !== null |
| 225 | ) { |
| 226 | tooltipLabel = ( |
| 227 | <ul className={styles.TooltipList}> |
| 228 | {priorityLevel !== null && ( |
| 229 | <li className={styles.TooltipListItem}> |
| 230 | <label className={styles.TooltipLabel}>Priority</label> |
| 231 | <span className={styles.TooltipValue}>{priorityLevel}</span> |
| 232 | </li> |
| 233 | )} |
| 234 | <li className={styles.TooltipListItem}> |
| 235 | <label className={styles.TooltipLabel}>Committed at</label> |
| 236 | <span className={styles.TooltipValue}> |
| 237 | {formatTime(timestamp)}s |
| 238 | </span> |
| 239 | </li> |
| 240 | <li className={styles.TooltipListItem}> |
| 241 | <div className={styles.DurationsWrapper}> |
| 242 | <label className={styles.TooltipLabel}>Durations</label> |
| 243 | <ul className={styles.DurationsList}> |
| 244 | <li className={styles.DurationsListItem}> |
| 245 | <label className={styles.DurationsLabel}>Render</label> |
| 246 | <span className={styles.DurationsValue}> |
| 247 | {formatDuration(duration)}ms |
| 248 | </span> |
| 249 | </li> |
| 250 | {effectDuration !== null && ( |
| 251 | <li className={styles.DurationsListItem}> |
| 252 | <label className={styles.DurationsLabel}> |
| 253 | Layout effects |
| 254 | </label> |
| 255 | <span className={styles.DurationsValue}> |
| 256 | {formatDuration(effectDuration)}ms |
| 257 | </span> |
| 258 | </li> |
| 259 | )} |
| 260 | {passiveEffectDuration !== null && ( |
| 261 | <li className={styles.DurationsListItem}> |
| 262 | <label className={styles.DurationsLabel}> |
| 263 | Passive effects |
| 264 | </label> |
| 265 | <span className={styles.DurationsValue}> |
| 266 | {formatDuration(passiveEffectDuration)}ms |
| 267 | </span> |
| 268 | </li> |
| 269 | )} |
| 270 | </ul> |
| 271 | </div> |
| 272 | </li> |
| 273 | </ul> |
| 274 | ); |
| 275 | } else { |
| 276 | tooltipLabel = `${formatDuration(duration)}ms at ${formatTime( |
| 277 | timestamp, |
| 278 | )}s`; |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | return ( |
| 283 | <Tooltip className={styles.Tooltip} label={tooltipLabel}> |
| 284 | <div |
| 285 | ref={divRef} |
| 286 | style={{height, width}} |
| 287 | onMouseLeave={() => setHoveredCommitIndex(null)}> |
| 288 | <FixedSizeList |
| 289 | className={styles.List} |
| 290 | layout="horizontal" |
| 291 | height={height} |
| 292 | itemCount={filteredCommitIndices.length} |
| 293 | itemData={itemData} |
| 294 | itemSize={itemSize} |
| 295 | ref={ |
| 296 | listRef as any |
| 297 | |
| 298 | /* Flow bug? */ |
| 299 | } |
| 300 | width={width}> |
| 301 | {SnapshotCommitListItem} |
| 302 | </FixedSizeList> |
| 303 | </div> |
| 304 | </Tooltip> |
| 305 | ); |
| 306 | } |