[DevTools] Track virtual debug info from suspensey images (#34181)

Same as #34166 but for Suspensey images.

The trick here is to check the `SuspenseyImagesMode` since not all
versions of React and not all subtrees will have Suspensey images
enabled yet.

The other trick is to read back from `currentSrc` to get the image url
we actually resolved to in this case. Similar to how for Suspensey CSS
we check if the media query would've matched.

<img width="591" height="205" alt="Screenshot 2025-08-11 at 9 32 56 PM"
src="https://github.com/user-attachments/assets/ac98785c-d3e0-407c-84e0-c27f86c0ecac"
/>
This commit is contained in:
Sebastian Markbåge
2025-08-13 09:26:21 -04:00
committed by GitHub
parent 9433fe357a
commit db06f6b751
2 changed files with 129 additions and 9 deletions

View File

@@ -367,6 +367,7 @@ export function getInternalReactConstants(version: string): {
ReactPriorityLevels: ReactPriorityLevelsType,
ReactTypeOfWork: WorkTagMap,
StrictModeBits: number,
SuspenseyImagesMode: number,
} {
// **********************************************************
// The section below is copied from files in React repo.
@@ -407,6 +408,8 @@ export function getInternalReactConstants(version: string): {
StrictModeBits = 0b10;
}
const SuspenseyImagesMode = 0b0100000;
let ReactTypeOfWork: WorkTagMap = ((null: any): WorkTagMap);
// **********************************************************
@@ -820,6 +823,7 @@ export function getInternalReactConstants(version: string): {
ReactPriorityLevels,
ReactTypeOfWork,
StrictModeBits,
SuspenseyImagesMode,
};
}
@@ -988,6 +992,7 @@ export function attach(
ReactPriorityLevels,
ReactTypeOfWork,
StrictModeBits,
SuspenseyImagesMode,
} = getInternalReactConstants(version);
const {
ActivityComponent,
@@ -3345,6 +3350,114 @@ export function attach(
insertSuspendedBy(asyncInfo);
}
function trackDebugInfoFromHostComponent(
devtoolsInstance: DevToolsInstance,
fiber: Fiber,
): void {
if (fiber.tag !== HostComponent) {
return;
}
if ((fiber.mode & SuspenseyImagesMode) === 0) {
// In any released version, Suspensey Images are only enabled inside a ViewTransition
// subtree, which is enabled by the SuspenseyImagesMode.
// TODO: If we ever enable the enableSuspenseyImages flag then it would be enabled for
// all images and we'd need some other check for if the version of React has that enabled.
return;
}
const type = fiber.type;
const props: {
src?: string,
onLoad?: (event: any) => void,
loading?: 'eager' | 'lazy',
...
} = fiber.memoizedProps;
const maySuspendCommit =
type === 'img' &&
props.src != null &&
props.src !== '' &&
props.onLoad == null &&
props.loading !== 'lazy';
// Note: We don't track "maySuspendCommitOnUpdate" separately because it doesn't matter if
// it didn't suspend this particular update if it would've suspended if it mounted in this
// state, since we're tracking the dependencies inside the current state.
if (!maySuspendCommit) {
return;
}
const instance = fiber.stateNode;
if (instance == null) {
// Should never happen.
return;
}
// Unlike props.src, currentSrc will be fully qualified which we need for comparison below.
// Unlike instance.src it will be resolved into the media queries currently matching which is
// the state we're inspecting.
const src = instance.currentSrc;
if (typeof src !== 'string' || src === '') {
return;
}
let start = -1;
let end = -1;
let fileSize = 0;
// $FlowFixMe[method-unbinding]
if (typeof performance.getEntriesByType === 'function') {
// We may be able to collect the start and end time of this resource from Performance Observer.
const resourceEntries = performance.getEntriesByType('resource');
for (let i = 0; i < resourceEntries.length; i++) {
const resourceEntry = resourceEntries[i];
if (resourceEntry.name === src) {
start = resourceEntry.startTime;
end = start + resourceEntry.duration;
// $FlowFixMe[prop-missing]
fileSize = (resourceEntry.encodedBodySize: any) || 0;
}
}
}
// A representation of the image data itself.
// TODO: We could render a little preview in the front end from the resource API.
const value: {
currentSrc: string,
naturalWidth?: number,
naturalHeight?: number,
fileSize?: number,
} = {
currentSrc: src,
};
if (instance.naturalWidth > 0 && instance.naturalHeight > 0) {
// The intrinsic size of the file value itself, if it's loaded
value.naturalWidth = instance.naturalWidth;
value.naturalHeight = instance.naturalHeight;
}
if (fileSize > 0) {
// Cross-origin images won't have a file size that we can access.
value.fileSize = fileSize;
}
const promise = Promise.resolve(value);
(promise: any).status = 'fulfilled';
(promise: any).value = value;
const ioInfo: ReactIOInfo = {
name: 'img',
start,
end,
value: promise,
// $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file.
owner: fiber, // Allow linking to the <link> if it's not filtered.
};
const asyncInfo: ReactAsyncInfo = {
awaited: ioInfo,
// $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file.
owner: fiber._debugOwner == null ? null : fiber._debugOwner,
debugStack: fiber._debugStack == null ? null : fiber._debugStack,
debugTask: fiber._debugTask == null ? null : fiber._debugTask,
};
insertSuspendedBy(asyncInfo);
}
function mountVirtualChildrenRecursively(
firstChild: Fiber,
lastChild: null | Fiber, // non-inclusive
@@ -3619,6 +3732,7 @@ export function attach(
throw new Error('Did not expect a host hoistable to be the root');
}
aquireHostInstance(nearestInstance, fiber.stateNode);
trackDebugInfoFromHostComponent(nearestInstance, fiber);
}
if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) {
@@ -4447,20 +4561,22 @@ export function attach(
aquireHostResource(nearestInstance, nextFiber.memoizedState);
trackDebugInfoFromHostResource(nearestInstance, nextFiber);
} else if (
(nextFiber.tag === HostComponent ||
nextFiber.tag === HostText ||
nextFiber.tag === HostSingleton) &&
prevFiber.stateNode !== nextFiber.stateNode
nextFiber.tag === HostComponent ||
nextFiber.tag === HostText ||
nextFiber.tag === HostSingleton
) {
// In persistent mode, it's possible for the stateNode to update with
// a new clone. In that case we need to release the old one and aquire
// new one instead.
const nearestInstance = reconcilingParent;
if (nearestInstance === null) {
throw new Error('Did not expect a host hoistable to be the root');
}
releaseHostInstance(nearestInstance, prevFiber.stateNode);
aquireHostInstance(nearestInstance, nextFiber.stateNode);
if (prevFiber.stateNode !== nextFiber.stateNode) {
// In persistent mode, it's possible for the stateNode to update with
// a new clone. In that case we need to release the old one and aquire
// new one instead.
releaseHostInstance(nearestInstance, prevFiber.stateNode);
aquireHostInstance(nearestInstance, nextFiber.stateNode);
}
trackDebugInfoFromHostComponent(nearestInstance, nextFiber);
}
let updateFlags = NoUpdate;

View File

@@ -26,6 +26,10 @@ export function getIODescription(value: any): string {
return value.url;
} else if (typeof value.href === 'string') {
return value.href;
} else if (typeof value.src === 'string') {
return value.src;
} else if (typeof value.currentSrc === 'string') {
return value.currentSrc;
} else if (typeof value.command === 'string') {
return value.command;
} else if (