main
js 225 lines 6.58 KB
Raw
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 }