@samitouri / QOS-React / commits / 8dba9311e5

[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