Repository: facebook/react
Pull request: [DevTools] Handle fallback unmount in Suspense update path

When we unmount a fallback, we reconciled it in the context of the current Suspense instead of parent Suspense. But since we mounted it in the context of the parent Suspense, we also need to unmount it in the context of the parent Suspense.


Changed files:
filename: packages/react-devtools-shared/src/__tests__/store-test.js
patch: @@ -2696,4 +2696,83 @@ describe('Store', () => {
               <ClientComponent key="D">
       `);
   });
+
+  // @reactVersion >= 18.0
+  it('can reconcile Suspense in fallback positions', async () => {
+    let resolveFallback;
+    const fallbackPromise = new Promise(resolve => {
+      resolveFallback = resolve;
+    });
+    let resolveContent;
+    const contentPromise = new Promise(resolve => {
+      resolveContent = resolve;
+    });
+
+    function Component({children, promise}) {
+      if (promise) {
+        React.use(promise);
+      }
+      return <div>{children}</div>;
+    }
+
+    await actAsync(() =>
+      render(
+        <React.Suspense
+          name="content"
+          fallback={
+            <React.Suspense
+              name="fallback"
+              fallback={
+                <Component key="fallback-fallback">
+                  Loading fallback...
+                </Component>
+              }>
+              <Component key="fallback-content" promise={fallbackPromise}>
+                Loading...
+              </Component>
+            </React.Suspense>
+          }>
+          <Component key="content" promise={contentPromise}>
+            done
+          </Component>
+        </React.Suspense>,
+      ),
+    );
+
+    expect(store).toMatchInlineSnapshot(`
+      [root]
+        ▾ <Suspense name="content">
+          ▾ <Suspense name="fallback">
+              <Component key="fallback-fallback">
+      [shell]
+        <Suspense name="content" rects={null}>
+        <Suspense name="fallback" rects={null}>
+    `);
+
+    await actAsync(() => {
+      resolveFallback();
+    });
+
+    expect(store).toMatchInlineSnapshot(`
+      [root]
+        ▾ <Suspense name="content">
+          ▾ <Suspense name="fallback">
+              <Component key="fallback-content">
+      [shell]
+        <Suspense name="content" rects={null}>
+        <Suspense name="fallback" rects={[{x:1,y:2,width:10,height:1}]}>
+    `);
+
+    await actAsync(() => {
+      resolveContent();
+    });
+
+    expect(store).toMatchInlineSnapshot(`
+      [root]
+        ▾ <Suspense name="content">
+            <Component key="content">
+      [shell]
+        <Suspense name="content" rects={[{x:1,y:2,width:4,height:1}]}>
+    `);
+  });
 });

filename: packages/react-devtools-shared/src/backend/fiber/renderer.js
patch: @@ -4736,34 +4736,39 @@ export function attach(
         );
 
         shouldMeasureSuspenseNode = false;
-        if (nextFallbackFiber !== null) {
+        if (prevFallbackFiber !== null || nextFallbackFiber !== null) {
           const fallbackStashedSuspenseParent = reconcilingParentSuspenseNode;
           const fallbackStashedSuspensePrevious =
             previouslyReconciledSiblingSuspenseNode;
           const fallbackStashedSuspenseRemaining =
             remainingReconcilingChildrenSuspenseNodes;
           // Next, we'll pop back out of the SuspenseNode that we added above and now we'll
-          // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode.
+          // reconcile the fallback, reconciling anything in the context of the parent SuspenseNode.
           // Since the fallback conceptually blocks the parent.
           reconcilingParentSuspenseNode = stashedSuspenseParent;
           previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
           remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
           try {
-            updateFlags |= updateVirtualChildrenRecursively(
-              nextFallbackFiber,
-              null,
-              prevFallbackFiber,
-              traceNearestHostComponentUpdate,
-              0,
-            );
+            if (nextFallbackFiber === null) {
+              unmountRemainingChildren();
+            } else {
+              updateFlags |= updateVirtualChildrenRecursively(
+                nextFallbackFiber,
+                null,
+                prevFallbackFiber,
+                traceNearestHostComponentUpdate,
+                0,
+              );
+            }
           } finally {
             reconcilingParentSuspenseNode = fallbackStashedSuspenseParent;
             previouslyReconciledSiblingSuspenseNode =
               fallbackStashedSuspensePrevious;
             remainingReconcilingChildrenSuspenseNodes =
               fallbackStashedSuspenseRemaining;
           }
-        } else if (nextFiber.memoizedState === null) {
+        }
+        if (nextFiber.memoizedState === null) {
           // Measure this Suspense node in case it changed. We don't update the rect while
           // we're inside a disconnected subtree nor if we are the Suspense boundary that
           // is suspended. This lets us keep the rectangle of the displayed content while