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 {SuspenseEvent, TimelineData} from '../types';
    
  11. import type {
    
  12.   Interaction,
    
  13.   IntrinsicSize,
    
  14.   MouseMoveInteraction,
    
  15.   Rect,
    
  16.   ViewRefs,
    
  17. } from '../view-base';
    
  18. 
    
  19. import {
    
  20.   durationToWidth,
    
  21.   positioningScaleFactor,
    
  22.   positionToTimestamp,
    
  23.   timestampToPosition,
    
  24.   widthToDuration,
    
  25. } from './utils/positioning';
    
  26. import {drawText} from './utils/text';
    
  27. import {formatDuration} from '../utils/formatting';
    
  28. import {
    
  29.   View,
    
  30.   Surface,
    
  31.   rectContainsPoint,
    
  32.   rectIntersectsRect,
    
  33.   intersectionOfRects,
    
  34. } from '../view-base';
    
  35. import {
    
  36.   BORDER_SIZE,
    
  37.   COLORS,
    
  38.   PENDING_SUSPENSE_EVENT_SIZE,
    
  39.   SUSPENSE_EVENT_HEIGHT,
    
  40. } from './constants';
    
  41. 
    
  42. const ROW_WITH_BORDER_HEIGHT = SUSPENSE_EVENT_HEIGHT + BORDER_SIZE;
    
  43. const MAX_ROWS_TO_SHOW_INITIALLY = 3;
    
  44. 
    
  45. export class SuspenseEventsView extends View {
    
  46.   _depthToSuspenseEvent: Map<number, SuspenseEvent[]>;
    
  47.   _hoveredEvent: SuspenseEvent | null = null;
    
  48.   _intrinsicSize: IntrinsicSize;
    
  49.   _maxDepth: number = 0;
    
  50.   _profilerData: TimelineData;
    
  51. 
    
  52.   onHover: ((event: SuspenseEvent | null) => void) | null = null;
    
  53. 
    
  54.   constructor(surface: Surface, frame: Rect, profilerData: TimelineData) {
    
  55.     super(surface, frame);
    
  56. 
    
  57.     this._profilerData = profilerData;
    
  58. 
    
  59.     this._performPreflightComputations();
    
  60.   }
    
  61. 
    
  62.   _performPreflightComputations() {
    
  63.     this._depthToSuspenseEvent = new Map();
    
  64. 
    
  65.     const {duration, suspenseEvents} = this._profilerData;
    
  66. 
    
  67.     suspenseEvents.forEach(event => {
    
  68.       const depth = event.depth;
    
  69. 
    
  70.       this._maxDepth = Math.max(this._maxDepth, depth);
    
  71. 
    
  72.       if (!this._depthToSuspenseEvent.has(depth)) {
    
  73.         this._depthToSuspenseEvent.set(depth, [event]);
    
  74.       } else {
    
  75.         // $FlowFixMe[incompatible-use] This is unnecessary.
    
  76.         this._depthToSuspenseEvent.get(depth).push(event);
    
  77.       }
    
  78.     });
    
  79. 
    
  80.     this._intrinsicSize = {
    
  81.       width: duration,
    
  82.       height: (this._maxDepth + 1) * ROW_WITH_BORDER_HEIGHT,
    
  83.       hideScrollBarIfLessThanHeight: ROW_WITH_BORDER_HEIGHT,
    
  84.       maxInitialHeight: ROW_WITH_BORDER_HEIGHT * MAX_ROWS_TO_SHOW_INITIALLY,
    
  85.     };
    
  86.   }
    
  87. 
    
  88.   desiredSize(): IntrinsicSize {
    
  89.     return this._intrinsicSize;
    
  90.   }
    
  91. 
    
  92.   setHoveredEvent(hoveredEvent: SuspenseEvent | null) {
    
  93.     if (this._hoveredEvent === hoveredEvent) {
    
  94.       return;
    
  95.     }
    
  96.     this._hoveredEvent = hoveredEvent;
    
  97.     this.setNeedsDisplay();
    
  98.   }
    
  99. 
    
  100.   /**
    
  101.    * Draw a single `SuspenseEvent` as a box/span with text inside of it.
    
  102.    */
    
  103.   _drawSingleSuspenseEvent(
    
  104.     context: CanvasRenderingContext2D,
    
  105.     rect: Rect,
    
  106.     event: SuspenseEvent,
    
  107.     baseY: number,
    
  108.     scaleFactor: number,
    
  109.     showHoverHighlight: boolean,
    
  110.   ) {
    
  111.     const {frame} = this;
    
  112.     const {
    
  113.       componentName,
    
  114.       depth,
    
  115.       duration,
    
  116.       phase,
    
  117.       promiseName,
    
  118.       resolution,
    
  119.       timestamp,
    
  120.       warning,
    
  121.     } = event;
    
  122. 
    
  123.     baseY += depth * ROW_WITH_BORDER_HEIGHT;
    
  124. 
    
  125.     let fillStyle = ((null: any): string);
    
  126.     if (warning !== null) {
    
  127.       fillStyle = showHoverHighlight
    
  128.         ? COLORS.WARNING_BACKGROUND_HOVER
    
  129.         : COLORS.WARNING_BACKGROUND;
    
  130.     } else {
    
  131.       switch (resolution) {
    
  132.         case 'rejected':
    
  133.           fillStyle = showHoverHighlight
    
  134.             ? COLORS.REACT_SUSPENSE_REJECTED_EVENT_HOVER
    
  135.             : COLORS.REACT_SUSPENSE_REJECTED_EVENT;
    
  136.           break;
    
  137.         case 'resolved':
    
  138.           fillStyle = showHoverHighlight
    
  139.             ? COLORS.REACT_SUSPENSE_RESOLVED_EVENT_HOVER
    
  140.             : COLORS.REACT_SUSPENSE_RESOLVED_EVENT;
    
  141.           break;
    
  142.         case 'unresolved':
    
  143.           fillStyle = showHoverHighlight
    
  144.             ? COLORS.REACT_SUSPENSE_UNRESOLVED_EVENT_HOVER
    
  145.             : COLORS.REACT_SUSPENSE_UNRESOLVED_EVENT;
    
  146.           break;
    
  147.       }
    
  148.     }
    
  149. 
    
  150.     const xStart = timestampToPosition(timestamp, scaleFactor, frame);
    
  151. 
    
  152.     // Pending suspense events (ones that never resolved) won't have durations.
    
  153.     // So instead we draw them as diamonds.
    
  154.     if (duration === null) {
    
  155.       const size = PENDING_SUSPENSE_EVENT_SIZE;
    
  156.       const halfSize = size / 2;
    
  157. 
    
  158.       baseY += (SUSPENSE_EVENT_HEIGHT - PENDING_SUSPENSE_EVENT_SIZE) / 2;
    
  159. 
    
  160.       const y = baseY + halfSize;
    
  161. 
    
  162.       const suspenseRect: Rect = {
    
  163.         origin: {
    
  164.           x: xStart - halfSize,
    
  165.           y: baseY,
    
  166.         },
    
  167.         size: {width: size, height: size},
    
  168.       };
    
  169.       if (!rectIntersectsRect(suspenseRect, rect)) {
    
  170.         return; // Not in view
    
  171.       }
    
  172. 
    
  173.       context.beginPath();
    
  174.       context.fillStyle = fillStyle;
    
  175.       context.moveTo(xStart, y - halfSize);
    
  176.       context.lineTo(xStart + halfSize, y);
    
  177.       context.lineTo(xStart, y + halfSize);
    
  178.       context.lineTo(xStart - halfSize, y);
    
  179.       context.fill();
    
  180.     } else {
    
  181.       const xStop = timestampToPosition(
    
  182.         timestamp + duration,
    
  183.         scaleFactor,
    
  184.         frame,
    
  185.       );
    
  186.       const eventRect: Rect = {
    
  187.         origin: {
    
  188.           x: xStart,
    
  189.           y: baseY,
    
  190.         },
    
  191.         size: {width: xStop - xStart, height: SUSPENSE_EVENT_HEIGHT},
    
  192.       };
    
  193.       if (!rectIntersectsRect(eventRect, rect)) {
    
  194.         return; // Not in view
    
  195.       }
    
  196. 
    
  197.       const width = durationToWidth(duration, scaleFactor);
    
  198.       if (width < 1) {
    
  199.         return; // Too small to render at this zoom level
    
  200.       }
    
  201. 
    
  202.       const drawableRect = intersectionOfRects(eventRect, rect);
    
  203.       context.beginPath();
    
  204.       context.fillStyle = fillStyle;
    
  205.       context.fillRect(
    
  206.         drawableRect.origin.x,
    
  207.         drawableRect.origin.y,
    
  208.         drawableRect.size.width,
    
  209.         drawableRect.size.height,
    
  210.       );
    
  211. 
    
  212.       let label = 'suspended';
    
  213.       if (promiseName != null) {
    
  214.         label = promiseName;
    
  215.       } else if (componentName != null) {
    
  216.         label = `${componentName} ${label}`;
    
  217.       }
    
  218.       if (phase !== null) {
    
  219.         label += ` during ${phase}`;
    
  220.       }
    
  221.       if (resolution !== 'unresolved') {
    
  222.         label += ` - ${formatDuration(duration)}`;
    
  223.       }
    
  224. 
    
  225.       drawText(label, context, eventRect, drawableRect);
    
  226.     }
    
  227.   }
    
  228. 
    
  229.   draw(context: CanvasRenderingContext2D) {
    
  230.     const {
    
  231.       frame,
    
  232.       _profilerData: {suspenseEvents},
    
  233.       _hoveredEvent,
    
  234.       visibleArea,
    
  235.     } = this;
    
  236. 
    
  237.     context.fillStyle = COLORS.PRIORITY_BACKGROUND;
    
  238.     context.fillRect(
    
  239.       visibleArea.origin.x,
    
  240.       visibleArea.origin.y,
    
  241.       visibleArea.size.width,
    
  242.       visibleArea.size.height,
    
  243.     );
    
  244. 
    
  245.     // Draw events
    
  246.     const scaleFactor = positioningScaleFactor(
    
  247.       this._intrinsicSize.width,
    
  248.       frame,
    
  249.     );
    
  250. 
    
  251.     suspenseEvents.forEach(event => {
    
  252.       this._drawSingleSuspenseEvent(
    
  253.         context,
    
  254.         visibleArea,
    
  255.         event,
    
  256.         frame.origin.y,
    
  257.         scaleFactor,
    
  258.         event === _hoveredEvent,
    
  259.       );
    
  260.     });
    
  261. 
    
  262.     // Render bottom borders.
    
  263.     for (let i = 0; i <= this._maxDepth; i++) {
    
  264.       const borderFrame: Rect = {
    
  265.         origin: {
    
  266.           x: frame.origin.x,
    
  267.           y: frame.origin.y + (i + 1) * ROW_WITH_BORDER_HEIGHT - BORDER_SIZE,
    
  268.         },
    
  269.         size: {
    
  270.           width: frame.size.width,
    
  271.           height: BORDER_SIZE,
    
  272.         },
    
  273.       };
    
  274.       if (rectIntersectsRect(borderFrame, visibleArea)) {
    
  275.         const borderDrawableRect = intersectionOfRects(
    
  276.           borderFrame,
    
  277.           visibleArea,
    
  278.         );
    
  279.         context.fillStyle = COLORS.REACT_WORK_BORDER;
    
  280.         context.fillRect(
    
  281.           borderDrawableRect.origin.x,
    
  282.           borderDrawableRect.origin.y,
    
  283.           borderDrawableRect.size.width,
    
  284.           borderDrawableRect.size.height,
    
  285.         );
    
  286.       }
    
  287.     }
    
  288.   }
    
  289. 
    
  290.   /**
    
  291.    * @private
    
  292.    */
    
  293.   _handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) {
    
  294.     const {frame, _intrinsicSize, onHover, visibleArea} = this;
    
  295.     if (!onHover) {
    
  296.       return;
    
  297.     }
    
  298. 
    
  299.     const {location} = interaction.payload;
    
  300.     if (!rectContainsPoint(location, visibleArea)) {
    
  301.       onHover(null);
    
  302.       return;
    
  303.     }
    
  304. 
    
  305.     const scaleFactor = positioningScaleFactor(_intrinsicSize.width, frame);
    
  306.     const hoverTimestamp = positionToTimestamp(location.x, scaleFactor, frame);
    
  307. 
    
  308.     const adjustedCanvasMouseY = location.y - frame.origin.y;
    
  309.     const depth = Math.floor(adjustedCanvasMouseY / ROW_WITH_BORDER_HEIGHT);
    
  310.     const suspenseEventsAtDepth = this._depthToSuspenseEvent.get(depth);
    
  311. 
    
  312.     if (suspenseEventsAtDepth) {
    
  313.       // Find the event being hovered over.
    
  314.       for (let index = suspenseEventsAtDepth.length - 1; index >= 0; index--) {
    
  315.         const suspenseEvent = suspenseEventsAtDepth[index];
    
  316.         const {duration, timestamp} = suspenseEvent;
    
  317. 
    
  318.         if (duration === null) {
    
  319.           const timestampAllowance = widthToDuration(
    
  320.             PENDING_SUSPENSE_EVENT_SIZE / 2,
    
  321.             scaleFactor,
    
  322.           );
    
  323. 
    
  324.           if (
    
  325.             timestamp - timestampAllowance <= hoverTimestamp &&
    
  326.             hoverTimestamp <= timestamp + timestampAllowance
    
  327.           ) {
    
  328.             this.currentCursor = 'context-menu';
    
  329. 
    
  330.             viewRefs.hoveredView = this;
    
  331. 
    
  332.             onHover(suspenseEvent);
    
  333.             return;
    
  334.           }
    
  335.         } else if (
    
  336.           hoverTimestamp >= timestamp &&
    
  337.           hoverTimestamp <= timestamp + duration
    
  338.         ) {
    
  339.           this.currentCursor = 'context-menu';
    
  340. 
    
  341.           viewRefs.hoveredView = this;
    
  342. 
    
  343.           onHover(suspenseEvent);
    
  344.           return;
    
  345.         }
    
  346.       }
    
  347.     }
    
  348. 
    
  349.     onHover(null);
    
  350.   }
    
  351. 
    
  352.   handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
    
  353.     switch (interaction.type) {
    
  354.       case 'mousemove':
    
  355.         this._handleMouseMove(interaction, viewRefs);
    
  356.         break;
    
  357.     }
    
  358.   }
    
  359. }