Repository: facebook/react
Pull request: [DevTools] Handle LegacyHidden Fibers like Offscreen Fibers.

This is mostly for React 17 which uses `LegacyHiddenComponent` instead of `Offscreen` for the content Fiber. Though it's nice that support for `enableLegacyHidden` comes with it.


## How did you test this change?

```
$ node ./scripts/ci/download_devtools_regression_build.js 17.0 --replaceBuild
$ node ./scripts/jest/jest-cli.js --build --project devtools --release-channel=experimental --reactVersion 17.0
```

Changed files:
filename: packages/react-devtools-shared/src/__tests__/profilingCache-test.js
patch: @@ -725,14 +725,14 @@ describe('ProfilingCache', () => {
     const commitData = store.profilerStore.getDataForRoot(rootID).commitData;
     expect(commitData).toHaveLength(2);
 
-    const isLegacySuspense = React.version.startsWith('17');
-    if (isLegacySuspense) {
+    if (React.version.startsWith('17')) {
+      // React 17 will mount all children until it suspends in a LegacyHidden
+      // The ID gap is from the Fiber for <Async> that's in the disconnected tree.
       expect(commitData[0].fiberActualDurations).toMatchInlineSnapshot(`
         Map {
           1 => 15,
           2 => 15,
           3 => 5,
-          4 => 3,
           5 => 2,
         }
       `);
@@ -741,7 +741,6 @@ describe('ProfilingCache', () => {
           1 => 0,
           2 => 10,
           3 => 3,
-          4 => 3,
           5 => 2,
         }
       `);

filename: packages/react-devtools-shared/src/__tests__/profilingCommitTreeBuilder-test.js
patch: @@ -19,8 +19,6 @@ describe('commit tree', () => {
   let Scheduler;
   let store: Store;
   let utils;
-  const isLegacySuspense =
-    React.version.startsWith('16') || React.version.startsWith('17');
 
   beforeEach(() => {
     utils = require('./utils');
@@ -186,24 +184,13 @@ describe('commit tree', () => {
       utils.act(() => store.profilerStore.startProfiling());
       utils.act(() => legacyRender(<App renderChildren={true} />));
       await Promise.resolve();
-      if (isLegacySuspense) {
-        expect(store).toMatchInlineSnapshot(`
-          [root]
-            ▾ <App>
-              ▾ <Suspense>
-                  <Lazy>
-          [suspense-root]  rects={null}
-            <Suspense name="App" rects={null}>
-        `);
-      } else {
-        expect(store).toMatchInlineSnapshot(`
-          [root]
-            ▾ <App>
-                <Suspense>
-          [suspense-root]  rects={null}
-            <Suspense name="App" rects={null}>
-        `);
-      }
+      expect(store).toMatchInlineSnapshot(`
+        [root]
+          ▾ <App>
+              <Suspense>
+        [suspense-root]  rects={null}
+          <Suspense name="App" rects={null}>
+      `);
       utils.act(() => legacyRender(<App renderChildren={true} />));
       expect(store).toMatchInlineSnapshot(`
         [root]
@@ -231,13 +218,7 @@ describe('commit tree', () => {
         );
       }
 
-      expect(commitTrees[0].nodes.size).toBe(
-        isLegacySuspense
-          ? // <Root> + <App> + <Suspense> + <Lazy>
-            4
-          : // <Root> + <App> + <Suspense>
-            3,
-      );
+      expect(commitTrees[0].nodes.size).toBe(3);
       expect(commitTrees[1].nodes.size).toBe(4); // <Root> + <App> + <Suspense> + <LazyInnerComponent>
       expect(commitTrees[2].nodes.size).toBe(2); // <Root> + <App>
     });
@@ -291,24 +272,13 @@ describe('commit tree', () => {
     it('should support Lazy components that are unmounted before resolving (legacy render)', async () => {
       utils.act(() => store.profilerStore.startProfiling());
       utils.act(() => legacyRender(<App renderChildren={true} />));
-      if (isLegacySuspense) {
-        expect(store).toMatchInlineSnapshot(`
-          [root]
-            ▾ <App>
-              ▾ <Suspense>
-                  <Lazy>
-          [suspense-root]  rects={null}
-            <Suspense name="App" rects={null}>
-        `);
-      } else {
-        expect(store).toMatchInlineSnapshot(`
-          [root]
-            ▾ <App>
-                <Suspense>
-          [suspense-root]  rects={null}
-            <Suspense name="App" rects={null}>
-        `);
-      }
+      expect(store).toMatchInlineSnapshot(`
+        [root]
+          ▾ <App>
+              <Suspense>
+        [suspense-root]  rects={null}
+          <Suspense name="App" rects={null}>
+      `);
       utils.act(() => legacyRender(<App renderChildren={false} />));
       expect(store).toMatchInlineSnapshot(`
         [root]
@@ -327,13 +297,7 @@ describe('commit tree', () => {
         );
       }
 
-      expect(commitTrees[0].nodes.size).toBe(
-        isLegacySuspense
-          ? // <Root> + <App> + <Suspense> + <Lazy>
-            4
-          : // <Root> + <App> + <Suspense>
-            3,
-      );
+      expect(commitTrees[0].nodes.size).toBe(3);
       expect(commitTrees[1].nodes.size).toBe(2); // <Root> + <App>
     });
 

filename: packages/react-devtools-shared/src/__tests__/store-test.js
patch: @@ -2828,7 +2828,7 @@ describe('Store', () => {
       `);
   });
 
-  // @reactVersion >= 18.0
+  // @reactVersion >= 17.0
   it('can reconcile Suspense in fallback positions', async () => {
     let resolveFallback;
     const fallbackPromise = new Promise(resolve => {
@@ -2907,7 +2907,7 @@ describe('Store', () => {
     `);
   });
 
-  // @reactVersion >= 18.0
+  // @reactVersion >= 17.0
   it('can reconcile resuspended Suspense with Suspense in fallback positions', async () => {
     let resolveHeadFallback;
     let resolveHeadContent;

filename: packages/react-devtools-shared/src/backend/fiber/renderer.js
patch: @@ -460,10 +460,10 @@ export function getInternalReactConstants(version: string): {
       IncompleteFunctionComponent: 28,
       IndeterminateComponent: 2, // removed in 19.0.0
       LazyComponent: 16,
-      LegacyHiddenComponent: 23,
+      LegacyHiddenComponent: 23, // Does not exist in 18+ OSS but exists in fb builds
       MemoComponent: 14,
       Mode: 8,
-      OffscreenComponent: 22, // Experimental
+      OffscreenComponent: 22, // Experimental in 17. Stable in 18+
       Profiler: 12,
       ScopeComponent: 21, // Experimental
       SimpleMemoComponent: 15,
@@ -3057,13 +3057,23 @@ export function attach(
     }
   }
 
+  function isHiddenOffscreen(fiber: Fiber): boolean {
+    switch (fiber.tag) {
+      case LegacyHiddenComponent:
+      // fallthrough since all published implementations currently implement the same state as Offscreen.
+      case OffscreenComponent:
+        return fiber.memoizedState !== null;
+      default:
+        return false;
+    }
+  }
+
   function unmountRemainingChildren() {
     if (
       reconcilingParent !== null &&
       (reconcilingParent.kind === FIBER_INSTANCE ||
         reconcilingParent.kind === FILTERED_FIBER_INSTANCE) &&
-      reconcilingParent.data.tag === OffscreenComponent &&
-      reconcilingParent.data.memoizedState !== null &&
+      isHiddenOffscreen(reconcilingParent.data) &&
       !isInDisconnectedSubtree
     ) {
       // This is a hidden offscreen, we need to execute this in the context of a disconnected subtree.
@@ -3170,8 +3180,7 @@ export function attach(
       if (
         (parent.kind === FIBER_INSTANCE ||
           parent.kind === FILTERED_FIBER_INSTANCE) &&
-        parent.data.tag === OffscreenComponent &&
-        parent.data.memoizedState !== null
+        isHiddenOffscreen(parent.data)
       ) {
         // We're inside a hidden offscreen Fiber. We're in a disconnected tree.
         return;
@@ -3819,7 +3828,9 @@ export function attach(
       (reconcilingParent !== null &&
         reconcilingParent.kind === VIRTUAL_INSTANCE) ||
       fiber.tag === SuspenseComponent ||
-      fiber.tag === OffscreenComponent // Use to keep resuspended instances alive inside a SuspenseComponent.
+      // Use to keep resuspended instances alive inside a SuspenseComponent.
+      fiber.tag === OffscreenComponent ||
+      fiber.tag === LegacyHiddenComponent
     ) {
       // If the parent is a Virtual Instance and we filtered this Fiber we include a
       // hidden node. We also include this if it's a Suspense boundary so we can track those
@@ -3939,7 +3950,7 @@ export function attach(
         trackDebugInfoFromHostComponent(nearestInstance, fiber);
       }
 
-      if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) {
+      if (isHiddenOffscreen(fiber)) {
         // If an Offscreen component is hidden, mount its children as disconnected.
         const stashedDisconnected = isInDisconnectedSubtree;
         isInDisconnectedSubtree = true;
@@ -4261,7 +4272,7 @@ export function attach(
     while (child !== null) {
       if (child.kind === FILTERED_FIBER_INSTANCE) {
         const fiber = child.data;
-        if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) {
+        if (isHiddenOffscreen(fiber)) {
           // The children of this Offscreen are hidden so they don't get added.
         } else {
           addUnfilteredChildrenIDs(child, nextChildren);
@@ -4888,9 +4899,8 @@ export function attach(
       const nextDidTimeOut =
         isLegacySuspense && nextFiber.memoizedState !== null;
 
-      const isOffscreen = nextFiber.tag === OffscreenComponent;
-      const prevWasHidden = isOffscreen && prevFiber.memoizedState !== null;
-      const nextIsHidden = isOffscreen && nextFiber.memoizedState !== null;
+      const prevWasHidden = isHiddenOffscreen(prevFiber);
+      const nextIsHidden = isHiddenOffscreen(nextFiber);
 
       if (isLegacySuspense) {
         if (
@@ -5245,8 +5255,7 @@ export function attach(
       if (
         (child.kind === FIBER_INSTANCE ||
           child.kind === FILTERED_FIBER_INSTANCE) &&
-        child.data.tag === OffscreenComponent &&
-        child.data.memoizedState !== null
+        isHiddenOffscreen(child.data)
       ) {
         // This instance's children are already disconnected.
       } else {
@@ -5275,8 +5284,7 @@ export function attach(
       if (
         (child.kind === FIBER_INSTANCE ||
           child.kind === FILTERED_FIBER_INSTANCE) &&
-        child.data.tag === OffscreenComponent &&
-        child.data.memoizedState !== null
+        isHiddenOffscreen(child.data)
       ) {
         // This instance's children should remain disconnected.
       } else {