[DevTools] Handle fallback unmount in Suspense update path (#34199)
Sebastian "Sebbie" Silbermann committed
Aug 15, 2025 at 19:40 UTC
8dba9311e581c005af40ca0986155b85e959ae83
2 files changed
+94
-10
packages/react-devtools-shared/src/__tests__/store-test.js
+79
@@ -2696,4 +2696,83 @@ describe('Store', () => {
2696
<ClientComponent key="D">
2697
`);
2698
});
2699
+
2700
+ // @reactVersion >= 18.0
2701
+ it('can reconcile Suspense in fallback positions', async () => {
2702
+ let resolveFallback;
2703
+ const fallbackPromise = new Promise(resolve => {
2704
+ resolveFallback = resolve;
2705
+ });
2706
+ let resolveContent;
2707
+ const contentPromise = new Promise(resolve => {
2708
+ resolveContent = resolve;
2709
+ });
2710
+
2711
+ function Component({children, promise}) {
2712
+ if (promise) {
2713
+ React.use(promise);
2714
+ }
2715
+ return <div>{children}</div>;
2716
+ }
2717
+
2718
+ await actAsync(() =>
2719
+ render(
2720
+ <React.Suspense
2721
+ name="content"
2722
+ fallback={
2723
+ <React.Suspense
2724
+ name="fallback"
2725
+ fallback={
2726
+ <Component key="fallback-fallback">
2727
+ Loading fallback...
2728
+ </Component>
2729
+ }>
2730
+ <Component key="fallback-content" promise={fallbackPromise}>
2731
+ Loading...
2732
+ </Component>
2733
+ </React.Suspense>
2734
+ }>
2735
+ <Component key="content" promise={contentPromise}>
2736
+ done
2737
+ </Component>
2738
+ </React.Suspense>,
2739
+ ),
2740
+ );
2741
+
2742
+ expect(store).toMatchInlineSnapshot(`
2743
+ [root]
2744
+ ▾ <Suspense name="content">
2745
+ ▾ <Suspense name="fallback">
2746
+ <Component key="fallback-fallback">
2747
+ [shell]
2748
+ <Suspense name="content" rects={null}>
2749
+ <Suspense name="fallback" rects={null}>
2750
+ `);
2751
+
2752
+ await actAsync(() => {
2753
+ resolveFallback();
2754
+ });
2755
+
2756
+ expect(store).toMatchInlineSnapshot(`
2757
+ [root]
2758
+ ▾ <Suspense name="content">
2759
+ ▾ <Suspense name="fallback">
2760
+ <Component key="fallback-content">
2761
+ [shell]
2762
+ <Suspense name="content" rects={null}>
2763
+ <Suspense name="fallback" rects={[{x:1,y:2,width:10,height:1}]}>
2764
+ `);
2765
+
2766
+ await actAsync(() => {
2767
+ resolveContent();
2768
+ });
2769
+
2770
+ expect(store).toMatchInlineSnapshot(`
2771
+ [root]
2772
+ ▾ <Suspense name="content">
2773
+ <Component key="content">
2774
+ [shell]
2775
+ <Suspense name="content" rects={[{x:1,y:2,width:4,height:1}]}>
2776
+ `);
2777
+ });
2778
});
packages/react-devtools-shared/src/backend/fiber/renderer.js
+15
-10
@@ -4736,26 +4736,30 @@ export function attach(
4736
);
4737
4738
shouldMeasureSuspenseNode = false;
4739
- if (nextFallbackFiber !== null) {
4739
+ if (prevFallbackFiber !== null || nextFallbackFiber !== null) {
4740
const fallbackStashedSuspenseParent = reconcilingParentSuspenseNode;
4741
const fallbackStashedSuspensePrevious =
4742
previouslyReconciledSiblingSuspenseNode;
4743
const fallbackStashedSuspenseRemaining =
4744
remainingReconcilingChildrenSuspenseNodes;
4745
// Next, we'll pop back out of the SuspenseNode that we added above and now we'll
4746
- // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode.
4746
+ // reconcile the fallback, reconciling anything in the context of the parent SuspenseNode.
4747
// Since the fallback conceptually blocks the parent.
4748
reconcilingParentSuspenseNode = stashedSuspenseParent;
4749
previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
4750
remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
4751
try {
4752
- updateFlags |= updateVirtualChildrenRecursively(
4753
- nextFallbackFiber,
4754
- null,
4755
- prevFallbackFiber,
4756
- traceNearestHostComponentUpdate,
4757
- 0,
4758
- );
4752
+ if (nextFallbackFiber === null) {
4753
+ unmountRemainingChildren();
4754
+ } else {
4755
+ updateFlags |= updateVirtualChildrenRecursively(
4756
+ nextFallbackFiber,
4757
+ null,
4758
+ prevFallbackFiber,
4759
+ traceNearestHostComponentUpdate,
4760
+ 0,
4761
+ );
4762
+ }
4763
} finally {
4764
reconcilingParentSuspenseNode = fallbackStashedSuspenseParent;
4765
previouslyReconciledSiblingSuspenseNode =
@@ -4763,7 +4767,8 @@ export function attach(
4767
remainingReconcilingChildrenSuspenseNodes =
4768
fallbackStashedSuspenseRemaining;
4769
}
4766
- } else if (nextFiber.memoizedState === null) {
4770
+ }
4771
+ if (nextFiber.memoizedState === null) {
4772
// Measure this Suspense node in case it changed. We don't update the rect while
4773
// we're inside a disconnected subtree nor if we are the Suspense boundary that
4774
// is suspended. This lets us keep the rectangle of the displayed content while