Repository: facebook/react
Pull request: [DevTools] Attach async info in filtered fallback to parent of Suspense

## Summary

When a DevTools instance is filtered, we're using an unfiltered parent that's still in the reconciling Suspense boundary to attach async info to. If we attach async info to a DEvTools instance of a Suspense boundary, we treat that async info as if it's triggering the fallback of that Suspense boundary.

However, if we're inside the fallback, async info is not triggering the fallback of the nearest Suspense boundary but its parent. 

Now we're using the parent of the Suspense boundary to attach async info to if that's not actually the Suspense boundary we're reconciling against (which should only happen if we're reconciling the fallback). We should always have a parent of a DevTools instance associated with a Suspense boundary if we're reconciling the fallback. The only DevTools instance associated with a Suspense boundary without a parent is the Host root. But a host root doesn't have a fallback so we can ignore that case.

## How did you test this change?

- [x] Added test passes CI (did fail without this change)
- [x] https://tangled.org/danabra.mov/sidetrail no longer triggers an instrumentation error
    Before:

    https://github.com/user-attachments/assets/2587eb82-3c18-4195-9910-43da54fd4c0a




Changed files:
filename: packages/react-devtools-shared/src/__tests__/store-test.js
patch: @@ -3143,6 +3143,59 @@ describe('Store', () => {
     expect(store).toMatchInlineSnapshot(``);
   });
 
+  // @reactVersion >= 17.0
+  it('should track suspended by in filtered fallback', async () => {
+    function IgnoreMe({promise}) {
+      return readValue(promise);
+    }
+
+    function Component({promise}) {
+      return readValue(promise);
+    }
+
+    await actAsync(
+      async () =>
+        (store.componentFilters = [createDisplayNameFilter('^IgnoreMe', true)]),
+    );
+
+    let resolveFallback;
+    const fallbackPromise = new Promise(resolve => {
+      resolveFallback = resolve;
+    });
+    let resolveContent;
+    const contentPromise = new Promise(resolve => {
+      resolveContent = resolve;
+    });
+
+    await actAsync(() =>
+      render(
+        <React.Suspense
+          name="main"
+          fallback={<IgnoreMe promise={fallbackPromise} />}>
+          <Component promise={contentPromise} />
+        </React.Suspense>,
+      ),
+    );
+    expect(store).toMatchInlineSnapshot(``);
+
+    await actAsync(() => resolveFallback('loading'));
+    expect(store).toMatchInlineSnapshot(`
+      [root]
+          <Suspense name="main">
+      [suspense-root]  rects={null}
+        <Suspense name="main" rects={null}>
+    `);
+
+    await actAsync(() => resolveContent('content'));
+    expect(store).toMatchInlineSnapshot(`
+      [root]
+        ▾ <Suspense name="main">
+            <Component>
+      [suspense-root]  rects={null}
+        <Suspense name="main" rects={null}>
+    `);
+  });
+
   // @reactVersion >= 19
   it('should keep suspended boundaries in the Suspense tree but not hidden Activity', async () => {
     const Activity = React.Activity || React.unstable_Activity;

filename: packages/react-devtools-shared/src/backend/fiber/renderer.js
patch: @@ -2992,6 +2992,30 @@ export function attach(
     ) {
       parentInstance = parentInstance.parent;
     }
+    if (parentInstance.kind === FIBER_INSTANCE) {
+      const fiber = parentInstance.data;
+
+      if (
+        fiber.tag === SuspenseComponent &&
+        parentInstance !== parentSuspenseNode.instance
+      ) {
+        // We're about to attach async info to a Suspense boundary we're not
+        // actually considering the parent Suspense boundary for this async info.
+        // We must have not found a suitable Fiber inside the fallback (e.g. due to filtering).
+        // Use the parent of this instance instead since we treat async info
+        // attached to a Suspense boundary as that async info triggering the
+        // fallback of that boundary.
+        const parent = parentInstance.parent;
+        if (parent === null) {
+          // This shouldn't happen. Any <Suspense> would have at least have the
+          // host root as the parent which can't have a fallback.
+          throw new Error(
+            'Did not find a suitable instance for this async info. This is a bug in React.',
+          );
+        }
+        parentInstance = parent;
+      }
+    }
 
     const suspenseNodeSuspendedBy = parentSuspenseNode.suspendedBy;
     const ioInfo = asyncInfo.awaited;
@@ -5255,9 +5279,9 @@ export function attach(
       // It might even result in a bad user experience for e.g. node selection in the Elements panel.
       // The easiest fix is to strip out the intermediate Fragment fibers,
       // so the Elements panel and Profiler don't need to special case them.
-      // Suspense components only have a non-null memoizedState if they're timed-out.
       const isLegacySuspense =
         nextFiber.tag === SuspenseComponent && OffscreenComponent === -1;
+      // Suspense components only have a non-null memoizedState if they're timed-out.
       const prevDidTimeout =
         isLegacySuspense && prevFiber.memoizedState !== null;
       const nextDidTimeOut =