[DevTools] Unmount fallbacks in the context of the parent Suspense (#34475)
Co-authored-by: Ruslan Lesiutin <hoxy@meta.com>
Sebastian "Sebbie" Silbermann committed
Sep 13, 2025 at 11:03 UTC
5502d85cc7d21ac096e4bb8d6ed692231c0f8462
2 files changed
+60
-2
packages/react-devtools-shared/src/__tests__/store-test.js
+4
@@ -3079,6 +3079,10 @@ describe('Store', () => {
3079
<Suspense name="head-fallback" rects={[{x:1,y:2,width:10,height:1}]}>
3080
<Suspense name="main" rects={[{x:1,y:2,width:4,height:1}]}>
3081
`);
3082
+
3083
+ await actAsync(() => render(null));
3084
+
3085
+ expect(store).toMatchInlineSnapshot(``);
3086
});
3087
3088
it('should handle an empty root', async () => {
packages/react-devtools-shared/src/backend/fiber/renderer.js
+56
-2
@@ -3070,6 +3070,24 @@ export function attach(
3070
}
3071
}
3072
3073
+ function unmountSuspenseChildrenRecursively(
3074
+ contentInstance: DevToolsInstance,
3075
+ stashedSuspenseParent: null | SuspenseNode,
3076
+ stashedSuspensePrevious: null | SuspenseNode,
3077
+ stashedSuspenseRemaining: null | SuspenseNode,
3078
+ ): void {
3079
+ // First unmount only the Offscreen boundary. I.e. the main content.
3080
+ unmountInstanceRecursively(contentInstance);
3081
+
3082
+ // Next, we'll pop back out of the SuspenseNode that we added above and now we'll
3083
+ // unmount the fallback, unmounting anything in the context of the parent SuspenseNode.
3084
+ // Since the fallback conceptually blocks the parent.
3085
+ reconcilingParentSuspenseNode = stashedSuspenseParent;
3086
+ previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
3087
+ remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
3088
+ unmountRemainingChildren();
3089
+ }
3090
+
3091
function isChildOf(
3092
parentInstance: DevToolsInstance,
3093
childInstance: DevToolsInstance,
@@ -4015,6 +4033,7 @@ export function attach(
4033
debug('unmountInstanceRecursively()', instance, reconcilingParent);
4034
}
4035
4036
+ let shouldPopSuspenseNode = false;
4037
const stashedParent = reconcilingParent;
4038
const stashedPrevious = previouslyReconciledSibling;
4039
const stashedRemaining = remainingReconcilingChildren;
@@ -4035,11 +4054,46 @@ export function attach(
4054
previouslyReconciledSiblingSuspenseNode = null;
4055
remainingReconcilingChildrenSuspenseNodes =
4056
instance.suspenseNode.firstChild;
4057
+
4058
+ shouldPopSuspenseNode = true;
4059
}
4060
4061
try {
4062
// Unmount the remaining set.
4042
- unmountRemainingChildren();
4063
+ if (
4064
+ (instance.kind === FIBER_INSTANCE ||
4065
+ instance.kind === FILTERED_FIBER_INSTANCE) &&
4066
+ instance.data.tag === SuspenseComponent &&
4067
+ OffscreenComponent !== -1
4068
+ ) {
4069
+ const fiber = instance.data;
4070
+ const contentFiberInstance = remainingReconcilingChildren;
4071
+ const hydrated = isFiberHydrated(fiber);
4072
+ if (hydrated) {
4073
+ if (contentFiberInstance === null) {
4074
+ throw new Error(
4075
+ 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.',
4076
+ );
4077
+ }
4078
+
4079
+ unmountSuspenseChildrenRecursively(
4080
+ contentFiberInstance,
4081
+ stashedSuspenseParent,
4082
+ stashedSuspensePrevious,
4083
+ stashedSuspenseRemaining,
4084
+ );
4085
+ // unmountSuspenseChildren already popped
4086
+ shouldPopSuspenseNode = false;
4087
+ } else {
4088
+ if (contentFiberInstance !== null) {
4089
+ throw new Error(
4090
+ 'A dehydrated Suspense node should not have a content Fiber.',
4091
+ );
4092
+ }
4093
+ }
4094
+ } else {
4095
+ unmountRemainingChildren();
4096
+ }
4097
removePreviousSuspendedBy(
4098
instance,
4099
previousSuspendedBy,
@@ -4049,7 +4103,7 @@ export function attach(
4103
reconcilingParent = stashedParent;
4104
previouslyReconciledSibling = stashedPrevious;
4105
remainingReconcilingChildren = stashedRemaining;
4052
- if (instance.suspenseNode !== null) {
4106
+ if (shouldPopSuspenseNode) {
4107
reconcilingParentSuspenseNode = stashedSuspenseParent;
4108
previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
4109
remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;