@samitouri / QOS-React-1 / commits / c97ec75324

[DevTools] Disconnect and Reconnect children of Suspense boundaries instead of Unmounting and Mounting (#34089)

Stacked on #34082. This keeps the DevToolsInstance children alive inside Offscreen trees while they're hidden. However, they're sent as unmounted to the front end store. This allows DevTools state to be preserved between these two states. Such as it keeps the "suspended by" set on the SuspenseNode alive since the children are still mounted. So now you when you resuspend, you can see what in the children was suspended. This is useful when you're simulating a suspense but can also be a bit misleading when something suspended for real since it'll only show the previous suspended set and not what is currently suspending it since that hasn't committed yet. SuspenseNodes inside resuspended trees are now kept alive too. That way they can contribute to the timeline even when resuspended. We can choose whether to keep them visible in the rects while hidden or not. In the future we'll also need to add more special cases around Activity. Because right now if SuspenseNodes are kept alive in the Suspense tab UI while hidden, then they're also alive inside Activity that are hidden which maybe we don't want. Maybe simplest would be that they both disappear from the Suspense tab UI but can be considered for the timeline. Another case is that when Activity goes hidden, Fiber will no longer cause its content to suspend the parent but that's not modeled here. So hidden Activity will show up as "suspended by" in a parent Suspense. When they disconnect, they should really be removed from the "suspended by" set of the parent (and perhaps be shown only on the Activity boundary itself).

Sebastian Markbåge committed Aug 6, 2025 at 11:05 UTC c97ec75324b8d89426b6a44271cea50494a7d5e2
1 file changed +270 -48
packages/react-devtools-shared/src/backend/fiber/renderer.js
+270 -48
@@ -2156,6 +2156,8 @@ export function attach(
2156 return id;
2157 }
2158
2159 + let isInDisconnectedSubtree = false;
2160 +
2161 function recordMount(
2162 fiber: Fiber,
2163 parentInstance: DevToolsInstance | null,
@@ -2173,14 +2175,29 @@ export function attach(
2175 }
2176 idToDevToolsInstanceMap.set(fiberInstance.id, fiberInstance);
2177
2176 - const id = fiberInstance.id;
2177 -
2178 if (__DEBUG__) {
2179 debug('recordMount()', fiberInstance, parentInstance);
2180 }
2181
2182 + recordReconnect(fiberInstance, parentInstance);
2183 + return fiberInstance;
2184 + }
2185 +
2186 + function recordReconnect(
2187 + fiberInstance: FiberInstance,
2188 + parentInstance: DevToolsInstance | null,
2189 + ): void {
2190 + if (isInDisconnectedSubtree) {
2191 + // We're disconnected. We'll reconnect a hidden mount after the parent reappears.
2192 + return;
2193 + }
2194 + const id = fiberInstance.id;
2195 + const fiber = fiberInstance.data;
2196 +
2197 const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration');
2198
2199 + const isRoot = fiber.tag === HostRoot;
2200 +
2201 if (isRoot) {
2202 const hasOwnerMetadata = fiber.hasOwnProperty('_debugOwner');
2203
@@ -2292,7 +2309,6 @@ export function attach(
2309 if (isProfilingSupported) {
2310 recordProfilingDurations(fiberInstance, null);
2311 }
2295 - return fiberInstance;
2312 }
2313
2314 function recordVirtualMount(
@@ -2304,6 +2320,18 @@ export function attach(
2320
2321 idToDevToolsInstanceMap.set(id, instance);
2322
2323 + recordVirtualReconnect(instance, parentInstance, secondaryEnv);
2324 + }
2325 +
2326 + function recordVirtualReconnect(
2327 + instance: VirtualInstance,
2328 + parentInstance: DevToolsInstance | null,
2329 + secondaryEnv: null | string,
2330 + ): void {
2331 + if (isInDisconnectedSubtree) {
2332 + // We're disconnected. We'll reconnect a hidden mount after the parent reappears.
2333 + return;
2334 + }
2335 const componentInfo = instance.data;
2336
2337 const key =
@@ -2355,6 +2383,8 @@ export function attach(
2383 const keyString = key === null ? null : String(key);
2384 const keyStringID = getStringID(keyString);
2385
2386 + const id = instance.id;
2387 +
2388 pushOperation(TREE_OPERATION_ADD);
2389 pushOperation(id);
2390 pushOperation(elementType);
@@ -2369,14 +2399,27 @@ export function attach(
2399 }
2400
2401 function recordUnmount(fiberInstance: FiberInstance): void {
2372 - const fiber = fiberInstance.data;
2402 if (__DEBUG__) {
2403 debug('recordUnmount()', fiberInstance, reconcilingParent);
2404 }
2405
2406 + recordDisconnect(fiberInstance);
2407 +
2408 + idToDevToolsInstanceMap.delete(fiberInstance.id);
2409 +
2410 + untrackFiber(fiberInstance, fiberInstance.data);
2411 + }
2412 +
2413 + function recordDisconnect(fiberInstance: FiberInstance): void {
2414 + if (isInDisconnectedSubtree) {
2415 + // Already disconnected.
2416 + return;
2417 + }
2418 + const fiber = fiberInstance.data;
2419 +
2420 if (trackedPathMatchInstance === fiberInstance) {
2421 // We're in the process of trying to restore previous selection.
2379 - // If this fiber matched but is being unmounted, there's no use trying.
2422 + // If this fiber matched but is being hidden, there's no use trying.
2423 // Reset the state so we don't keep holding onto it.
2424 setTrackedPath(null);
2425 }
@@ -2393,10 +2436,6 @@ export function attach(
2436 // and later arrange them in the correct order.
2437 pendingRealUnmountedIDs.push(id);
2438 }
2396 -
2397 - idToDevToolsInstanceMap.delete(fiberInstance.id);
2398 -
2399 - untrackFiber(fiberInstance, fiber);
2439 }
2440
2441 // Running state of the remaining children from the previous version of this parent that
@@ -2416,11 +2455,6 @@ export function attach(
2455 // the current parent here as well.
2456 let reconcilingParentSuspenseNode: null | SuspenseNode = null;
2457
2419 - function isSuspenseInFallback(suspenseNode: SuspenseNode) {
2420 - const fiber = suspenseNode.instance.data;
2421 - return fiber.tag === SuspenseComponent && fiber.memoizedState !== null;
2422 - }
2423 -
2458 function ioExistsInSuspenseAncestor(
2459 suspenseNode: SuspenseNode,
2460 ioInfo: ReactIOInfo,
@@ -2436,21 +2470,13 @@ export function attach(
2470 }
2471
2472 function insertSuspendedBy(asyncInfo: ReactAsyncInfo): void {
2439 - let parentSuspenseNode = reconcilingParentSuspenseNode;
2440 - while (
2441 - parentSuspenseNode !== null &&
2442 - isSuspenseInFallback(parentSuspenseNode)
2443 - ) {
2444 - // If we have something that suspends inside the fallback tree of a Suspense boundary, then
2445 - // we bubble that up to the nearest parent Suspense boundary that isn't in fallback mode.
2446 - parentSuspenseNode = parentSuspenseNode.parent;
2447 - }
2448 - if (reconcilingParent === null || parentSuspenseNode === null) {
2473 + if (reconcilingParent === null || reconcilingParentSuspenseNode === null) {
2474 throw new Error(
2475 'It should not be possible to have suspended data outside the root. ' +
2476 'Even suspending at the first position is still a child of the root.',
2477 );
2478 }
2479 + const parentSuspenseNode = reconcilingParentSuspenseNode;
2480 // Use the nearest unfiltered parent so that there's always some component that has
2481 // the entry on it even if you filter, or the root if all are filtered.
2482 let parentInstance = reconcilingParent;
@@ -2694,10 +2720,31 @@ export function attach(
2720 }
2721
2722 function unmountRemainingChildren() {
2697 - let child = remainingReconcilingChildren;
2698 - while (child !== null) {
2699 - unmountInstanceRecursively(child);
2700 - child = remainingReconcilingChildren;
2723 + if (
2724 + reconcilingParent !== null &&
2725 + (reconcilingParent.kind === FIBER_INSTANCE ||
2726 + reconcilingParent.kind === FILTERED_FIBER_INSTANCE) &&
2727 + reconcilingParent.data.tag === OffscreenComponent &&
2728 + reconcilingParent.data.memoizedState !== null &&
2729 + !isInDisconnectedSubtree
2730 + ) {
2731 + // This is a hidden offscreen, we need to execute this in the context of a disconnected subtree.
2732 + isInDisconnectedSubtree = true;
2733 + try {
2734 + let child = remainingReconcilingChildren;
2735 + while (child !== null) {
2736 + unmountInstanceRecursively(child);
2737 + child = remainingReconcilingChildren;
2738 + }
2739 + } finally {
2740 + isInDisconnectedSubtree = false;
2741 + }
2742 + } else {
2743 + let child = remainingReconcilingChildren;
2744 + while (child !== null) {
2745 + unmountInstanceRecursively(child);
2746 + child = remainingReconcilingChildren;
2747 + }
2748 }
2749 }
2750
@@ -2811,6 +2858,14 @@ export function attach(
2858 }
2859
2860 function recordVirtualUnmount(instance: VirtualInstance) {
2861 + recordVirtualDisconnect(instance);
2862 + idToDevToolsInstanceMap.delete(instance.id);
2863 + }
2864 +
2865 + function recordVirtualDisconnect(instance: VirtualInstance) {
2866 + if (isInDisconnectedSubtree) {
2867 + return;
2868 + }
2869 if (trackedPathMatchInstance === instance) {
2870 // We're in the process of trying to restore previous selection.
2871 // If this fiber matched but is being unmounted, there's no use trying.
@@ -2820,8 +2875,6 @@ export function attach(
2875
2876 const id = instance.id;
2877 pendingRealUnmountedIDs.push(id);
2823 -
2824 - idToDevToolsInstanceMap.delete(instance.id);
2878 }
2879
2880 function getSecondaryEnvironmentName(
@@ -3030,10 +3083,12 @@ export function attach(
3083 previouslyReconciledSibling = null;
3084 remainingReconcilingChildren = null;
3085 }
3086 + let shouldPopSuspenseNode = false;
3087 if (newSuspenseNode !== null) {
3088 reconcilingParentSuspenseNode = newSuspenseNode;
3089 previouslyReconciledSiblingSuspenseNode = null;
3090 remainingReconcilingChildrenSuspenseNodes = null;
3091 + shouldPopSuspenseNode = true;
3092 }
3093 try {
3094 if (traceUpdatesEnabled) {
@@ -3069,7 +3124,16 @@ export function attach(
3124 }
3125
3126 if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) {
3072 - // If an Offscreen component is hidden, don't mount its children yet.
3127 + // If an Offscreen component is hidden, mount its children as disconnected.
3128 + const stashedDisconnected = isInDisconnectedSubtree;
3129 + isInDisconnectedSubtree = true;
3130 + try {
3131 + if (fiber.child !== null) {
3132 + mountChildrenRecursively(fiber.child, false);
3133 + }
3134 + } finally {
3135 + isInDisconnectedSubtree = stashedDisconnected;
3136 + }
3137 } else if (fiber.tag === SuspenseComponent && OffscreenComponent === -1) {
3138 // Legacy Suspense without the Offscreen wrapper. For the modern Suspense we just handle the
3139 // Offscreen wrapper itself specially.
@@ -3102,6 +3166,44 @@ export function attach(
3166 );
3167 }
3168 }
3169 + } else if (
3170 + fiber.tag === SuspenseComponent &&
3171 + OffscreenComponent !== -1 &&
3172 + newInstance !== null &&
3173 + newSuspenseNode !== null
3174 + ) {
3175 + // Modern Suspense path
3176 + const contentFiber = fiber.child;
3177 + if (contentFiber === null) {
3178 + throw new Error(
3179 + 'There should always be an Offscreen Fiber child in a Suspense boundary.',
3180 + );
3181 + }
3182 + const fallbackFiber = contentFiber.sibling;
3183 +
3184 + // First update only the Offscreen boundary. I.e. the main content.
3185 + mountVirtualChildrenRecursively(
3186 + contentFiber,
3187 + fallbackFiber,
3188 + traceNearestHostComponentUpdate,
3189 + 0, // first level
3190 + );
3191 +
3192 + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll
3193 + // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode.
3194 + // Since the fallback conceptually blocks the parent.
3195 + reconcilingParentSuspenseNode = stashedSuspenseParent;
3196 + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
3197 + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
3198 + shouldPopSuspenseNode = false;
3199 + if (fallbackFiber !== null) {
3200 + mountVirtualChildrenRecursively(
3201 + fallbackFiber,
3202 + null,
3203 + traceNearestHostComponentUpdate,
3204 + 0, // first level
3205 + );
3206 + }
3207 } else {
3208 if (fiber.child !== null) {
3209 mountChildrenRecursively(
@@ -3116,7 +3218,7 @@ export function attach(
3218 previouslyReconciledSibling = stashedPrevious;
3219 remainingReconcilingChildren = stashedRemaining;
3220 }
3119 - if (newSuspenseNode !== null) {
3221 + if (shouldPopSuspenseNode) {
3222 reconcilingParentSuspenseNode = stashedSuspenseParent;
3223 previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
3224 remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
@@ -3305,7 +3407,12 @@ export function attach(
3407 let child: null | DevToolsInstance = parentInstance.firstChild;
3408 while (child !== null) {
3409 if (child.kind === FILTERED_FIBER_INSTANCE) {
3308 - addUnfilteredChildrenIDs(child, nextChildren);
3410 + const fiber = child.data;
3411 + if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) {
3412 + // The children of this Offscreen are hidden so they don't get added.
3413 + } else {
3414 + addUnfilteredChildrenIDs(child, nextChildren);
3415 + }
3416 } else {
3417 nextChildren.push(child.id);
3418 }
@@ -3742,6 +3849,7 @@ export function attach(
3849 const stashedSuspenseParent = reconcilingParentSuspenseNode;
3850 const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode;
3851 const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes;
3852 + let shouldPopSuspenseNode = false;
3853 let previousSuspendedBy = null;
3854 if (fiberInstance !== null) {
3855 previousSuspendedBy = fiberInstance.suspendedBy;
@@ -3771,6 +3879,7 @@ export function attach(
3879 previouslyReconciledSiblingSuspenseNode = null;
3880 remainingReconcilingChildrenSuspenseNodes = suspenseNode.firstChild;
3881 suspenseNode.firstChild = null;
3882 + shouldPopSuspenseNode = true;
3883 }
3884 }
3885 try {
@@ -3888,25 +3997,93 @@ export function attach(
3997 );
3998 shouldResetChildren = true;
3999 }
3891 - } else if (prevWasHidden && nextIsHidden) {
3892 - // We don't update any children while they're still hidden.
4000 + } else if (nextIsHidden) {
4001 + if (!prevWasHidden) {
4002 + // We're hiding the children. Disconnect them from the front end but keep state.
4003 + if (fiberInstance !== null && !isInDisconnectedSubtree) {
4004 + disconnectChildrenRecursively(remainingReconcilingChildren);
4005 + }
4006 + }
4007 + // Update children inside the hidden tree if they committed with a new updates.
4008 + const stashedDisconnected = isInDisconnectedSubtree;
4009 + isInDisconnectedSubtree = true;
4010 + try {
4011 + updateChildrenRecursively(nextFiber.child, prevFiber.child, false);
4012 + } finally {
4013 + isInDisconnectedSubtree = stashedDisconnected;
4014 + }
4015 } else if (prevWasHidden && !nextIsHidden) {
4016 // We're revealing the hidden children. We now need to update them to the latest state.
3895 - if (nextFiber.child !== null) {
3896 - mountChildrenRecursively(
3897 - nextFiber.child,
3898 - traceNearestHostComponentUpdate,
4017 + // We do this while still in the disconnected state and then we reconnect the new ones.
4018 + // This avoids reconnecting things that are about to be removed anyway.
4019 + const stashedDisconnected = isInDisconnectedSubtree;
4020 + isInDisconnectedSubtree = true;
4021 + try {
4022 + if (nextFiber.child !== null) {
4023 + updateChildrenRecursively(nextFiber.child, prevFiber.child, false);
4024 + }
4025 + // Ensure we unmount any remaining children inside the isInDisconnectedSubtree flag
4026 + // since they should not trigger real deletions.
4027 + unmountRemainingChildren();
4028 + remainingReconcilingChildren = null;
4029 + } finally {
4030 + isInDisconnectedSubtree = stashedDisconnected;
4031 + }
4032 + if (fiberInstance !== null && !isInDisconnectedSubtree) {
4033 + reconnectChildrenRecursively(fiberInstance);
4034 + // Children may have reordered while they were hidden.
4035 + shouldResetChildren = true;
4036 + }
4037 + } else if (
4038 + nextFiber.tag === SuspenseComponent &&
4039 + OffscreenComponent !== -1 &&
4040 + fiberInstance !== null &&
4041 + fiberInstance.suspenseNode !== null
4042 + ) {
4043 + // Modern Suspense path
4044 + const prevContentFiber = prevFiber.child;
4045 + const nextContentFiber = nextFiber.child;
4046 + if (nextContentFiber === null || prevContentFiber === null) {
4047 + throw new Error(
4048 + 'There should always be an Offscreen Fiber child in a Suspense boundary.',
4049 );
4050 + }
4051 + const prevFallbackFiber = prevContentFiber.sibling;
4052 + const nextFallbackFiber = nextContentFiber.sibling;
4053 +
4054 + // First update only the Offscreen boundary. I.e. the main content.
4055 + if (
4056 + updateVirtualChildrenRecursively(
4057 + nextContentFiber,
4058 + nextFallbackFiber,
4059 + prevContentFiber,
4060 + traceNearestHostComponentUpdate,
4061 + 0,
4062 + )
4063 + ) {
4064 shouldResetChildren = true;
4065 }
3902 - } else if (!prevWasHidden && nextIsHidden) {
3903 - // We're hiding the children. We really just unmount them for now.
3904 - updateChildrenRecursively(
3905 - null,
3906 - prevFiber.child,
3907 - traceNearestHostComponentUpdate,
3908 - );
3909 - shouldResetChildren = true;
4066 +
4067 + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll
4068 + // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode.
4069 + // Since the fallback conceptually blocks the parent.
4070 + reconcilingParentSuspenseNode = stashedSuspenseParent;
4071 + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
4072 + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
4073 + shouldPopSuspenseNode = false;
4074 + if (nextFallbackFiber !== null) {
4075 + if (
4076 + updateVirtualChildrenRecursively(
4077 + nextFallbackFiber,
4078 + null,
4079 + prevFallbackFiber,
4080 + traceNearestHostComponentUpdate,
4081 + 0,
4082 + )
4083 + ) {
4084 + shouldResetChildren = true;
4085 + }
4086 + }
4087 } else {
4088 // Common case: Primary -> Primary.
4089 // This is the same code path as for non-Suspense fibers.
@@ -4000,7 +4177,7 @@ export function attach(
4177 reconcilingParent = stashedParent;
4178 previouslyReconciledSibling = stashedPrevious;
4179 remainingReconcilingChildren = stashedRemaining;
4003 - if (fiberInstance.suspenseNode !== null) {
4180 + if (shouldPopSuspenseNode) {
4181 reconcilingParentSuspenseNode = stashedSuspenseParent;
4182 previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
4183 remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
@@ -4009,6 +4186,51 @@ export function attach(
4186 }
4187 }
4188
4189 + function disconnectChildrenRecursively(firstChild: null | DevToolsInstance) {
4190 + for (let child = firstChild; child !== null; child = child.nextSibling) {
4191 + if (
4192 + (child.kind === FIBER_INSTANCE ||
4193 + child.kind === FILTERED_FIBER_INSTANCE) &&
4194 + child.data.tag === OffscreenComponent &&
4195 + child.data.memoizedState !== null
4196 + ) {
4197 + // This instance's children are already disconnected.
4198 + } else {
4199 + disconnectChildrenRecursively(child.firstChild);
4200 + }
4201 + if (child.kind === FIBER_INSTANCE) {
4202 + recordDisconnect(child);
4203 + } else if (child.kind === VIRTUAL_INSTANCE) {
4204 + recordVirtualDisconnect(child);
4205 + }
4206 + }
4207 + }
4208 +
4209 + function reconnectChildrenRecursively(parentInstance: DevToolsInstance) {
4210 + for (
4211 + let child = parentInstance.firstChild;
4212 + child !== null;
4213 + child = child.nextSibling
4214 + ) {
4215 + if (child.kind === FIBER_INSTANCE) {
4216 + recordReconnect(child, parentInstance);
4217 + } else if (child.kind === VIRTUAL_INSTANCE) {
4218 + const secondaryEnv = null; // TODO: We don't have this data anywhere. We could just stash it somewhere.
4219 + recordVirtualReconnect(child, parentInstance, secondaryEnv);
4220 + }
4221 + if (
4222 + (child.kind === FIBER_INSTANCE ||
4223 + child.kind === FILTERED_FIBER_INSTANCE) &&
4224 + child.data.tag === OffscreenComponent &&
4225 + child.data.memoizedState !== null
4226 + ) {
4227 + // This instance's children should remain disconnected.
4228 + } else {
4229 + reconnectChildrenRecursively(child);
4230 + }
4231 + }
4232 + }
4233 +
4234 function cleanup() {
4235 isProfiling = false;
4236 }