[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" />
Sebastian Markbåge committed
Aug 13, 2025 at 09:26 UTC
db06f6b75108a45c8b70e526de6db5bc774acca0
2 files changed
+129
-9
packages/react-devtools-shared/src/backend/fiber/renderer.js
+125
-9
@@ -367,6 +367,7 @@ export function getInternalReactConstants(version: string): {
367
ReactPriorityLevels: ReactPriorityLevelsType,
368
ReactTypeOfWork: WorkTagMap,
369
StrictModeBits: number,
370
+ SuspenseyImagesMode: number,
371
} {
372
// **********************************************************
373
// The section below is copied from files in React repo.
@@ -407,6 +408,8 @@ export function getInternalReactConstants(version: string): {
408
StrictModeBits = 0b10;
409
}
410
411
+ const SuspenseyImagesMode = 0b0100000;
412
+
413
let ReactTypeOfWork: WorkTagMap = ((null: any): WorkTagMap);
414
415
// **********************************************************
@@ -820,6 +823,7 @@ export function getInternalReactConstants(version: string): {
823
ReactPriorityLevels,
824
ReactTypeOfWork,
825
StrictModeBits,
826
+ SuspenseyImagesMode,
827
};
828
}
829
@@ -988,6 +992,7 @@ export function attach(
992
ReactPriorityLevels,
993
ReactTypeOfWork,
994
StrictModeBits,
995
+ SuspenseyImagesMode,
996
} = getInternalReactConstants(version);
997
const {
998
ActivityComponent,
@@ -3345,6 +3350,114 @@ export function attach(
3350
insertSuspendedBy(asyncInfo);
3351
}
3352
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
+
3461
function mountVirtualChildrenRecursively(
3462
firstChild: Fiber,
3463
lastChild: null | Fiber, // non-inclusive
@@ -3619,6 +3732,7 @@ export function attach(
3732
throw new Error('Did not expect a host hoistable to be the root');
3733
}
3734
aquireHostInstance(nearestInstance, fiber.stateNode);
3735
+ trackDebugInfoFromHostComponent(nearestInstance, fiber);
3736
}
3737
3738
if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) {
@@ -4447,20 +4561,22 @@ export function attach(
4561
aquireHostResource(nearestInstance, nextFiber.memoizedState);
4562
trackDebugInfoFromHostResource(nearestInstance, nextFiber);
4563
} 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
4567
) {
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.
4568
const nearestInstance = reconcilingParent;
4569
if (nearestInstance === null) {
4570
throw new Error('Did not expect a host hoistable to be the root');
4571
}
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);
4580
}
4581
4582
let updateFlags = NoUpdate;
packages/shared/ReactIODescription.js
+4
@@ -26,6 +26,10 @@ export function getIODescription(value: any): string {
26
return value.url;
27
} else if (typeof value.href === 'string') {
28
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;
33
} else if (typeof value.command === 'string') {
34
return value.command;
35
} else if (