main
js 745 lines 23.2 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 {copy} from 'clipboard-js';
11 import * as React from 'react';
12 import {use, useContext, useState, useTransition} from 'react';
13 import Button from '../Button';
14 import ButtonIcon from '../ButtonIcon';
15 import KeyValue from './KeyValue';
16 import {serializeDataForCopy, pluralize} from '../utils';
17 import Store from '../../store';
18 import styles from './InspectedElementSharedStyles.css';
19 import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
20 import FetchFileWithCachingContext from './FetchFileWithCachingContext';
21 import StackTraceView, {IgnoreListToggleButton} from './StackTraceView';
22 import OwnerView from './OwnerView';
23 import {meta} from '../../../hydration';
24 import Skeleton from './Skeleton';
25 import useInferredName from '../useInferredName';
26 import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource';
27
28 import {getClassNameForEnvironment} from '../SuspenseTab/SuspenseEnvironmentColors.js';
29
30 import type {
31 InspectedElement,
32 SerializedAsyncInfo,
33 } from 'react-devtools-shared/src/frontend/types';
34 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
35 import type {ReactStackTrace} from 'shared/ReactTypes';
36 import type {SourceMappedLocation} from 'react-devtools-shared/src/symbolicateSource';
37
38 import {
39 UNKNOWN_SUSPENDERS_NONE,
40 UNKNOWN_SUSPENDERS_REASON_PRODUCTION,
41 UNKNOWN_SUSPENDERS_REASON_OLD_VERSION,
42 UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE,
43 } from '../../../constants';
44 import {ElementTypeRoot} from 'react-devtools-shared/src/frontend/types';
45
46 type RowProps = {
47 bridge: FrontendBridge,
48 element: Element,
49 inspectedElement: InspectedElement,
50 store: Store,
51 asyncInfo: SerializedAsyncInfo,
52 index: number,
53 minTime: number,
54 maxTime: number,
55 skipName?: boolean,
56 };
57
58 function getShortDescription(name: string, description: string): string {
59 const descMaxLength = 30 - name.length;
60 if (descMaxLength > 1) {
61 const l = description.length;
62 if (l > 0 && l <= descMaxLength) {
63 // We can fit the full description
64 return description;
65 } else if (
66 description.startsWith('http://') ||
67 description.startsWith('https://') ||
68 description.startsWith('/')
69 ) {
70 // Looks like a URL. Let's see if we can extract something shorter.
71 // We don't have to do a full parse so let's try something cheaper.
72 let queryIdx = description.indexOf('?');
73 if (queryIdx === -1) {
74 queryIdx = description.length;
75 }
76 if (description.charCodeAt(queryIdx - 1) === 47 /* "/" */) {
77 // Ends with slash. Look before that.
78 queryIdx--;
79 }
80 const slashIdx = description.lastIndexOf('/', queryIdx - 1);
81 // This may now be either the file name or the host.
82 // Include the slash to make it more obvious what we trimmed.
83 return '' + description.slice(slashIdx, queryIdx);
84 }
85 }
86 return '';
87 }
88
89 function formatBytes(bytes: number) {
90 if (bytes < 1_000) {
91 return bytes + ' bytes';
92 }
93 if (bytes < 1_000_000) {
94 return (bytes / 1_000).toFixed(1) + ' kB';
95 }
96 if (bytes < 1_000_000_000) {
97 return (bytes / 1_000_000).toFixed(1) + ' mB';
98 }
99 return (bytes / 1_000_000_000).toFixed(1) + ' gB';
100 }
101
102 type StackTraceGroupProps = {
103 children: (showIgnoreList: boolean) => React.Node,
104 ioStack: null | ReactStackTrace,
105 asyncInfoStack: null | ReactStackTrace,
106 };
107
108 function StackTraceGroup({
109 children,
110 ioStack,
111 asyncInfoStack,
112 }: StackTraceGroupProps): React.Node {
113 const [showIgnoreList, setShowIgnoreList] = useState(false);
114 const fetchFileWithCaching = useContext(FetchFileWithCachingContext);
115
116 const ioStackHasIgnoredFrames =
117 ioStack !== null &&
118 ioStack.some(callSite => {
119 const [, virtualURL, virtualLine, virtualColumn] = callSite;
120
121 // symbolicated output is cached
122 const symbolicatedCallSite: null | SourceMappedLocation =
123 fetchFileWithCaching !== null
124 ? use(
125 symbolicateSourceWithCache(
126 fetchFileWithCaching,
127 virtualURL,
128 virtualLine,
129 virtualColumn,
130 ),
131 )
132 : null;
133
134 return symbolicatedCallSite !== null && symbolicatedCallSite.ignored;
135 });
136
137 const asyncInfoStackHasIgnoredFrames =
138 asyncInfoStack !== null &&
139 asyncInfoStack.some(callSite => {
140 const [, virtualURL, virtualLine, virtualColumn] = callSite;
141
142 // symbolicated output is cached
143 const symbolicatedCallSite: null | SourceMappedLocation =
144 fetchFileWithCaching !== null
145 ? use(
146 symbolicateSourceWithCache(
147 fetchFileWithCaching,
148 virtualURL,
149 virtualLine,
150 virtualColumn,
151 ),
152 )
153 : null;
154
155 return symbolicatedCallSite !== null && symbolicatedCallSite.ignored;
156 });
157
158 const hasIgnoredFrames =
159 ioStackHasIgnoredFrames || asyncInfoStackHasIgnoredFrames;
160
161 return (
162 <>
163 {children(showIgnoreList)}
164 {hasIgnoredFrames && (
165 <IgnoreListToggleButton
166 onClick={() => setShowIgnoreList(prev => !prev)}
167 showIgnoreList={showIgnoreList}
168 />
169 )}
170 </>
171 );
172 }
173
174 function SuspendedByRow({
175 bridge,
176 element,
177 inspectedElement,
178 store,
179 asyncInfo,
180 index,
181 minTime,
182 maxTime,
183 skipName,
184 }: RowProps) {
185 const [isOpen, setIsOpen] = useState(false);
186 const [openIsPending, startOpenTransition] = useTransition();
187 const ioInfo = asyncInfo.awaited;
188 const name = useInferredName(asyncInfo);
189 const description = ioInfo.description;
190 const longName = description === '' ? name : name + ' (' + description + ')';
191 const shortDescription = getShortDescription(name, description);
192 const start = ioInfo.start;
193 const end = ioInfo.end;
194 const timeScale = 100 / (maxTime - minTime);
195 let left = (start - minTime) * timeScale;
196 let width = (end - start) * timeScale;
197 if (width < 5) {
198 // Use at least a 5% width to avoid showing too small indicators.
199 width = 5;
200 if (left > 95) {
201 left = 95;
202 }
203 }
204
205 const ioOwner = ioInfo.owner;
206 const asyncOwner = asyncInfo.owner;
207 const showIOStack = ioInfo.stack !== null && ioInfo.stack.length !== 0;
208 // Only show the awaited stack if the I/O started in a different owner
209 // than where it was awaited. If it's started by the same component it's
210 // probably easy enough to infer and less noise in the common case.
211 const canShowAwaitStack =
212 (asyncInfo.stack !== null && asyncInfo.stack.length > 0) ||
213 (asyncOwner !== null && asyncOwner.id !== inspectedElement.id);
214 const showAwaitStack =
215 canShowAwaitStack &&
216 (!showIOStack ||
217 (ioOwner === null
218 ? asyncOwner !== null
219 : asyncOwner === null || ioOwner.id !== asyncOwner.id));
220
221 const value: any = ioInfo.value;
222 const metaName =
223 value !== null && typeof value === 'object' ? value[meta.name] : null;
224 const isFulfilled = metaName === 'fulfilled Thenable';
225 const isRejected = metaName === 'rejected Thenable';
226 return (
227 <div className={styles.CollapsableRow}>
228 <Button
229 className={styles.CollapsableHeader}
230 // TODO: May be better to leave to React's default Transition indicator.
231 // Though no apps implement this option at the moment.
232 data-pending={openIsPending}
233 onClick={() => {
234 startOpenTransition(() => {
235 setIsOpen(prevIsOpen => !prevIsOpen);
236 });
237 }}
238 // Changing the title on pending transition will not be visible since
239 // (Reach?) tooltips are dismissed on activation.
240 title={
241 longName +
242 '' +
243 (end - start).toFixed(2) +
244 ' ms' +
245 (ioInfo.byteSize != null ? '' + formatBytes(ioInfo.byteSize) : '')
246 }>
247 <ButtonIcon
248 className={styles.CollapsableHeaderIcon}
249 type={isOpen ? 'expanded' : 'collapsed'}
250 />
251 <span className={styles.CollapsableHeaderTitle}>
252 {skipName && shortDescription !== '' ? shortDescription : name}
253 </span>
254 {skipName || shortDescription === '' ? null : (
255 <>
256 <span className={styles.CollapsableHeaderSeparator}>{' ('}</span>
257 <span className={styles.CollapsableHeaderTitle}>
258 {shortDescription}
259 </span>
260 <span className={styles.CollapsableHeaderSeparator}>{') '}</span>
261 </>
262 )}
263 <div className={styles.CollapsableHeaderFiller} />
264 <div
265 className={
266 styles.TimeBarContainer +
267 ' ' +
268 getClassNameForEnvironment(ioInfo.env)
269 }>
270 <div
271 className={
272 !isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
273 }
274 style={{
275 left: left.toFixed(2) + '%',
276 width: width.toFixed(2) + '%',
277 }}
278 />
279 </div>
280 </Button>
281 {isOpen && (
282 <div className={styles.CollapsableContent}>
283 <React.Suspense
284 fallback={
285 <div className={styles.SuspendedBySkeleton}>
286 <Skeleton height={16} width="40%" />
287 </div>
288 }>
289 <StackTraceGroup
290 ioStack={showIOStack ? ioInfo.stack : null}
291 asyncInfoStack={showAwaitStack ? asyncInfo.stack : null}>
292 {(showIgnoreList: boolean) => (
293 <>
294 {showIOStack && (
295 <StackTraceView
296 stack={ioInfo.stack}
297 environmentName={
298 ioOwner !== null && ioOwner.env === ioInfo.env
299 ? null
300 : ioInfo.env
301 }
302 showIgnoreList={showIgnoreList}
303 />
304 )}
305 {ioOwner !== null &&
306 ioOwner.id !== inspectedElement.id &&
307 (showIOStack ||
308 !showAwaitStack ||
309 asyncOwner === null ||
310 ioOwner.id !== asyncOwner.id) ? (
311 <OwnerView
312 key={ioOwner.id}
313 displayName={ioOwner.displayName || 'Anonymous'}
314 environmentName={
315 ioOwner.env === inspectedElement.env &&
316 ioOwner.env === ioInfo.env
317 ? null
318 : ioOwner.env
319 }
320 hocDisplayNames={ioOwner.hocDisplayNames}
321 compiledWithForget={ioOwner.compiledWithForget}
322 id={ioOwner.id}
323 isInStore={store.containsElement(ioOwner.id)}
324 type={ioOwner.type}
325 />
326 ) : null}
327 {showAwaitStack ? (
328 <>
329 <div className={styles.SmallHeader}>awaited at:</div>
330 {asyncInfo.stack !== null &&
331 asyncInfo.stack.length > 0 && (
332 <StackTraceView
333 stack={asyncInfo.stack}
334 environmentName={
335 asyncOwner !== null &&
336 asyncOwner.env === asyncInfo.env
337 ? null
338 : asyncInfo.env
339 }
340 showIgnoreList={showIgnoreList}
341 />
342 )}
343 {asyncOwner !== null &&
344 asyncOwner.id !== inspectedElement.id ? (
345 <OwnerView
346 key={asyncOwner.id}
347 displayName={asyncOwner.displayName || 'Anonymous'}
348 environmentName={
349 asyncOwner.env === inspectedElement.env &&
350 asyncOwner.env === asyncInfo.env
351 ? null
352 : asyncOwner.env
353 }
354 hocDisplayNames={asyncOwner.hocDisplayNames}
355 compiledWithForget={asyncOwner.compiledWithForget}
356 id={asyncOwner.id}
357 isInStore={store.containsElement(asyncOwner.id)}
358 type={asyncOwner.type}
359 />
360 ) : null}
361 </>
362 ) : null}
363 <div className={styles.PreviewContainer}>
364 <KeyValue
365 alphaSort={true}
366 bridge={bridge}
367 canDeletePaths={false}
368 canEditValues={false}
369 canRenamePaths={false}
370 depth={1}
371 element={element}
372 hidden={false}
373 inspectedElement={inspectedElement}
374 name={
375 isFulfilled
376 ? 'awaited value'
377 : isRejected
378 ? 'rejected with'
379 : 'pending value'
380 }
381 path={
382 isFulfilled
383 ? [index, 'awaited', 'value', 'value']
384 : isRejected
385 ? [index, 'awaited', 'value', 'reason']
386 : [index, 'awaited', 'value']
387 }
388 pathRoot="suspendedBy"
389 store={store}
390 value={
391 isFulfilled
392 ? value.value
393 : isRejected
394 ? value.reason
395 : value
396 }
397 />
398 </div>
399 </>
400 )}
401 </StackTraceGroup>
402 </React.Suspense>
403 </div>
404 )}
405 </div>
406 );
407 }
408
409 type Props = {
410 bridge: FrontendBridge,
411 element: Element,
412 inspectedElement: InspectedElement,
413 store: Store,
414 };
415
416 function withIndex(
417 value: SerializedAsyncInfo,
418 index: number,
419 ): {
420 index: number,
421 value: SerializedAsyncInfo,
422 } {
423 return {
424 index,
425 value,
426 };
427 }
428
429 function compareTime(
430 a: {
431 index: number,
432 value: SerializedAsyncInfo,
433 },
434 b: {
435 index: number,
436 value: SerializedAsyncInfo,
437 },
438 ): number {
439 const ioA = a.value.awaited;
440 const ioB = b.value.awaited;
441 if (ioA.start === ioB.start) {
442 return ioA.end - ioB.end;
443 }
444 return ioA.start - ioB.start;
445 }
446
447 type GroupProps = {
448 bridge: FrontendBridge,
449 element: Element,
450 inspectedElement: InspectedElement,
451 store: Store,
452 name: string,
453 environment: null | string,
454 suspendedBy: Array<{
455 index: number,
456 value: SerializedAsyncInfo,
457 }>,
458 minTime: number,
459 maxTime: number,
460 };
461
462 function SuspendedByGroup({
463 bridge,
464 element,
465 inspectedElement,
466 store,
467 name,
468 environment,
469 suspendedBy,
470 minTime,
471 maxTime,
472 }: GroupProps) {
473 const [isOpen, setIsOpen] = useState(false);
474 let start = Infinity;
475 let end = -Infinity;
476 let isRejected = false;
477 for (let i = 0; i < suspendedBy.length; i++) {
478 const asyncInfo: SerializedAsyncInfo = suspendedBy[i].value;
479 const ioInfo = asyncInfo.awaited;
480 if (ioInfo.start < start) {
481 start = ioInfo.start;
482 }
483 if (ioInfo.end > end) {
484 end = ioInfo.end;
485 }
486 const value: any = ioInfo.value;
487 if (
488 value !== null &&
489 typeof value === 'object' &&
490 value[meta.name] === 'rejected Thenable'
491 ) {
492 isRejected = true;
493 }
494 }
495 const timeScale = 100 / (maxTime - minTime);
496 let left = (start - minTime) * timeScale;
497 let width = (end - start) * timeScale;
498 if (width < 5) {
499 // Use at least a 5% width to avoid showing too small indicators.
500 width = 5;
501 if (left > 95) {
502 left = 95;
503 }
504 }
505 const pluralizedName = pluralize(name);
506 return (
507 <div className={styles.CollapsableRow}>
508 <Button
509 className={styles.CollapsableHeader}
510 onClick={() => {
511 setIsOpen(prevIsOpen => !prevIsOpen);
512 }}
513 title={pluralizedName}>
514 <ButtonIcon
515 className={styles.CollapsableHeaderIcon}
516 type={isOpen ? 'expanded' : 'collapsed'}
517 />
518 <span className={styles.CollapsableHeaderTitle}>{pluralizedName}</span>
519 <div className={styles.CollapsableHeaderFiller} />
520 {isOpen ? null : (
521 <div
522 className={
523 styles.TimeBarContainer +
524 ' ' +
525 getClassNameForEnvironment(environment)
526 }>
527 <div
528 className={
529 !isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
530 }
531 style={{
532 left: left.toFixed(2) + '%',
533 width: width.toFixed(2) + '%',
534 }}
535 />
536 </div>
537 )}
538 </Button>
539 {isOpen &&
540 suspendedBy.map(({value, index}) => (
541 <SuspendedByRow
542 key={index}
543 index={index}
544 asyncInfo={value}
545 bridge={bridge}
546 element={element}
547 inspectedElement={inspectedElement}
548 store={store}
549 minTime={minTime}
550 maxTime={maxTime}
551 skipName={true}
552 />
553 ))}
554 </div>
555 );
556 }
557
558 export default function InspectedElementSuspendedBy({
559 bridge,
560 element,
561 inspectedElement,
562 store,
563 }: Props): React.Node {
564 const {suspendedBy, suspendedByRange} = inspectedElement;
565
566 // Skip the section if nothing suspended this component.
567 if (
568 (suspendedBy == null || suspendedBy.length === 0) &&
569 inspectedElement.unknownSuspenders === UNKNOWN_SUSPENDERS_NONE
570 ) {
571 if (inspectedElement.isSuspended) {
572 // If we're still suspended, show a place holder until the data loads.
573 // We don't know what we're suspended by until it has loaded.
574 return (
575 <div>
576 <div className={styles.HeaderRow}>
577 <div className={styles.Header}>suspended...</div>
578 </div>
579 </div>
580 );
581 }
582 // For roots, show an empty state since there's nothing else to show for
583 // these elements.
584 // This can happen for older versions of React without Suspense, older versions
585 // of React with less sources for Suspense, or simple UIs that don't have any suspenders.
586 if (inspectedElement.type === ElementTypeRoot) {
587 return (
588 <div>
589 <div className={styles.HeaderRow}>
590 <div className={`${styles.Header} ${styles.Empty}`}>
591 Nothing suspended the initial paint.
592 </div>
593 </div>
594 </div>
595 );
596 }
597 }
598
599 const handleCopy = withPermissionsCheck(
600 {permissions: ['clipboardWrite']},
601 () => copy(serializeDataForCopy(suspendedBy)),
602 );
603
604 let minTime = Infinity;
605 let maxTime = -Infinity;
606 if (suspendedByRange !== null) {
607 // The range of the whole suspense boundary.
608 minTime = suspendedByRange[0];
609 maxTime = suspendedByRange[1];
610 }
611 for (let i = 0; i < suspendedBy.length; i++) {
612 const asyncInfo: SerializedAsyncInfo = suspendedBy[i];
613 if (asyncInfo.awaited.start < minTime) {
614 minTime = asyncInfo.awaited.start;
615 }
616 if (asyncInfo.awaited.end > maxTime) {
617 maxTime = asyncInfo.awaited.end;
618 }
619 }
620
621 if (maxTime - minTime < 25) {
622 // Stretch the time span a bit to ensure that we don't show
623 // large bars that represent very small timespans.
624 minTime = maxTime - 25;
625 }
626
627 const sortedSuspendedBy =
628 suspendedBy === null ? [] : suspendedBy.map(withIndex);
629 sortedSuspendedBy.sort(compareTime);
630
631 // Organize into groups of consecutive entries with the same name.
632 const groups = [];
633 let currentGroup = null;
634 let currentGroupName = null;
635 let currentGroupEnv = null;
636 for (let i = 0; i < sortedSuspendedBy.length; i++) {
637 const entry = sortedSuspendedBy[i];
638 const name = entry.value.awaited.name;
639 const env = entry.value.awaited.env;
640 if (
641 currentGroupName !== name ||
642 currentGroupEnv !== env ||
643 !name ||
644 name === 'Promise' ||
645 currentGroup === null
646 ) {
647 // Create a new group.
648 currentGroupName = name;
649 currentGroupEnv = env;
650 currentGroup = [];
651 groups.push(currentGroup);
652 }
653 currentGroup.push(entry);
654 }
655
656 let unknownSuspenders = null;
657 switch (inspectedElement.unknownSuspenders) {
658 case UNKNOWN_SUSPENDERS_REASON_PRODUCTION:
659 unknownSuspenders = (
660 <div className={styles.InfoRow}>
661 Something suspended but we don't know the exact reason in production
662 builds of React. Test this in development mode to see exactly what
663 might suspend.
664 </div>
665 );
666 break;
667 case UNKNOWN_SUSPENDERS_REASON_OLD_VERSION:
668 unknownSuspenders = (
669 <div className={styles.InfoRow}>
670 Something suspended but we don't track all the necessary information
671 in older versions of React. Upgrade to the latest version of React to
672 see exactly what might suspend.
673 </div>
674 );
675 break;
676 case UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE:
677 unknownSuspenders = (
678 <div className={styles.InfoRow}>
679 Something threw a Promise to suspend this boundary. It's likely an
680 outdated version of a library that doesn't yet fully take advantage of
681 use(). Upgrade your data fetching library to see exactly what might
682 suspend.
683 </div>
684 );
685 break;
686 }
687
688 if (groups.length === 0) {
689 return null;
690 }
691 return (
692 <div>
693 <div className={styles.HeaderRow}>
694 <div className={styles.Header}>suspended by</div>
695 <Button onClick={handleCopy} title="Copy to clipboard">
696 <ButtonIcon type="copy" />
697 </Button>
698 </div>
699 {groups.length === 1
700 ? // If it's only one type of suspender we can flatten it.
701 groups[0].map(entry => (
702 <SuspendedByRow
703 key={entry.index}
704 index={entry.index}
705 asyncInfo={entry.value}
706 bridge={bridge}
707 element={element}
708 inspectedElement={inspectedElement}
709 store={store}
710 minTime={minTime}
711 maxTime={maxTime}
712 />
713 ))
714 : groups.map((entries, index) =>
715 entries.length === 1 ? (
716 <SuspendedByRow
717 key={entries[0].index}
718 index={entries[0].index}
719 asyncInfo={entries[0].value}
720 bridge={bridge}
721 element={element}
722 inspectedElement={inspectedElement}
723 store={store}
724 minTime={minTime}
725 maxTime={maxTime}
726 />
727 ) : (
728 <SuspendedByGroup
729 key={entries[0].index}
730 name={entries[0].value.awaited.name}
731 environment={entries[0].value.awaited.env}
732 suspendedBy={entries}
733 bridge={bridge}
734 element={element}
735 inspectedElement={inspectedElement}
736 store={store}
737 minTime={minTime}
738 maxTime={maxTime}
739 />
740 ),
741 )}
742 {unknownSuspenders}
743 </div>
744 );
745 }