Skip to content

Commit db06f6b

Browse files
authored
[DevTools] Track virtual debug info from suspensey images (facebook#34181)
Same as facebook#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" />
1 parent 9433fe3 commit db06f6b

File tree

2 files changed

+129
-9
lines changed

2 files changed

+129
-9
lines changed

packages/react-devtools-shared/src/backend/fiber/renderer.js

Lines changed: 125 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ export function getInternalReactConstants(version: string): {
367367
ReactPriorityLevels: ReactPriorityLevelsType,
368368
ReactTypeOfWork: WorkTagMap,
369369
StrictModeBits: number,
370+
SuspenseyImagesMode: number,
370371
} {
371372
// **********************************************************
372373
// The section below is copied from files in React repo.
@@ -407,6 +408,8 @@ export function getInternalReactConstants(version: string): {
407408
StrictModeBits = 0b10;
408409
}
409410

411+
const SuspenseyImagesMode = 0b0100000;
412+
410413
let ReactTypeOfWork: WorkTagMap = ((null: any): WorkTagMap);
411414

412415
// **********************************************************
@@ -820,6 +823,7 @@ export function getInternalReactConstants(version: string): {
820823
ReactPriorityLevels,
821824
ReactTypeOfWork,
822825
StrictModeBits,
826+
SuspenseyImagesMode,
823827
};
824828
}
825829
@@ -988,6 +992,7 @@ export function attach(
988992
ReactPriorityLevels,
989993
ReactTypeOfWork,
990994
StrictModeBits,
995+
SuspenseyImagesMode,
991996
} = getInternalReactConstants(version);
992997
const {
993998
ActivityComponent,
@@ -3345,6 +3350,114 @@ export function attach(
33453350
insertSuspendedBy(asyncInfo);
33463351
}
33473352
3353+
function trackDebugInfoFromHostComponent(
3354+
devtoolsInstance: DevToolsInstance,
3355+
fiber: Fiber,
3356+
): void {
3357+
if (fiber.tag !== HostComponent) {
3358+
return;
3359+
}
3360+
if ((fiber.mode & SuspenseyImagesMode) === 0) {
3361+
// In any released version, Suspensey Images are only enabled inside a ViewTransition
3362+
// subtree, which is enabled by the SuspenseyImagesMode.
3363+
// TODO: If we ever enable the enableSuspenseyImages flag then it would be enabled for
3364+
// all images and we'd need some other check for if the version of React has that enabled.
3365+
return;
3366+
}
3367+
3368+
const type = fiber.type;
3369+
const props: {
3370+
src?: string,
3371+
onLoad?: (event: any) => void,
3372+
loading?: 'eager' | 'lazy',
3373+
...
3374+
} = fiber.memoizedProps;
3375+
3376+
const maySuspendCommit =
3377+
type === 'img' &&
3378+
props.src != null &&
3379+
props.src !== '' &&
3380+
props.onLoad == null &&
3381+
props.loading !== 'lazy';
3382+
3383+
// Note: We don't track "maySuspendCommitOnUpdate" separately because it doesn't matter if
3384+
// it didn't suspend this particular update if it would've suspended if it mounted in this
3385+
// state, since we're tracking the dependencies inside the current state.
3386+
3387+
if (!maySuspendCommit) {
3388+
return;
3389+
}
3390+
3391+
const instance = fiber.stateNode;
3392+
if (instance == null) {
3393+
// Should never happen.
3394+
return;
3395+
}
3396+
3397+
// Unlike props.src, currentSrc will be fully qualified which we need for comparison below.
3398+
// Unlike instance.src it will be resolved into the media queries currently matching which is
3399+
// the state we're inspecting.
3400+
const src = instance.currentSrc;
3401+
if (typeof src !== 'string' || src === '') {
3402+
return;
3403+
}
3404+
let start = -1;
3405+
let end = -1;
3406+
let fileSize = 0;
3407+
// $FlowFixMe[method-unbinding]
3408+
if (typeof performance.getEntriesByType === 'function') {
3409+
// We may be able to collect the start and end time of this resource from Performance Observer.
3410+
const resourceEntries = performance.getEntriesByType('resource');
3411+
for (let i = 0; i < resourceEntries.length; i++) {
3412+
const resourceEntry = resourceEntries[i];
3413+
if (resourceEntry.name === src) {
3414+
start = resourceEntry.startTime;
3415+
end = start + resourceEntry.duration;
3416+
// $FlowFixMe[prop-missing]
3417+
fileSize = (resourceEntry.encodedBodySize: any) || 0;
3418+
}
3419+
}
3420+
}
3421+
// A representation of the image data itself.
3422+
// TODO: We could render a little preview in the front end from the resource API.
3423+
const value: {
3424+
currentSrc: string,
3425+
naturalWidth?: number,
3426+
naturalHeight?: number,
3427+
fileSize?: number,
3428+
} = {
3429+
currentSrc: src,
3430+
};
3431+
if (instance.naturalWidth > 0 && instance.naturalHeight > 0) {
3432+
// The intrinsic size of the file value itself, if it's loaded
3433+
value.naturalWidth = instance.naturalWidth;
3434+
value.naturalHeight = instance.naturalHeight;
3435+
}
3436+
if (fileSize > 0) {
3437+
// Cross-origin images won't have a file size that we can access.
3438+
value.fileSize = fileSize;
3439+
}
3440+
const promise = Promise.resolve(value);
3441+
(promise: any).status = 'fulfilled';
3442+
(promise: any).value = value;
3443+
const ioInfo: ReactIOInfo = {
3444+
name: 'img',
3445+
start,
3446+
end,
3447+
value: promise,
3448+
// $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file.
3449+
owner: fiber, // Allow linking to the <link> if it's not filtered.
3450+
};
3451+
const asyncInfo: ReactAsyncInfo = {
3452+
awaited: ioInfo,
3453+
// $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file.
3454+
owner: fiber._debugOwner == null ? null : fiber._debugOwner,
3455+
debugStack: fiber._debugStack == null ? null : fiber._debugStack,
3456+
debugTask: fiber._debugTask == null ? null : fiber._debugTask,
3457+
};
3458+
insertSuspendedBy(asyncInfo);
3459+
}
3460+
33483461
function mountVirtualChildrenRecursively(
33493462
firstChild: Fiber,
33503463
lastChild: null | Fiber, // non-inclusive
@@ -3619,6 +3732,7 @@ export function attach(
36193732
throw new Error('Did not expect a host hoistable to be the root');
36203733
}
36213734
aquireHostInstance(nearestInstance, fiber.stateNode);
3735+
trackDebugInfoFromHostComponent(nearestInstance, fiber);
36223736
}
36233737
36243738
if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) {
@@ -4447,20 +4561,22 @@ export function attach(
44474561
aquireHostResource(nearestInstance, nextFiber.memoizedState);
44484562
trackDebugInfoFromHostResource(nearestInstance, nextFiber);
44494563
} else if (
4450-
(nextFiber.tag === HostComponent ||
4451-
nextFiber.tag === HostText ||
4452-
nextFiber.tag === HostSingleton) &&
4453-
prevFiber.stateNode !== nextFiber.stateNode
4564+
nextFiber.tag === HostComponent ||
4565+
nextFiber.tag === HostText ||
4566+
nextFiber.tag === HostSingleton
44544567
) {
4455-
// In persistent mode, it's possible for the stateNode to update with
4456-
// a new clone. In that case we need to release the old one and aquire
4457-
// new one instead.
44584568
const nearestInstance = reconcilingParent;
44594569
if (nearestInstance === null) {
44604570
throw new Error('Did not expect a host hoistable to be the root');
44614571
}
4462-
releaseHostInstance(nearestInstance, prevFiber.stateNode);
4463-
aquireHostInstance(nearestInstance, nextFiber.stateNode);
4572+
if (prevFiber.stateNode !== nextFiber.stateNode) {
4573+
// In persistent mode, it's possible for the stateNode to update with
4574+
// a new clone. In that case we need to release the old one and aquire
4575+
// new one instead.
4576+
releaseHostInstance(nearestInstance, prevFiber.stateNode);
4577+
aquireHostInstance(nearestInstance, nextFiber.stateNode);
4578+
}
4579+
trackDebugInfoFromHostComponent(nearestInstance, nextFiber);
44644580
}
44654581
44664582
let updateFlags = NoUpdate;

packages/shared/ReactIODescription.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export function getIODescription(value: any): string {
2626
return value.url;
2727
} else if (typeof value.href === 'string') {
2828
return value.href;
29+
} else if (typeof value.src === 'string') {
30+
return value.src;
31+
} else if (typeof value.currentSrc === 'string') {
32+
return value.currentSrc;
2933
} else if (typeof value.command === 'string') {
3034
return value.command;
3135
} else if (

0 commit comments

Comments
 (0)