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
141 changes: 133 additions & 8 deletions packages/react-devtools-shared/src/backend/fiber/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
* @flow
*/

import type {ReactComponentInfo, ReactDebugInfo} from 'shared/ReactTypes';
import type {
ReactComponentInfo,
ReactDebugInfo,
ReactAsyncInfo,
} from 'shared/ReactTypes';

import {
ComponentFilterDisplayName,
Expand Down Expand Up @@ -135,6 +139,7 @@ import type {
ReactRenderer,
RendererInterface,
SerializedElement,
SerializedAsyncInfo,
WorkTagMap,
CurrentDispatcherRef,
LegacyDispatcherRef,
Expand Down Expand Up @@ -165,6 +170,7 @@ type FiberInstance = {
source: null | string | Error | ReactFunctionLocation, // source location of this component function, or owned child stack
logCount: number, // total number of errors/warnings last seen
treeBaseDuration: number, // the profiled time of the last render of this subtree
suspendedBy: null | Array<ReactAsyncInfo>, // things that suspended in the children position of this component
data: Fiber, // one of a Fiber pair
};

Expand All @@ -178,6 +184,7 @@ function createFiberInstance(fiber: Fiber): FiberInstance {
source: null,
logCount: 0,
treeBaseDuration: 0,
suspendedBy: null,
data: fiber,
};
}
Expand All @@ -193,6 +200,7 @@ type FilteredFiberInstance = {
source: null | string | Error | ReactFunctionLocation, // always null here.
logCount: number, // total number of errors/warnings last seen
treeBaseDuration: number, // the profiled time of the last render of this subtree
suspendedBy: null | Array<ReactAsyncInfo>, // not used
data: Fiber, // one of a Fiber pair
};

Expand All @@ -207,6 +215,7 @@ function createFilteredFiberInstance(fiber: Fiber): FilteredFiberInstance {
source: null,
logCount: 0,
treeBaseDuration: 0,
suspendedBy: null,
data: fiber,
}: any);
}
Expand All @@ -225,6 +234,7 @@ type VirtualInstance = {
source: null | string | Error | ReactFunctionLocation, // source location of this server component, or owned child stack
logCount: number, // total number of errors/warnings last seen
treeBaseDuration: number, // the profiled time of the last render of this subtree
suspendedBy: null | Array<ReactAsyncInfo>, // things that blocked the server component's child from rendering
// The latest info for this instance. This can be updated over time and the
// same info can appear in more than once ServerComponentInstance.
data: ReactComponentInfo,
Expand All @@ -242,6 +252,7 @@ function createVirtualInstance(
source: null,
logCount: 0,
treeBaseDuration: 0,
suspendedBy: null,
data: debugEntry,
};
}
Expand Down Expand Up @@ -2354,6 +2365,21 @@ export function attach(
// the current parent here as well.
let reconcilingParent: null | DevToolsInstance = null;

function insertSuspendedBy(asyncInfo: ReactAsyncInfo): void {
const parentInstance = reconcilingParent;
if (parentInstance === null) {
// Suspending at the root is not attributed to any particular component
// TODO: It should be attributed to the shell.
return;
}
const suspendedBy = parentInstance.suspendedBy;
if (suspendedBy === null) {
parentInstance.suspendedBy = [asyncInfo];
} else if (suspendedBy.indexOf(asyncInfo) === -1) {
suspendedBy.push(asyncInfo);
}
}

function insertChild(instance: DevToolsInstance): void {
const parentInstance = reconcilingParent;
if (parentInstance === null) {
Expand Down Expand Up @@ -2515,6 +2541,17 @@ export function attach(
if (fiber._debugInfo) {
for (let i = 0; i < fiber._debugInfo.length; i++) {
const debugEntry = fiber._debugInfo[i];
if (debugEntry.awaited) {
// Async Info
const asyncInfo: ReactAsyncInfo = (debugEntry: any);
if (level === virtualLevel) {
// Track any async info between the previous virtual instance up until to this
// instance and add it to the parent. This can add the same set multiple times
// so we assume insertSuspendedBy dedupes.
insertSuspendedBy(asyncInfo);
}
if (previousVirtualInstance) continue;
}
if (typeof debugEntry.name !== 'string') {
// Not a Component. Some other Debug Info.
continue;
Expand Down Expand Up @@ -2768,6 +2805,7 @@ export function attach(
// Move all the children of this instance to the remaining set.
remainingReconcilingChildren = instance.firstChild;
instance.firstChild = null;
instance.suspendedBy = null;
try {
// Unmount the remaining set.
unmountRemainingChildren();
Expand Down Expand Up @@ -2968,6 +3006,7 @@ export function attach(
// We'll move them back one by one, and anything that remains is deleted.
remainingReconcilingChildren = virtualInstance.firstChild;
virtualInstance.firstChild = null;
virtualInstance.suspendedBy = null;
try {
if (
updateVirtualChildrenRecursively(
Expand Down Expand Up @@ -3019,6 +3058,17 @@ export function attach(
if (nextChild._debugInfo) {
for (let i = 0; i < nextChild._debugInfo.length; i++) {
const debugEntry = nextChild._debugInfo[i];
if (debugEntry.awaited) {
// Async Info
const asyncInfo: ReactAsyncInfo = (debugEntry: any);
if (level === virtualLevel) {
// Track any async info between the previous virtual instance up until to this
// instance and add it to the parent. This can add the same set multiple times
// so we assume insertSuspendedBy dedupes.
insertSuspendedBy(asyncInfo);
}
if (previousVirtualInstance) continue;
}
if (typeof debugEntry.name !== 'string') {
// Not a Component. Some other Debug Info.
continue;
Expand Down Expand Up @@ -3343,6 +3393,7 @@ export function attach(
// We'll move them back one by one, and anything that remains is deleted.
remainingReconcilingChildren = fiberInstance.firstChild;
fiberInstance.firstChild = null;
fiberInstance.suspendedBy = null;
}
try {
if (
Expand Down Expand Up @@ -4051,6 +4102,42 @@ export function attach(
return null;
}

function serializeAsyncInfo(
asyncInfo: ReactAsyncInfo,
index: number,
parentInstance: DevToolsInstance,
): SerializedAsyncInfo {
const ioInfo = asyncInfo.awaited;
const ioOwnerInstance = findNearestOwnerInstance(
parentInstance,
ioInfo.owner,
);
const awaitOwnerInstance = findNearestOwnerInstance(
parentInstance,
asyncInfo.owner,
);
return {
awaited: {
name: ioInfo.name,
start: ioInfo.start,
end: ioInfo.end,
value: ioInfo.value == null ? null : ioInfo.value,
env: ioInfo.env == null ? null : ioInfo.env,
owner:
ioOwnerInstance === null
? null
: instanceToSerializedElement(ioOwnerInstance),
stack: ioInfo.stack == null ? null : ioInfo.stack,
},
env: asyncInfo.env == null ? null : asyncInfo.env,
owner:
awaitOwnerInstance === null
? null
: instanceToSerializedElement(awaitOwnerInstance),
stack: asyncInfo.stack == null ? null : asyncInfo.stack,
};
}

// Fast path props lookup for React Native style editor.
// Could use inspectElementRaw() but that would require shallow rendering hooks components,
// and could also mess with memoization.
Expand Down Expand Up @@ -4342,6 +4429,13 @@ export function attach(
nativeTag = getNativeTag(fiber.stateNode);
}

// This set is an edge case where if you pass a promise to a Client Component into a children
// position without a Server Component as the direct parent. E.g. <div>{promise}</div>
// In this case, this becomes associated with the Client/Host Component where as normally
// you'd expect these to be associated with the Server Component that awaited the data.
// TODO: Prepend other suspense sources like css, images and use().
const suspendedBy = fiberInstance.suspendedBy;

return {
id: fiberInstance.id,

Expand Down Expand Up @@ -4398,6 +4492,13 @@ export function attach(
? []
: Array.from(componentLogsEntry.warnings.entries()),

suspendedBy:
suspendedBy === null
? []
: suspendedBy.map((info, index) =>
serializeAsyncInfo(info, index, fiberInstance),
),

// List of owners
owners,

Expand Down Expand Up @@ -4451,6 +4552,9 @@ export function attach(
const componentLogsEntry =
componentInfoToComponentLogsMap.get(componentInfo);

// Things that Suspended this Server Component (use(), awaits and direct child promises)
const suspendedBy = virtualInstance.suspendedBy;

return {
id: virtualInstance.id,

Expand Down Expand Up @@ -4490,6 +4594,14 @@ export function attach(
componentLogsEntry === undefined
? []
: Array.from(componentLogsEntry.warnings.entries()),

suspendedBy:
suspendedBy === null
? []
: suspendedBy.map((info, index) =>
serializeAsyncInfo(info, index, virtualInstance),
),

// List of owners
owners,

Expand Down Expand Up @@ -4534,7 +4646,7 @@ export function attach(

function createIsPathAllowed(
key: string | null,
secondaryCategory: 'hooks' | null,
secondaryCategory: 'suspendedBy' | 'hooks' | null,
) {
// This function helps prevent previously-inspected paths from being dehydrated in updates.
// This is important to avoid a bad user experience where expanded toggles collapse on update.
Expand Down Expand Up @@ -4566,6 +4678,13 @@ export function attach(
return true;
}
break;
case 'suspendedBy':
if (path.length < 5) {
// Never dehydrate anything above suspendedBy[index].awaited.value
// Those are part of the internal meta data. We only dehydrate inside the Promise.
return true;
}
break;
default:
break;
}
Expand Down Expand Up @@ -4789,36 +4908,42 @@ export function attach(
type: 'not-found',
};
}
const inspectedElement = mostRecentlyInspectedElement;

// Any time an inspected element has an update,
// we should update the selected $r value as wel.
// Do this before dehydration (cleanForBridge).
updateSelectedElement(mostRecentlyInspectedElement);
updateSelectedElement(inspectedElement);

// Clone before cleaning so that we preserve the full data.
// This will enable us to send patches without re-inspecting if hydrated paths are requested.
// (Reducing how often we shallow-render is a better DX for function components that use hooks.)
const cleanedInspectedElement = {...mostRecentlyInspectedElement};
const cleanedInspectedElement = {...inspectedElement};
// $FlowFixMe[prop-missing] found when upgrading Flow
cleanedInspectedElement.context = cleanForBridge(
cleanedInspectedElement.context,
inspectedElement.context,
createIsPathAllowed('context', null),
);
// $FlowFixMe[prop-missing] found when upgrading Flow
cleanedInspectedElement.hooks = cleanForBridge(
cleanedInspectedElement.hooks,
inspectedElement.hooks,
createIsPathAllowed('hooks', 'hooks'),
);
// $FlowFixMe[prop-missing] found when upgrading Flow
cleanedInspectedElement.props = cleanForBridge(
cleanedInspectedElement.props,
inspectedElement.props,
createIsPathAllowed('props', null),
);
// $FlowFixMe[prop-missing] found when upgrading Flow
cleanedInspectedElement.state = cleanForBridge(
cleanedInspectedElement.state,
inspectedElement.state,
createIsPathAllowed('state', null),
);
// $FlowFixMe[prop-missing] found when upgrading Flow
cleanedInspectedElement.suspendedBy = cleanForBridge(
inspectedElement.suspendedBy,
createIsPathAllowed('suspendedBy', 'suspendedBy'),
);

return {
id,
Expand Down
7 changes: 7 additions & 0 deletions packages/react-devtools-shared/src/backend/legacy/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,10 @@ export function attach(
inspectedElement.state,
createIsPathAllowed('state'),
);
inspectedElement.suspendedBy = cleanForBridge(
inspectedElement.suspendedBy,
createIsPathAllowed('suspendedBy'),
);
Comment on lines +758 to +761
Copy link
Contributor

Choose a reason for hiding this comment

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

Just an empty array here, maybe?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's not the same because this converts it to DehydratedData which gets hydrated on the frontend.


return {
id,
Expand Down Expand Up @@ -847,6 +851,9 @@ export function attach(
errors,
warnings,

// Not supported in legacy renderers.
suspendedBy: [],

// List of owners
owners,

Expand Down
32 changes: 27 additions & 5 deletions packages/react-devtools-shared/src/backend/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import type {
import type {InitBackend} from 'react-devtools-shared/src/backend';
import type {TimelineDataExport} from 'react-devtools-timeline/src/types';
import type {BackendBridge} from 'react-devtools-shared/src/bridge';
import type {ReactFunctionLocation} from 'shared/ReactTypes';
import type {ReactFunctionLocation, ReactStackTrace} from 'shared/ReactTypes';
import type Agent from './agent';

type BundleType =
Expand Down Expand Up @@ -232,6 +232,25 @@ export type PathMatch = {
isFullMatch: boolean,
};

// Serialized version of ReactIOInfo
export type SerializedIOInfo = {
name: string,
start: number,
end: number,
value: null | Promise<mixed>,
env: null | string,
owner: null | SerializedElement,
stack: null | ReactStackTrace,
};

// Serialized version of ReactAsyncInfo
export type SerializedAsyncInfo = {
awaited: SerializedIOInfo,
env: null | string,
owner: null | SerializedElement,
stack: null | ReactStackTrace,
};

export type SerializedElement = {
displayName: string | null,
id: number,
Expand Down Expand Up @@ -268,14 +287,17 @@ export type InspectedElement = {
hasLegacyContext: boolean,

// Inspectable properties.
context: Object | null,
hooks: Object | null,
props: Object | null,
state: Object | null,
context: Object | null, // DehydratedData or {[string]: mixed}
hooks: Object | null, // DehydratedData or {[string]: mixed}
props: Object | null, // DehydratedData or {[string]: mixed}
state: Object | null, // DehydratedData or {[string]: mixed}
key: number | string | null,
errors: Array<[string, number]>,
warnings: Array<[string, number]>,

// Things that suspended this Instances
suspendedBy: Object, // DehydratedData or Array<SerializedAsyncInfo>

// List of owners
owners: Array<SerializedElement> | null,
source: ReactFunctionLocation | null,
Expand Down
Loading
Loading