[DevTools] Mark Unknown Reasons for Suspending with a Note (#34200)

We currently only track the reason something might suspend in
development mode through debug info but this excludes some cases. As a
result we can end up with boundary that suspends but has no cause. This
tries to detect that and show a notice for why that might be. I'm also
trying to make it work with old React versions to cover everything.

In production we don't track any of this meta data like `_debugInfo`,
`_debugThenable` etc. so after resolution there's no information to take
from. Except suspensey images / css which we can track in prod too. We
could track lazy component types already. We'd have to add something
that tracks after the fact if something used a lazy child, child as a
promise, hooks, etc. which doesn't exist today. So that's not backwards
compatible and might add some perf/memory cost. However, another
strategy is also to try to replay the components after the fact which
could be backwards compatible. That's tricky for child position since
there's so many rules for how to do that which would have to be
replicated.

If you're in development you get a different error. Given that we've
added instrumentation very recently. If you're on an older development
version of React, then you get a different error. Unfortunately I think
my feature test is not quite perfect because it's tricky to test for the
instrumentation I just added.
https://github.com/facebook/react/pull/34146 So I think for some
prereleases that has `_debugOwner` but doesn't have that you'll get a
misleading error.

Finally, if you're in a modern development environment, the only reason
we should have any gaps is because of throw-a-Promise. This will
highlight it as missing. We can detect that something threw if a
Suspense boundary commits with a RetryCache but since it's a WeakSet we
can't look into it to see anything about what it might have been. I
don't plan on doing anything to improve this since it would only apply
to new versions of React anyway and it's just inherently flawed. So just
deprecate it #34032.

Note that nothing in here can detect that we suspended Transition. So
throwing at the root or in an update won't show that anywhere.
This commit is contained in:
Sebastian Markbåge
2025-08-15 18:32:27 -04:00
committed by GitHub
parent 5063b3283f
commit 431bb0bddb
8 changed files with 144 additions and 2 deletions
@@ -15,6 +15,7 @@ import type {
ReactIOInfo,
ReactStackTrace,
ReactCallSite,
Wakeable,
} from 'shared/ReactTypes';
import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks';
@@ -87,6 +88,10 @@ import {
SUSPENSE_TREE_OPERATION_REMOVE,
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
SUSPENSE_TREE_OPERATION_RESIZE,
UNKNOWN_SUSPENDERS_NONE,
UNKNOWN_SUSPENDERS_REASON_PRODUCTION,
UNKNOWN_SUSPENDERS_REASON_OLD_VERSION,
UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE,
} from '../../constants';
import {inspectHooksOfFiber} from 'react-debug-tools';
import {
@@ -296,6 +301,9 @@ type SuspenseNode = {
// Track whether any of the items in suspendedBy are unique this this Suspense boundaries or if they're all
// also in the parent sets. This determine whether this could contribute in the loading sequence.
hasUniqueSuspenders: boolean,
// Track whether anything suspended in this boundary that we can't track either because it was using throw
// a promise, an older version of React or because we're inspecting prod.
hasUnknownSuspenders: boolean,
};
function createSuspenseNode(
@@ -309,6 +317,7 @@ function createSuspenseNode(
rects: null,
suspendedBy: new Map(),
hasUniqueSuspenders: false,
hasUnknownSuspenders: false,
});
}
@@ -2745,6 +2754,8 @@ export function attach(
parentSuspenseNode.hasUniqueSuspenders = true;
}
}
// We have observed at least one known reason this might have been suspended.
parentSuspenseNode.hasUnknownSuspenders = false;
// Suspending right below the root is not attributed to any particular component in UI
// other than the SuspenseNode and the HostRoot's FiberInstance.
const suspendedBy = parentInstance.suspendedBy;
@@ -2783,6 +2794,7 @@ export function attach(
// It can now be marked as having unique suspenders. We can skip its children
// since they'll still be blocked by this one.
node.hasUniqueSuspenders = true;
node.hasUnknownSuspenders = false;
} else if (node.firstChild !== null) {
node = node.firstChild;
continue;
@@ -3458,6 +3470,25 @@ export function attach(
insertSuspendedBy(asyncInfo);
}
function trackThrownPromisesFromRetryCache(
suspenseNode: SuspenseNode,
retryCache: ?WeakSet<Wakeable>,
): void {
if (retryCache != null) {
// If a Suspense boundary ever committed in fallback state with a retryCache, that
// suggests that something unique to that boundary was suspensey since otherwise
// it wouldn't have thrown and so never created the retryCache.
// Unfortunately if we don't have any DEV time debug info or debug thenables then
// we have no meta data to show. However, we still mark this Suspense boundary as
// participating in the loading sequence since apparently it can suspend.
suspenseNode.hasUniqueSuspenders = true;
// We have not seen any reason yet for why this suspense node might have been
// suspended but it clearly has been at some point. If we later discover a reason
// we'll clear this flag again.
suspenseNode.hasUnknownSuspenders = true;
}
}
function mountVirtualChildrenRecursively(
firstChild: Fiber,
lastChild: null | Fiber, // non-inclusive
@@ -3749,6 +3780,9 @@ export function attach(
} else if (fiber.tag === SuspenseComponent && OffscreenComponent === -1) {
// Legacy Suspense without the Offscreen wrapper. For the modern Suspense we just handle the
// Offscreen wrapper itself specially.
if (newSuspenseNode !== null) {
trackThrownPromisesFromRetryCache(newSuspenseNode, fiber.stateNode);
}
const isTimedOut = fiber.memoizedState !== null;
if (isTimedOut) {
// Special case: if Suspense mounts in a timed-out state,
@@ -3791,6 +3825,9 @@ export function attach(
'There should always be an Offscreen Fiber child in a Suspense boundary.',
);
}
trackThrownPromisesFromRetryCache(newSuspenseNode, fiber.stateNode);
const fallbackFiber = contentFiber.sibling;
// First update only the Offscreen boundary. I.e. the main content.
@@ -4600,6 +4637,18 @@ export function attach(
const prevWasHidden = isOffscreen && prevFiber.memoizedState !== null;
const nextIsHidden = isOffscreen && nextFiber.memoizedState !== null;
if (isLegacySuspense) {
if (
fiberInstance !== null &&
fiberInstance.suspenseNode !== null &&
(prevFiber.stateNode === null) !== (nextFiber.stateNode === null)
) {
trackThrownPromisesFromRetryCache(
fiberInstance.suspenseNode,
nextFiber.stateNode,
);
}
}
// The logic below is inspired by the code paths in updateSuspenseComponent()
// inside ReactFiberBeginWork in the React source code.
if (prevDidTimeout && nextDidTimeOut) {
@@ -4726,6 +4775,13 @@ export function attach(
const prevFallbackFiber = prevContentFiber.sibling;
const nextFallbackFiber = nextContentFiber.sibling;
if ((prevFiber.stateNode === null) !== (nextFiber.stateNode === null)) {
trackThrownPromisesFromRetryCache(
fiberInstance.suspenseNode,
nextFiber.stateNode,
);
}
// First update only the Offscreen boundary. I.e. the main content.
updateFlags |= updateVirtualChildrenRecursively(
nextContentFiber,
@@ -6100,6 +6156,23 @@ export function attach(
getNearestSuspenseNode(fiberInstance),
);
let unknownSuspenders = UNKNOWN_SUSPENDERS_NONE;
if (
fiberInstance.suspenseNode !== null &&
fiberInstance.suspenseNode.hasUnknownSuspenders &&
!isTimedOutSuspense
) {
// Something unknown threw to suspended this boundary. Let's figure out why that might be.
if (renderer.bundleType === 0) {
unknownSuspenders = UNKNOWN_SUSPENDERS_REASON_PRODUCTION;
} else if (!('_debugInfo' in fiber)) {
// TODO: We really should detect _debugThenable and the auto-instrumentation for lazy/thenables too.
unknownSuspenders = UNKNOWN_SUSPENDERS_REASON_OLD_VERSION;
} else {
unknownSuspenders = UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE;
}
}
return {
id: fiberInstance.id,
@@ -6164,6 +6237,7 @@ export function attach(
suspendedBy: suspendedBy,
suspendedByRange: suspendedByRange,
unknownSuspenders: unknownSuspenders,
// List of owners
owners,
@@ -6280,6 +6354,7 @@ export function attach(
serializeAsyncInfo(info, virtualInstance, null),
),
suspendedByRange: suspendedByRange,
unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,
// List of owners
owners,
@@ -34,6 +34,7 @@ import {
TREE_OPERATION_ADD,
TREE_OPERATION_REMOVE,
TREE_OPERATION_REORDER_CHILDREN,
UNKNOWN_SUSPENDERS_NONE,
} from '../../constants';
import {decorateMany, forceUpdate, restoreMany} from './utils';
@@ -860,6 +861,7 @@ export function attach(
// Not supported in legacy renderers.
suspendedBy: [],
suspendedByRange: null,
unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,
// List of owners
owners,
+2
View File
@@ -34,6 +34,7 @@ import type {TimelineDataExport} from 'react-devtools-timeline/src/types';
import type {BackendBridge} from 'react-devtools-shared/src/bridge';
import type {ReactFunctionLocation, ReactStackTrace} from 'shared/ReactTypes';
import type Agent from './agent';
import type {UnknownSuspendersReason} from '../constants';
type BundleType =
| 0 // PROD
@@ -303,6 +304,7 @@ export type InspectedElement = {
// Things that suspended this Instances
suspendedBy: Object, // DehydratedData or Array<SerializedAsyncInfo>
suspendedByRange: null | [number, number],
unknownSuspenders: UnknownSuspendersReason,
// List of owners
owners: Array<SerializedElement> | null,
+2
View File
@@ -272,6 +272,7 @@ export function convertInspectedElementBackendToFrontend(
warnings,
suspendedBy,
suspendedByRange,
unknownSuspenders,
nativeTag,
} = inspectedElementBackend;
@@ -317,6 +318,7 @@ export function convertInspectedElementBackendToFrontend(
? []
: hydratedSuspendedBy.map(backendToFrontendSerializedAsyncInfo),
suspendedByRange,
unknownSuspenders,
nativeTag,
};
+7
View File
@@ -32,6 +32,13 @@ export const SUSPENSE_TREE_OPERATION_RESIZE = 11;
export const PROFILING_FLAG_BASIC_SUPPORT = 0b01;
export const PROFILING_FLAG_TIMELINE_SUPPORT = 0b10;
export const UNKNOWN_SUSPENDERS_NONE: UnknownSuspendersReason = 0; // If we had at least one debugInfo, then that might have been the reason.
export const UNKNOWN_SUSPENDERS_REASON_PRODUCTION: UnknownSuspendersReason = 1; // We're running in prod. That might be why we had unknown suspenders.
export const UNKNOWN_SUSPENDERS_REASON_OLD_VERSION: UnknownSuspendersReason = 2; // We're running an old version of React that doesn't have full coverage. That might be the reason.
export const UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE: UnknownSuspendersReason = 3; // If we're in dev, didn't detect and debug info and still suspended (other than CSS/image) the only reason is thrown promise.
export opaque type UnknownSuspendersReason = 0 | 1 | 2 | 3;
export const LOCAL_STORAGE_DEFAULT_TAB_KEY = 'React::DevTools::defaultTab';
export const LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY =
'React::DevTools::componentFilters';
@@ -52,6 +52,15 @@
min-width: 1rem;
}
.InfoRow {
border-top: 1px solid var(--color-border);
padding: 0.5rem 1rem;
}
.InfoRow:last-child {
margin-bottom: -0.25rem;
}
.CollapsableRow {
border-top: 1px solid var(--color-border);
}
@@ -27,6 +27,13 @@ import type {
} from 'react-devtools-shared/src/frontend/types';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import {
UNKNOWN_SUSPENDERS_NONE,
UNKNOWN_SUSPENDERS_REASON_PRODUCTION,
UNKNOWN_SUSPENDERS_REASON_OLD_VERSION,
UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE,
} from '../../../constants';
type RowProps = {
bridge: FrontendBridge,
element: Element,
@@ -295,7 +302,10 @@ export default function InspectedElementSuspendedBy({
const {suspendedBy, suspendedByRange} = inspectedElement;
// Skip the section if nothing suspended this component.
if (suspendedBy == null || suspendedBy.length === 0) {
if (
(suspendedBy == null || suspendedBy.length === 0) &&
inspectedElement.unknownSuspenders === UNKNOWN_SUSPENDERS_NONE
) {
return null;
}
@@ -327,9 +337,41 @@ export default function InspectedElementSuspendedBy({
minTime = maxTime - 25;
}
const sortedSuspendedBy = suspendedBy.slice(0);
const sortedSuspendedBy = suspendedBy === null ? [] : suspendedBy.slice(0);
sortedSuspendedBy.sort(compareTime);
let unknownSuspenders = null;
switch (inspectedElement.unknownSuspenders) {
case UNKNOWN_SUSPENDERS_REASON_PRODUCTION:
unknownSuspenders = (
<div className={styles.InfoRow}>
Something suspended but we don't know the exact reason in production
builds of React. Test this in development mode to see exactly what
might suspend.
</div>
);
break;
case UNKNOWN_SUSPENDERS_REASON_OLD_VERSION:
unknownSuspenders = (
<div className={styles.InfoRow}>
Something suspended but we don't track all the necessary information
in older versions of React. Upgrade to the latest version of React to
see exactly what might suspend.
</div>
);
break;
case UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE:
unknownSuspenders = (
<div className={styles.InfoRow}>
Something threw a Promise to suspend this boundary. It's likely an
outdated version of a library that doesn't yet fully take advantage of
use(). Upgrade your data fetching library to see exactly what might
suspend.
</div>
);
break;
}
return (
<div>
<div className={styles.HeaderRow}>
@@ -351,6 +393,7 @@ export default function InspectedElementSuspendedBy({
maxTime={maxTime}
/>
))}
{unknownSuspenders}
</div>
);
}
+2
View File
@@ -19,6 +19,7 @@ import type {
Unserializable,
} from 'react-devtools-shared/src/hydration';
import type {ReactFunctionLocation, ReactStackTrace} from 'shared/ReactTypes';
import type {UnknownSuspendersReason} from '../constants';
export type BrowserTheme = 'dark' | 'light';
@@ -283,6 +284,7 @@ export type InspectedElement = {
suspendedBy: Object,
// Minimum start time to maximum end time + a potential (not actual) throttle, within the nearest boundary.
suspendedByRange: null | [number, number],
unknownSuspenders: UnknownSuspendersReason,
// List of owners
owners: Array<SerializedElement> | null,