main
js 492 lines 17.5 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 import type {ReactContext} from 'shared/ReactTypes';
10 import type {
11 SuspenseNode,
12 SuspenseTimelineStep,
13 } from 'react-devtools-shared/src/frontend/types';
14 import type Store from '../../store';
15
16 import * as React from 'react';
17 import {
18 createContext,
19 startTransition,
20 useContext,
21 useEffect,
22 useMemo,
23 useReducer,
24 } from 'react';
25 import {StoreContext} from '../context';
26
27 export type SuspenseTreeState = {
28 lineage: $ReadOnlyArray<SuspenseNode['id']> | null,
29 roots: $ReadOnlyArray<SuspenseNode['id']>,
30 selectedSuspenseID: SuspenseNode['id'] | null,
31 timeline: $ReadOnlyArray<SuspenseTimelineStep>,
32 timelineIndex: number | -1,
33 hoveredTimelineIndex: number | -1,
34 uniqueSuspendersOnly: boolean,
35 playing: boolean,
36 autoSelect: boolean,
37 autoScroll: {id: number}, // Ref that's set to 0 after scrolling once.
38 };
39
40 type ACTION_SUSPENSE_TREE_MUTATION = {
41 type: 'HANDLE_SUSPENSE_TREE_MUTATION',
42 payload: [Map<SuspenseNode['id'], SuspenseNode['id']>],
43 };
44 type ACTION_SET_SUSPENSE_LINEAGE = {
45 type: 'SET_SUSPENSE_LINEAGE',
46 payload: SuspenseNode['id'],
47 };
48 type ACTION_SELECT_SUSPENSE_BY_ID = {
49 type: 'SELECT_SUSPENSE_BY_ID',
50 payload: SuspenseNode['id'],
51 };
52 type ACTION_SET_SUSPENSE_TIMELINE = {
53 type: 'SET_SUSPENSE_TIMELINE',
54 payload: [
55 $ReadOnlyArray<SuspenseTimelineStep>,
56 // The next Suspense ID to select in the timeline
57 SuspenseNode['id'] | null,
58 // Whether this timeline includes only unique suspenders
59 boolean,
60 ],
61 };
62 type ACTION_SUSPENSE_SET_TIMELINE_INDEX = {
63 type: 'SUSPENSE_SET_TIMELINE_INDEX',
64 payload: number,
65 };
66 type ACTION_SUSPENSE_SKIP_TIMELINE_INDEX = {
67 type: 'SUSPENSE_SKIP_TIMELINE_INDEX',
68 payload: boolean,
69 };
70 type ACTION_SUSPENSE_PLAY_PAUSE = {
71 type: 'SUSPENSE_PLAY_PAUSE',
72 payload: 'toggle' | 'play' | 'pause',
73 };
74 type ACTION_SUSPENSE_PLAY_TICK = {
75 type: 'SUSPENSE_PLAY_TICK',
76 };
77 type ACTION_TOGGLE_TIMELINE_FOR_ID = {
78 type: 'TOGGLE_TIMELINE_FOR_ID',
79 payload: SuspenseNode['id'],
80 };
81 type ACTION_HOVER_TIMELINE_FOR_ID = {
82 type: 'HOVER_TIMELINE_FOR_ID',
83 payload: SuspenseNode['id'],
84 };
85
86 export type SuspenseTreeAction =
87 | ACTION_SUSPENSE_TREE_MUTATION
88 | ACTION_SET_SUSPENSE_LINEAGE
89 | ACTION_SELECT_SUSPENSE_BY_ID
90 | ACTION_SET_SUSPENSE_TIMELINE
91 | ACTION_SUSPENSE_SET_TIMELINE_INDEX
92 | ACTION_SUSPENSE_SKIP_TIMELINE_INDEX
93 | ACTION_SUSPENSE_PLAY_PAUSE
94 | ACTION_SUSPENSE_PLAY_TICK
95 | ACTION_TOGGLE_TIMELINE_FOR_ID
96 | ACTION_HOVER_TIMELINE_FOR_ID;
97 export type SuspenseTreeDispatch = (action: SuspenseTreeAction) => void;
98
99 const SuspenseTreeStateContext: ReactContext<SuspenseTreeState> =
100 createContext<SuspenseTreeState>(null as any as SuspenseTreeState);
101 SuspenseTreeStateContext.displayName = 'SuspenseTreeStateContext';
102
103 const SuspenseTreeDispatcherContext: ReactContext<SuspenseTreeDispatch> =
104 createContext<SuspenseTreeDispatch>(null as any as SuspenseTreeDispatch);
105 SuspenseTreeDispatcherContext.displayName = 'SuspenseTreeDispatcherContext';
106
107 type Props = {
108 children: React$Node,
109 };
110
111 function getInitialState(store: Store): SuspenseTreeState {
112 const uniqueSuspendersOnly = true;
113 const timeline =
114 store.getEndTimeOrDocumentOrderSuspense(uniqueSuspendersOnly);
115 const timelineIndex = timeline.length - 1;
116 const selectedSuspenseID =
117 timelineIndex === -1 ? null : timeline[timelineIndex].id;
118 const lineage =
119 selectedSuspenseID !== null
120 ? store.getSuspenseLineage(selectedSuspenseID)
121 : [];
122 const initialState: SuspenseTreeState = {
123 selectedSuspenseID,
124 lineage,
125 roots: store.roots,
126 timeline,
127 timelineIndex,
128 hoveredTimelineIndex: -1,
129 uniqueSuspendersOnly,
130 playing: false,
131 autoSelect: true,
132 autoScroll: {id: 0}, // Don't auto-scroll initially
133 };
134
135 return initialState;
136 }
137
138 function SuspenseTreeContextController({children}: Props): React.Node {
139 const store = useContext(StoreContext);
140 // This reducer is created inline because it needs access to the Store.
141 // The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
142 // so it's okay for the reducer to have an empty dependencies array.
143 const reducer = useMemo(
144 () =>
145 (
146 state: SuspenseTreeState,
147 action: SuspenseTreeAction,
148 ): SuspenseTreeState => {
149 switch (action.type) {
150 case 'HANDLE_SUSPENSE_TREE_MUTATION': {
151 let {selectedSuspenseID} = state;
152 // If the currently-selected Element has been removed from the tree, update selection state.
153 const removedIDs = action.payload[0];
154 // Find the closest parent that wasn't removed during this batch.
155 // We deduce the parent-child mapping from removedIDs (id -> parentID)
156 // because by now it's too late to read them from the store.
157
158 while (
159 selectedSuspenseID !== null &&
160 removedIDs.has(selectedSuspenseID)
161 ) {
162 // $FlowExpectedError[incompatible-type]
163 selectedSuspenseID = removedIDs.get(selectedSuspenseID);
164 }
165 if (selectedSuspenseID === 0) {
166 // The whole root was removed.
167 selectedSuspenseID = null;
168 }
169
170 const selectedTimelineStep =
171 // $FlowFixMe[invalid-compare]
172 state.timeline === null || state.timelineIndex === -1
173 ? null
174 : state.timeline[state.timelineIndex];
175 let selectedTimelineID: null | number = null;
176 if (selectedTimelineStep !== null) {
177 selectedTimelineID = selectedTimelineStep.id;
178 // $FlowFixMe[incompatible-type]
179 while (removedIDs.has(selectedTimelineID)) {
180 // $FlowFixMe[incompatible-type]
181 selectedTimelineID = removedIDs.get(selectedTimelineID);
182 }
183 }
184
185 // TODO: Handle different timeline modes (e.g. random order)
186 const nextTimeline = store.getEndTimeOrDocumentOrderSuspense(
187 state.uniqueSuspendersOnly,
188 );
189
190 let nextTimelineIndex = -1;
191 if (selectedTimelineID !== null && nextTimeline.length !== 0) {
192 for (let i = 0; i < nextTimeline.length; i++) {
193 if (nextTimeline[i].id === selectedTimelineID) {
194 nextTimelineIndex = i;
195 break;
196 }
197 }
198 }
199 if (
200 nextTimeline.length > 0 &&
201 (nextTimelineIndex === -1 || state.autoSelect)
202 ) {
203 nextTimelineIndex = nextTimeline.length - 1;
204 selectedSuspenseID = nextTimeline[nextTimelineIndex].id;
205 }
206
207 if (selectedSuspenseID === null && nextTimeline.length > 0) {
208 selectedSuspenseID = nextTimeline[nextTimeline.length - 1].id;
209 }
210
211 const nextLineage =
212 selectedSuspenseID !== null &&
213 state.selectedSuspenseID !== selectedSuspenseID
214 ? store.getSuspenseLineage(selectedSuspenseID)
215 : state.lineage;
216
217 return {
218 ...state,
219 lineage: nextLineage,
220 roots: store.roots,
221 selectedSuspenseID,
222 timeline: nextTimeline,
223 timelineIndex: nextTimelineIndex,
224 };
225 }
226 case 'SELECT_SUSPENSE_BY_ID': {
227 const selectedSuspenseID = action.payload;
228
229 return {
230 ...state,
231 selectedSuspenseID,
232 playing: false, // pause
233 autoSelect: false,
234 autoScroll: {id: selectedSuspenseID}, // scroll
235 };
236 }
237 case 'SET_SUSPENSE_LINEAGE': {
238 const suspenseID = action.payload;
239 const lineage = store.getSuspenseLineage(suspenseID);
240
241 return {
242 ...state,
243 lineage,
244 selectedSuspenseID: suspenseID,
245 playing: false, // pause
246 autoSelect: false,
247 };
248 }
249 case 'SET_SUSPENSE_TIMELINE': {
250 const previousMilestoneIndex = state.timelineIndex;
251 const previousTimeline = state.timeline;
252 const nextTimeline = action.payload[0];
253 const nextRootID: SuspenseNode['id'] | null = action.payload[1];
254 const nextUniqueSuspendersOnly = action.payload[2];
255 let nextLineage = state.lineage;
256 let nextMilestoneIndex: number | -1 = -1;
257 let nextSelectedSuspenseID = state.selectedSuspenseID;
258 // Action has indicated it has no preference for the selected Node.
259 // Try to reconcile the new timeline with the previous index.
260 if (
261 nextRootID === null &&
262 // $FlowFixMe[invalid-compare]
263 previousTimeline !== null &&
264 // $FlowFixMe[invalid-compare]
265 previousMilestoneIndex !== null
266 ) {
267 const previousMilestoneID =
268 previousTimeline[previousMilestoneIndex];
269 nextMilestoneIndex = nextTimeline.indexOf(previousMilestoneID);
270 if (nextMilestoneIndex === -1 && nextTimeline.length > 0) {
271 nextMilestoneIndex = nextTimeline.length - 1;
272 nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex].id;
273 nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
274 }
275 } else if (nextRootID !== null) {
276 nextMilestoneIndex = nextTimeline.length - 1;
277 nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex].id;
278 nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
279 }
280
281 return {
282 ...state,
283 selectedSuspenseID: nextSelectedSuspenseID,
284 lineage: nextLineage,
285 timeline: nextTimeline,
286 timelineIndex: nextMilestoneIndex,
287 uniqueSuspendersOnly: nextUniqueSuspendersOnly,
288 };
289 }
290 case 'SUSPENSE_SET_TIMELINE_INDEX': {
291 const nextTimelineIndex = action.payload;
292 const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
293 const nextLineage = store.getSuspenseLineage(
294 nextSelectedSuspenseID,
295 );
296
297 return {
298 ...state,
299 lineage: nextLineage,
300 selectedSuspenseID: nextSelectedSuspenseID,
301 timelineIndex: nextTimelineIndex,
302 playing: false, // pause
303 autoSelect: false,
304 autoScroll: {id: nextSelectedSuspenseID}, // scroll
305 };
306 }
307 case 'SUSPENSE_SKIP_TIMELINE_INDEX': {
308 const direction = action.payload;
309 const nextTimelineIndex =
310 state.timelineIndex + (direction ? 1 : -1);
311 if (
312 nextTimelineIndex < 0 ||
313 nextTimelineIndex > state.timeline.length - 1
314 ) {
315 return state;
316 }
317 const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
318 const nextLineage = store.getSuspenseLineage(
319 nextSelectedSuspenseID,
320 );
321 return {
322 ...state,
323 lineage: nextLineage,
324 selectedSuspenseID: nextSelectedSuspenseID,
325 timelineIndex: nextTimelineIndex,
326 playing: false, // pause
327 autoSelect: false,
328 autoScroll: {id: nextSelectedSuspenseID}, // scroll
329 };
330 }
331 case 'SUSPENSE_PLAY_PAUSE': {
332 const mode = action.payload;
333
334 let nextTimelineIndex = state.timelineIndex;
335 let nextSelectedSuspenseID = state.selectedSuspenseID;
336 let nextLineage = state.lineage;
337
338 if (
339 !state.playing &&
340 mode !== 'pause' &&
341 nextTimelineIndex === state.timeline.length - 1
342 ) {
343 // If we're restarting at the end. Then loop around and start again from the beginning.
344 nextTimelineIndex = 0;
345 nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
346 nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
347 }
348
349 return {
350 ...state,
351 lineage: nextLineage,
352 selectedSuspenseID: nextSelectedSuspenseID,
353 timelineIndex: nextTimelineIndex,
354 playing: mode === 'toggle' ? !state.playing : mode === 'play',
355 autoSelect: false,
356 };
357 }
358 case 'SUSPENSE_PLAY_TICK': {
359 if (!state.playing) {
360 // We stopped but haven't yet cleaned up the callback. Noop.
361 return state;
362 }
363 // Advance time
364 const nextTimelineIndex = state.timelineIndex + 1;
365 if (nextTimelineIndex > state.timeline.length - 1) {
366 return state;
367 }
368 const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
369 const nextLineage = store.getSuspenseLineage(
370 nextSelectedSuspenseID,
371 );
372 // Stop once we reach the end.
373 const nextPlaying = nextTimelineIndex < state.timeline.length - 1;
374 return {
375 ...state,
376 lineage: nextLineage,
377 selectedSuspenseID: nextSelectedSuspenseID,
378 timelineIndex: nextTimelineIndex,
379 playing: nextPlaying,
380 autoScroll: {id: nextSelectedSuspenseID}, // scroll
381 };
382 }
383 case 'TOGGLE_TIMELINE_FOR_ID': {
384 const suspenseID = action.payload;
385
386 let timelineIndexForSuspenseID = -1;
387 for (let i = 0; i < state.timeline.length; i++) {
388 if (state.timeline[i].id === suspenseID) {
389 timelineIndexForSuspenseID = i;
390 break;
391 }
392 }
393 if (timelineIndexForSuspenseID === -1) {
394 // This boundary is no longer in the timeline.
395 return state;
396 }
397 const nextTimelineIndex =
398 timelineIndexForSuspenseID === 0
399 ? // For roots, there's no toggling. It's always just jump to beginning.
400 0
401 : // For boundaries, we'll either jump to before or after its reveal depending
402 // on if we're currently displaying it or not according to the timeline.
403 state.timelineIndex < timelineIndexForSuspenseID
404 ? // We're currently before this suspense boundary has been revealed so we
405 // should jump ahead to reveal it.
406 timelineIndexForSuspenseID
407 : // Otherwise, if we're currently showing it, jump to right before to hide it.
408 timelineIndexForSuspenseID - 1;
409 const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
410 const nextLineage = store.getSuspenseLineage(
411 nextSelectedSuspenseID,
412 );
413 return {
414 ...state,
415 lineage: nextLineage,
416 selectedSuspenseID: nextSelectedSuspenseID,
417 timelineIndex: nextTimelineIndex,
418 playing: false, // pause
419 autoSelect: false,
420 autoScroll: {id: nextSelectedSuspenseID},
421 };
422 }
423 case 'HOVER_TIMELINE_FOR_ID': {
424 const suspenseID = action.payload;
425 let timelineIndexForSuspenseID = -1;
426 for (let i = 0; i < state.timeline.length; i++) {
427 if (state.timeline[i].id === suspenseID) {
428 timelineIndexForSuspenseID = i;
429 break;
430 }
431 }
432 return {
433 ...state,
434 hoveredTimelineIndex: timelineIndexForSuspenseID,
435 };
436 }
437 default:
438 throw new Error(`Unrecognized action "${action.type}"`);
439 }
440 },
441 [],
442 );
443
444 const [state, dispatch] = useReducer(reducer, store, getInitialState);
445
446 const initialRevision = useMemo(() => store.revisionSuspense, [store]);
447 // We're currently storing everything Suspense related in the same Store as
448 // Components. However, most reads are currently stateless. This ensures
449 // the latest state is always read from the Store.
450 useEffect(() => {
451 const handleSuspenseTreeMutated = ([removedElementIDs]: [
452 Map<number, number>,
453 ]) => {
454 dispatch({
455 type: 'HANDLE_SUSPENSE_TREE_MUTATION',
456 payload: [removedElementIDs],
457 });
458 };
459
460 // Since this is a passive effect, the tree may have been mutated before our initial subscription.
461 if (store.revisionSuspense !== initialRevision) {
462 // At the moment, we can treat this as a mutation.
463 handleSuspenseTreeMutated([new Map()]);
464 }
465
466 store.addListener('suspenseTreeMutated', handleSuspenseTreeMutated);
467 return () =>
468 store.removeListener('suspenseTreeMutated', handleSuspenseTreeMutated);
469 }, [initialRevision, store]);
470
471 const transitionDispatch = useMemo(
472 () => (action: SuspenseTreeAction) =>
473 startTransition(() => {
474 dispatch(action);
475 }),
476 [dispatch],
477 );
478
479 return (
480 <SuspenseTreeStateContext.Provider value={state}>
481 <SuspenseTreeDispatcherContext.Provider value={transitionDispatch}>
482 {children}
483 </SuspenseTreeDispatcherContext.Provider>
484 </SuspenseTreeStateContext.Provider>
485 );
486 }
487
488 export {
489 SuspenseTreeDispatcherContext,
490 SuspenseTreeStateContext,
491 SuspenseTreeContextController,
492 };