| 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 * as React from 'react'; |
| 11 | import {useContext, useEffect} from 'react'; |
| 12 | import {BridgeContext, StoreContext} from '../context'; |
| 13 | import {TreeDispatcherContext} from '../Components/TreeContext'; |
| 14 | import {useScrollToHostInstance} from '../hooks'; |
| 15 | import { |
| 16 | SuspenseTreeDispatcherContext, |
| 17 | SuspenseTreeStateContext, |
| 18 | } from './SuspenseTreeContext'; |
| 19 | import styles from './SuspenseTimeline.css'; |
| 20 | import SuspenseScrubber from './SuspenseScrubber'; |
| 21 | import Button from '../Button'; |
| 22 | import ButtonIcon from '../ButtonIcon'; |
| 23 | import type {SuspenseNode} from '../../../frontend/types'; |
| 24 | |
| 25 | function SuspenseTimelineInput() { |
| 26 | const bridge = useContext(BridgeContext); |
| 27 | const store = useContext(StoreContext); |
| 28 | const treeDispatch = useContext(TreeDispatcherContext); |
| 29 | const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); |
| 30 | const scrollToHostInstance = useScrollToHostInstance(); |
| 31 | |
| 32 | const {timeline, timelineIndex, hoveredTimelineIndex, playing, autoScroll} = |
| 33 | useContext(SuspenseTreeStateContext); |
| 34 | |
| 35 | const min = 0; |
| 36 | const max = timeline.length > 0 ? timeline.length - 1 : 0; |
| 37 | |
| 38 | function switchSuspenseNode(nextTimelineIndex: number) { |
| 39 | const nextSelectedSuspenseID = timeline[nextTimelineIndex].id; |
| 40 | treeDispatch({ |
| 41 | type: 'SELECT_ELEMENT_BY_ID', |
| 42 | payload: nextSelectedSuspenseID, |
| 43 | }); |
| 44 | suspenseTreeDispatch({ |
| 45 | type: 'SUSPENSE_SET_TIMELINE_INDEX', |
| 46 | payload: nextTimelineIndex, |
| 47 | }); |
| 48 | } |
| 49 | |
| 50 | function handleChange(pendingTimelineIndex: number) { |
| 51 | switchSuspenseNode(pendingTimelineIndex); |
| 52 | } |
| 53 | |
| 54 | function handleFocus() { |
| 55 | switchSuspenseNode(timelineIndex); |
| 56 | } |
| 57 | |
| 58 | function handleHoverSegment(hoveredIndex: number) { |
| 59 | const nextSelectedSuspenseID = timeline[hoveredIndex].id; |
| 60 | suspenseTreeDispatch({ |
| 61 | type: 'HOVER_TIMELINE_FOR_ID', |
| 62 | payload: nextSelectedSuspenseID, |
| 63 | }); |
| 64 | } |
| 65 | function handleUnhoverSegment() { |
| 66 | suspenseTreeDispatch({ |
| 67 | type: 'HOVER_TIMELINE_FOR_ID', |
| 68 | payload: -1, |
| 69 | }); |
| 70 | } |
| 71 | |
| 72 | function skipPrevious() { |
| 73 | const nextSelectedSuspenseID = timeline[timelineIndex - 1].id; |
| 74 | treeDispatch({ |
| 75 | type: 'SELECT_ELEMENT_BY_ID', |
| 76 | payload: nextSelectedSuspenseID, |
| 77 | }); |
| 78 | suspenseTreeDispatch({ |
| 79 | type: 'SUSPENSE_SKIP_TIMELINE_INDEX', |
| 80 | payload: false, |
| 81 | }); |
| 82 | } |
| 83 | |
| 84 | function skipForward() { |
| 85 | const nextSelectedSuspenseID = timeline[timelineIndex + 1].id; |
| 86 | treeDispatch({ |
| 87 | type: 'SELECT_ELEMENT_BY_ID', |
| 88 | payload: nextSelectedSuspenseID, |
| 89 | }); |
| 90 | suspenseTreeDispatch({ |
| 91 | type: 'SUSPENSE_SKIP_TIMELINE_INDEX', |
| 92 | payload: true, |
| 93 | }); |
| 94 | } |
| 95 | |
| 96 | function togglePlaying() { |
| 97 | suspenseTreeDispatch({ |
| 98 | type: 'SUSPENSE_PLAY_PAUSE', |
| 99 | payload: 'toggle', |
| 100 | }); |
| 101 | } |
| 102 | |
| 103 | // TODO: useEffectEvent here once it's supported in all versions DevTools supports. |
| 104 | // For now we just exclude it from deps since we don't lint those anyway. |
| 105 | function changeTimelineIndex(newIndex: number) { |
| 106 | const suspendedSetByRendererID = new Map< |
| 107 | number, |
| 108 | Array<SuspenseNode['id']>, |
| 109 | >(); |
| 110 | // Unsuspend everything by default. |
| 111 | // We might not encounter every renderer after the milestone e.g. |
| 112 | // if we clicked at the end of the timeline. |
| 113 | // eslint-disable-next-line no-for-of-loops/no-for-of-loops |
| 114 | for (const rendererID of store.rootIDToRendererID.values()) { |
| 115 | suspendedSetByRendererID.set(rendererID, []); |
| 116 | } |
| 117 | |
| 118 | // Synchronize timeline index with what is resuspended. |
| 119 | // We suspend everything after the current selection. The root isn't showing |
| 120 | // anything suspended in the root. The step after that should have one less |
| 121 | // thing suspended. I.e. the first suspense boundary should be unsuspended |
| 122 | // when it's selected. This also lets you show everything in the last step. |
| 123 | for (let i = timelineIndex + 1; i < timeline.length; i++) { |
| 124 | const step = timeline[i]; |
| 125 | const {rendererID} = step; |
| 126 | const suspendedSetForRendererID = |
| 127 | suspendedSetByRendererID.get(rendererID); |
| 128 | if (suspendedSetForRendererID === undefined) { |
| 129 | throw new Error( |
| 130 | `Should have initialized suspended set for renderer ID "${rendererID}" earlier. This is a bug in React DevTools.`, |
| 131 | ); |
| 132 | } |
| 133 | suspendedSetForRendererID.push(step.id); |
| 134 | } |
| 135 | |
| 136 | // eslint-disable-next-line no-for-of-loops/no-for-of-loops |
| 137 | for (const [rendererID, suspendedSet] of suspendedSetByRendererID) { |
| 138 | bridge.send('overrideSuspenseMilestone', { |
| 139 | rendererID, |
| 140 | suspendedSet, |
| 141 | }); |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | useEffect(() => { |
| 146 | changeTimelineIndex(timelineIndex); |
| 147 | }, [timelineIndex]); |
| 148 | |
| 149 | useEffect(() => { |
| 150 | if (autoScroll.id > 0) { |
| 151 | const scrollToId = autoScroll.id; |
| 152 | // Consume the scroll ref so that we only trigger this scroll once. |
| 153 | autoScroll.id = 0; |
| 154 | scrollToHostInstance(scrollToId); |
| 155 | } |
| 156 | }, [autoScroll]); |
| 157 | |
| 158 | useEffect(() => { |
| 159 | if (!playing) { |
| 160 | return undefined; |
| 161 | } |
| 162 | // While playing, advance one step every second. |
| 163 | const PLAY_SPEED_INTERVAL = 1000; |
| 164 | const timer = setInterval(() => { |
| 165 | suspenseTreeDispatch({ |
| 166 | type: 'SUSPENSE_PLAY_TICK', |
| 167 | }); |
| 168 | }, PLAY_SPEED_INTERVAL); |
| 169 | return () => { |
| 170 | clearInterval(timer); |
| 171 | }; |
| 172 | }, [playing]); |
| 173 | |
| 174 | if (timeline.length === 0) { |
| 175 | return ( |
| 176 | <div className={styles.SuspenseTimelineInput}> |
| 177 | Root contains no Suspense nodes. |
| 178 | </div> |
| 179 | ); |
| 180 | } |
| 181 | |
| 182 | return ( |
| 183 | <> |
| 184 | <Button |
| 185 | disabled={timelineIndex === 0} |
| 186 | title={'Previous'} |
| 187 | onClick={skipPrevious}> |
| 188 | <ButtonIcon type={'skip-previous'} /> |
| 189 | </Button> |
| 190 | <Button |
| 191 | disabled={max === 0 && !playing} |
| 192 | title={playing ? 'Pause' : 'Play'} |
| 193 | onClick={togglePlaying}> |
| 194 | <ButtonIcon type={playing ? 'pause' : 'play'} /> |
| 195 | </Button> |
| 196 | <Button |
| 197 | disabled={timelineIndex === max} |
| 198 | title={'Next'} |
| 199 | onClick={skipForward}> |
| 200 | <ButtonIcon type={'skip-next'} /> |
| 201 | </Button> |
| 202 | <div className={styles.SuspenseTimelineInput}> |
| 203 | <SuspenseScrubber |
| 204 | min={min} |
| 205 | max={max} |
| 206 | timeline={timeline} |
| 207 | value={timelineIndex} |
| 208 | highlight={hoveredTimelineIndex} |
| 209 | onChange={handleChange} |
| 210 | onFocus={handleFocus} |
| 211 | onHoverSegment={handleHoverSegment} |
| 212 | onHoverLeave={handleUnhoverSegment} |
| 213 | /> |
| 214 | </div> |
| 215 | </> |
| 216 | ); |
| 217 | } |
| 218 | |
| 219 | export default function SuspenseTimeline(): React$Node { |
| 220 | return ( |
| 221 | <div className={styles.SuspenseTimelineContainer}> |
| 222 | <SuspenseTimelineInput /> |
| 223 | </div> |
| 224 | ); |
| 225 | } |