@samitouri / QOS-React / commits / 886b3d36d7

[DevTools] Show suspended by subtree from Activity to next Suspense boundary (#34438)

Stacked on #34435. This adds a method to get all suspended by filtered by a specific Instance. The purpose of this is to power the feature when you filter by Activity. This would show you the "root" within that Activity boundary. This works by selecting the nearest Suspense boundary parent and then filtering its data based on if all the instances for a given I/O info is within the Activity instance. If something suspended within the Suspense boundary but outside the Activity it's not included even if it's also suspending inside the Activity since we assume it would've already been loaded then. Right now I wire this up to be a special case when you select an Activity boundary same as when you select a Suspense boundary in the Components tab but we could also only use this when you select the root in the Suspense tab for example.

Sebastian Markbåge committed Sep 10, 2025 at 09:44 UTC 886b3d36d7994259df2c3ab1983f425a4b718615
1 file changed +53 -9
packages/react-devtools-shared/src/backend/fiber/renderer.js
+53 -9
@@ -5721,6 +5721,7 @@ export function attach(
5721
5722 function getSuspendedByOfSuspenseNode(
5723 suspenseNode: SuspenseNode,
5724 + filterByChildInstance: null | DevToolsInstance, // only include suspended by instances in this subtree
5725 ): Array<SerializedAsyncInfo> {
5726 // Collect all ReactAsyncInfo that was suspending this SuspenseNode but
5727 // isn't also in any parent set.
@@ -5756,8 +5757,30 @@ export function attach(
5757 if (set.size === 0) {
5758 return;
5759 }
5759 - const firstInstance: DevToolsInstance = (set.values().next().value: any);
5760 - if (firstInstance.suspendedBy !== null) {
5760 + let firstInstance: null | DevToolsInstance = null;
5761 + if (filterByChildInstance === null) {
5762 + firstInstance = (set.values().next().value: any);
5763 + } else {
5764 + // eslint-disable-next-line no-for-of-loops/no-for-of-loops
5765 + for (const childInstance of set.values()) {
5766 + if (firstInstance === null) {
5767 + firstInstance = childInstance;
5768 + }
5769 + if (
5770 + childInstance !== filterByChildInstance &&
5771 + !isChildOf(
5772 + filterByChildInstance,
5773 + childInstance,
5774 + suspenseNode.instance,
5775 + )
5776 + ) {
5777 + // Something suspended on this outside the filtered instance. That means that
5778 + // it is not unique to just this filtered instance so we skip including it.
5779 + return;
5780 + }
5781 + }
5782 + }
5783 + if (firstInstance !== null && firstInstance.suspendedBy !== null) {
5784 const asyncInfo = getAwaitInSuspendedByFromIO(
5785 firstInstance.suspendedBy,
5786 ioInfo,
@@ -5870,6 +5893,23 @@ export function attach(
5893 return result;
5894 }
5895
5896 + function getSuspendedByOfInstanceSubtree(
5897 + devtoolsInstance: DevToolsInstance,
5898 + ): Array<SerializedAsyncInfo> {
5899 + // Get everything suspending below this instance down to the next Suspense node.
5900 + // First find the parent Suspense boundary which will have accumulated everything
5901 + let suspenseParentInstance = devtoolsInstance;
5902 + while (suspenseParentInstance.suspenseNode === null) {
5903 + if (suspenseParentInstance.parent === null) {
5904 + // We don't expect to hit this. We should always find the root.
5905 + return [];
5906 + }
5907 + suspenseParentInstance = suspenseParentInstance.parent;
5908 + }
5909 + const suspenseNode: SuspenseNode = suspenseParentInstance.suspenseNode;
5910 + return getSuspendedByOfSuspenseNode(suspenseNode, devtoolsInstance);
5911 + }
5912 +
5913 const FALLBACK_THROTTLE_MS: number = 300;
5914
5915 function getSuspendedByRange(
@@ -6383,13 +6423,17 @@ export function attach(
6423 fiberInstance.suspenseNode !== null
6424 ? // If this is a Suspense boundary, then we include everything in the subtree that might suspend
6425 // this boundary down to the next Suspense boundary.
6386 - getSuspendedByOfSuspenseNode(fiberInstance.suspenseNode)
6387 - : // This set is an edge case where if you pass a promise to a Client Component into a children
6388 - // position without a Server Component as the direct parent. E.g. <div>{promise}</div>
6389 - // In this case, this becomes associated with the Client/Host Component where as normally
6390 - // you'd expect these to be associated with the Server Component that awaited the data.
6391 - // TODO: Prepend other suspense sources like css, images and use().
6392 - getSuspendedByOfInstance(fiberInstance, hooks);
6426 + getSuspendedByOfSuspenseNode(fiberInstance.suspenseNode, null)
6427 + : tag === ActivityComponent
6428 + ? // For Activity components we show everything that suspends the subtree down to the next boundary
6429 + // so that you can see what suspends a Transition at that level.
6430 + getSuspendedByOfInstanceSubtree(fiberInstance)
6431 + : // This set is an edge case where if you pass a promise to a Client Component into a children
6432 + // position without a Server Component as the direct parent. E.g. <div>{promise}</div>
6433 + // In this case, this becomes associated with the Client/Host Component where as normally
6434 + // you'd expect these to be associated with the Server Component that awaited the data.
6435 + // TODO: Prepend other suspense sources like css, images and use().
6436 + getSuspendedByOfInstance(fiberInstance, hooks);
6437 const suspendedByRange = getSuspendedByRange(
6438 getNearestSuspenseNode(fiberInstance),
6439 );