@samitouri / QOS-React / commits / ae5c2f82b3

[DevTools] Handle reorders when resuspending while fallback contains Suspense (#34225)

Sebastian "Sebbie" Silbermann committed Aug 19, 2025 at 20:22 UTC ae5c2f82b3ff71e4c8808c4dc3ebe60b71a1d125
2 files changed +284 -73
packages/react-devtools-shared/src/__tests__/store-test.js
+189
@@ -2775,4 +2775,193 @@ describe('Store', () => {
2775 <Suspense name="content" rects={[{x:1,y:2,width:4,height:1}]}>
2776 `);
2777 });
2778 +
2779 + // @reactVersion >= 18.0
2780 + it('can reconcile resuspended Suspense with Suspense in fallback positions', async () => {
2781 + let resolveHeadFallback;
2782 + let resolveHeadContent;
2783 + let resolveMainFallback;
2784 + let resolveMainContent;
2785 +
2786 + function Component({children, promise}) {
2787 + if (promise) {
2788 + React.use(promise);
2789 + }
2790 + return <div>{children}</div>;
2791 + }
2792 +
2793 + function WithSuspenseInFallback({fallbackPromise, contentPromise, name}) {
2794 + return (
2795 + <React.Suspense
2796 + name={name}
2797 + fallback={
2798 + <React.Suspense
2799 + name={`${name}-fallback`}
2800 + fallback={
2801 + <Component key={`${name}-fallback-fallback`}>
2802 + Loading fallback...
2803 + </Component>
2804 + }>
2805 + <Component
2806 + key={`${name}-fallback-content`}
2807 + promise={fallbackPromise}>
2808 + Loading...
2809 + </Component>
2810 + </React.Suspense>
2811 + }>
2812 + <Component key={`${name}-content`} promise={contentPromise}>
2813 + done
2814 + </Component>
2815 + </React.Suspense>
2816 + );
2817 + }
2818 +
2819 + function App({
2820 + headFallbackPromise,
2821 + headContentPromise,
2822 + mainContentPromise,
2823 + mainFallbackPromise,
2824 + tailContentPromise,
2825 + tailFallbackPromise,
2826 + }) {
2827 + return (
2828 + <>
2829 + <WithSuspenseInFallback
2830 + fallbackPromise={headFallbackPromise}
2831 + contentPromise={headContentPromise}
2832 + name="head"
2833 + />
2834 + <WithSuspenseInFallback
2835 + fallbackPromise={mainFallbackPromise}
2836 + contentPromise={mainContentPromise}
2837 + name="main"
2838 + />
2839 + </>
2840 + );
2841 + }
2842 +
2843 + const initialHeadContentPromise = new Promise(resolve => {
2844 + resolveHeadContent = resolve;
2845 + });
2846 + const initialHeadFallbackPromise = new Promise(resolve => {
2847 + resolveHeadFallback = resolve;
2848 + });
2849 + const initialMainContentPromise = new Promise(resolve => {
2850 + resolveMainContent = resolve;
2851 + });
2852 + const initialMainFallbackPromise = new Promise(resolve => {
2853 + resolveMainFallback = resolve;
2854 + });
2855 + await actAsync(() =>
2856 + render(
2857 + <App
2858 + headFallbackPromise={initialHeadFallbackPromise}
2859 + headContentPromise={initialHeadContentPromise}
2860 + mainContentPromise={initialMainContentPromise}
2861 + mainFallbackPromise={initialMainFallbackPromise}
2862 + />,
2863 + ),
2864 + );
2865 +
2866 + expect(store).toMatchInlineSnapshot(`
2867 + [root]
2868 + ▾ <App>
2869 + ▾ <WithSuspenseInFallback>
2870 + ▾ <Suspense name="head">
2871 + ▾ <Suspense name="head-fallback">
2872 + <Component key="head-fallback-fallback">
2873 + ▾ <WithSuspenseInFallback>
2874 + ▾ <Suspense name="main">
2875 + ▾ <Suspense name="main-fallback">
2876 + <Component key="main-fallback-fallback">
2877 + [shell]
2878 + <Suspense name="head" rects={null}>
2879 + <Suspense name="head-fallback" rects={null}>
2880 + <Suspense name="main" rects={null}>
2881 + <Suspense name="main-fallback" rects={null}>
2882 + `);
2883 +
2884 + await actAsync(() => {
2885 + resolveHeadFallback();
2886 + resolveMainFallback();
2887 + resolveHeadContent();
2888 + resolveMainContent();
2889 + });
2890 +
2891 + expect(store).toMatchInlineSnapshot(`
2892 + [root]
2893 + ▾ <App>
2894 + ▾ <WithSuspenseInFallback>
2895 + ▾ <Suspense name="head">
2896 + <Component key="head-content">
2897 + ▾ <WithSuspenseInFallback>
2898 + ▾ <Suspense name="main">
2899 + <Component key="main-content">
2900 + [shell]
2901 + <Suspense name="head" rects={[{x:1,y:2,width:4,height:1}]}>
2902 + <Suspense name="main" rects={[{x:1,y:2,width:4,height:1}]}>
2903 + `);
2904 +
2905 + // Resuspend head content
2906 + const nextHeadContentPromise = new Promise(resolve => {
2907 + resolveHeadContent = resolve;
2908 + });
2909 + await actAsync(() =>
2910 + render(
2911 + <App
2912 + headFallbackPromise={initialHeadFallbackPromise}
2913 + headContentPromise={nextHeadContentPromise}
2914 + mainContentPromise={initialMainContentPromise}
2915 + mainFallbackPromise={initialMainFallbackPromise}
2916 + />,
2917 + ),
2918 + );
2919 +
2920 + expect(store).toMatchInlineSnapshot(`
2921 + [root]
2922 + ▾ <App>
2923 + ▾ <WithSuspenseInFallback>
2924 + ▾ <Suspense name="head">
2925 + ▾ <Suspense name="head-fallback">
2926 + <Component key="head-fallback-content">
2927 + ▾ <WithSuspenseInFallback>
2928 + ▾ <Suspense name="main">
2929 + <Component key="main-content">
2930 + [shell]
2931 + <Suspense name="head" rects={[{x:1,y:2,width:4,height:1}]}>
2932 + <Suspense name="head-fallback" rects={[{x:1,y:2,width:10,height:1}]}>
2933 + <Suspense name="main" rects={[{x:1,y:2,width:4,height:1}]}>
2934 + `);
2935 +
2936 + // Resuspend head fallback
2937 + const nextHeadFallbackPromise = new Promise(resolve => {
2938 + resolveHeadFallback = resolve;
2939 + });
2940 + await actAsync(() =>
2941 + render(
2942 + <App
2943 + headFallbackPromise={nextHeadFallbackPromise}
2944 + headContentPromise={nextHeadContentPromise}
2945 + mainContentPromise={initialMainContentPromise}
2946 + mainFallbackPromise={initialMainFallbackPromise}
2947 + />,
2948 + ),
2949 + );
2950 +
2951 + expect(store).toMatchInlineSnapshot(`
2952 + [root]
2953 + ▾ <App>
2954 + ▾ <WithSuspenseInFallback>
2955 + ▾ <Suspense name="head">
2956 + ▾ <Suspense name="head-fallback">
2957 + <Component key="head-fallback-fallback">
2958 + ▾ <WithSuspenseInFallback>
2959 + ▾ <Suspense name="main">
2960 + <Component key="main-content">
2961 + [shell]
2962 + <Suspense name="head" rects={[{x:1,y:2,width:4,height:1}]}>
2963 + <Suspense name="head-fallback" rects={[{x:1,y:2,width:10,height:1}]}>
2964 + <Suspense name="main" rects={[{x:1,y:2,width:4,height:1}]}>
2965 + `);
2966 + });
2967 });
packages/react-devtools-shared/src/backend/fiber/renderer.js
+95 -73
@@ -306,6 +306,16 @@ type SuspenseNode = {
306 hasUnknownSuspenders: boolean,
307 };
308
309 +// Update flags need to be propagated up until the caller that put the corresponding
310 +// node on the stack.
311 +// If you push a new node, you need to handle ShouldResetChildren when you pop it.
312 +// If you push a new Suspense node, you need to handle ShouldResetSuspenseChildren when you pop it.
313 +type UpdateFlags = number;
314 +const NoUpdate = /* */ 0b000;
315 +const ShouldResetChildren = /* */ 0b001;
316 +const ShouldResetSuspenseChildren = /* */ 0b010;
317 +const ShouldResetParentSuspenseChildren = /* */ 0b100;
318 +
319 function createSuspenseNode(
320 instance: FiberInstance | FilteredFiberInstance,
321 ): SuspenseNode {
@@ -2828,10 +2838,10 @@ export function attach(
2838 function removePreviousSuspendedBy(
2839 instance: DevToolsInstance,
2840 previousSuspendedBy: null | Array<ReactAsyncInfo>,
2841 + parentSuspenseNode: null | SuspenseNode,
2842 ): void {
2843 // Remove any async info from the parent, if they were in the previous set but
2844 // is no longer in the new set.
2834 - const parentSuspenseNode = reconcilingParentSuspenseNode;
2845 if (previousSuspendedBy !== null && parentSuspenseNode !== null) {
2846 const nextSuspendedBy = instance.suspendedBy;
2847 for (let i = 0; i < previousSuspendedBy.length; i++) {
@@ -3657,30 +3667,19 @@ export function attach(
3667 0, // first level
3668 );
3669
3670 + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll
3671 + // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode.
3672 + // Since the fallback conceptually blocks the parent.
3673 + reconcilingParentSuspenseNode = stashedSuspenseParent;
3674 + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
3675 + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
3676 if (fallbackFiber !== null) {
3661 - const fallbackStashedSuspenseParent = stashedSuspenseParent;
3662 - const fallbackStashedSuspensePrevious = stashedSuspensePrevious;
3663 - const fallbackStashedSuspenseRemaining = stashedSuspenseRemaining;
3664 - // Next, we'll pop back out of the SuspenseNode that we added above and now we'll
3665 - // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode.
3666 - // Since the fallback conceptually blocks the parent.
3667 - reconcilingParentSuspenseNode = stashedSuspenseParent;
3668 - previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
3669 - remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
3670 - try {
3671 - mountVirtualChildrenRecursively(
3672 - fallbackFiber,
3673 - null,
3674 - traceNearestHostComponentUpdate,
3675 - 0, // first level
3676 - );
3677 - } finally {
3678 - reconcilingParentSuspenseNode = fallbackStashedSuspenseParent;
3679 - previouslyReconciledSiblingSuspenseNode =
3680 - fallbackStashedSuspensePrevious;
3681 - remainingReconcilingChildrenSuspenseNodes =
3682 - fallbackStashedSuspenseRemaining;
3683 - }
3677 + mountVirtualChildrenRecursively(
3678 + fallbackFiber,
3679 + null,
3680 + traceNearestHostComponentUpdate,
3681 + 0, // first level
3682 + );
3683 }
3684 }
3685
@@ -3924,6 +3923,8 @@ export function attach(
3923 stashedSuspensePrevious,
3924 stashedSuspenseRemaining,
3925 );
3926 + // mountSuspenseChildrenRecursively popped already
3927 + shouldPopSuspenseNode = false;
3928 } else {
3929 // This Suspense Fiber is still dehydrated. It won't have any children
3930 // until hydration.
@@ -3979,13 +3980,18 @@ export function attach(
3980 if (instance.suspenseNode !== null) {
3981 reconcilingParentSuspenseNode = instance.suspenseNode;
3982 previouslyReconciledSiblingSuspenseNode = null;
3982 - remainingReconcilingChildrenSuspenseNodes = null;
3983 + remainingReconcilingChildrenSuspenseNodes =
3984 + instance.suspenseNode.firstChild;
3985 }
3986
3987 try {
3988 // Unmount the remaining set.
3989 unmountRemainingChildren();
3988 - removePreviousSuspendedBy(instance, previousSuspendedBy);
3990 + removePreviousSuspendedBy(
3991 + instance,
3992 + previousSuspendedBy,
3993 + reconcilingParentSuspenseNode,
3994 + );
3995 } finally {
3996 reconcilingParent = stashedParent;
3997 previouslyReconciledSibling = stashedPrevious;
@@ -4222,10 +4228,6 @@ export function attach(
4228 }
4229 }
4230
4225 - const NoUpdate = /* */ 0b00;
4226 - const ShouldResetChildren = /* */ 0b01;
4227 - const ShouldResetSuspenseChildren = /* */ 0b10;
4228 -
4231 function updateVirtualInstanceRecursively(
4232 virtualInstance: VirtualInstance,
4233 nextFirstChild: Fiber,
@@ -4233,7 +4235,7 @@ export function attach(
4235 prevFirstChild: null | Fiber,
4236 traceNearestHostComponentUpdate: boolean,
4237 virtualLevel: number, // the nth level of virtual instances
4236 - ): number {
4238 + ): UpdateFlags {
4239 const stashedParent = reconcilingParent;
4240 const stashedPrevious = previouslyReconciledSibling;
4241 const stashedRemaining = remainingReconcilingChildren;
@@ -4258,7 +4260,11 @@ export function attach(
4260 recordResetChildren(virtualInstance);
4261 updateFlags &= ~ShouldResetChildren;
4262 }
4261 - removePreviousSuspendedBy(virtualInstance, previousSuspendedBy);
4263 + removePreviousSuspendedBy(
4264 + virtualInstance,
4265 + previousSuspendedBy,
4266 + reconcilingParentSuspenseNode,
4267 + );
4268 // Update the errors/warnings count. If this Instance has switched to a different
4269 // ReactComponentInfo instance, such as when refreshing Server Components, then
4270 // we replace all the previous logs with the ones associated with the new ones rather
@@ -4285,7 +4291,7 @@ export function attach(
4291 prevFirstChild: null | Fiber,
4292 traceNearestHostComponentUpdate: boolean,
4293 virtualLevel: number, // the nth level of virtual instances
4288 - ): number {
4294 + ): UpdateFlags {
4295 let updateFlags = NoUpdate;
4296 // If the first child is different, we need to traverse them.
4297 // Each next child will be either a new child (mount) or an alternate (update).
@@ -4567,7 +4573,7 @@ export function attach(
4573 nextFirstChild: null | Fiber,
4574 prevFirstChild: null | Fiber,
4575 traceNearestHostComponentUpdate: boolean,
4570 - ): number {
4576 + ): UpdateFlags {
4577 if (nextFirstChild === null) {
4578 return prevFirstChild !== null ? ShouldResetChildren : NoUpdate;
4579 }
@@ -4587,7 +4593,7 @@ export function attach(
4593 stashedSuspenseParent: null | SuspenseNode,
4594 stashedSuspensePrevious: null | SuspenseNode,
4595 stashedSuspenseRemaining: null | SuspenseNode,
4590 - ): number {
4596 + ): UpdateFlags {
4597 let updateFlags = NoUpdate;
4598 const prevFallbackFiber = prevContentFiber.sibling;
4599 const nextFallbackFiber = nextContentFiber.sibling;
@@ -4601,36 +4607,28 @@ export function attach(
4607 0,
4608 );
4609
4610 + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll
4611 + // reconcile the fallback, reconciling anything in the context of the parent SuspenseNode.
4612 + // Since the fallback conceptually blocks the parent.
4613 + reconcilingParentSuspenseNode = stashedSuspenseParent;
4614 + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
4615 + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
4616 if (prevFallbackFiber !== null || nextFallbackFiber !== null) {
4605 - const fallbackStashedSuspenseParent = reconcilingParentSuspenseNode;
4606 - const fallbackStashedSuspensePrevious =
4607 - previouslyReconciledSiblingSuspenseNode;
4608 - const fallbackStashedSuspenseRemaining =
4609 - remainingReconcilingChildrenSuspenseNodes;
4610 - // Next, we'll pop back out of the SuspenseNode that we added above and now we'll
4611 - // reconcile the fallback, reconciling anything in the context of the parent SuspenseNode.
4612 - // Since the fallback conceptually blocks the parent.
4613 - reconcilingParentSuspenseNode = stashedSuspenseParent;
4614 - previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
4615 - remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
4616 - try {
4617 - if (nextFallbackFiber === null) {
4618 - unmountRemainingChildren();
4619 - } else {
4620 - updateFlags |= updateVirtualChildrenRecursively(
4621 - nextFallbackFiber,
4622 - null,
4623 - prevFallbackFiber,
4624 - traceNearestHostComponentUpdate,
4625 - 0,
4626 - );
4617 + if (nextFallbackFiber === null) {
4618 + unmountRemainingChildren();
4619 + } else {
4620 + updateFlags |= updateVirtualChildrenRecursively(
4621 + nextFallbackFiber,
4622 + null,
4623 + prevFallbackFiber,
4624 + traceNearestHostComponentUpdate,
4625 + 0,
4626 + );
4627 +
4628 + if ((updateFlags & ShouldResetSuspenseChildren) !== NoUpdate) {
4629 + updateFlags |= ShouldResetParentSuspenseChildren;
4630 + updateFlags &= ~ShouldResetSuspenseChildren;
4631 }
4628 - } finally {
4629 - reconcilingParentSuspenseNode = fallbackStashedSuspenseParent;
4630 - previouslyReconciledSiblingSuspenseNode =
4631 - fallbackStashedSuspensePrevious;
4632 - remainingReconcilingChildrenSuspenseNodes =
4633 - fallbackStashedSuspenseRemaining;
4632 }
4633 }
4634
@@ -4643,7 +4641,7 @@ export function attach(
4641 nextFiber: Fiber,
4642 prevFiber: Fiber,
4643 traceNearestHostComponentUpdate: boolean,
4646 - ): number {
4644 + ): UpdateFlags {
4645 if (__DEBUG__) {
4646 if (fiberInstance !== null) {
4647 debug('updateFiberRecursively()', fiberInstance, reconcilingParent);
@@ -4681,7 +4679,9 @@ export function attach(
4679 const stashedSuspenseParent = reconcilingParentSuspenseNode;
4680 const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode;
4681 const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes;
4682 + let updateFlags = NoUpdate;
4683 let shouldMeasureSuspenseNode = false;
4684 + let shouldPopSuspenseNode = false;
4685 let previousSuspendedBy = null;
4686 if (fiberInstance !== null) {
4687 previousSuspendedBy = fiberInstance.suspendedBy;
@@ -4712,6 +4712,7 @@ export function attach(
4712 remainingReconcilingChildrenSuspenseNodes = suspenseNode.firstChild;
4713 suspenseNode.firstChild = null;
4714 shouldMeasureSuspenseNode = true;
4715 + shouldPopSuspenseNode = true;
4716 }
4717 }
4718 try {
@@ -4747,8 +4748,6 @@ export function attach(
4748 trackDebugInfoFromHostComponent(nearestInstance, nextFiber);
4749 }
4750
4750 - let updateFlags = NoUpdate;
4751 -
4751 // The behavior of timed-out legacy Suspense trees is unique. Without the Offscreen wrapper.
4752 // Rather than unmount the timed out content (and possibly lose important state),
4753 // React re-parents this content within a hidden Fragment while the fallback is showing.
@@ -4927,6 +4926,8 @@ export function attach(
4926 stashedSuspensePrevious,
4927 stashedSuspenseRemaining,
4928 );
4929 + // updateSuspenseChildrenRecursively popped already
4930 + shouldPopSuspenseNode = false;
4931 if (nextFiber.memoizedState === null) {
4932 // Measure this Suspense node in case it changed. We don't update the rect while
4933 // we're inside a disconnected subtree nor if we are the Suspense boundary that
@@ -4950,6 +4951,8 @@ export function attach(
4951 stashedSuspensePrevious,
4952 stashedSuspenseRemaining,
4953 );
4954 + // mountSuspenseChildrenRecursively popped already
4955 + shouldPopSuspenseNode = false;
4956 } else if (previousHydrated && !nextHydrated) {
4957 throw new Error(
4958 'Encountered a dehydrated Suspense boundary that was previously hydrated.',
@@ -5007,7 +5010,13 @@ export function attach(
5010 }
5011
5012 if (fiberInstance !== null) {
5010 - removePreviousSuspendedBy(fiberInstance, previousSuspendedBy);
5013 + removePreviousSuspendedBy(
5014 + fiberInstance,
5015 + previousSuspendedBy,
5016 + shouldPopSuspenseNode
5017 + ? reconcilingParentSuspenseNode
5018 + : stashedSuspenseParent,
5019 + );
5020
5021 if (fiberInstance.kind === FIBER_INSTANCE) {
5022 let componentLogsEntry = fiberToComponentLogsMap.get(
@@ -5057,6 +5066,17 @@ export function attach(
5066 // Let the closest unfiltered parent Fiber reset its child order instead.
5067 }
5068 }
5069 + if ((updateFlags & ShouldResetParentSuspenseChildren) !== NoUpdate) {
5070 + if (fiberInstance !== null && fiberInstance.kind === FIBER_INSTANCE) {
5071 + const suspenseNode = fiberInstance.suspenseNode;
5072 + if (suspenseNode !== null) {
5073 + updateFlags &= ~ShouldResetParentSuspenseChildren;
5074 + updateFlags |= ShouldResetSuspenseChildren;
5075 + }
5076 + } else {
5077 + // Let the closest unfiltered parent Fiber reset its child order instead.
5078 + }
5079 + }
5080
5081 return updateFlags;
5082 } finally {
@@ -5066,14 +5086,16 @@ export function attach(
5086 previouslyReconciledSibling = stashedPrevious;
5087 remainingReconcilingChildren = stashedRemaining;
5088 if (shouldMeasureSuspenseNode) {
5069 - if (
5070 - !isInDisconnectedSubtree &&
5071 - reconcilingParentSuspenseNode !== null
5072 - ) {
5089 + if (!isInDisconnectedSubtree) {
5090 // Measure this Suspense node in case it changed. We don't update the rect
5091 // while we're inside a disconnected subtree so that we keep the outline
5092 // as it was before we hid the parent.
5076 - const suspenseNode = reconcilingParentSuspenseNode;
5093 + const suspenseNode = fiberInstance.suspenseNode;
5094 + if (suspenseNode === null) {
5095 + throw new Error(
5096 + 'Attempted to measure a Suspense node that does not exist.',
5097 + );
5098 + }
5099 const prevRects = suspenseNode.rects;
5100 const nextRects = measureInstance(fiberInstance);
5101 if (!areEqualRects(prevRects, nextRects)) {
@@ -5082,7 +5104,7 @@ export function attach(
5104 }
5105 }
5106 }
5085 - if (fiberInstance.suspenseNode !== null) {
5107 + if (shouldPopSuspenseNode) {
5108 reconcilingParentSuspenseNode = stashedSuspenseParent;
5109 previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
5110 remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;