main
js 648 lines 18.9 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 * as React from 'react';
10 import {
11 useContext,
12 useEffect,
13 useLayoutEffect,
14 useReducer,
15 useRef,
16 Fragment,
17 } from 'react';
18
19 import {enableActivitySlices} from 'react-devtools-feature-flags';
20 import {
21 localStorageGetItem,
22 localStorageSetItem,
23 } from 'react-devtools-shared/src/storage';
24 import ButtonIcon, {type IconType} from '../ButtonIcon';
25 import InspectHostNodesToggle from '../Components/InspectHostNodesToggle';
26 import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBoundary';
27 import InspectedElement from '../Components/InspectedElement';
28 import portaledContent from '../portaledContent';
29 import styles from './SuspenseTab.css';
30 import SuspenseBreadcrumbs from './SuspenseBreadcrumbs';
31 import SuspenseRects from './SuspenseRects';
32 import SuspenseTimeline from './SuspenseTimeline';
33 import ActivityList from './ActivityList';
34 import {
35 SuspenseTreeDispatcherContext,
36 SuspenseTreeStateContext,
37 } from './SuspenseTreeContext';
38 import {BridgeContext, StoreContext, OptionsContext} from '../context';
39 import Button from '../Button';
40 import Toggle from '../Toggle';
41 import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
42 import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal';
43 import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle';
44 import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext';
45 import {TreeStateContext} from '../Components/TreeContext';
46
47 type Orientation = 'horizontal' | 'vertical';
48
49 type LayoutActionType =
50 | 'ACTION_SET_ACTIVITY_LIST_TOGGLE'
51 | 'ACTION_SET_ACTIVITY_LIST_HORIZONTAL_FRACTION'
52 | 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE'
53 | 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION'
54 | 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION';
55 type LayoutAction = {
56 type: LayoutActionType,
57 payload: any,
58 };
59
60 type LayoutState = {
61 activityListHidden: boolean,
62 activityListHorizontalFraction: number,
63 inspectedElementHidden: boolean,
64 inspectedElementHorizontalFraction: number,
65 inspectedElementVerticalFraction: number,
66 };
67 type LayoutDispatch = (action: LayoutAction) => void;
68
69 function ToggleUniqueSuspenders() {
70 const store = useContext(StoreContext);
71 const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
72
73 const {uniqueSuspendersOnly} = useContext(SuspenseTreeStateContext);
74
75 function handleToggleUniqueSuspenders() {
76 const nextUniqueSuspendersOnly = !uniqueSuspendersOnly;
77 // TODO: Handle different timeline modes (e.g. random order)
78 const nextTimeline = store.getEndTimeOrDocumentOrderSuspense(
79 nextUniqueSuspendersOnly,
80 );
81 suspenseTreeDispatch({
82 type: 'SET_SUSPENSE_TIMELINE',
83 payload: [nextTimeline, null, nextUniqueSuspendersOnly],
84 });
85 }
86
87 return (
88 <Toggle
89 isChecked={uniqueSuspendersOnly}
90 onChange={handleToggleUniqueSuspenders}
91 title={
92 'Filter Suspense which does not suspend, or if the parent also suspend on the same.'
93 }>
94 <ButtonIcon type={uniqueSuspendersOnly ? 'filter-on' : 'filter-off'} />
95 </Toggle>
96 );
97 }
98
99 function ToggleActivityList({
100 dispatch,
101 state,
102 }: {
103 dispatch: LayoutDispatch,
104 state: LayoutState,
105 }) {
106 return (
107 <Button
108 onClick={() =>
109 dispatch({
110 type: 'ACTION_SET_ACTIVITY_LIST_TOGGLE',
111 payload: null,
112 })
113 }
114 title={
115 state.activityListHidden ? 'Show Activity List' : 'Hide Activity List'
116 }>
117 <ButtonIcon
118 type={state.activityListHidden ? 'panel-left-open' : 'panel-left-close'}
119 />
120 </Button>
121 );
122 }
123
124 function ToggleInspectedElement({
125 dispatch,
126 state,
127 orientation,
128 }: {
129 dispatch: LayoutDispatch,
130 state: LayoutState,
131 orientation: 'horizontal' | 'vertical',
132 }) {
133 let iconType: IconType;
134 if (orientation === 'horizontal') {
135 iconType = state.inspectedElementHidden
136 ? 'panel-right-open'
137 : 'panel-right-close';
138 } else {
139 iconType = state.inspectedElementHidden
140 ? 'panel-bottom-open'
141 : 'panel-bottom-close';
142 }
143 return (
144 <Button
145 className={styles.ToggleInspectedElement}
146 data-orientation={orientation}
147 onClick={() =>
148 dispatch({
149 type: 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE',
150 payload: null,
151 })
152 }
153 title={
154 state.inspectedElementHidden
155 ? 'Show Inspected Element'
156 : 'Hide Inspected Element'
157 }>
158 <ButtonIcon type={iconType} />
159 </Button>
160 );
161 }
162
163 function SynchronizedScrollContainer({
164 className,
165 children,
166 scaleRef,
167 }: {
168 className?: string,
169 children?: React.Node,
170 scaleRef: {current: number},
171 }) {
172 const bridge = useContext(BridgeContext);
173 const ref = useRef(null);
174 const applyingScrollRef = useRef(false);
175
176 // TODO: useEffectEvent
177 function scrollContainerTo({
178 left,
179 top,
180 right,
181 bottom,
182 }: {
183 left: number,
184 top: number,
185 right: number,
186 bottom: number,
187 }): void {
188 const element = ref.current;
189 if (element === null) {
190 return;
191 }
192 const scale = scaleRef.current / element.clientWidth;
193 const targetLeft = Math.round(left / scale);
194 const targetTop = Math.round(top / scale);
195 if (
196 targetLeft !== Math.round(element.scrollLeft) ||
197 targetTop !== Math.round(element.scrollTop)
198 ) {
199 // Disable scroll events until we've applied the new scroll position.
200 applyingScrollRef.current = true;
201 element.scrollTo({
202 left: targetLeft,
203 top: targetTop,
204 behavior: 'smooth',
205 });
206 }
207 }
208
209 useEffect(() => {
210 const callback = scrollContainerTo;
211 bridge.addListener('scrollTo', callback);
212 // Ask for the current scroll position when we mount so we can attach ourselves to it.
213 bridge.send('requestScrollPosition');
214 return () => bridge.removeListener('scrollTo', callback);
215 }, [bridge]);
216
217 const scrollTimer = useRef<null | TimeoutID>(null);
218
219 // TODO: useEffectEvent
220 function sendScroll() {
221 if (scrollTimer.current) {
222 clearTimeout(scrollTimer.current);
223 scrollTimer.current = null;
224 }
225 if (applyingScrollRef.current) {
226 return;
227 }
228 const element = ref.current;
229 if (element === null) {
230 return;
231 }
232 const scale = scaleRef.current / element.clientWidth;
233 const left = element.scrollLeft * scale;
234 const top = element.scrollTop * scale;
235 const right = left + element.clientWidth * scale;
236 const bottom = top + element.clientHeight * scale;
237 bridge.send('scrollTo', {left, top, right, bottom});
238 }
239
240 // TODO: useEffectEvent
241 function throttleScroll() {
242 if (!scrollTimer.current) {
243 // Periodically synchronize the scroll while scrolling.
244 scrollTimer.current = setTimeout(sendScroll, 400);
245 }
246 }
247
248 function scrollEnd() {
249 // Upon scrollend send it immediately.
250 sendScroll();
251 applyingScrollRef.current = false;
252 }
253
254 useEffect(() => {
255 const element = ref.current;
256 if (element === null) {
257 return;
258 }
259 const scrollCallback = throttleScroll;
260 const scrollEndCallback = scrollEnd;
261 element.addEventListener('scroll', scrollCallback);
262 element.addEventListener('scrollend', scrollEndCallback);
263 return () => {
264 element.removeEventListener('scroll', scrollCallback);
265 element.removeEventListener('scrollend', scrollEndCallback);
266 };
267 }, [ref]);
268
269 return (
270 <div className={className} ref={ref}>
271 {children}
272 </div>
273 );
274 }
275
276 function SuspenseTab(_: {}) {
277 const store = useContext(StoreContext);
278 const {hideSettings} = useContext(OptionsContext);
279 const [state, dispatch] = useReducer<LayoutState, null, LayoutAction>(
280 layoutReducer,
281 null,
282 initLayoutState,
283 );
284
285 const {activities} = useContext(TreeStateContext);
286 // If there are no named Activity boundaries, we don't have any tree list and we should hide
287 // both the panel and the button to toggle it.
288 const activityListDisabled = !enableActivitySlices || activities.length === 0;
289
290 const wrapperTreeRef = useRef<null | HTMLElement>(null);
291 const resizeTreeRef = useRef<null | HTMLElement>(null);
292 const resizeActivityListRef = useRef<null | HTMLElement>(null);
293
294 // TODO: We'll show the recently inspected element in this tab when it should probably
295 // switch to the nearest Suspense boundary when we switch into this tab.
296
297 const {
298 inspectedElementHidden,
299 inspectedElementHorizontalFraction,
300 inspectedElementVerticalFraction,
301 activityListHidden,
302 activityListHorizontalFraction,
303 } = state;
304
305 useLayoutEffect(() => {
306 const wrapperElement = wrapperTreeRef.current;
307
308 setResizeCSSVariable(
309 wrapperElement,
310 'tree',
311 'horizontal',
312 inspectedElementHorizontalFraction * 100,
313 );
314 setResizeCSSVariable(
315 wrapperElement,
316 'tree',
317 'vertical',
318 inspectedElementVerticalFraction * 100,
319 );
320
321 const resizeActivityListElement = resizeActivityListRef.current;
322 setResizeCSSVariable(
323 resizeActivityListElement,
324 'activity-list',
325 'horizontal',
326 activityListHorizontalFraction * 100,
327 );
328 }, []);
329 useEffect(() => {
330 const timeoutID = setTimeout(() => {
331 localStorageSetItem(
332 LOCAL_STORAGE_KEY,
333 JSON.stringify({
334 inspectedElementHidden,
335 inspectedElementHorizontalFraction,
336 inspectedElementVerticalFraction,
337 activityListHidden,
338 activityListHorizontalFraction,
339 }),
340 );
341 }, 500);
342
343 return () => clearTimeout(timeoutID);
344 }, [
345 inspectedElementHidden,
346 inspectedElementHorizontalFraction,
347 inspectedElementVerticalFraction,
348 activityListHidden,
349 activityListHorizontalFraction,
350 ]);
351
352 const onResizeStart = (event: SyntheticPointerEvent) => {
353 const element = event.currentTarget;
354 element.setPointerCapture(event.pointerId);
355 };
356
357 const onResizeEnd = (event: SyntheticPointerEvent) => {
358 const element = event.currentTarget;
359 element.releasePointerCapture(event.pointerId);
360 };
361
362 const onResizeTree = (event: SyntheticPointerEvent) => {
363 const element = event.currentTarget;
364 const isResizing = element.hasPointerCapture(event.pointerId);
365 if (!isResizing) {
366 return;
367 }
368
369 const resizeElement = resizeTreeRef.current;
370 const wrapperElement = wrapperTreeRef.current;
371
372 if (wrapperElement === null || resizeElement === null) {
373 return;
374 }
375
376 event.preventDefault();
377
378 const orientation = getTreeOrientation(wrapperElement);
379
380 const {height, width, left, top} = wrapperElement.getBoundingClientRect();
381
382 const currentMousePosition =
383 orientation === 'horizontal' ? event.clientX - left : event.clientY - top;
384
385 const boundaryMin = MINIMUM_TREE_SIZE;
386 const boundaryMax =
387 orientation === 'horizontal'
388 ? width - MINIMUM_TREE_SIZE
389 : height - MINIMUM_TREE_SIZE;
390
391 const isMousePositionInBounds =
392 currentMousePosition > boundaryMin && currentMousePosition < boundaryMax;
393
394 if (isMousePositionInBounds) {
395 const resizedElementDimension =
396 orientation === 'horizontal' ? width : height;
397 const actionType =
398 orientation === 'horizontal'
399 ? 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION'
400 : 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION';
401 const fraction = currentMousePosition / resizedElementDimension;
402 const percentage = fraction * 100;
403
404 setResizeCSSVariable(wrapperElement, 'tree', orientation, percentage);
405
406 dispatch({
407 type: actionType,
408 payload: fraction,
409 });
410 }
411 };
412
413 const onResizeActivityList = (event: SyntheticPointerEvent) => {
414 const element = event.currentTarget;
415 const isResizing = element.hasPointerCapture(event.pointerId);
416 if (!isResizing) {
417 return;
418 }
419
420 const resizeElement = resizeActivityListRef.current;
421 const wrapperElement = resizeTreeRef.current;
422
423 if (wrapperElement === null || resizeElement === null) {
424 return;
425 }
426
427 event.preventDefault();
428
429 const orientation = 'horizontal';
430
431 const {height, width, left, top} = wrapperElement.getBoundingClientRect();
432
433 const currentMousePosition =
434 orientation === 'horizontal' ? event.clientX - left : event.clientY - top;
435
436 const boundaryMin = MINIMUM_ACTIVITY_LIST_SIZE;
437 const boundaryMax =
438 orientation === 'horizontal'
439 ? width - MINIMUM_ACTIVITY_LIST_SIZE
440 : height - MINIMUM_ACTIVITY_LIST_SIZE;
441
442 const isMousePositionInBounds =
443 currentMousePosition > boundaryMin && currentMousePosition < boundaryMax;
444
445 if (isMousePositionInBounds) {
446 const resizedElementDimension =
447 orientation === 'horizontal' ? width : height;
448 const actionType = 'ACTION_SET_ACTIVITY_LIST_HORIZONTAL_FRACTION';
449 const percentage = (currentMousePosition / resizedElementDimension) * 100;
450
451 setResizeCSSVariable(
452 resizeElement,
453 'activity-list',
454 orientation,
455 percentage,
456 );
457
458 dispatch({
459 type: actionType,
460 payload: currentMousePosition / resizedElementDimension,
461 });
462 }
463 };
464
465 const scaleRef = useRef(0);
466
467 return (
468 <SettingsModalContextController>
469 <div className={styles.SuspenseTab} ref={wrapperTreeRef}>
470 <div className={styles.TreeWrapper} ref={resizeTreeRef}>
471 {activityListDisabled ? null : (
472 <div
473 className={styles.ActivityList}
474 hidden={activityListHidden}
475 ref={resizeActivityListRef}>
476 <ActivityList activities={activities} />
477 </div>
478 )}
479 {activityListDisabled ? null : (
480 <div
481 className={styles.ResizeBarWrapper}
482 hidden={activityListHidden}>
483 <div
484 onPointerDown={onResizeStart}
485 onPointerMove={onResizeActivityList}
486 onPointerUp={onResizeEnd}
487 className={styles.ResizeBar}
488 />
489 </div>
490 )}
491 <div className={styles.TreeView}>
492 <header className={styles.SuspenseTreeViewHeader}>
493 {activityListDisabled ? (
494 <div />
495 ) : (
496 <ToggleActivityList dispatch={dispatch} state={state} />
497 )}
498 {store.supportsClickToInspect && (
499 <Fragment>
500 <InspectHostNodesToggle onlySuspenseNodes={true} />
501 <div className={styles.VRule} />
502 </Fragment>
503 )}
504 <SuspenseBreadcrumbs />
505 <div className={styles.VRule} />
506 <ToggleUniqueSuspenders />
507 {!hideSettings && <SettingsModalContextToggle />}
508 <ToggleInspectedElement
509 dispatch={dispatch}
510 state={state}
511 orientation="horizontal"
512 />
513 </header>
514 <SynchronizedScrollContainer
515 className={styles.Rects}
516 scaleRef={scaleRef}>
517 <SuspenseRects scaleRef={scaleRef} />
518 </SynchronizedScrollContainer>
519 <footer className={styles.SuspenseTreeViewFooter}>
520 <SuspenseTimeline />
521 <div className={styles.SuspenseTreeViewFooterButtons}>
522 <ToggleInspectedElement
523 dispatch={dispatch}
524 state={state}
525 orientation="vertical"
526 />
527 </div>
528 </footer>
529 </div>
530 </div>
531 <div
532 className={styles.ResizeBarWrapper}
533 hidden={inspectedElementHidden}>
534 <div
535 onPointerDown={onResizeStart}
536 onPointerMove={onResizeTree}
537 onPointerUp={onResizeEnd}
538 className={styles.ResizeBar}
539 />
540 </div>
541 <div
542 className={styles.InspectedElementWrapper}
543 hidden={inspectedElementHidden}>
544 <InspectedElementErrorBoundary>
545 <InspectedElement
546 fallbackEmpty={
547 'No React element selected. Select a Suspense boundary in the minimap to inspect.'
548 }
549 />
550 </InspectedElementErrorBoundary>
551 </div>
552 <SettingsModal />
553 </div>
554 </SettingsModalContextController>
555 );
556 }
557
558 const LOCAL_STORAGE_KEY = 'React::DevTools::SuspenseTab::layout';
559 const VERTICAL_TREE_MODE_MAX_WIDTH = 600;
560 const MINIMUM_TREE_SIZE = 100;
561 const MINIMUM_ACTIVITY_LIST_SIZE = 100;
562
563 function layoutReducer(state: LayoutState, action: LayoutAction): LayoutState {
564 switch (action.type) {
565 case 'ACTION_SET_ACTIVITY_LIST_TOGGLE':
566 return {
567 ...state,
568 activityListHidden: !state.activityListHidden,
569 };
570 case 'ACTION_SET_ACTIVITY_LIST_HORIZONTAL_FRACTION':
571 return {
572 ...state,
573 activityListHorizontalFraction: action.payload,
574 };
575 case 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE':
576 return {
577 ...state,
578 inspectedElementHidden: !state.inspectedElementHidden,
579 };
580 case 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION':
581 return {
582 ...state,
583 inspectedElementHorizontalFraction: action.payload,
584 };
585 case 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION':
586 return {
587 ...state,
588 inspectedElementVerticalFraction: action.payload,
589 };
590 default:
591 return state;
592 }
593 }
594
595 function initLayoutState(): LayoutState {
596 let inspectedElementHidden = false;
597 let inspectedElementHorizontalFraction = 0.65;
598 let inspectedElementVerticalFraction = 0.5;
599 let activityListHidden = false;
600 let activityListHorizontalFraction = 0.35;
601
602 try {
603 let data = localStorageGetItem(LOCAL_STORAGE_KEY);
604 if (data != null) {
605 data = JSON.parse(data);
606 inspectedElementHidden = data.inspectedElementHidden;
607 inspectedElementHorizontalFraction =
608 data.inspectedElementHorizontalFraction;
609 inspectedElementVerticalFraction = data.inspectedElementVerticalFraction;
610 activityListHidden = data.activityListHidden;
611 activityListHorizontalFraction = data.activityListHorizontalFraction;
612 }
613 } catch (error) {}
614
615 return {
616 inspectedElementHidden,
617 inspectedElementHorizontalFraction,
618 inspectedElementVerticalFraction,
619 activityListHidden,
620 activityListHorizontalFraction,
621 };
622 }
623
624 function getTreeOrientation(
625 wrapperElement: null | HTMLElement,
626 ): null | Orientation {
627 if (wrapperElement != null) {
628 const {width} = wrapperElement.getBoundingClientRect();
629 return width > VERTICAL_TREE_MODE_MAX_WIDTH ? 'horizontal' : 'vertical';
630 }
631 return null;
632 }
633
634 function setResizeCSSVariable(
635 resizeElement: null | HTMLElement,
636 name: 'tree' | 'activity-list',
637 orientation: null | Orientation,
638 percentage: number,
639 ): void {
640 if (resizeElement !== null && orientation !== null) {
641 resizeElement.style.setProperty(
642 `--${orientation}-resize-${name}-percentage`,
643 `${percentage}%`,
644 );
645 }
646 }
647
648 export default portaledContent(SuspenseTab) as component();