From c97ec75324b8d89426b6a44271cea50494a7d5e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 6 Aug 2025 11:05:19 -0400 Subject: [PATCH 1/4] [DevTools] Disconnect and Reconnect children of Suspense boundaries instead of Unmounting and Mounting (#34089) Stacked on #34082. This keeps the DevToolsInstance children alive inside Offscreen trees while they're hidden. However, they're sent as unmounted to the front end store. This allows DevTools state to be preserved between these two states. Such as it keeps the "suspended by" set on the SuspenseNode alive since the children are still mounted. So now you when you resuspend, you can see what in the children was suspended. This is useful when you're simulating a suspense but can also be a bit misleading when something suspended for real since it'll only show the previous suspended set and not what is currently suspending it since that hasn't committed yet. SuspenseNodes inside resuspended trees are now kept alive too. That way they can contribute to the timeline even when resuspended. We can choose whether to keep them visible in the rects while hidden or not. In the future we'll also need to add more special cases around Activity. Because right now if SuspenseNodes are kept alive in the Suspense tab UI while hidden, then they're also alive inside Activity that are hidden which maybe we don't want. Maybe simplest would be that they both disappear from the Suspense tab UI but can be considered for the timeline. Another case is that when Activity goes hidden, Fiber will no longer cause its content to suspend the parent but that's not modeled here. So hidden Activity will show up as "suspended by" in a parent Suspense. When they disconnect, they should really be removed from the "suspended by" set of the parent (and perhaps be shown only on the Activity boundary itself). --- .../src/backend/fiber/renderer.js | 318 +++++++++++++++--- 1 file changed, 270 insertions(+), 48 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 2a41f6e08f096..7e9f46dc0cbbf 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2156,6 +2156,8 @@ export function attach( return id; } + let isInDisconnectedSubtree = false; + function recordMount( fiber: Fiber, parentInstance: DevToolsInstance | null, @@ -2173,14 +2175,29 @@ export function attach( } idToDevToolsInstanceMap.set(fiberInstance.id, fiberInstance); - const id = fiberInstance.id; - if (__DEBUG__) { debug('recordMount()', fiberInstance, parentInstance); } + recordReconnect(fiberInstance, parentInstance); + return fiberInstance; + } + + function recordReconnect( + fiberInstance: FiberInstance, + parentInstance: DevToolsInstance | null, + ): void { + if (isInDisconnectedSubtree) { + // We're disconnected. We'll reconnect a hidden mount after the parent reappears. + return; + } + const id = fiberInstance.id; + const fiber = fiberInstance.data; + const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration'); + const isRoot = fiber.tag === HostRoot; + if (isRoot) { const hasOwnerMetadata = fiber.hasOwnProperty('_debugOwner'); @@ -2292,7 +2309,6 @@ export function attach( if (isProfilingSupported) { recordProfilingDurations(fiberInstance, null); } - return fiberInstance; } function recordVirtualMount( @@ -2304,6 +2320,18 @@ export function attach( idToDevToolsInstanceMap.set(id, instance); + recordVirtualReconnect(instance, parentInstance, secondaryEnv); + } + + function recordVirtualReconnect( + instance: VirtualInstance, + parentInstance: DevToolsInstance | null, + secondaryEnv: null | string, + ): void { + if (isInDisconnectedSubtree) { + // We're disconnected. We'll reconnect a hidden mount after the parent reappears. + return; + } const componentInfo = instance.data; const key = @@ -2355,6 +2383,8 @@ export function attach( const keyString = key === null ? null : String(key); const keyStringID = getStringID(keyString); + const id = instance.id; + pushOperation(TREE_OPERATION_ADD); pushOperation(id); pushOperation(elementType); @@ -2369,14 +2399,27 @@ export function attach( } function recordUnmount(fiberInstance: FiberInstance): void { - const fiber = fiberInstance.data; if (__DEBUG__) { debug('recordUnmount()', fiberInstance, reconcilingParent); } + recordDisconnect(fiberInstance); + + idToDevToolsInstanceMap.delete(fiberInstance.id); + + untrackFiber(fiberInstance, fiberInstance.data); + } + + function recordDisconnect(fiberInstance: FiberInstance): void { + if (isInDisconnectedSubtree) { + // Already disconnected. + return; + } + const fiber = fiberInstance.data; + if (trackedPathMatchInstance === fiberInstance) { // We're in the process of trying to restore previous selection. - // If this fiber matched but is being unmounted, there's no use trying. + // If this fiber matched but is being hidden, there's no use trying. // Reset the state so we don't keep holding onto it. setTrackedPath(null); } @@ -2393,10 +2436,6 @@ export function attach( // and later arrange them in the correct order. pendingRealUnmountedIDs.push(id); } - - idToDevToolsInstanceMap.delete(fiberInstance.id); - - untrackFiber(fiberInstance, fiber); } // Running state of the remaining children from the previous version of this parent that @@ -2416,11 +2455,6 @@ export function attach( // the current parent here as well. let reconcilingParentSuspenseNode: null | SuspenseNode = null; - function isSuspenseInFallback(suspenseNode: SuspenseNode) { - const fiber = suspenseNode.instance.data; - return fiber.tag === SuspenseComponent && fiber.memoizedState !== null; - } - function ioExistsInSuspenseAncestor( suspenseNode: SuspenseNode, ioInfo: ReactIOInfo, @@ -2436,21 +2470,13 @@ export function attach( } function insertSuspendedBy(asyncInfo: ReactAsyncInfo): void { - let parentSuspenseNode = reconcilingParentSuspenseNode; - while ( - parentSuspenseNode !== null && - isSuspenseInFallback(parentSuspenseNode) - ) { - // If we have something that suspends inside the fallback tree of a Suspense boundary, then - // we bubble that up to the nearest parent Suspense boundary that isn't in fallback mode. - parentSuspenseNode = parentSuspenseNode.parent; - } - if (reconcilingParent === null || parentSuspenseNode === null) { + if (reconcilingParent === null || reconcilingParentSuspenseNode === null) { throw new Error( 'It should not be possible to have suspended data outside the root. ' + 'Even suspending at the first position is still a child of the root.', ); } + const parentSuspenseNode = reconcilingParentSuspenseNode; // Use the nearest unfiltered parent so that there's always some component that has // the entry on it even if you filter, or the root if all are filtered. let parentInstance = reconcilingParent; @@ -2694,10 +2720,31 @@ export function attach( } function unmountRemainingChildren() { - let child = remainingReconcilingChildren; - while (child !== null) { - unmountInstanceRecursively(child); - child = remainingReconcilingChildren; + if ( + reconcilingParent !== null && + (reconcilingParent.kind === FIBER_INSTANCE || + reconcilingParent.kind === FILTERED_FIBER_INSTANCE) && + reconcilingParent.data.tag === OffscreenComponent && + reconcilingParent.data.memoizedState !== null && + !isInDisconnectedSubtree + ) { + // This is a hidden offscreen, we need to execute this in the context of a disconnected subtree. + isInDisconnectedSubtree = true; + try { + let child = remainingReconcilingChildren; + while (child !== null) { + unmountInstanceRecursively(child); + child = remainingReconcilingChildren; + } + } finally { + isInDisconnectedSubtree = false; + } + } else { + let child = remainingReconcilingChildren; + while (child !== null) { + unmountInstanceRecursively(child); + child = remainingReconcilingChildren; + } } } @@ -2811,6 +2858,14 @@ export function attach( } function recordVirtualUnmount(instance: VirtualInstance) { + recordVirtualDisconnect(instance); + idToDevToolsInstanceMap.delete(instance.id); + } + + function recordVirtualDisconnect(instance: VirtualInstance) { + if (isInDisconnectedSubtree) { + return; + } if (trackedPathMatchInstance === instance) { // We're in the process of trying to restore previous selection. // If this fiber matched but is being unmounted, there's no use trying. @@ -2820,8 +2875,6 @@ export function attach( const id = instance.id; pendingRealUnmountedIDs.push(id); - - idToDevToolsInstanceMap.delete(instance.id); } function getSecondaryEnvironmentName( @@ -3030,10 +3083,12 @@ export function attach( previouslyReconciledSibling = null; remainingReconcilingChildren = null; } + let shouldPopSuspenseNode = false; if (newSuspenseNode !== null) { reconcilingParentSuspenseNode = newSuspenseNode; previouslyReconciledSiblingSuspenseNode = null; remainingReconcilingChildrenSuspenseNodes = null; + shouldPopSuspenseNode = true; } try { if (traceUpdatesEnabled) { @@ -3069,7 +3124,16 @@ export function attach( } if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) { - // If an Offscreen component is hidden, don't mount its children yet. + // If an Offscreen component is hidden, mount its children as disconnected. + const stashedDisconnected = isInDisconnectedSubtree; + isInDisconnectedSubtree = true; + try { + if (fiber.child !== null) { + mountChildrenRecursively(fiber.child, false); + } + } finally { + isInDisconnectedSubtree = stashedDisconnected; + } } else if (fiber.tag === SuspenseComponent && OffscreenComponent === -1) { // Legacy Suspense without the Offscreen wrapper. For the modern Suspense we just handle the // Offscreen wrapper itself specially. @@ -3102,6 +3166,44 @@ export function attach( ); } } + } else if ( + fiber.tag === SuspenseComponent && + OffscreenComponent !== -1 && + newInstance !== null && + newSuspenseNode !== null + ) { + // 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.', + ); + } + const fallbackFiber = contentFiber.sibling; + + // First update only the Offscreen boundary. I.e. the main content. + mountVirtualChildrenRecursively( + contentFiber, + fallbackFiber, + traceNearestHostComponentUpdate, + 0, // first level + ); + + // 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, + traceNearestHostComponentUpdate, + 0, // first level + ); + } } else { if (fiber.child !== null) { mountChildrenRecursively( @@ -3116,7 +3218,7 @@ export function attach( previouslyReconciledSibling = stashedPrevious; remainingReconcilingChildren = stashedRemaining; } - if (newSuspenseNode !== null) { + if (shouldPopSuspenseNode) { reconcilingParentSuspenseNode = stashedSuspenseParent; previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; @@ -3305,7 +3407,12 @@ export function attach( let child: null | DevToolsInstance = parentInstance.firstChild; while (child !== null) { if (child.kind === FILTERED_FIBER_INSTANCE) { - addUnfilteredChildrenIDs(child, nextChildren); + const fiber = child.data; + if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) { + // The children of this Offscreen are hidden so they don't get added. + } else { + addUnfilteredChildrenIDs(child, nextChildren); + } } else { nextChildren.push(child.id); } @@ -3742,6 +3849,7 @@ export function attach( const stashedSuspenseParent = reconcilingParentSuspenseNode; const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode; const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes; + let shouldPopSuspenseNode = false; let previousSuspendedBy = null; if (fiberInstance !== null) { previousSuspendedBy = fiberInstance.suspendedBy; @@ -3771,6 +3879,7 @@ export function attach( previouslyReconciledSiblingSuspenseNode = null; remainingReconcilingChildrenSuspenseNodes = suspenseNode.firstChild; suspenseNode.firstChild = null; + shouldPopSuspenseNode = true; } } try { @@ -3888,25 +3997,93 @@ export function attach( ); shouldResetChildren = true; } - } else if (prevWasHidden && nextIsHidden) { - // We don't update any children while they're still hidden. + } else if (nextIsHidden) { + if (!prevWasHidden) { + // We're hiding the children. Disconnect them from the front end but keep state. + if (fiberInstance !== null && !isInDisconnectedSubtree) { + disconnectChildrenRecursively(remainingReconcilingChildren); + } + } + // Update children inside the hidden tree if they committed with a new updates. + const stashedDisconnected = isInDisconnectedSubtree; + isInDisconnectedSubtree = true; + try { + updateChildrenRecursively(nextFiber.child, prevFiber.child, false); + } finally { + isInDisconnectedSubtree = stashedDisconnected; + } } else if (prevWasHidden && !nextIsHidden) { // We're revealing the hidden children. We now need to update them to the latest state. - if (nextFiber.child !== null) { - mountChildrenRecursively( - nextFiber.child, - traceNearestHostComponentUpdate, + // We do this while still in the disconnected state and then we reconnect the new ones. + // This avoids reconnecting things that are about to be removed anyway. + const stashedDisconnected = isInDisconnectedSubtree; + isInDisconnectedSubtree = true; + try { + if (nextFiber.child !== null) { + updateChildrenRecursively(nextFiber.child, prevFiber.child, false); + } + // Ensure we unmount any remaining children inside the isInDisconnectedSubtree flag + // since they should not trigger real deletions. + unmountRemainingChildren(); + remainingReconcilingChildren = null; + } finally { + isInDisconnectedSubtree = stashedDisconnected; + } + if (fiberInstance !== null && !isInDisconnectedSubtree) { + reconnectChildrenRecursively(fiberInstance); + // Children may have reordered while they were hidden. + shouldResetChildren = true; + } + } else if ( + nextFiber.tag === SuspenseComponent && + OffscreenComponent !== -1 && + fiberInstance !== null && + fiberInstance.suspenseNode !== null + ) { + // Modern Suspense path + 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; + + // First update only the Offscreen boundary. I.e. the main content. + if ( + updateVirtualChildrenRecursively( + nextContentFiber, + nextFallbackFiber, + prevContentFiber, + traceNearestHostComponentUpdate, + 0, + ) + ) { shouldResetChildren = true; } - } else if (!prevWasHidden && nextIsHidden) { - // We're hiding the children. We really just unmount them for now. - updateChildrenRecursively( - null, - prevFiber.child, - traceNearestHostComponentUpdate, - ); - shouldResetChildren = true; + + // 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 (nextFallbackFiber !== null) { + if ( + updateVirtualChildrenRecursively( + nextFallbackFiber, + null, + prevFallbackFiber, + traceNearestHostComponentUpdate, + 0, + ) + ) { + shouldResetChildren = true; + } + } } else { // Common case: Primary -> Primary. // This is the same code path as for non-Suspense fibers. @@ -4000,7 +4177,7 @@ export function attach( reconcilingParent = stashedParent; previouslyReconciledSibling = stashedPrevious; remainingReconcilingChildren = stashedRemaining; - if (fiberInstance.suspenseNode !== null) { + if (shouldPopSuspenseNode) { reconcilingParentSuspenseNode = stashedSuspenseParent; previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; @@ -4009,6 +4186,51 @@ export function attach( } } + function disconnectChildrenRecursively(firstChild: null | DevToolsInstance) { + for (let child = firstChild; child !== null; child = child.nextSibling) { + if ( + (child.kind === FIBER_INSTANCE || + child.kind === FILTERED_FIBER_INSTANCE) && + child.data.tag === OffscreenComponent && + child.data.memoizedState !== null + ) { + // This instance's children are already disconnected. + } else { + disconnectChildrenRecursively(child.firstChild); + } + if (child.kind === FIBER_INSTANCE) { + recordDisconnect(child); + } else if (child.kind === VIRTUAL_INSTANCE) { + recordVirtualDisconnect(child); + } + } + } + + function reconnectChildrenRecursively(parentInstance: DevToolsInstance) { + for ( + let child = parentInstance.firstChild; + child !== null; + child = child.nextSibling + ) { + if (child.kind === FIBER_INSTANCE) { + recordReconnect(child, parentInstance); + } else if (child.kind === VIRTUAL_INSTANCE) { + const secondaryEnv = null; // TODO: We don't have this data anywhere. We could just stash it somewhere. + recordVirtualReconnect(child, parentInstance, secondaryEnv); + } + if ( + (child.kind === FIBER_INSTANCE || + child.kind === FILTERED_FIBER_INSTANCE) && + child.data.tag === OffscreenComponent && + child.data.memoizedState !== null + ) { + // This instance's children should remain disconnected. + } else { + reconnectChildrenRecursively(child); + } + } + } + function cleanup() { isProfiling = false; } From 0825d019be044b24504be494620022d972eeb160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 6 Aug 2025 11:21:01 -0400 Subject: [PATCH 2/4] [DevTools] Prefer I/O stack and show await stack after only if it's a different owner (#34101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #34094. This shows the I/O stack if available. If it's not available or if it has a different owner (like if it was passed in) then we show the `"awaited at:"` stack below it so you can see where it started and where it was awaited. If it's the same owner this tends to be unnecessary noise. We could maybe be smarter if the stacks are very different then you might want to show both even with the same owner. Screenshot 2025-08-04 at 11 57 28 AM Additionally, this adds an inferred await if there's no owner and no stack for the await. The inferred await of a function/class component is just the owner. No stack. Because the stack trace would be the return value. This will also be the case if you use throw-a-Promise. The inferred await in the child position of a built-in is the JSX location of that await like if you pass a promise to a child. This inference already happens when you pass a Promise from RSC so in this case it already has an await - so this is mainly for client promises. --- .../src/backend/fiber/renderer.js | 93 +++++++++++++++---- .../InspectedElementSharedStyles.css | 7 ++ .../Components/InspectedElementSuspendedBy.js | 73 ++++++++++----- .../views/Components/StackTraceView.js | 31 ++++++- .../src/symbolicateSource.js | 2 +- 5 files changed, 160 insertions(+), 46 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 7e9f46dc0cbbf..189d504ad5125 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -59,6 +59,7 @@ import { import { extractLocationFromComponentStack, extractLocationFromOwnerStack, + parseStackTrace, } from 'react-devtools-shared/src/backend/utils/parseStackTrace'; import { cleanForBridge, @@ -4746,10 +4747,10 @@ export function attach( function getSuspendedByOfSuspenseNode( suspenseNode: SuspenseNode, - ): Array { + ): Array { // Collect all ReactAsyncInfo that was suspending this SuspenseNode but // isn't also in any parent set. - const result: Array = []; + const result: Array = []; if (!suspenseNode.hasUniqueSuspenders) { return result; } @@ -4774,7 +4775,8 @@ export function attach( ioInfo, ); if (asyncInfo !== null) { - result.push(asyncInfo); + const index = result.length; + result.push(serializeAsyncInfo(asyncInfo, index, firstInstance)); } } }); @@ -4791,10 +4793,63 @@ export function attach( parentInstance, ioInfo.owner, ); - const awaitOwnerInstance = findNearestOwnerInstance( - parentInstance, - asyncInfo.owner, - ); + let awaitStack = + asyncInfo.debugStack == null + ? null + : // While we have a ReactStackTrace on ioInfo.stack, that will point to the location on + // the server. We need a location that points to the virtual source on the client which + // we can then use to source map to the original location. + parseStackTrace(asyncInfo.debugStack, 1); + let awaitOwnerInstance: null | FiberInstance | VirtualInstance; + if ( + asyncInfo.owner == null && + (awaitStack === null || awaitStack.length === 0) + ) { + // We had no owner nor stack for the await. This can happen if you render it as a child + // or throw a Promise. Replace it with the parent as the await. + awaitStack = null; + awaitOwnerInstance = + parentInstance.kind === FILTERED_FIBER_INSTANCE ? null : parentInstance; + if ( + parentInstance.kind === FIBER_INSTANCE || + parentInstance.kind === FILTERED_FIBER_INSTANCE + ) { + const fiber = parentInstance.data; + switch (fiber.tag) { + case ClassComponent: + case FunctionComponent: + case IncompleteClassComponent: + case IncompleteFunctionComponent: + case IndeterminateComponent: + case MemoComponent: + case SimpleMemoComponent: + // If we awaited in the child position of a component, then the best stack would be the + // return callsite but we don't have that available so instead we skip. The callsite of + // the JSX would be misleading in this case. The same thing happens with throw-a-Promise. + break; + default: + // If we awaited by passing a Promise to a built-in element, then the JSX callsite is a + // good stack trace to use for the await. + if ( + fiber._debugOwner != null && + fiber._debugStack != null && + typeof fiber._debugStack !== 'string' + ) { + awaitStack = parseStackTrace(fiber._debugStack, 1); + awaitOwnerInstance = findNearestOwnerInstance( + parentInstance, + fiber._debugOwner, + ); + } + } + } + } else { + awaitOwnerInstance = findNearestOwnerInstance( + parentInstance, + asyncInfo.owner, + ); + } + const value: any = ioInfo.value; let resolvedValue = undefined; if ( @@ -4823,14 +4878,20 @@ export function attach( ioOwnerInstance === null ? null : instanceToSerializedElement(ioOwnerInstance), - stack: ioInfo.stack == null ? null : ioInfo.stack, + stack: + ioInfo.debugStack == null + ? null + : // While we have a ReactStackTrace on ioInfo.stack, that will point to the location on + // the server. We need a location that points to the virtual source on the client which + // we can then use to source map to the original location. + parseStackTrace(ioInfo.debugStack, 1), }, env: asyncInfo.env == null ? null : asyncInfo.env, owner: awaitOwnerInstance === null ? null : instanceToSerializedElement(awaitOwnerInstance), - stack: asyncInfo.stack == null ? null : asyncInfo.stack, + stack: awaitStack, }; } @@ -5136,8 +5197,11 @@ export function attach( // In this case, this becomes associated with the Client/Host Component where as normally // you'd expect these to be associated with the Server Component that awaited the data. // TODO: Prepend other suspense sources like css, images and use(). - fiberInstance.suspendedBy; - + fiberInstance.suspendedBy === null + ? [] + : fiberInstance.suspendedBy.map((info, index) => + serializeAsyncInfo(info, index, fiberInstance), + ); return { id: fiberInstance.id, @@ -5194,12 +5258,7 @@ export function attach( ? [] : Array.from(componentLogsEntry.warnings.entries()), - suspendedBy: - suspendedBy === null - ? [] - : suspendedBy.map((info, index) => - serializeAsyncInfo(info, index, fiberInstance), - ), + suspendedBy: suspendedBy, // List of owners owners, diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css index ded305bbc66ca..0fb5107361c1c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css @@ -123,3 +123,10 @@ .TimeBarSpanErrored { background-color: var(--color-timespan-background-errored); } + +.SmallHeader { + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); + padding-left: 1.25rem; + margin-top: 0.25rem; +} 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 c7d0b39df3b83..79fdbd1a36111 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -80,21 +80,13 @@ function SuspendedByRow({ maxTime, }: RowProps) { const [isOpen, setIsOpen] = useState(false); - const name = asyncInfo.awaited.name; - const description = asyncInfo.awaited.description; + const ioInfo = asyncInfo.awaited; + const name = ioInfo.name; + const description = ioInfo.description; const longName = description === '' ? name : name + ' (' + description + ')'; const shortDescription = getShortDescription(name, description); - let stack; - let owner; - if (asyncInfo.stack === null || asyncInfo.stack.length === 0) { - stack = asyncInfo.awaited.stack; - owner = asyncInfo.awaited.owner; - } else { - stack = asyncInfo.stack; - owner = asyncInfo.owner; - } - const start = asyncInfo.awaited.start; - const end = asyncInfo.awaited.end; + const start = ioInfo.start; + const end = ioInfo.end; const timeScale = 100 / (maxTime - minTime); let left = (start - minTime) * timeScale; let width = (end - start) * timeScale; @@ -106,7 +98,19 @@ function SuspendedByRow({ } } - const value: any = asyncInfo.awaited.value; + const ioOwner = ioInfo.owner; + const asyncOwner = asyncInfo.owner; + const showIOStack = ioInfo.stack !== null && ioInfo.stack.length !== 0; + // Only show the awaited stack if the I/O started in a different owner + // than where it was awaited. If it's started by the same component it's + // probably easy enough to infer and less noise in the common case. + const showAwaitStack = + !showIOStack || + (ioOwner === null + ? asyncOwner !== null + : asyncOwner === null || ioOwner.id !== asyncOwner.id); + + const value: any = ioInfo.value; const metaName = value !== null && typeof value === 'object' ? value[meta.name] : null; const isFulfilled = metaName === 'fulfilled Thenable'; @@ -146,20 +150,39 @@ function SuspendedByRow({ {isOpen && (
- {stack !== null && stack.length > 0 && ( - - )} - {owner !== null && owner.id !== inspectedElement.id ? ( + {showIOStack && } + {(showIOStack || !showAwaitStack) && + ioOwner !== null && + ioOwner.id !== inspectedElement.id ? ( ) : null} + {showAwaitStack ? ( + <> +
awaited at:
+ {asyncInfo.stack !== null && asyncInfo.stack.length > 0 && ( + + )} + {asyncOwner !== null && asyncOwner.id !== inspectedElement.id ? ( + + ) : null} + + ) : null}
- {functionName} + {functionName || virtualFunctionName} {' @ '} , > = new Map(); -export async function symbolicateSourceWithCache( +export function symbolicateSourceWithCache( fetchFileWithCaching: FetchFileWithCaching, sourceURL: string, line: number, // 1-based From 66f09bd0540d0a094b80c94d013df885903c97da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 6 Aug 2025 11:23:00 -0400 Subject: [PATCH 3/4] [DevTools] Sort "Suspended By" view by the start time (#34105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit or end time if they have the same start time. Screenshot 2025-08-04 at 4 00 23 PM They would typically appear in this order naturally but not always. Especially in Suspense boundaries where the order can also be depended on when the components are discovered. --- .../Components/InspectedElementSuspendedBy.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 79fdbd1a36111..a1b76e49b6add 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -228,6 +228,15 @@ type Props = { store: Store, }; +function compareTime(a: SerializedAsyncInfo, b: SerializedAsyncInfo): number { + const ioA = a.awaited; + const ioB = b.awaited; + if (ioA.start === ioB.start) { + return ioA.end - ioB.end; + } + return ioA.start - ioB.start; +} + export default function InspectedElementSuspendedBy({ bridge, element, @@ -264,6 +273,9 @@ export default function InspectedElementSuspendedBy({ minTime = maxTime - 25; } + const sortedSuspendedBy = suspendedBy.slice(0); + sortedSuspendedBy.sort(compareTime); + return (
@@ -272,7 +284,7 @@ export default function InspectedElementSuspendedBy({
- {suspendedBy.map((asyncInfo, index) => ( + {sortedSuspendedBy.map((asyncInfo, index) => ( Date: Wed, 6 Aug 2025 13:45:06 -0400 Subject: [PATCH 4/4] [DevTools] Source Map Stack Traces such in await locations (#34094) Stacked on #34093. Instead of using the original `ReactStackTrace` that has the call sites on the server, this parses the `Error` object which has the virtual call sites on the client. We'll need this technique for things stack traces suspending on the client anyway like `use()`. We can then use these callsites to source map in the front end. We currently don't source map function names but might be useful for this use case as well as getting original component names from prod. One thing this doesn't do yet is that it doesn't ignore list the stack traces on the client using the source map's ignore list setting. It's not super important since we expect to have already ignore listed on the server but this will become important for client stack traces like `use()`. --- packages/react-devtools-shared/src/__tests__/utils-test.js | 2 +- packages/react-devtools-shared/src/symbolicateSource.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/utils-test.js b/packages/react-devtools-shared/src/__tests__/utils-test.js index 83b31903e06a8..dacbe0f46b9a6 100644 --- a/packages/react-devtools-shared/src/__tests__/utils-test.js +++ b/packages/react-devtools-shared/src/__tests__/utils-test.js @@ -401,7 +401,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.f = f; function f() { } //# sourceMappingURL=`; - const result = ['', 'http://test/a.mts', 1, 16]; + const result = ['', 'http://test/a.mts', 1, 17]; const fs = { 'http://test/a.mts': `export function f() {}`, 'http://test/a.mjs.map': `{"version":3,"file":"a.mjs","sourceRoot":"","sources":["a.mts"],"names":[],"mappings":";;AAAA,cAAsB;AAAtB,SAAgB,CAAC,KAAI,CAAC"}`, diff --git a/packages/react-devtools-shared/src/symbolicateSource.js b/packages/react-devtools-shared/src/symbolicateSource.js index e5f469a21a58e..49a30984c2800 100644 --- a/packages/react-devtools-shared/src/symbolicateSource.js +++ b/packages/react-devtools-shared/src/symbolicateSource.js @@ -82,12 +82,14 @@ export async function symbolicateSource( const { sourceURL: possiblyURL, line, - column, + column: columnZeroBased, } = consumer.originalPositionFor({ lineNumber, // 1-based columnNumber, // 1-based }); + const column = columnZeroBased + 1; + if (possiblyURL === null) { return null; }