@samitouri / QOS-React / commits / 431bb0bddb

[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.

Sebastian Markbåge committed Aug 15, 2025 at 18:32 UTC 431bb0bddb640d01d668448f1133e44bd3eb3e11
8 files changed +144 -2
packages/react-devtools-shared/src/backend/fiber/renderer.js
+75
@@ -15,6 +15,7 @@ import type {
15 ReactIOInfo,
16 ReactStackTrace,
17 ReactCallSite,
18 + Wakeable,
19 } from 'shared/ReactTypes';
20
21 import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks';
@@ -87,6 +88,10 @@ import {
88 SUSPENSE_TREE_OPERATION_REMOVE,
89 SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
90 SUSPENSE_TREE_OPERATION_RESIZE,
91 + UNKNOWN_SUSPENDERS_NONE,
92 + UNKNOWN_SUSPENDERS_REASON_PRODUCTION,
93 + UNKNOWN_SUSPENDERS_REASON_OLD_VERSION,
94 + UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE,
95 } from '../../constants';
96 import {inspectHooksOfFiber} from 'react-debug-tools';
97 import {
@@ -296,6 +301,9 @@ type SuspenseNode = {
301 // Track whether any of the items in suspendedBy are unique this this Suspense boundaries or if they're all
302 // also in the parent sets. This determine whether this could contribute in the loading sequence.
303 hasUniqueSuspenders: boolean,
304 + // Track whether anything suspended in this boundary that we can't track either because it was using throw
305 + // a promise, an older version of React or because we're inspecting prod.
306 + hasUnknownSuspenders: boolean,
307 };
308
309 function createSuspenseNode(
@@ -309,6 +317,7 @@ function createSuspenseNode(
317 rects: null,
318 suspendedBy: new Map(),
319 hasUniqueSuspenders: false,
320 + hasUnknownSuspenders: false,
321 });
322 }
323
@@ -2745,6 +2754,8 @@ export function attach(
2754 parentSuspenseNode.hasUniqueSuspenders = true;
2755 }
2756 }
2757 + // We have observed at least one known reason this might have been suspended.
2758 + parentSuspenseNode.hasUnknownSuspenders = false;
2759 // Suspending right below the root is not attributed to any particular component in UI
2760 // other than the SuspenseNode and the HostRoot's FiberInstance.
2761 const suspendedBy = parentInstance.suspendedBy;
@@ -2783,6 +2794,7 @@ export function attach(
2794 // It can now be marked as having unique suspenders. We can skip its children
2795 // since they'll still be blocked by this one.
2796 node.hasUniqueSuspenders = true;
2797 + node.hasUnknownSuspenders = false;
2798 } else if (node.firstChild !== null) {
2799 node = node.firstChild;
2800 continue;
@@ -3458,6 +3470,25 @@ export function attach(
3470 insertSuspendedBy(asyncInfo);
3471 }
3472
3473 + function trackThrownPromisesFromRetryCache(
3474 + suspenseNode: SuspenseNode,
3475 + retryCache: ?WeakSet<Wakeable>,
3476 + ): void {
3477 + if (retryCache != null) {
3478 + // If a Suspense boundary ever committed in fallback state with a retryCache, that
3479 + // suggests that something unique to that boundary was suspensey since otherwise
3480 + // it wouldn't have thrown and so never created the retryCache.
3481 + // Unfortunately if we don't have any DEV time debug info or debug thenables then
3482 + // we have no meta data to show. However, we still mark this Suspense boundary as
3483 + // participating in the loading sequence since apparently it can suspend.
3484 + suspenseNode.hasUniqueSuspenders = true;
3485 + // We have not seen any reason yet for why this suspense node might have been
3486 + // suspended but it clearly has been at some point. If we later discover a reason
3487 + // we'll clear this flag again.
3488 + suspenseNode.hasUnknownSuspenders = true;
3489 + }
3490 + }
3491 +
3492 function mountVirtualChildrenRecursively(
3493 firstChild: Fiber,
3494 lastChild: null | Fiber, // non-inclusive
@@ -3749,6 +3780,9 @@ export function attach(
3780 } else if (fiber.tag === SuspenseComponent && OffscreenComponent === -1) {
3781 // Legacy Suspense without the Offscreen wrapper. For the modern Suspense we just handle the
3782 // Offscreen wrapper itself specially.
3783 + if (newSuspenseNode !== null) {
3784 + trackThrownPromisesFromRetryCache(newSuspenseNode, fiber.stateNode);
3785 + }
3786 const isTimedOut = fiber.memoizedState !== null;
3787 if (isTimedOut) {
3788 // Special case: if Suspense mounts in a timed-out state,
@@ -3791,6 +3825,9 @@ export function attach(
3825 'There should always be an Offscreen Fiber child in a Suspense boundary.',
3826 );
3827 }
3828 +
3829 + trackThrownPromisesFromRetryCache(newSuspenseNode, fiber.stateNode);
3830 +
3831 const fallbackFiber = contentFiber.sibling;
3832
3833 // First update only the Offscreen boundary. I.e. the main content.
@@ -4600,6 +4637,18 @@ export function attach(
4637 const prevWasHidden = isOffscreen && prevFiber.memoizedState !== null;
4638 const nextIsHidden = isOffscreen && nextFiber.memoizedState !== null;
4639
4640 + if (isLegacySuspense) {
4641 + if (
4642 + fiberInstance !== null &&
4643 + fiberInstance.suspenseNode !== null &&
4644 + (prevFiber.stateNode === null) !== (nextFiber.stateNode === null)
4645 + ) {
4646 + trackThrownPromisesFromRetryCache(
4647 + fiberInstance.suspenseNode,
4648 + nextFiber.stateNode,
4649 + );
4650 + }
4651 + }
4652 // The logic below is inspired by the code paths in updateSuspenseComponent()
4653 // inside ReactFiberBeginWork in the React source code.
4654 if (prevDidTimeout && nextDidTimeOut) {
@@ -4726,6 +4775,13 @@ export function attach(
4775 const prevFallbackFiber = prevContentFiber.sibling;
4776 const nextFallbackFiber = nextContentFiber.sibling;
4777
4778 + if ((prevFiber.stateNode === null) !== (nextFiber.stateNode === null)) {
4779 + trackThrownPromisesFromRetryCache(
4780 + fiberInstance.suspenseNode,
4781 + nextFiber.stateNode,
4782 + );
4783 + }
4784 +
4785 // First update only the Offscreen boundary. I.e. the main content.
4786 updateFlags |= updateVirtualChildrenRecursively(
4787 nextContentFiber,
@@ -6100,6 +6156,23 @@ export function attach(
6156 getNearestSuspenseNode(fiberInstance),
6157 );
6158
6159 + let unknownSuspenders = UNKNOWN_SUSPENDERS_NONE;
6160 + if (
6161 + fiberInstance.suspenseNode !== null &&
6162 + fiberInstance.suspenseNode.hasUnknownSuspenders &&
6163 + !isTimedOutSuspense
6164 + ) {
6165 + // Something unknown threw to suspended this boundary. Let's figure out why that might be.
6166 + if (renderer.bundleType === 0) {
6167 + unknownSuspenders = UNKNOWN_SUSPENDERS_REASON_PRODUCTION;
6168 + } else if (!('_debugInfo' in fiber)) {
6169 + // TODO: We really should detect _debugThenable and the auto-instrumentation for lazy/thenables too.
6170 + unknownSuspenders = UNKNOWN_SUSPENDERS_REASON_OLD_VERSION;
6171 + } else {
6172 + unknownSuspenders = UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE;
6173 + }
6174 + }
6175 +
6176 return {
6177 id: fiberInstance.id,
6178
@@ -6164,6 +6237,7 @@ export function attach(
6237
6238 suspendedBy: suspendedBy,
6239 suspendedByRange: suspendedByRange,
6240 + unknownSuspenders: unknownSuspenders,
6241
6242 // List of owners
6243 owners,
@@ -6280,6 +6354,7 @@ export function attach(
6354 serializeAsyncInfo(info, virtualInstance, null),
6355 ),
6356 suspendedByRange: suspendedByRange,
6357 + unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,
6358
6359 // List of owners
6360 owners,
packages/react-devtools-shared/src/backend/legacy/renderer.js
+2
@@ -34,6 +34,7 @@ import {
34 TREE_OPERATION_ADD,
35 TREE_OPERATION_REMOVE,
36 TREE_OPERATION_REORDER_CHILDREN,
37 + UNKNOWN_SUSPENDERS_NONE,
38 } from '../../constants';
39 import {decorateMany, forceUpdate, restoreMany} from './utils';
40
@@ -860,6 +861,7 @@ export function attach(
861 // Not supported in legacy renderers.
862 suspendedBy: [],
863 suspendedByRange: null,
864 + unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,
865
866 // List of owners
867 owners,
packages/react-devtools-shared/src/backend/types.js
+2
@@ -34,6 +34,7 @@ import type {TimelineDataExport} from 'react-devtools-timeline/src/types';
34 import type {BackendBridge} from 'react-devtools-shared/src/bridge';
35 import type {ReactFunctionLocation, ReactStackTrace} from 'shared/ReactTypes';
36 import type Agent from './agent';
37 +import type {UnknownSuspendersReason} from '../constants';
38
39 type BundleType =
40 | 0 // PROD
@@ -303,6 +304,7 @@ export type InspectedElement = {
304 // Things that suspended this Instances
305 suspendedBy: Object, // DehydratedData or Array<SerializedAsyncInfo>
306 suspendedByRange: null | [number, number],
307 + unknownSuspenders: UnknownSuspendersReason,
308
309 // List of owners
310 owners: Array<SerializedElement> | null,
packages/react-devtools-shared/src/backendAPI.js
+2
@@ -272,6 +272,7 @@ export function convertInspectedElementBackendToFrontend(
272 warnings,
273 suspendedBy,
274 suspendedByRange,
275 + unknownSuspenders,
276 nativeTag,
277 } = inspectedElementBackend;
278
@@ -317,6 +318,7 @@ export function convertInspectedElementBackendToFrontend(
318 ? []
319 : hydratedSuspendedBy.map(backendToFrontendSerializedAsyncInfo),
320 suspendedByRange,
321 + unknownSuspenders,
322 nativeTag,
323 };
324
packages/react-devtools-shared/src/constants.js
+7
@@ -32,6 +32,13 @@ export const SUSPENSE_TREE_OPERATION_RESIZE = 11;
32 export const PROFILING_FLAG_BASIC_SUPPORT = 0b01;
33 export const PROFILING_FLAG_TIMELINE_SUPPORT = 0b10;
34
35 +export const UNKNOWN_SUSPENDERS_NONE: UnknownSuspendersReason = 0; // If we had at least one debugInfo, then that might have been the reason.
36 +export const UNKNOWN_SUSPENDERS_REASON_PRODUCTION: UnknownSuspendersReason = 1; // We're running in prod. That might be why we had unknown suspenders.
37 +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.
38 +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.
39 +
40 +export opaque type UnknownSuspendersReason = 0 | 1 | 2 | 3;
41 +
42 export const LOCAL_STORAGE_DEFAULT_TAB_KEY = 'React::DevTools::defaultTab';
43 export const LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY =
44 'React::DevTools::componentFilters';
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css
+9
@@ -52,6 +52,15 @@
52 min-width: 1rem;
53 }
54
55 +.InfoRow {
56 + border-top: 1px solid var(--color-border);
57 + padding: 0.5rem 1rem;
58 +}
59 +
60 +.InfoRow:last-child {
61 + margin-bottom: -0.25rem;
62 +}
63 +
64 .CollapsableRow {
65 border-top: 1px solid var(--color-border);
66 }
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
+45 -2
@@ -27,6 +27,13 @@ import type {
27 } from 'react-devtools-shared/src/frontend/types';
28 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
29
30 +import {
31 + UNKNOWN_SUSPENDERS_NONE,
32 + UNKNOWN_SUSPENDERS_REASON_PRODUCTION,
33 + UNKNOWN_SUSPENDERS_REASON_OLD_VERSION,
34 + UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE,
35 +} from '../../../constants';
36 +
37 type RowProps = {
38 bridge: FrontendBridge,
39 element: Element,
@@ -295,7 +302,10 @@ export default function InspectedElementSuspendedBy({
302 const {suspendedBy, suspendedByRange} = inspectedElement;
303
304 // Skip the section if nothing suspended this component.
298 - if (suspendedBy == null || suspendedBy.length === 0) {
305 + if (
306 + (suspendedBy == null || suspendedBy.length === 0) &&
307 + inspectedElement.unknownSuspenders === UNKNOWN_SUSPENDERS_NONE
308 + ) {
309 return null;
310 }
311
@@ -327,9 +337,41 @@ export default function InspectedElementSuspendedBy({
337 minTime = maxTime - 25;
338 }
339
330 - const sortedSuspendedBy = suspendedBy.slice(0);
340 + const sortedSuspendedBy = suspendedBy === null ? [] : suspendedBy.slice(0);
341 sortedSuspendedBy.sort(compareTime);
342
343 + let unknownSuspenders = null;
344 + switch (inspectedElement.unknownSuspenders) {
345 + case UNKNOWN_SUSPENDERS_REASON_PRODUCTION:
346 + unknownSuspenders = (
347 + <div className={styles.InfoRow}>
348 + Something suspended but we don't know the exact reason in production
349 + builds of React. Test this in development mode to see exactly what
350 + might suspend.
351 + </div>
352 + );
353 + break;
354 + case UNKNOWN_SUSPENDERS_REASON_OLD_VERSION:
355 + unknownSuspenders = (
356 + <div className={styles.InfoRow}>
357 + Something suspended but we don't track all the necessary information
358 + in older versions of React. Upgrade to the latest version of React to
359 + see exactly what might suspend.
360 + </div>
361 + );
362 + break;
363 + case UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE:
364 + unknownSuspenders = (
365 + <div className={styles.InfoRow}>
366 + Something threw a Promise to suspend this boundary. It's likely an
367 + outdated version of a library that doesn't yet fully take advantage of
368 + use(). Upgrade your data fetching library to see exactly what might
369 + suspend.
370 + </div>
371 + );
372 + break;
373 + }
374 +
375 return (
376 <div>
377 <div className={styles.HeaderRow}>
@@ -351,6 +393,7 @@ export default function InspectedElementSuspendedBy({
393 maxTime={maxTime}
394 />
395 ))}
396 + {unknownSuspenders}
397 </div>
398 );
399 }
packages/react-devtools-shared/src/frontend/types.js
+2
@@ -19,6 +19,7 @@ import type {
19 Unserializable,
20 } from 'react-devtools-shared/src/hydration';
21 import type {ReactFunctionLocation, ReactStackTrace} from 'shared/ReactTypes';
22 +import type {UnknownSuspendersReason} from '../constants';
23
24 export type BrowserTheme = 'dark' | 'light';
25
@@ -283,6 +284,7 @@ export type InspectedElement = {
284 suspendedBy: Object,
285 // Minimum start time to maximum end time + a potential (not actual) throttle, within the nearest boundary.
286 suspendedByRange: null | [number, number],
287 + unknownSuspenders: UnknownSuspendersReason,
288
289 // List of owners
290 owners: Array<SerializedElement> | null,