From 2cb8edbb058431c3ebd2c78661a4819c2e8c62c5 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Sat, 16 Aug 2025 10:34:19 +0200 Subject: [PATCH 01/10] [DevTools] Handle dehydrated Suspense boundaries (#34196) --- .../src/backend/fiber/renderer.js | 310 ++++++++++++------ .../src/ReactFiberBeginWork.js | 2 +- 2 files changed, 213 insertions(+), 99 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 94c394cbd701..9a342a6731ca 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -1543,6 +1543,22 @@ export function attach( return Array.from(knownEnvironmentNames); } + function isFiberHydrated(fiber: Fiber): boolean { + if (OffscreenComponent === -1) { + throw new Error('not implemented for legacy suspense'); + } + switch (fiber.tag) { + case HostRoot: + const rootState = fiber.memoizedState; + return !rootState.isDehydrated; + case SuspenseComponent: + const suspenseState = fiber.memoizedState; + return suspenseState === null || suspenseState.dehydrated === null; + default: + throw new Error('not implemented for work tag ' + fiber.tag); + } + } + function shouldFilterVirtual( data: ReactComponentInfo, secondaryEnv: null | string, @@ -3610,6 +3626,50 @@ export function attach( ); } + function mountSuspenseChildrenRecursively( + contentFiber: Fiber, + traceNearestHostComponentUpdate: boolean, + stashedSuspenseParent: SuspenseNode | null, + stashedSuspensePrevious: SuspenseNode | null, + stashedSuspenseRemaining: SuspenseNode | null, + ) { + const fallbackFiber = contentFiber.sibling; + + // First update only the Offscreen boundary. I.e. the main content. + mountVirtualChildrenRecursively( + contentFiber, + fallbackFiber, + traceNearestHostComponentUpdate, + 0, // first level + ); + + if (fallbackFiber !== null) { + const fallbackStashedSuspenseParent = stashedSuspenseParent; + const fallbackStashedSuspensePrevious = stashedSuspensePrevious; + const fallbackStashedSuspenseRemaining = stashedSuspenseRemaining; + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll + // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode. + // Since the fallback conceptually blocks the parent. + reconcilingParentSuspenseNode = stashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; + try { + mountVirtualChildrenRecursively( + fallbackFiber, + null, + traceNearestHostComponentUpdate, + 0, // first level + ); + } finally { + reconcilingParentSuspenseNode = fallbackStashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = + fallbackStashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = + fallbackStashedSuspenseRemaining; + } + } + } + function mountFiberRecursively( fiber: Fiber, traceNearestHostComponentUpdate: boolean, @@ -3632,11 +3692,17 @@ export function attach( newSuspenseNode.rects = measureInstance(newInstance); } } else { - const contentFiber = fiber.child; - if (contentFiber === null) { - throw new Error( - 'There should always be an Offscreen Fiber child in a Suspense boundary.', - ); + const hydrated = isFiberHydrated(fiber); + if (hydrated) { + const contentFiber = fiber.child; + if (contentFiber === null) { + throw new Error( + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.', + ); + } + } else { + // This Suspense Fiber is still dehydrated. It won't have any children + // until hydration. } const isTimedOut = fiber.memoizedState !== null; if (!isTimedOut) { @@ -3684,13 +3750,20 @@ export function attach( newSuspenseNode.rects = measureInstance(newInstance); } } else { - const contentFiber = fiber.child; - if (contentFiber === null) { - throw new Error( - 'There should always be an Offscreen Fiber child in a Suspense boundary.', - ); + const hydrated = isFiberHydrated(fiber); + if (hydrated) { + const contentFiber = fiber.child; + if (contentFiber === null) { + throw new Error( + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.', + ); + } + } else { + // This Suspense Fiber is still dehydrated. It won't have any children + // until hydration. } - const isTimedOut = fiber.memoizedState !== null; + const suspenseState = fiber.memoizedState; + const isTimedOut = suspenseState !== null; if (!isTimedOut) { newSuspenseNode.rects = measureInstance(newInstance); } @@ -3820,38 +3893,26 @@ export function attach( ) { // Modern Suspense path const contentFiber = fiber.child; - if (contentFiber === null) { - throw new Error( - 'There should always be an Offscreen Fiber child in a Suspense boundary.', - ); - } - - trackThrownPromisesFromRetryCache(newSuspenseNode, fiber.stateNode); - - const fallbackFiber = contentFiber.sibling; + const hydrated = isFiberHydrated(fiber); + if (hydrated) { + if (contentFiber === null) { + throw new Error( + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.', + ); + } - // First update only the Offscreen boundary. I.e. the main content. - mountVirtualChildrenRecursively( - contentFiber, - fallbackFiber, - traceNearestHostComponentUpdate, - 0, // first level - ); + trackThrownPromisesFromRetryCache(newSuspenseNode, fiber.stateNode); - // Next, we'll pop back out of the SuspenseNode that we added above and now we'll - // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode. - // Since the fallback conceptually blocks the parent. - reconcilingParentSuspenseNode = stashedSuspenseParent; - previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; - remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; - shouldPopSuspenseNode = false; - if (fallbackFiber !== null) { - mountVirtualChildrenRecursively( - fallbackFiber, - null, + mountSuspenseChildrenRecursively( + contentFiber, traceNearestHostComponentUpdate, - 0, // first level + stashedSuspenseParent, + stashedSuspensePrevious, + stashedSuspenseRemaining, ); + } else { + // This Suspense Fiber is still dehydrated. It won't have any children + // until hydration. } } else { if (fiber.child !== null) { @@ -4505,6 +4566,63 @@ export function attach( ); } + function updateSuspenseChildrenRecursively( + nextContentFiber: Fiber, + prevContentFiber: Fiber, + traceNearestHostComponentUpdate: boolean, + stashedSuspenseParent: null | SuspenseNode, + stashedSuspensePrevious: null | SuspenseNode, + stashedSuspenseRemaining: null | SuspenseNode, + ): number { + let updateFlags = NoUpdate; + const prevFallbackFiber = prevContentFiber.sibling; + const nextFallbackFiber = nextContentFiber.sibling; + + // First update only the Offscreen boundary. I.e. the main content. + updateFlags |= updateVirtualChildrenRecursively( + nextContentFiber, + nextFallbackFiber, + prevContentFiber, + traceNearestHostComponentUpdate, + 0, + ); + + if (prevFallbackFiber !== null || nextFallbackFiber !== null) { + const fallbackStashedSuspenseParent = reconcilingParentSuspenseNode; + const fallbackStashedSuspensePrevious = + previouslyReconciledSiblingSuspenseNode; + const fallbackStashedSuspenseRemaining = + remainingReconcilingChildrenSuspenseNodes; + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll + // reconcile the fallback, reconciling anything in the context of the parent SuspenseNode. + // Since the fallback conceptually blocks the parent. + reconcilingParentSuspenseNode = stashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; + try { + if (nextFallbackFiber === null) { + unmountRemainingChildren(); + } else { + updateFlags |= updateVirtualChildrenRecursively( + nextFallbackFiber, + null, + prevFallbackFiber, + traceNearestHostComponentUpdate, + 0, + ); + } + } finally { + reconcilingParentSuspenseNode = fallbackStashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = + fallbackStashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = + fallbackStashedSuspenseRemaining; + } + } + + return updateFlags; + } + // Returns whether closest unfiltered fiber parent needs to reset its child list. function updateFiberRecursively( fiberInstance: null | FiberInstance | FilteredFiberInstance, // null if this should be filtered @@ -4765,71 +4883,67 @@ export function attach( fiberInstance.suspenseNode !== null ) { // Modern Suspense path + const suspenseNode = fiberInstance.suspenseNode; const prevContentFiber = prevFiber.child; const nextContentFiber = nextFiber.child; - if (nextContentFiber === null || prevContentFiber === null) { - throw new Error( - 'There should always be an Offscreen Fiber child in a Suspense boundary.', - ); - } - const prevFallbackFiber = prevContentFiber.sibling; - const nextFallbackFiber = nextContentFiber.sibling; + const previousHydrated = isFiberHydrated(prevFiber); + const nextHydrated = isFiberHydrated(nextFiber); + if (previousHydrated && nextHydrated) { + if (nextContentFiber === null || prevContentFiber === null) { + throw new Error( + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.', + ); + } - if ((prevFiber.stateNode === null) !== (nextFiber.stateNode === null)) { - trackThrownPromisesFromRetryCache( - fiberInstance.suspenseNode, - nextFiber.stateNode, + if ( + (prevFiber.stateNode === null) !== + (nextFiber.stateNode === null) + ) { + trackThrownPromisesFromRetryCache( + suspenseNode, + nextFiber.stateNode, + ); + } + + shouldMeasureSuspenseNode = false; + updateFlags |= updateSuspenseChildrenRecursively( + nextContentFiber, + prevContentFiber, + traceNearestHostComponentUpdate, + stashedSuspenseParent, + stashedSuspensePrevious, + stashedSuspenseRemaining, ); - } + if (nextFiber.memoizedState === null) { + // Measure this Suspense node in case it changed. We don't update the rect while + // we're inside a disconnected subtree nor if we are the Suspense boundary that + // is suspended. This lets us keep the rectangle of the displayed content while + // we're suspended to visualize the resulting state. + shouldMeasureSuspenseNode = !isInDisconnectedSubtree; + } + } else if (!previousHydrated && nextHydrated) { + if (nextContentFiber === null) { + throw new Error( + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.', + ); + } - // First update only the Offscreen boundary. I.e. the main content. - updateFlags |= updateVirtualChildrenRecursively( - nextContentFiber, - nextFallbackFiber, - prevContentFiber, - traceNearestHostComponentUpdate, - 0, - ); + trackThrownPromisesFromRetryCache(suspenseNode, nextFiber.stateNode); - shouldMeasureSuspenseNode = false; - if (prevFallbackFiber !== null || nextFallbackFiber !== null) { - const fallbackStashedSuspenseParent = reconcilingParentSuspenseNode; - const fallbackStashedSuspensePrevious = - previouslyReconciledSiblingSuspenseNode; - const fallbackStashedSuspenseRemaining = - remainingReconcilingChildrenSuspenseNodes; - // Next, we'll pop back out of the SuspenseNode that we added above and now we'll - // reconcile the fallback, reconciling anything in the context of the parent SuspenseNode. - // Since the fallback conceptually blocks the parent. - reconcilingParentSuspenseNode = stashedSuspenseParent; - previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; - remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; - try { - if (nextFallbackFiber === null) { - unmountRemainingChildren(); - } else { - updateFlags |= updateVirtualChildrenRecursively( - nextFallbackFiber, - null, - prevFallbackFiber, - traceNearestHostComponentUpdate, - 0, - ); - } - } finally { - reconcilingParentSuspenseNode = fallbackStashedSuspenseParent; - previouslyReconciledSiblingSuspenseNode = - fallbackStashedSuspensePrevious; - remainingReconcilingChildrenSuspenseNodes = - fallbackStashedSuspenseRemaining; - } - } - if (nextFiber.memoizedState === null) { - // Measure this Suspense node in case it changed. We don't update the rect while - // we're inside a disconnected subtree nor if we are the Suspense boundary that - // is suspended. This lets us keep the rectangle of the displayed content while - // we're suspended to visualize the resulting state. - shouldMeasureSuspenseNode = !isInDisconnectedSubtree; + mountSuspenseChildrenRecursively( + nextContentFiber, + traceNearestHostComponentUpdate, + stashedSuspenseParent, + stashedSuspensePrevious, + stashedSuspenseRemaining, + ); + } else if (previousHydrated && !nextHydrated) { + throw new Error( + 'Encountered a dehydrated Suspense boundary that was previously hydrated.', + ); + } else { + // This Suspense Fiber is still dehydrated. It won't have any children + // until hydration. } } else { // Common case: Primary -> Primary. diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 372a74f97b20..52b9b9ebe582 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -1796,7 +1796,7 @@ function updateHostRoot( } const nextProps = workInProgress.pendingProps; - const prevState = workInProgress.memoizedState; + const prevState: RootState = workInProgress.memoizedState; const prevChildren = prevState.element; cloneUpdateQueue(current, workInProgress); processUpdateQueue(workInProgress, nextProps, null, renderLanes); From 546bac728125bed821c0205e3d7216a7c9a563cb Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Sat, 16 Aug 2025 10:45:39 +0200 Subject: [PATCH 02/10] [DevTools] Always attempt to mount dehydrated roots (#34209) --- .../react-devtools-shared/src/backend/fiber/renderer.js | 9 ++------- packages/react-devtools-shared/src/utils.js | 4 ++-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 9a342a6731ca..fc054d817d46 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -5278,14 +5278,9 @@ export function attach( // TODO: relying on this seems a bit fishy. const wasMounted = prevFiber.memoizedState != null && - prevFiber.memoizedState.element != null && - // A dehydrated root is not considered mounted - prevFiber.memoizedState.isDehydrated !== true; + prevFiber.memoizedState.element != null; const isMounted = - current.memoizedState != null && - current.memoizedState.element != null && - // A dehydrated root is not considered mounted - current.memoizedState.isDehydrated !== true; + current.memoizedState != null && current.memoizedState.element != null; if (!wasMounted && isMounted) { // Mount a new root. setRootPseudoKey(currentRoot.id, current); diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index ea921c2988c3..34c258ebe2b9 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -300,7 +300,7 @@ export function printOperationsArray(operations: Array) { } case TREE_OPERATION_SET_SUBTREE_MODE: { const id = operations[i + 1]; - const mode = operations[i + 1]; + const mode = operations[i + 2]; i += 3; @@ -339,11 +339,11 @@ export function printOperationsArray(operations: Array) { const fiberID = operations[i + 1]; const parentID = operations[i + 2]; const nameStringID = operations[i + 3]; - const name = stringTable[nameStringID]; const numRects = operations[i + 4]; i += 5; + const name = stringTable[nameStringID]; let rects: string; if (numRects === -1) { rects = 'null'; From 7a36dfedc70ffb49be2e4e23b40e01d34cef267e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sat, 16 Aug 2025 12:16:58 -0400 Subject: [PATCH 03/10] [Fizz] Delay retrying hydration until after an animation frame (#34220) The theory here is that when we reveal a boundary coming from the server we want to paint that before hydrating it. Hydration gets scheduled in a macrotask with the scheduler but it's in theory possible that it runs before the paint. If that's the case, then the JS that runs before yielding during hydration might slightly delay the paint and we might miss a window to skip the previous paint. --- .../ReactDOMFizzInstructionSetInlineCodeStrings.js | 2 +- .../fizz-instruction-set/ReactDOMFizzInstructionSetShared.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index 0cfff921d0a3..8c4a0a8e9a00 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -6,7 +6,7 @@ export const markShellTime = export const clientRenderBoundary = '$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};'; export const completeBoundary = - '$RB=[];$RV=function(b){$RT=performance.now();for(var a=0;aa&&2E3a&&2E3q&&2E3 Date: Sun, 17 Aug 2025 16:17:11 -0400 Subject: [PATCH 04/10] [DevTools] Add byteSize field to ReactIOInfo and show this in the tooltip (#34221) This is intended to be used by various client side resources where the transfer size is interesting to know how it'll perform in various network conditions. Not intended to be added by the server. For now it's only added internally by DevTools itself on img/css but I'll add it from Flight Client too in a follow up. This now shows this as the "transfer size" which is the encoded body size + headers/overhead. Where as the "fileSize" that I add to images is the decoded body size, like what you'd see on disk. This is what Chrome shows so it's less confusing if you compare Network tab and this view. --- .../src/backend/fiber/renderer.js | 28 ++++++++++++++----- .../src/backend/types.js | 1 + .../react-devtools-shared/src/backendAPI.js | 1 + .../Components/InspectedElementSuspendedBy.js | 21 +++++++++++++- .../src/frontend/types.js | 1 + packages/shared/ReactTypes.js | 1 + 6 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index fc054d817d46..a49cf25a1d1f 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -3343,6 +3343,7 @@ export function attach( } let start = -1; let end = -1; + let byteSize = 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. @@ -3352,6 +3353,8 @@ export function attach( if (resourceEntry.name === href) { start = resourceEntry.startTime; end = start + resourceEntry.duration; + // $FlowFixMe[prop-missing] + byteSize = (resourceEntry.transferSize: any) || 0; } } } @@ -3367,6 +3370,10 @@ export function attach( // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. owner: fiber, // Allow linking to the if it's not filtered. }; + if (byteSize > 0) { + // $FlowFixMe[cannot-write] + ioInfo.byteSize = byteSize; + } const asyncInfo: ReactAsyncInfo = { awaited: ioInfo, // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. @@ -3431,6 +3438,7 @@ export function attach( } let start = -1; let end = -1; + let byteSize = 0; let fileSize = 0; // $FlowFixMe[method-unbinding] if (typeof performance.getEntriesByType === 'function') { @@ -3442,7 +3450,9 @@ export function attach( start = resourceEntry.startTime; end = start + resourceEntry.duration; // $FlowFixMe[prop-missing] - fileSize = (resourceEntry.encodedBodySize: any) || 0; + fileSize = (resourceEntry.decodedBodySize: any) || 0; + // $FlowFixMe[prop-missing] + byteSize = (resourceEntry.transferSize: any) || 0; } } } @@ -3476,6 +3486,10 @@ export function attach( // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. owner: fiber, // Allow linking to the if it's not filtered. }; + if (byteSize > 0) { + // $FlowFixMe[cannot-write] + ioInfo.byteSize = byteSize; + } const asyncInfo: ReactAsyncInfo = { awaited: ioInfo, // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. @@ -4704,16 +4718,15 @@ export function attach( trackDebugInfoFromLazyType(nextFiber); trackDebugInfoFromUsedThenables(nextFiber); - if ( - nextFiber.tag === HostHoistable && - prevFiber.memoizedState !== nextFiber.memoizedState - ) { + if (nextFiber.tag === HostHoistable) { const nearestInstance = reconcilingParent; if (nearestInstance === null) { throw new Error('Did not expect a host hoistable to be the root'); } - releaseHostResource(nearestInstance, prevFiber.memoizedState); - aquireHostResource(nearestInstance, nextFiber.memoizedState); + if (prevFiber.memoizedState !== nextFiber.memoizedState) { + releaseHostResource(nearestInstance, prevFiber.memoizedState); + aquireHostResource(nearestInstance, nextFiber.memoizedState); + } trackDebugInfoFromHostResource(nearestInstance, nextFiber); } else if ( nextFiber.tag === HostComponent || @@ -5948,6 +5961,7 @@ export function attach( description: getIODescription(resolvedValue), start: ioInfo.start, end: ioInfo.end, + byteSize: ioInfo.byteSize == null ? null : ioInfo.byteSize, value: ioInfo.value == null ? null : ioInfo.value, env: ioInfo.env == null ? null : ioInfo.env, owner: diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index ffbacea01aab..12b082aeb2e5 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -239,6 +239,7 @@ export type SerializedIOInfo = { description: string, start: number, end: number, + byteSize: null | number, value: null | Promise, env: null | string, owner: null | SerializedElement, diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js index f8fa4da37254..f6c11fb10d66 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -221,6 +221,7 @@ function backendToFrontendSerializedAsyncInfo( description: ioInfo.description, start: ioInfo.start, end: ioInfo.end, + byteSize: ioInfo.byteSize, value: ioInfo.value, env: ioInfo.env, owner: diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js index 451b53b4ac59..95c4fe817d52 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -76,6 +76,19 @@ function getShortDescription(name: string, description: string): string { return ''; } +function formatBytes(bytes: number) { + if (bytes < 1_000) { + return bytes + ' bytes'; + } + if (bytes < 1_000_000) { + return (bytes / 1_000).toFixed(1) + ' kB'; + } + if (bytes < 1_000_000_000) { + return (bytes / 1_000_000).toFixed(1) + ' mB'; + } + return (bytes / 1_000_000_000).toFixed(1) + ' gB'; +} + function SuspendedByRow({ bridge, element, @@ -145,7 +158,13 @@ function SuspendedByRow({