@samitouri / QOS-React / commits / db06f6b751

[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 (