Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/react-devtools-shared/src/backend/fiber/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
ReactIOInfo,
ReactStackTrace,
ReactCallSite,
Wakeable,
} from 'shared/ReactTypes';

import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -309,6 +317,7 @@ function createSuspenseNode(
rects: null,
suspendedBy: new Map(),
hasUniqueSuspenders: false,
hasUnknownSuspenders: false,
});
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -6090,6 +6146,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,

Expand Down Expand Up @@ -6153,6 +6226,7 @@ export function attach(

suspendedBy: suspendedBy,
suspendedByRange: suspendedByRange,
unknownSuspenders: unknownSuspenders,

// List of owners
owners,
Expand Down Expand Up @@ -6267,6 +6341,7 @@ export function attach(
serializeAsyncInfo(info, virtualInstance, null),
),
suspendedByRange: suspendedByRange,
unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,

// List of owners
owners,
Expand Down
2 changes: 2 additions & 0 deletions packages/react-devtools-shared/src/backend/legacy/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -859,6 +860,7 @@ export function attach(
// Not supported in legacy renderers.
suspendedBy: [],
suspendedByRange: null,
unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,

// List of owners
owners,
Expand Down
2 changes: 2 additions & 0 deletions packages/react-devtools-shared/src/backend/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -301,6 +302,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,
Expand Down
2 changes: 2 additions & 0 deletions packages/react-devtools-shared/src/backendAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export function convertInspectedElementBackendToFrontend(
warnings,
suspendedBy,
suspendedByRange,
unknownSuspenders,
nativeTag,
} = inspectedElementBackend;

Expand Down Expand Up @@ -315,6 +316,7 @@ export function convertInspectedElementBackendToFrontend(
? []
: hydratedSuspendedBy.map(backendToFrontendSerializedAsyncInfo),
suspendedByRange,
unknownSuspenders,
nativeTag,
};

Expand Down
7 changes: 7 additions & 0 deletions packages/react-devtools-shared/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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>
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this error can also be shown when use() is conditionally used. #34030.

);
break;
}

return (
<div>
<div className={styles.HeaderRow}>
Expand All @@ -351,6 +393,7 @@ export default function InspectedElementSuspendedBy({
maxTime={maxTime}
/>
))}
{unknownSuspenders}
</div>
);
}
2 changes: 2 additions & 0 deletions packages/react-devtools-shared/src/frontend/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -281,6 +282,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,
Expand Down