diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 96c7a38863b..31db8a74334 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -7,7 +7,11 @@ * @flow */ -import type {ReactComponentInfo, ReactDebugInfo} from 'shared/ReactTypes'; +import type { + ReactComponentInfo, + ReactDebugInfo, + ReactAsyncInfo, +} from 'shared/ReactTypes'; import { ComponentFilterDisplayName, @@ -135,6 +139,7 @@ import type { ReactRenderer, RendererInterface, SerializedElement, + SerializedAsyncInfo, WorkTagMap, CurrentDispatcherRef, LegacyDispatcherRef, @@ -165,6 +170,7 @@ type FiberInstance = { source: null | string | Error | ReactFunctionLocation, // source location of this component function, or owned child stack logCount: number, // total number of errors/warnings last seen treeBaseDuration: number, // the profiled time of the last render of this subtree + suspendedBy: null | Array, // things that suspended in the children position of this component data: Fiber, // one of a Fiber pair }; @@ -178,6 +184,7 @@ function createFiberInstance(fiber: Fiber): FiberInstance { source: null, logCount: 0, treeBaseDuration: 0, + suspendedBy: null, data: fiber, }; } @@ -193,6 +200,7 @@ type FilteredFiberInstance = { source: null | string | Error | ReactFunctionLocation, // always null here. logCount: number, // total number of errors/warnings last seen treeBaseDuration: number, // the profiled time of the last render of this subtree + suspendedBy: null | Array, // not used data: Fiber, // one of a Fiber pair }; @@ -207,6 +215,7 @@ function createFilteredFiberInstance(fiber: Fiber): FilteredFiberInstance { source: null, logCount: 0, treeBaseDuration: 0, + suspendedBy: null, data: fiber, }: any); } @@ -225,6 +234,7 @@ type VirtualInstance = { source: null | string | Error | ReactFunctionLocation, // source location of this server component, or owned child stack logCount: number, // total number of errors/warnings last seen treeBaseDuration: number, // the profiled time of the last render of this subtree + suspendedBy: null | Array, // things that blocked the server component's child from rendering // The latest info for this instance. This can be updated over time and the // same info can appear in more than once ServerComponentInstance. data: ReactComponentInfo, @@ -242,6 +252,7 @@ function createVirtualInstance( source: null, logCount: 0, treeBaseDuration: 0, + suspendedBy: null, data: debugEntry, }; } @@ -2354,6 +2365,21 @@ export function attach( // the current parent here as well. let reconcilingParent: null | DevToolsInstance = null; + function insertSuspendedBy(asyncInfo: ReactAsyncInfo): void { + const parentInstance = reconcilingParent; + if (parentInstance === null) { + // Suspending at the root is not attributed to any particular component + // TODO: It should be attributed to the shell. + return; + } + const suspendedBy = parentInstance.suspendedBy; + if (suspendedBy === null) { + parentInstance.suspendedBy = [asyncInfo]; + } else if (suspendedBy.indexOf(asyncInfo) === -1) { + suspendedBy.push(asyncInfo); + } + } + function insertChild(instance: DevToolsInstance): void { const parentInstance = reconcilingParent; if (parentInstance === null) { @@ -2515,6 +2541,17 @@ export function attach( if (fiber._debugInfo) { for (let i = 0; i < fiber._debugInfo.length; i++) { const debugEntry = fiber._debugInfo[i]; + if (debugEntry.awaited) { + // Async Info + const asyncInfo: ReactAsyncInfo = (debugEntry: any); + if (level === virtualLevel) { + // Track any async info between the previous virtual instance up until to this + // instance and add it to the parent. This can add the same set multiple times + // so we assume insertSuspendedBy dedupes. + insertSuspendedBy(asyncInfo); + } + if (previousVirtualInstance) continue; + } if (typeof debugEntry.name !== 'string') { // Not a Component. Some other Debug Info. continue; @@ -2768,6 +2805,7 @@ export function attach( // Move all the children of this instance to the remaining set. remainingReconcilingChildren = instance.firstChild; instance.firstChild = null; + instance.suspendedBy = null; try { // Unmount the remaining set. unmountRemainingChildren(); @@ -2968,6 +3006,7 @@ export function attach( // We'll move them back one by one, and anything that remains is deleted. remainingReconcilingChildren = virtualInstance.firstChild; virtualInstance.firstChild = null; + virtualInstance.suspendedBy = null; try { if ( updateVirtualChildrenRecursively( @@ -3019,6 +3058,17 @@ export function attach( if (nextChild._debugInfo) { for (let i = 0; i < nextChild._debugInfo.length; i++) { const debugEntry = nextChild._debugInfo[i]; + if (debugEntry.awaited) { + // Async Info + const asyncInfo: ReactAsyncInfo = (debugEntry: any); + if (level === virtualLevel) { + // Track any async info between the previous virtual instance up until to this + // instance and add it to the parent. This can add the same set multiple times + // so we assume insertSuspendedBy dedupes. + insertSuspendedBy(asyncInfo); + } + if (previousVirtualInstance) continue; + } if (typeof debugEntry.name !== 'string') { // Not a Component. Some other Debug Info. continue; @@ -3343,6 +3393,7 @@ export function attach( // We'll move them back one by one, and anything that remains is deleted. remainingReconcilingChildren = fiberInstance.firstChild; fiberInstance.firstChild = null; + fiberInstance.suspendedBy = null; } try { if ( @@ -4051,6 +4102,42 @@ export function attach( return null; } + function serializeAsyncInfo( + asyncInfo: ReactAsyncInfo, + index: number, + parentInstance: DevToolsInstance, + ): SerializedAsyncInfo { + const ioInfo = asyncInfo.awaited; + const ioOwnerInstance = findNearestOwnerInstance( + parentInstance, + ioInfo.owner, + ); + const awaitOwnerInstance = findNearestOwnerInstance( + parentInstance, + asyncInfo.owner, + ); + return { + awaited: { + name: ioInfo.name, + start: ioInfo.start, + end: ioInfo.end, + value: ioInfo.value == null ? null : ioInfo.value, + env: ioInfo.env == null ? null : ioInfo.env, + owner: + ioOwnerInstance === null + ? null + : instanceToSerializedElement(ioOwnerInstance), + stack: ioInfo.stack == null ? null : ioInfo.stack, + }, + env: asyncInfo.env == null ? null : asyncInfo.env, + owner: + awaitOwnerInstance === null + ? null + : instanceToSerializedElement(awaitOwnerInstance), + stack: asyncInfo.stack == null ? null : asyncInfo.stack, + }; + } + // Fast path props lookup for React Native style editor. // Could use inspectElementRaw() but that would require shallow rendering hooks components, // and could also mess with memoization. @@ -4342,6 +4429,13 @@ export function attach( nativeTag = getNativeTag(fiber.stateNode); } + // This set is an edge case where if you pass a promise to a Client Component into a children + // position without a Server Component as the direct parent. E.g.
{promise}
+ // In this case, this becomes associated with the Client/Host Component where as normally + // you'd expect these to be associated with the Server Component that awaited the data. + // TODO: Prepend other suspense sources like css, images and use(). + const suspendedBy = fiberInstance.suspendedBy; + return { id: fiberInstance.id, @@ -4398,6 +4492,13 @@ export function attach( ? [] : Array.from(componentLogsEntry.warnings.entries()), + suspendedBy: + suspendedBy === null + ? [] + : suspendedBy.map((info, index) => + serializeAsyncInfo(info, index, fiberInstance), + ), + // List of owners owners, @@ -4451,6 +4552,9 @@ export function attach( const componentLogsEntry = componentInfoToComponentLogsMap.get(componentInfo); + // Things that Suspended this Server Component (use(), awaits and direct child promises) + const suspendedBy = virtualInstance.suspendedBy; + return { id: virtualInstance.id, @@ -4490,6 +4594,14 @@ export function attach( componentLogsEntry === undefined ? [] : Array.from(componentLogsEntry.warnings.entries()), + + suspendedBy: + suspendedBy === null + ? [] + : suspendedBy.map((info, index) => + serializeAsyncInfo(info, index, virtualInstance), + ), + // List of owners owners, @@ -4534,7 +4646,7 @@ export function attach( function createIsPathAllowed( key: string | null, - secondaryCategory: 'hooks' | null, + secondaryCategory: 'suspendedBy' | 'hooks' | null, ) { // This function helps prevent previously-inspected paths from being dehydrated in updates. // This is important to avoid a bad user experience where expanded toggles collapse on update. @@ -4566,6 +4678,13 @@ export function attach( return true; } break; + case 'suspendedBy': + if (path.length < 5) { + // Never dehydrate anything above suspendedBy[index].awaited.value + // Those are part of the internal meta data. We only dehydrate inside the Promise. + return true; + } + break; default: break; } @@ -4789,36 +4908,42 @@ export function attach( type: 'not-found', }; } + const inspectedElement = mostRecentlyInspectedElement; // Any time an inspected element has an update, // we should update the selected $r value as wel. // Do this before dehydration (cleanForBridge). - updateSelectedElement(mostRecentlyInspectedElement); + updateSelectedElement(inspectedElement); // Clone before cleaning so that we preserve the full data. // This will enable us to send patches without re-inspecting if hydrated paths are requested. // (Reducing how often we shallow-render is a better DX for function components that use hooks.) - const cleanedInspectedElement = {...mostRecentlyInspectedElement}; + const cleanedInspectedElement = {...inspectedElement}; // $FlowFixMe[prop-missing] found when upgrading Flow cleanedInspectedElement.context = cleanForBridge( - cleanedInspectedElement.context, + inspectedElement.context, createIsPathAllowed('context', null), ); // $FlowFixMe[prop-missing] found when upgrading Flow cleanedInspectedElement.hooks = cleanForBridge( - cleanedInspectedElement.hooks, + inspectedElement.hooks, createIsPathAllowed('hooks', 'hooks'), ); // $FlowFixMe[prop-missing] found when upgrading Flow cleanedInspectedElement.props = cleanForBridge( - cleanedInspectedElement.props, + inspectedElement.props, createIsPathAllowed('props', null), ); // $FlowFixMe[prop-missing] found when upgrading Flow cleanedInspectedElement.state = cleanForBridge( - cleanedInspectedElement.state, + inspectedElement.state, createIsPathAllowed('state', null), ); + // $FlowFixMe[prop-missing] found when upgrading Flow + cleanedInspectedElement.suspendedBy = cleanForBridge( + inspectedElement.suspendedBy, + createIsPathAllowed('suspendedBy', 'suspendedBy'), + ); return { id, diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index cc097c83790..d2b846bee24 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -755,6 +755,10 @@ export function attach( inspectedElement.state, createIsPathAllowed('state'), ); + inspectedElement.suspendedBy = cleanForBridge( + inspectedElement.suspendedBy, + createIsPathAllowed('suspendedBy'), + ); return { id, @@ -847,6 +851,9 @@ export function attach( errors, warnings, + // Not supported in legacy renderers. + suspendedBy: [], + // List of owners owners, diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index c9d6284b2f4..979baa3e0ae 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -32,7 +32,7 @@ import type { import type {InitBackend} from 'react-devtools-shared/src/backend'; import type {TimelineDataExport} from 'react-devtools-timeline/src/types'; import type {BackendBridge} from 'react-devtools-shared/src/bridge'; -import type {ReactFunctionLocation} from 'shared/ReactTypes'; +import type {ReactFunctionLocation, ReactStackTrace} from 'shared/ReactTypes'; import type Agent from './agent'; type BundleType = @@ -232,6 +232,25 @@ export type PathMatch = { isFullMatch: boolean, }; +// Serialized version of ReactIOInfo +export type SerializedIOInfo = { + name: string, + start: number, + end: number, + value: null | Promise, + env: null | string, + owner: null | SerializedElement, + stack: null | ReactStackTrace, +}; + +// Serialized version of ReactAsyncInfo +export type SerializedAsyncInfo = { + awaited: SerializedIOInfo, + env: null | string, + owner: null | SerializedElement, + stack: null | ReactStackTrace, +}; + export type SerializedElement = { displayName: string | null, id: number, @@ -268,14 +287,17 @@ export type InspectedElement = { hasLegacyContext: boolean, // Inspectable properties. - context: Object | null, - hooks: Object | null, - props: Object | null, - state: Object | null, + context: Object | null, // DehydratedData or {[string]: mixed} + hooks: Object | null, // DehydratedData or {[string]: mixed} + props: Object | null, // DehydratedData or {[string]: mixed} + state: Object | null, // DehydratedData or {[string]: mixed} key: number | string | null, errors: Array<[string, number]>, warnings: Array<[string, number]>, + // Things that suspended this Instances + suspendedBy: Object, // DehydratedData or Array + // List of owners owners: Array | null, source: ReactFunctionLocation | null, diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js index 20b4e99a101..9f96215026a 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -16,6 +16,7 @@ import ElementPollingCancellationError from 'react-devtools-shared/src/errors/El import type { InspectedElement as InspectedElementBackend, InspectedElementPayload, + SerializedAsyncInfo as SerializedAsyncInfoBackend, } from 'react-devtools-shared/src/backend/types'; import type { BackendEvents, @@ -24,6 +25,7 @@ import type { import type { DehydratedData, InspectedElement as InspectedElementFrontend, + SerializedAsyncInfo as SerializedAsyncInfoFrontend, } from 'react-devtools-shared/src/frontend/types'; import type {InspectedElementPath} from 'react-devtools-shared/src/frontend/types'; @@ -209,6 +211,32 @@ export function cloneInspectedElementWithPath( return clonedInspectedElement; } +function backendToFrontendSerializedAsyncInfo( + asyncInfo: SerializedAsyncInfoBackend, +): SerializedAsyncInfoFrontend { + const ioInfo = asyncInfo.awaited; + return { + awaited: { + name: ioInfo.name, + start: ioInfo.start, + end: ioInfo.end, + value: ioInfo.value, + env: ioInfo.env, + owner: + ioInfo.owner === null + ? null + : backendToFrontendSerializedElementMapper(ioInfo.owner), + stack: ioInfo.stack, + }, + env: asyncInfo.env, + owner: + asyncInfo.owner === null + ? null + : backendToFrontendSerializedElementMapper(asyncInfo.owner), + stack: asyncInfo.stack, + }; +} + export function convertInspectedElementBackendToFrontend( inspectedElementBackend: InspectedElementBackend, ): InspectedElementFrontend { @@ -238,9 +266,13 @@ export function convertInspectedElementBackendToFrontend( key, errors, warnings, + suspendedBy, nativeTag, } = inspectedElementBackend; + const hydratedSuspendedBy: null | Array = + hydrateHelper(suspendedBy); + const inspectedElement: InspectedElementFrontend = { canEditFunctionProps, canEditFunctionPropsDeletePaths, @@ -272,6 +304,10 @@ export function convertInspectedElementBackendToFrontend( state: hydrateHelper(state), errors, warnings, + suspendedBy: + hydratedSuspendedBy == null // backwards compat + ? [] + : hydratedSuspendedBy.map(backendToFrontendSerializedAsyncInfo), nativeTag, }; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css index e9916d467cf..65dd6baf4a6 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css @@ -51,3 +51,41 @@ .EditableValue { min-width: 1rem; } + +.CollapsableRow { + border-top: 1px solid var(--color-border); +} + +.CollapsableRow:last-child { + margin-bottom: -0.25rem; +} + +.CollapsableHeader { + width: 100%; + padding: 0.25rem; + display: flex; +} + +.CollapsableHeaderIcon { + flex: 0 0 1rem; + margin-left: -0.25rem; + width: 1rem; + height: 1rem; + padding: 0; + color: var(--color-expand-collapse-toggle); +} + +.CollapsableHeaderTitle { + flex: 1 1 auto; + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); + text-align: left; +} + +.CollapsableContent { + padding: 0.25rem 0; +} + +.PreviewContainer { + padding: 0 0.25rem 0.25rem 0.25rem; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js index 585361cedcf..604c3784b75 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js @@ -9,7 +9,6 @@ import * as React from 'react'; import {copy} from 'clipboard-js'; -import {toNormalUrl} from 'jsc-safe-url'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; @@ -21,6 +20,8 @@ import useOpenResource from '../useOpenResource'; import type {ReactFunctionLocation} from 'shared/ReactTypes'; import styles from './InspectedElementSourcePanel.css'; +import formatLocationForDisplay from './formatLocationForDisplay'; + type Props = { source: ReactFunctionLocation, symbolicatedSourcePromise: Promise, @@ -95,52 +96,21 @@ function FormattedSourceString({source, symbolicatedSourcePromise}: Props) { symbolicatedSource, ); - const [, sourceURL, line] = + const [, sourceURL, line, column] = symbolicatedSource == null ? source : symbolicatedSource; return (
- {linkIsEnabled ? ( - - {formatSourceForDisplay(sourceURL, line)} - - ) : ( - formatSourceForDisplay(sourceURL, line) - )} + + {formatLocationForDisplay(sourceURL, line, column)} +
); } -// This function is based on describeComponentFrame() in packages/shared/ReactComponentStackFrame -function formatSourceForDisplay(sourceURL: string, line: number) { - // Metro can return JSC-safe URLs, which have `//&` as a delimiter - // https://www.npmjs.com/package/jsc-safe-url - const sanitizedSourceURL = sourceURL.includes('//&') - ? toNormalUrl(sourceURL) - : sourceURL; - - // Note: this RegExp doesn't work well with URLs from Metro, - // which provides bundle URL with query parameters prefixed with /& - const BEFORE_SLASH_RE = /^(.*)[\\\/]/; - - let nameOnly = sanitizedSourceURL.replace(BEFORE_SLASH_RE, ''); - - // In DEV, include code for a common special case: - // prefer "folder/index.js" instead of just "index.js". - if (/^index\./.test(nameOnly)) { - const match = sanitizedSourceURL.match(BEFORE_SLASH_RE); - if (match) { - const pathBeforeSlash = match[1]; - if (pathBeforeSlash) { - const folderName = pathBeforeSlash.replace(BEFORE_SLASH_RE, ''); - nameOnly = folderName + '/' + nameOnly; - } - } - } - - return `${nameOnly}:${line}`; -} - export default InspectedElementSourcePanel; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js new file mode 100644 index 00000000000..a0bc761c506 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -0,0 +1,153 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import {copy} from 'clipboard-js'; +import * as React from 'react'; +import {useState} from 'react'; +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; +import KeyValue from './KeyValue'; +import {serializeDataForCopy} from '../utils'; +import Store from '../../store'; +import styles from './InspectedElementSharedStyles.css'; +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck'; +import StackTraceView from './StackTraceView'; +import OwnerView from './OwnerView'; + +import type {InspectedElement} from 'react-devtools-shared/src/frontend/types'; +import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; +import type {SerializedAsyncInfo} from 'react-devtools-shared/src/frontend/types'; + +type RowProps = { + bridge: FrontendBridge, + element: Element, + inspectedElement: InspectedElement, + store: Store, + asyncInfo: SerializedAsyncInfo, + index: number, +}; + +function SuspendedByRow({ + bridge, + element, + inspectedElement, + store, + asyncInfo, + index, +}: RowProps) { + const [isOpen, setIsOpen] = useState(false); + const name = asyncInfo.awaited.name; + let stack; + let owner; + if (asyncInfo.stack === null || asyncInfo.stack.length === 0) { + stack = asyncInfo.awaited.stack; + owner = asyncInfo.awaited.owner; + } else { + stack = asyncInfo.stack; + owner = asyncInfo.owner; + } + return ( +
+ + {isOpen && ( +
+
+
+ {stack !== null && stack.length > 0 && ( + + )} + {owner !== null && owner.id !== inspectedElement.id ? ( + + ) : null} +
+ )} +
+ ); +} + +type Props = { + bridge: FrontendBridge, + element: Element, + inspectedElement: InspectedElement, + store: Store, +}; + +export default function InspectedElementSuspendedBy({ + bridge, + element, + inspectedElement, + store, +}: Props): React.Node { + const {suspendedBy} = inspectedElement; + + // Skip the section if nothing suspended this component. + if (suspendedBy == null || suspendedBy.length === 0) { + return null; + } + + const handleCopy = withPermissionsCheck( + {permissions: ['clipboardWrite']}, + () => copy(serializeDataForCopy(suspendedBy)), + ); + + return ( +
+
+
suspended by
+ +
+ {suspendedBy.map((asyncInfo, index) => ( + + ))} +
+ ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.css index 57dc15d8b3d..3450a745b9e 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.css @@ -2,16 +2,6 @@ font-family: var(--font-family-sans); } -.Owner { - color: var(--color-component-name); - font-family: var(--font-family-monospace); - font-size: var(--font-size-monospace-normal); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} - .InspectedElement { overflow-x: hidden; overflow-y: auto; @@ -28,41 +18,6 @@ } } -.Owner { - border-radius: 0.25rem; - padding: 0.125rem 0.25rem; - background: none; - border: none; - display: block; -} -.Owner:focus { - outline: none; - background-color: var(--color-button-background-focus); -} - -.NotInStore { - color: var(--color-dim); - cursor: default; -} - -.OwnerButton { - cursor: pointer; - width: 100%; - padding: 0; -} - -.OwnerContent { - display: flex; - align-items: center; - padding-left: 1rem; - width: 100%; - border-radius: 0.25rem; -} - -.OwnerContent:hover { - background-color: var(--color-background-hover); -} - .OwnersMetaField { padding-left: 1.25rem; white-space: nowrap; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js index 037ca36b89f..8bf373f685c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js @@ -8,10 +8,8 @@ */ import * as React from 'react'; -import {Fragment, useCallback, useContext} from 'react'; -import {TreeDispatcherContext} from './TreeContext'; +import {Fragment, useContext} from 'react'; import {BridgeContext, StoreContext} from '../context'; -import Button from '../Button'; import InspectedElementBadges from './InspectedElementBadges'; import InspectedElementContextTree from './InspectedElementContextTree'; import InspectedElementErrorsAndWarningsTree from './InspectedElementErrorsAndWarningsTree'; @@ -20,12 +18,11 @@ import InspectedElementPropsTree from './InspectedElementPropsTree'; import InspectedElementStateTree from './InspectedElementStateTree'; import InspectedElementStyleXPlugin from './InspectedElementStyleXPlugin'; import InspectedElementSuspenseToggle from './InspectedElementSuspenseToggle'; +import InspectedElementSuspendedBy from './InspectedElementSuspendedBy'; import NativeStyleEditor from './NativeStyleEditor'; -import ElementBadges from './ElementBadges'; -import {useHighlightHostInstance} from '../hooks'; import {enableStyleXFeatures} from 'react-devtools-feature-flags'; -import {logEvent} from 'react-devtools-shared/src/Logger'; import InspectedElementSourcePanel from './InspectedElementSourcePanel'; +import OwnerView from './OwnerView'; import styles from './InspectedElementView.css'; @@ -156,6 +153,15 @@ export default function InspectedElementView({ +
+ +
+ {showRenderedBy && (
); } - -type OwnerViewProps = { - displayName: string, - hocDisplayNames: Array | null, - compiledWithForget: boolean, - id: number, - isInStore: boolean, -}; - -function OwnerView({ - displayName, - hocDisplayNames, - compiledWithForget, - id, - isInStore, -}: OwnerViewProps) { - const dispatch = useContext(TreeDispatcherContext); - const {highlightHostInstance, clearHighlightHostInstance} = - useHighlightHostInstance(); - - const handleClick = useCallback(() => { - logEvent({ - event_name: 'select-element', - metadata: {source: 'owner-view'}, - }); - dispatch({ - type: 'SELECT_ELEMENT_BY_ID', - payload: id, - }); - }, [dispatch, id]); - - return ( - - ); -} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.css b/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.css new file mode 100644 index 00000000000..b4e5cd157f3 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.css @@ -0,0 +1,41 @@ +.Owner { + color: var(--color-component-name); + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + border-radius: 0.25rem; + padding: 0.125rem 0.25rem; + background: none; + border: none; + display: block; +} +.Owner:focus { + outline: none; + background-color: var(--color-button-background-focus); +} + +.OwnerButton { + cursor: pointer; + width: 100%; + padding: 0; +} + +.OwnerContent { + display: flex; + align-items: center; + padding-left: 1rem; + width: 100%; + border-radius: 0.25rem; +} + +.OwnerContent:hover { + background-color: var(--color-background-hover); +} + +.NotInStore { + color: var(--color-dim); + cursor: default; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js b/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js new file mode 100644 index 00000000000..561e8a66513 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js @@ -0,0 +1,72 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react'; +import {useCallback, useContext} from 'react'; +import {TreeDispatcherContext} from './TreeContext'; +import Button from '../Button'; +import ElementBadges from './ElementBadges'; +import {useHighlightHostInstance} from '../hooks'; +import {logEvent} from 'react-devtools-shared/src/Logger'; + +import styles from './OwnerView.css'; + +type OwnerViewProps = { + displayName: string, + hocDisplayNames: Array | null, + compiledWithForget: boolean, + id: number, + isInStore: boolean, +}; + +export default function OwnerView({ + displayName, + hocDisplayNames, + compiledWithForget, + id, + isInStore, +}: OwnerViewProps): React.Node { + const dispatch = useContext(TreeDispatcherContext); + const {highlightHostInstance, clearHighlightHostInstance} = + useHighlightHostInstance(); + + const handleClick = useCallback(() => { + logEvent({ + event_name: 'select-element', + metadata: {source: 'owner-view'}, + }); + dispatch({ + type: 'SELECT_ELEMENT_BY_ID', + payload: id, + }); + }, [dispatch, id]); + + return ( + + ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.css b/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.css new file mode 100644 index 00000000000..2dd1410c8c7 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.css @@ -0,0 +1,24 @@ +.StackTraceView { + padding: 0.25rem; +} + +.CallSite { + display: block; + padding-left: 1rem; +} + +.Link { + color: var(--color-link); + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + cursor: pointer; + border-radius: 0.125rem; + padding: 0px 2px; +} + +.Link:hover { + background-color: var(--color-background-hover); +} + diff --git a/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js b/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js new file mode 100644 index 00000000000..62cf911b9fe --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js @@ -0,0 +1,58 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react'; + +import useOpenResource from '../useOpenResource'; + +import styles from './StackTraceView.css'; + +import type {ReactStackTrace, ReactCallSite} from 'shared/ReactTypes'; + +import formatLocationForDisplay from './formatLocationForDisplay'; + +type CallSiteViewProps = { + callSite: ReactCallSite, +}; + +export function CallSiteView({callSite}: CallSiteViewProps): React.Node { + const symbolicatedCallSite: null | ReactCallSite = null; // TODO + const [linkIsEnabled, viewSource] = useOpenResource( + callSite, + symbolicatedCallSite, + ); + const [functionName, url, line, column] = + symbolicatedCallSite !== null ? symbolicatedCallSite : callSite; + return ( +
+ {functionName} + {' @ '} + + {formatLocationForDisplay(url, line, column)} + +
+ ); +} + +type Props = { + stack: ReactStackTrace, +}; + +export default function StackTraceView({stack}: Props): React.Node { + return ( +
+ {stack.map((callSite, index) => ( + + ))} +
+ ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/formatLocationForDisplay.js b/packages/react-devtools-shared/src/devtools/views/Components/formatLocationForDisplay.js new file mode 100644 index 00000000000..1c113e38839 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/formatLocationForDisplay.js @@ -0,0 +1,44 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import {toNormalUrl} from 'jsc-safe-url'; + +// This function is based on describeComponentFrame() in packages/shared/ReactComponentStackFrame +export default function formatLocationForDisplay( + sourceURL: string, + line: number, + column: number, +): string { + // Metro can return JSC-safe URLs, which have `//&` as a delimiter + // https://www.npmjs.com/package/jsc-safe-url + const sanitizedSourceURL = sourceURL.includes('//&') + ? toNormalUrl(sourceURL) + : sourceURL; + + // Note: this RegExp doesn't work well with URLs from Metro, + // which provides bundle URL with query parameters prefixed with /& + const BEFORE_SLASH_RE = /^(.*)[\\\/]/; + + let nameOnly = sanitizedSourceURL.replace(BEFORE_SLASH_RE, ''); + + // In DEV, include code for a common special case: + // prefer "folder/index.js" instead of just "index.js". + if (/^index\./.test(nameOnly)) { + const match = sanitizedSourceURL.match(BEFORE_SLASH_RE); + if (match) { + const pathBeforeSlash = match[1]; + if (pathBeforeSlash) { + const folderName = pathBeforeSlash.replace(BEFORE_SLASH_RE, ''); + nameOnly = folderName + '/' + nameOnly; + } + } + } + + return `${nameOnly}:${line}`; +} diff --git a/packages/react-devtools-shared/src/devtools/views/utils.js b/packages/react-devtools-shared/src/devtools/views/utils.js index 2759a3f452c..ed14b2c236b 100644 --- a/packages/react-devtools-shared/src/devtools/views/utils.js +++ b/packages/react-devtools-shared/src/devtools/views/utils.js @@ -121,7 +121,7 @@ function sanitize(data: Object): void { } export function serializeDataForCopy(props: Object): string { - const cloned = Object.assign({}, props); + const cloned = isArray(props) ? props.slice(0) : Object.assign({}, props); sanitize(cloned); diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index 3f687a03da6..aa6b95a88ab 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -18,7 +18,7 @@ import type { Dehydrated, Unserializable, } from 'react-devtools-shared/src/hydration'; -import type {ReactFunctionLocation} from 'shared/ReactTypes'; +import type {ReactFunctionLocation, ReactStackTrace} from 'shared/ReactTypes'; export type BrowserTheme = 'dark' | 'light'; @@ -184,6 +184,25 @@ export type Element = { compiledWithForget: boolean, }; +// Serialized version of ReactIOInfo +export type SerializedIOInfo = { + name: string, + start: number, + end: number, + value: null | Promise, + env: null | string, + owner: null | SerializedElement, + stack: null | ReactStackTrace, +}; + +// Serialized version of ReactAsyncInfo +export type SerializedAsyncInfo = { + awaited: SerializedIOInfo, + env: null | string, + owner: null | SerializedElement, + stack: null | ReactStackTrace, +}; + export type SerializedElement = { displayName: string | null, id: number, @@ -239,6 +258,9 @@ export type InspectedElement = { errors: Array<[string, number]>, warnings: Array<[string, number]>, + // Things that suspended this Instances + suspendedBy: Object, + // List of owners owners: Array | null,