@samitouri / QOS-React / commits / 5502d85cc7

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