Repository: facebook/react
Pull request: [Fiber] Avoid duplicate debug info for array children


## Summary

For children arrays we create a Fragment Fiber with the debug info. However, we pushed that debug info again when reconciling the children arrays which lead to duplicate Server Component parent stacks in React DevTools.

Now we just push once when we create the Fragment Fiber.

## How did you test this change?

- [x] added test
- [x] Flight fixture with 
    ```
    function App() {
      return (
        <>
          <h1>App</h1>
          <div>Child</div>
        </>
      );
    }
    ```

Changed files:
filename: packages/react-client/src/__tests__/ReactFlight-test.js
patch: @@ -2820,7 +2820,8 @@ describe('ReactFlight', () => {
             ]
           : undefined,
       );
-      expect(getDebugInfo(thirdPartyChildren[2])).toEqual(
+      const fragment = thirdPartyChildren[2];
+      expect(getDebugInfo(fragment)).toEqual(
         __DEV__
           ? [
               {time: gate(flags => flags.enableAsyncDebugInfo) ? 54 : 22},
@@ -2835,6 +2836,9 @@ describe('ReactFlight', () => {
             ]
           : undefined,
       );
+      expect(getDebugInfo(fragment.props.children[0])).toEqual(
+        __DEV__ ? null : undefined,
+      );
       ReactNoop.render(result);
     });
 
@@ -2847,6 +2851,61 @@ describe('ReactFlight', () => {
     );
   });
 
+  it('preserves debug info for keyed Fragment', async () => {
+    function App() {
+      return ReactServer.createElement(
+        ReactServer.Fragment,
+        {key: 'app'},
+        ReactServer.createElement('h1', null, 'App'),
+        ReactServer.createElement('div', null, 'Child'),
+      );
+    }
+
+    const transport = ReactNoopFlightServer.render(
+      ReactServer.createElement(
+        ReactServer.Fragment,
+        null,
+        ReactServer.createElement('link', {key: 'styles'}),
+        ReactServer.createElement(App, null),
+      ),
+    );
+
+    await act(async () => {
+      const root = await ReactNoopFlightClient.read(transport);
+
+      const fragment = root[1];
+      expect(getDebugInfo(fragment)).toEqual(
+        __DEV__
+          ? [
+              {time: 12},
+              {
+                name: 'App',
+                env: 'Server',
+                key: null,
+                stack: '    in Object.<anonymous> (at **)',
+                props: {},
+              },
+              {time: 13},
+            ]
+          : undefined,
+      );
+      // Making sure debug info doesn't get added multiple times on Fragment children
+      expect(getDebugInfo(fragment[0])).toEqual(__DEV__ ? null : undefined);
+      const fragmentChild = fragment[0].props.children[0];
+      expect(getDebugInfo(fragmentChild)).toEqual(__DEV__ ? null : undefined);
+
+      ReactNoop.render(root);
+    });
+
+    expect(ReactNoop).toMatchRenderedOutput(
+      <>
+        <link />
+        <h1>App</h1>
+        <div>Child</div>
+      </>,
+    );
+  });
+
   // @gate enableAsyncIterableChildren && enableComponentPerformanceTrack
   it('preserves debug info for server-to-server pass through of async iterables', async () => {
     let resolve;

filename: packages/react-devtools-shared/src/__tests__/store-test.js
patch: @@ -2827,6 +2827,40 @@ describe('Store', () => {
       `);
   });
 
+  // @reactVersion >= 19.0
+  it('does not duplicate Server Component parents in keyed Fragments', async () => {
+    // TODO: Use an actual Flight renderer.
+    // See ReactFlight-test for the produced JSX from Flight.
+    function ClientComponent() {
+      return null;
+    }
+    // This used to be a keyed Fragment on the Server.
+    const children = [<ClientComponent key="app" />];
+    children._debugInfo = [
+      {time: 12},
+      {
+        name: 'App',
+        env: 'Server',
+        key: null,
+        stack: '    in Object.<anonymous> (at **)',
+        props: {},
+      },
+      {time: 13},
+    ];
+
+    const container = document.createElement('div');
+    const root = ReactDOMClient.createRoot(container);
+    await actAsync(() => {
+      root.render([children]);
+    });
+
+    expect(store).toMatchInlineSnapshot(`
+      [root]
+        ▾ <App> [Server]
+            <ClientComponent key="app">
+    `);
+  });
+
   // @reactVersion >= 17.0
   it('can reconcile Suspense in fallback positions', async () => {
     let resolveFallback;

filename: packages/react-reconciler/src/ReactChildFiber.js
patch: @@ -789,6 +789,7 @@ function createChildReconciler(
           // We treat the parent as the owner for stack purposes.
           created._debugOwner = returnFiber;
           created._debugTask = returnFiber._debugTask;
+          // Make sure to not push again when handling the Fragment child.
           const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
           created._debugInfo = currentDebugInfo;
           currentDebugInfo = prevDebugInfo;
@@ -1915,41 +1916,41 @@ function createChildReconciler(
       }
 
       if (isArray(newChild)) {
-        const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
+        // We created a Fragment for this child with the debug info.
+        // No need to push again.
         const firstChild = reconcileChildrenArray(
           returnFiber,
           currentFirstChild,
           newChild,
           lanes,
         );
-        currentDebugInfo = prevDebugInfo;
         return firstChild;
       }
 
       if (getIteratorFn(newChild)) {
-        const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
+        // We created a Fragment for this child with the debug info.
+        // No need to push again.
         const firstChild = reconcileChildrenIteratable(
           returnFiber,
           currentFirstChild,
           newChild,
           lanes,
         );
-        currentDebugInfo = prevDebugInfo;
         return firstChild;
       }
 
       if (
         enableAsyncIterableChildren &&
         typeof newChild[ASYNC_ITERATOR] === 'function'
       ) {
-        const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
+        // We created a Fragment for this child with the debug info.
+        // No need to push again.
         const firstChild = reconcileChildrenAsyncIteratable(
           returnFiber,
           currentFirstChild,
           newChild,
           lanes,
         );
-        currentDebugInfo = prevDebugInfo;
         return firstChild;
       }