Files
react/packages/react-devtools-shared/src/backendAPI.js
Sebastian Markbåge 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.
2025-08-15 18:32:27 -04:00

355 lines
8.2 KiB
JavaScript

/**
* 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 {hydrate, fillInPath} from 'react-devtools-shared/src/hydration';
import {backendToFrontendSerializedElementMapper} from 'react-devtools-shared/src/utils';
import Store from 'react-devtools-shared/src/devtools/store';
import TimeoutError from 'react-devtools-shared/src/errors/TimeoutError';
import ElementPollingCancellationError from 'react-devtools-shared/src/errors/ElementPollingCancellationError';
import type {
InspectedElement as InspectedElementBackend,
InspectedElementPayload,
SerializedAsyncInfo as SerializedAsyncInfoBackend,
} from 'react-devtools-shared/src/backend/types';
import type {
BackendEvents,
FrontendBridge,
} from 'react-devtools-shared/src/bridge';
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';
export function clearErrorsAndWarnings({
bridge,
store,
}: {
bridge: FrontendBridge,
store: Store,
}): void {
store.rootIDToRendererID.forEach(rendererID => {
bridge.send('clearErrorsAndWarnings', {rendererID});
});
}
export function clearErrorsForElement({
bridge,
id,
rendererID,
}: {
bridge: FrontendBridge,
id: number,
rendererID: number,
}): void {
bridge.send('clearErrorsForElementID', {
rendererID,
id,
});
}
export function clearWarningsForElement({
bridge,
id,
rendererID,
}: {
bridge: FrontendBridge,
id: number,
rendererID: number,
}): void {
bridge.send('clearWarningsForElementID', {
rendererID,
id,
});
}
export function copyInspectedElementPath({
bridge,
id,
path,
rendererID,
}: {
bridge: FrontendBridge,
id: number,
path: Array<string | number>,
rendererID: number,
}): void {
bridge.send('copyElementPath', {
id,
path,
rendererID,
});
}
export function inspectElement(
bridge: FrontendBridge,
forceFullData: boolean,
id: number,
path: InspectedElementPath | null,
rendererID: number,
shouldListenToPauseEvents: boolean = false,
): Promise<InspectedElementPayload> {
const requestID = requestCounter++;
const promise = getPromiseForRequestID<InspectedElementPayload>(
requestID,
'inspectedElement',
bridge,
`Timed out while inspecting element ${id}.`,
shouldListenToPauseEvents,
);
bridge.send('inspectElement', {
forceFullData,
id,
path,
rendererID,
requestID,
});
return promise;
}
let storeAsGlobalCount = 0;
export function storeAsGlobal({
bridge,
id,
path,
rendererID,
}: {
bridge: FrontendBridge,
id: number,
path: Array<string | number>,
rendererID: number,
}): void {
bridge.send('storeAsGlobal', {
count: storeAsGlobalCount++,
id,
path,
rendererID,
});
}
const TIMEOUT_DELAY = 10_000;
let requestCounter = 0;
function getPromiseForRequestID<T>(
requestID: number,
eventType: $Keys<BackendEvents>,
bridge: FrontendBridge,
timeoutMessage: string,
shouldListenToPauseEvents: boolean = false,
): Promise<T> {
return new Promise((resolve, reject) => {
const cleanup = () => {
bridge.removeListener(eventType, onInspectedElement);
bridge.removeListener('shutdown', onShutdown);
if (shouldListenToPauseEvents) {
bridge.removeListener('pauseElementPolling', onDisconnect);
}
clearTimeout(timeoutID);
};
const onShutdown = () => {
cleanup();
reject(
new Error(
'Failed to inspect element. Try again or restart React DevTools.',
),
);
};
const onDisconnect = () => {
cleanup();
reject(new ElementPollingCancellationError());
};
const onInspectedElement = (data: any) => {
if (data.responseID === requestID) {
cleanup();
resolve((data: T));
}
};
const onTimeout = () => {
cleanup();
reject(new TimeoutError(timeoutMessage));
};
bridge.addListener(eventType, onInspectedElement);
bridge.addListener('shutdown', onShutdown);
if (shouldListenToPauseEvents) {
bridge.addListener('pauseElementPolling', onDisconnect);
}
const timeoutID = setTimeout(onTimeout, TIMEOUT_DELAY);
});
}
export function cloneInspectedElementWithPath(
inspectedElement: InspectedElementFrontend,
path: Array<string | number>,
value: Object,
): InspectedElementFrontend {
const hydratedValue = hydrateHelper(value, path);
const clonedInspectedElement = {...inspectedElement};
fillInPath(clonedInspectedElement, value, path, hydratedValue);
return clonedInspectedElement;
}
function backendToFrontendSerializedAsyncInfo(
asyncInfo: SerializedAsyncInfoBackend,
): SerializedAsyncInfoFrontend {
const ioInfo = asyncInfo.awaited;
return {
awaited: {
name: ioInfo.name,
description: ioInfo.description,
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 {
const {
canEditFunctionProps,
canEditFunctionPropsDeletePaths,
canEditFunctionPropsRenamePaths,
canEditHooks,
canEditHooksAndDeletePaths,
canEditHooksAndRenamePaths,
canToggleError,
isErrored,
canToggleSuspense,
isSuspended,
hasLegacyContext,
id,
type,
owners,
env,
source,
stack,
context,
hooks,
plugins,
props,
rendererPackageName,
rendererVersion,
rootType,
state,
key,
errors,
warnings,
suspendedBy,
suspendedByRange,
unknownSuspenders,
nativeTag,
} = inspectedElementBackend;
const hydratedSuspendedBy: null | Array<SerializedAsyncInfoBackend> =
hydrateHelper(suspendedBy);
const inspectedElement: InspectedElementFrontend = {
canEditFunctionProps,
canEditFunctionPropsDeletePaths,
canEditFunctionPropsRenamePaths,
canEditHooks,
canEditHooksAndDeletePaths,
canEditHooksAndRenamePaths,
canToggleError,
isErrored,
canToggleSuspense,
isSuspended,
hasLegacyContext,
id,
key,
plugins,
rendererPackageName,
rendererVersion,
rootType,
// Previous backend implementations (<= 6.1.5) have a different interface for Source.
// This gates the source features for only compatible backends: >= 6.1.6
source: Array.isArray(source) ? source : null,
stack: stack,
type,
owners:
owners === null
? null
: owners.map(backendToFrontendSerializedElementMapper),
env,
context: hydrateHelper(context),
hooks: hydrateHelper(hooks),
props: hydrateHelper(props),
state: hydrateHelper(state),
errors,
warnings,
suspendedBy:
hydratedSuspendedBy == null // backwards compat
? []
: hydratedSuspendedBy.map(backendToFrontendSerializedAsyncInfo),
suspendedByRange,
unknownSuspenders,
nativeTag,
};
return inspectedElement;
}
export function hydrateHelper(
dehydratedData: DehydratedData | null,
path: ?InspectedElementPath,
): Object | null {
if (dehydratedData !== null) {
const {cleaned, data, unserializable} = dehydratedData;
if (path) {
const {length} = path;
if (length > 0) {
// Hydration helper requires full paths, but inspection dehydrates with relative paths.
// In that event it's important that we adjust the "cleaned" paths to match.
return hydrate(
data,
cleaned.map(cleanedPath => cleanedPath.slice(length)),
unserializable.map(unserializablePath =>
unserializablePath.slice(length),
),
);
}
}
return hydrate(data, cleaned, unserializable);
} else {
return null;
}
}