[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;