Repository: facebook/react
Pull request: [DevTools] Only block child Suspense boundaries if the parent has all shared suspenders removed


## Summary

Stacked on https://github.com/facebook/react/pull/35736

A Suspense boundary is considered unblocked by I/O removal if no more children (until the next Suspense boundary) are blocked on the same I/O. However, we used to unblock if at least one children with this I/O was removed. Now we check if no more children are blocked on the same I/O.

This was probably an oversight. The unblocking should happen in the same codeblock that checks if all dependencies are removed.

Found with Claude + Opus 4.6

## How did you test this change?

- [x] added test (see first commit for current behavior vs 2nd commit for changed behavior)

Changed files:
filename: packages/react-devtools-shared/src/__tests__/store-test.js
patch: @@ -3617,6 +3617,103 @@ describe('Store', () => {
     `);
   });
 
+  // @reactVersion >= 17.0
+  it('continues to consider Suspense boundary as blocking if some child still is suspended on removed io', async () => {
+    function Component({promise}) {
+      readValue(promise);
+      return null;
+    }
+
+    let resolve;
+    const promise = new Promise(_resolve => {
+      resolve = _resolve;
+    });
+
+    await actAsync(() => {
+      render(
+        <React.Suspense fallback={null} name="outer">
+          <Component key="one" promise={promise} />
+          <Component key="two" promise={promise} />
+          <React.Suspense fallback={null} name="inner">
+            <Component key="three" promise={promise} />
+          </React.Suspense>
+        </React.Suspense>,
+      );
+    });
+
+    expect(store).toMatchInlineSnapshot(`
+      [root]
+          <Suspense name="outer">
+      [suspense-root]  rects={null}
+        <Suspense name="outer" uniqueSuspenders={true} rects={null}>
+    `);
+
+    await actAsync(() => {
+      resolve('Hello, World!');
+    });
+
+    expect(store).toMatchInlineSnapshot(`
+      [root]
+        ▾ <Suspense name="outer">
+            <Component key="one">
+            <Component key="two">
+          ▾ <Suspense name="inner">
+              <Component key="three">
+      [suspense-root]  rects={null}
+        <Suspense name="outer" uniqueSuspenders={true} rects={null}>
+          <Suspense name="inner" uniqueSuspenders={false} rects={null}>
+    `);
+
+    // We remove one suspender.
+    // The inner one shouldn't have unique suspenders because it's still blocked
+    // by the outer one.
+    await actAsync(() => {
+      render(
+        <React.Suspense fallback={null} name="outer">
+          <Component key="one" promise={promise} />
+          <React.Suspense fallback={null} name="inner">
+            <Component key="three" promise={promise} />
+          </React.Suspense>
+        </React.Suspense>,
+      );
+    });
+
+    expect(store).toMatchInlineSnapshot(`
+      [root]
+        ▾ <Suspense name="outer">
+            <Component key="one">
+          ▾ <Suspense name="inner">
+              <Component key="three">
+      [suspense-root]  rects={null}
+        <Suspense name="outer" uniqueSuspenders={true} rects={null}>
+          <Suspense name="inner" uniqueSuspenders={false} rects={null}>
+    `);
+
+    // Now we remove all unique suspenders of the outer Suspense boundary.
+    // The inner one is now independently revealed from the parent and should
+    // be marked as having unique suspenders.
+    // TODO: The outer boundary no longer has unique suspenders.
+    await actAsync(() => {
+      render(
+        <React.Suspense fallback={null} name="outer">
+          <React.Suspense fallback={null} name="inner">
+            <Component key="three" promise={promise} />
+          </React.Suspense>
+        </React.Suspense>,
+      );
+    });
+
+    expect(store).toMatchInlineSnapshot(`
+      [root]
+        ▾ <Suspense name="outer">
+          ▾ <Suspense name="inner">
+              <Component key="three">
+      [suspense-root]  rects={null}
+        <Suspense name="outer" uniqueSuspenders={true} rects={null}>
+          <Suspense name="inner" uniqueSuspenders={true} rects={null}>
+    `);
+  });
+
   // @reactVersion >= 19
   it('cleans up host hoistables', async () => {
     function Left() {

filename: packages/react-devtools-shared/src/backend/fiber/renderer.js
patch: @@ -3197,15 +3197,16 @@ export function attach(
                 environmentCounts.set(env, count - 1);
               }
             }
-          }
-          if (
-            suspenseNode.hasUniqueSuspenders &&
-            !ioExistsInSuspenseAncestor(suspenseNode, ioInfo)
-          ) {
-            // This entry wasn't in any ancestor and is no longer in this suspense boundary.
-            // This means that a child might now be the unique suspender for this IO.
-            // Search the child boundaries to see if we can reveal any of them.
-            unblockSuspendedBy(suspenseNode, ioInfo);
+
+            if (
+              suspenseNode.hasUniqueSuspenders &&
+              !ioExistsInSuspenseAncestor(suspenseNode, ioInfo)
+            ) {
+              // This entry wasn't in any ancestor and is no longer in this suspense boundary.
+              // This means that a child might now be the unique suspender for this IO.
+              // Search the child boundaries to see if we can reveal any of them.
+              unblockSuspendedBy(suspenseNode, ioInfo);
+            }
           }
         }
       }