[DevTools] Add ignore-listed stack frame disclosure (#36828)

This commit is contained in:
Jiwon Choi
2026-06-25 15:57:28 +02:00
committed by GitHub
parent 0b98ace89e
commit 4950b5b789
5 changed files with 414 additions and 132 deletions
@@ -143,3 +143,8 @@
padding-left: 1.25rem;
margin-top: 0.25rem;
}
.SuspendedBySkeleton {
padding: 0.25rem;
padding-left: 1.25rem;
}
@@ -9,7 +9,7 @@
import {copy} from 'clipboard-js';
import * as React from 'react';
import {useState, useTransition} from 'react';
import {use, useContext, useState, useTransition} from 'react';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import KeyValue from './KeyValue';
@@ -17,10 +17,13 @@ import {serializeDataForCopy, pluralize} 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 FetchFileWithCachingContext from './FetchFileWithCachingContext';
import StackTraceView, {IgnoreListToggleButton} from './StackTraceView';
import OwnerView from './OwnerView';
import {meta} from '../../../hydration';
import Skeleton from './Skeleton';
import useInferredName from '../useInferredName';
import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource';
import {getClassNameForEnvironment} from '../SuspenseTab/SuspenseEnvironmentColors.js';
@@ -29,6 +32,8 @@ import type {
SerializedAsyncInfo,
} from 'react-devtools-shared/src/frontend/types';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import type {ReactStackTrace} from 'shared/ReactTypes';
import type {SourceMappedLocation} from 'react-devtools-shared/src/symbolicateSource';
import {
UNKNOWN_SUSPENDERS_NONE,
@@ -94,6 +99,78 @@ function formatBytes(bytes: number) {
return (bytes / 1_000_000_000).toFixed(1) + ' gB';
}
type StackTraceGroupProps = {
children: (showIgnoreList: boolean) => React.Node,
ioStack: null | ReactStackTrace,
asyncInfoStack: null | ReactStackTrace,
};
function StackTraceGroup({
children,
ioStack,
asyncInfoStack,
}: StackTraceGroupProps): React.Node {
const [showIgnoreList, setShowIgnoreList] = useState(false);
const fetchFileWithCaching = useContext(FetchFileWithCachingContext);
const ioStackHasIgnoredFrames =
ioStack !== null &&
ioStack.some(callSite => {
const [, virtualURL, virtualLine, virtualColumn] = callSite;
// symbolicated output is cached
const symbolicatedCallSite: null | SourceMappedLocation =
fetchFileWithCaching !== null
? use(
symbolicateSourceWithCache(
fetchFileWithCaching,
virtualURL,
virtualLine,
virtualColumn,
),
)
: null;
return symbolicatedCallSite !== null && symbolicatedCallSite.ignored;
});
const asyncInfoStackHasIgnoredFrames =
asyncInfoStack !== null &&
asyncInfoStack.some(callSite => {
const [, virtualURL, virtualLine, virtualColumn] = callSite;
// symbolicated output is cached
const symbolicatedCallSite: null | SourceMappedLocation =
fetchFileWithCaching !== null
? use(
symbolicateSourceWithCache(
fetchFileWithCaching,
virtualURL,
virtualLine,
virtualColumn,
),
)
: null;
return symbolicatedCallSite !== null && symbolicatedCallSite.ignored;
});
const hasIgnoredFrames =
ioStackHasIgnoredFrames || asyncInfoStackHasIgnoredFrames;
return (
<>
{children(showIgnoreList)}
{hasIgnoredFrames && (
<IgnoreListToggleButton
onClick={() => setShowIgnoreList(prev => !prev)}
showIgnoreList={showIgnoreList}
/>
)}
</>
);
}
function SuspendedByRow({
bridge,
element,
@@ -203,102 +280,126 @@ function SuspendedByRow({
</Button>
{isOpen && (
<div className={styles.CollapsableContent}>
{showIOStack && (
<StackTraceView
stack={ioInfo.stack}
environmentName={
ioOwner !== null && ioOwner.env === ioInfo.env
? null
: ioInfo.env
}
/>
)}
{ioOwner !== null &&
ioOwner.id !== inspectedElement.id &&
(showIOStack ||
!showAwaitStack ||
asyncOwner === null ||
ioOwner.id !== asyncOwner.id) ? (
<OwnerView
key={ioOwner.id}
displayName={ioOwner.displayName || 'Anonymous'}
environmentName={
ioOwner.env === inspectedElement.env &&
ioOwner.env === ioInfo.env
? null
: ioOwner.env
}
hocDisplayNames={ioOwner.hocDisplayNames}
compiledWithForget={ioOwner.compiledWithForget}
id={ioOwner.id}
isInStore={store.containsElement(ioOwner.id)}
type={ioOwner.type}
/>
) : null}
{showAwaitStack ? (
<>
<div className={styles.SmallHeader}>awaited at:</div>
{asyncInfo.stack !== null && asyncInfo.stack.length > 0 && (
<StackTraceView
stack={asyncInfo.stack}
environmentName={
asyncOwner !== null && asyncOwner.env === asyncInfo.env
? null
: asyncInfo.env
}
/>
<React.Suspense
fallback={
<div className={styles.SuspendedBySkeleton}>
<Skeleton height={16} width="40%" />
</div>
}>
<StackTraceGroup
ioStack={showIOStack ? ioInfo.stack : null}
asyncInfoStack={showAwaitStack ? asyncInfo.stack : null}>
{(showIgnoreList: boolean) => (
<>
{showIOStack && (
<StackTraceView
stack={ioInfo.stack}
environmentName={
ioOwner !== null && ioOwner.env === ioInfo.env
? null
: ioInfo.env
}
showIgnoreList={showIgnoreList}
/>
)}
{ioOwner !== null &&
ioOwner.id !== inspectedElement.id &&
(showIOStack ||
!showAwaitStack ||
asyncOwner === null ||
ioOwner.id !== asyncOwner.id) ? (
<OwnerView
key={ioOwner.id}
displayName={ioOwner.displayName || 'Anonymous'}
environmentName={
ioOwner.env === inspectedElement.env &&
ioOwner.env === ioInfo.env
? null
: ioOwner.env
}
hocDisplayNames={ioOwner.hocDisplayNames}
compiledWithForget={ioOwner.compiledWithForget}
id={ioOwner.id}
isInStore={store.containsElement(ioOwner.id)}
type={ioOwner.type}
/>
) : null}
{showAwaitStack ? (
<>
<div className={styles.SmallHeader}>awaited at:</div>
{asyncInfo.stack !== null &&
asyncInfo.stack.length > 0 && (
<StackTraceView
stack={asyncInfo.stack}
environmentName={
asyncOwner !== null &&
asyncOwner.env === asyncInfo.env
? null
: asyncInfo.env
}
showIgnoreList={showIgnoreList}
/>
)}
{asyncOwner !== null &&
asyncOwner.id !== inspectedElement.id ? (
<OwnerView
key={asyncOwner.id}
displayName={asyncOwner.displayName || 'Anonymous'}
environmentName={
asyncOwner.env === inspectedElement.env &&
asyncOwner.env === asyncInfo.env
? null
: asyncOwner.env
}
hocDisplayNames={asyncOwner.hocDisplayNames}
compiledWithForget={asyncOwner.compiledWithForget}
id={asyncOwner.id}
isInStore={store.containsElement(asyncOwner.id)}
type={asyncOwner.type}
/>
) : null}
</>
) : null}
<div className={styles.PreviewContainer}>
<KeyValue
alphaSort={true}
bridge={bridge}
canDeletePaths={false}
canEditValues={false}
canRenamePaths={false}
depth={1}
element={element}
hidden={false}
inspectedElement={inspectedElement}
name={
isFulfilled
? 'awaited value'
: isRejected
? 'rejected with'
: 'pending value'
}
path={
isFulfilled
? [index, 'awaited', 'value', 'value']
: isRejected
? [index, 'awaited', 'value', 'reason']
: [index, 'awaited', 'value']
}
pathRoot="suspendedBy"
store={store}
value={
isFulfilled
? value.value
: isRejected
? value.reason
: value
}
/>
</div>
</>
)}
{asyncOwner !== null && asyncOwner.id !== inspectedElement.id ? (
<OwnerView
key={asyncOwner.id}
displayName={asyncOwner.displayName || 'Anonymous'}
environmentName={
asyncOwner.env === inspectedElement.env &&
asyncOwner.env === asyncInfo.env
? null
: asyncOwner.env
}
hocDisplayNames={asyncOwner.hocDisplayNames}
compiledWithForget={asyncOwner.compiledWithForget}
id={asyncOwner.id}
isInStore={store.containsElement(asyncOwner.id)}
type={asyncOwner.type}
/>
) : null}
</>
) : null}
<div className={styles.PreviewContainer}>
<KeyValue
alphaSort={true}
bridge={bridge}
canDeletePaths={false}
canEditValues={false}
canRenamePaths={false}
depth={1}
element={element}
hidden={false}
inspectedElement={inspectedElement}
name={
isFulfilled
? 'awaited value'
: isRejected
? 'rejected with'
: 'pending value'
}
path={
isFulfilled
? [index, 'awaited', 'value', 'value']
: isRejected
? [index, 'awaited', 'value', 'reason']
: [index, 'awaited', 'value']
}
pathRoot="suspendedBy"
store={store}
value={
isFulfilled ? value.value : isRejected ? value.reason : value
}
/>
</div>
</StackTraceGroup>
</React.Suspense>
</div>
)}
</div>
@@ -8,7 +8,7 @@
*/
import * as React from 'react';
import {Fragment, useContext} from 'react';
import {Fragment, useContext, useState, use} from 'react';
import {BridgeContext, StoreContext} from '../context';
import InspectedElementBadges from './InspectedElementBadges';
import InspectedElementContextTree from './InspectedElementContextTree';
@@ -19,15 +19,17 @@ import InspectedElementStateTree from './InspectedElementStateTree';
import InspectedElementStyleXPlugin from './InspectedElementStyleXPlugin';
import InspectedElementSuspendedBy from './InspectedElementSuspendedBy';
import NativeStyleEditor from './NativeStyleEditor';
import FetchFileWithCachingContext from './FetchFileWithCachingContext';
import {enableStyleXFeatures} from 'react-devtools-feature-flags';
import InspectedElementSourcePanel from './InspectedElementSourcePanel';
import StackTraceView from './StackTraceView';
import StackTraceView, {IgnoreListToggleButton} from './StackTraceView';
import OwnerView from './OwnerView';
import Skeleton from './Skeleton';
import {
ElementTypeSuspense,
ElementTypeActivity,
} from 'react-devtools-shared/src/frontend/types';
import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource';
import styles from './InspectedElementView.css';
@@ -39,6 +41,83 @@ import type {HookNames} from 'react-devtools-shared/src/frontend/types';
import type {ToggleParseHookNames} from './InspectedElementContext';
import type {SourceMappedLocation} from 'react-devtools-shared/src/symbolicateSource';
type StackTraceGroupProps = {
children: (showIgnoreList: boolean) => React.Node,
componentStack: InspectedElement['stack'],
owners: InspectedElement['owners'],
};
function StackTraceGroup({
children,
componentStack,
owners,
}: StackTraceGroupProps): React.Node {
const [showIgnoreList, setShowIgnoreList] = useState(false);
const fetchFileWithCaching = useContext(FetchFileWithCachingContext);
const componentStackHasIgnoredFrames =
componentStack !== null &&
componentStack.some(callSite => {
const [, virtualURL, virtualLine, virtualColumn] = callSite;
// symbolicated output is cached
const symbolicatedCallSite: null | SourceMappedLocation =
fetchFileWithCaching !== null
? use(
symbolicateSourceWithCache(
fetchFileWithCaching,
virtualURL,
virtualLine,
virtualColumn,
),
)
: null;
return symbolicatedCallSite !== null && symbolicatedCallSite.ignored;
});
const ownerStacksHaveIgnoredFrames =
owners !== null &&
owners.some(owner => {
return (
owner.stack !== null &&
owner.stack.some(callSite => {
const [, virtualURL, virtualLine, virtualColumn] = callSite;
// symbolicated output is cached
const symbolicatedCallSite: null | SourceMappedLocation =
fetchFileWithCaching !== null
? use(
symbolicateSourceWithCache(
fetchFileWithCaching,
virtualURL,
virtualLine,
virtualColumn,
),
)
: null;
return symbolicatedCallSite !== null && symbolicatedCallSite.ignored;
})
);
});
const hasIgnoredFrames =
componentStackHasIgnoredFrames || ownerStacksHaveIgnoredFrames;
return (
<>
{children(showIgnoreList)}
{hasIgnoredFrames && (
<IgnoreListToggleButton
onClick={() => setShowIgnoreList(prev => !prev)}
showIgnoreList={showIgnoreList}
/>
)}
</>
);
}
type Props = {
element: Element,
hookNames: HookNames | null,
@@ -189,33 +268,51 @@ export default function InspectedElementView({
<Skeleton height={16} width="40%" />
</div>
}>
{showStack ? <StackTraceView stack={stack} /> : null}
{showOwnersList &&
owners?.map(owner => (
<Fragment key={owner.id}>
<OwnerView
displayName={owner.displayName || 'Anonymous'}
hocDisplayNames={owner.hocDisplayNames}
environmentName={
inspectedElement.env === owner.env ? null : owner.env
}
compiledWithForget={owner.compiledWithForget}
id={owner.id}
isInStore={store.containsElement(owner.id)}
type={owner.type}
/>
{owner.stack != null && owner.stack.length > 0 ? (
<StackTraceView stack={owner.stack} />
<StackTraceGroup componentStack={stack} owners={owners}>
{(showIgnoreList: boolean) => (
<>
{showStack ? (
<StackTraceView
stack={stack}
showIgnoreList={showIgnoreList}
/>
) : null}
</Fragment>
))}
{showOwnersList &&
owners?.map(owner => (
<Fragment key={owner.id}>
<OwnerView
displayName={owner.displayName || 'Anonymous'}
hocDisplayNames={owner.hocDisplayNames}
environmentName={
inspectedElement.env === owner.env
? null
: owner.env
}
compiledWithForget={owner.compiledWithForget}
id={owner.id}
isInStore={store.containsElement(owner.id)}
type={owner.type}
/>
{owner.stack != null && owner.stack.length > 0 ? (
<StackTraceView
stack={owner.stack}
showIgnoreList={showIgnoreList}
/>
) : null}
</Fragment>
))}
{rootType !== null && (
<div className={styles.OwnersMetaField}>{rootType}</div>
)}
{rendererLabel !== null && (
<div className={styles.OwnersMetaField}>{rendererLabel}</div>
)}
{rootType !== null && (
<div className={styles.OwnersMetaField}>{rootType}</div>
)}
{rendererLabel !== null && (
<div className={styles.OwnersMetaField}>
{rendererLabel}
</div>
)}
</>
)}
</StackTraceGroup>
</React.Suspense>
</div>
)}
@@ -8,10 +8,18 @@
white-space-collapse: preserve;
}
.IgnoredCallSite {
opacity: 0.5;
}
.IgnoredCallSite, .BuiltInCallSite {
display: none;
}
.IgnoredCallSite.CallSite {
display: flex;
}
.CallSite + .BuiltInCallSite {
display: block;
}
@@ -33,3 +41,14 @@
margin-left: 0.25rem;
}
.IgnoreListToggleContainer {
padding-left: 1rem;
}
.IgnoreListToggleButton {
background: none;
color: var(--color-link);
font: inherit;
font-style: italic;
text-decoration: underline;
}
@@ -10,6 +10,7 @@
import * as React from 'react';
import {use, useContext} from 'react';
import Button from '../Button';
import useOpenResource from '../useOpenResource';
import ElementBadges from './ElementBadges';
@@ -29,11 +30,13 @@ import formatLocationForDisplay from './formatLocationForDisplay';
type CallSiteViewProps = {
callSite: ReactCallSite,
environmentName: null | string,
showIgnoreList: boolean,
};
export function CallSiteView({
callSite,
environmentName,
showIgnoreList,
}: CallSiteViewProps): React.Node {
const fetchFileWithCaching = useContext(FetchFileWithCachingContext);
@@ -60,16 +63,14 @@ export function CallSiteView({
symbolicatedCallSite !== null ? symbolicatedCallSite.location : callSite;
const ignored =
symbolicatedCallSite !== null ? symbolicatedCallSite.ignored : false;
// TODO: Make an option to be able to toggle the display of ignore listed rows.
// Ideally this UI should be higher than a single Stack Trace so that there's not
// multiple buttons in a single inspection taking up space.
const isBuiltIn = url === '' || url.startsWith('<anonymous>'); // This looks like a fake anonymous through eval.
return (
<div
className={
ignored
? styles.IgnoredCallSite
? styles.IgnoredCallSite +
(showIgnoreList ? ' ' + styles.CallSite : '')
: isBuiltIn
? styles.BuiltInCallSite
: styles.CallSite
@@ -94,15 +95,75 @@ export function CallSiteView({
);
}
type IgnoreListToggleButtonProps = {
onClick: () => void,
showIgnoreList: boolean,
};
export function IgnoreListToggleButton({
onClick,
showIgnoreList,
}: IgnoreListToggleButtonProps): React.Node {
const label = showIgnoreList
? 'Hide ignore-listed frames'
: 'Show ignore-listed frames';
return (
<div className={styles.IgnoreListToggleContainer}>
<Button
className={styles.IgnoreListToggleButton}
onClick={onClick}
title={label}>
{label}
</Button>
</div>
);
}
type Props = {
stack: ReactStackTrace,
environmentName: null | string,
showIgnoreList: boolean,
};
export default function StackTraceView({
stack,
environmentName,
showIgnoreList,
}: Props): React.Node {
const fetchFileWithCaching = useContext(FetchFileWithCachingContext);
let lastMeaningfulFrameIndex = -1;
// Reverse loop to find the last non-ignored, non-built-in index
for (let index = stack.length - 1; index >= 0; index--) {
const callSite = stack[index];
const [, virtualURL, virtualLine, virtualColumn] = callSite;
// symbolicated output is cached
const symbolicatedCallSite: null | SourceMappedLocation =
fetchFileWithCaching !== null
? use(
symbolicateSourceWithCache(
fetchFileWithCaching,
virtualURL,
virtualLine,
virtualColumn,
),
)
: null;
const [, url] =
symbolicatedCallSite !== null ? symbolicatedCallSite.location : callSite;
const ignored =
symbolicatedCallSite !== null ? symbolicatedCallSite.ignored : false;
// This looks like a fake anonymous through eval.
const isBuiltIn = url === '' || url.startsWith('<anonymous>');
if (!ignored && !isBuiltIn) {
lastMeaningfulFrameIndex = index;
break;
}
}
return (
<div className={styles.StackTraceView}>
{stack.map((callSite, index) => (
@@ -110,11 +171,10 @@ export default function StackTraceView({
key={index}
callSite={callSite}
environmentName={
// Badge last row
// TODO: If we start ignore listing the last row, we should badge the last
// non-ignored row.
index === stack.length - 1 ? environmentName : null
// Badge last meaningful frame (non-ignored, non-built-in)
index === lastMeaningfulFrameIndex ? environmentName : null
}
showIgnoreList={showIgnoreList}
/>
))}
</div>