From dc2993dade624d7173aea85a822395c979b34f34 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 31 Jul 2025 00:16:12 -0400 Subject: [PATCH 1/8] Disconnect and Reconnect offscreen content instead of unmounting/remounting --- .../src/backend/fiber/renderer.js | 128 +++++++++++++++--- 1 file changed, 106 insertions(+), 22 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index b6bc24dd01b4c..497603ca3d4fc 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2173,14 +2173,24 @@ export function attach( } idToDevToolsInstanceMap.set(fiberInstance.id, fiberInstance); - const id = fiberInstance.id; - if (__DEBUG__) { debug('recordMount()', fiberInstance, parentInstance); } + return recordReconnect(fiberInstance, parentInstance); + } + + function recordReconnect( + fiberInstance: FiberInstance, + parentInstance: DevToolsInstance | null, + ): FiberInstance { + const id = fiberInstance.id; + const fiber = fiberInstance.data; + const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration'); + const isRoot = fiber.tag === HostRoot; + if (isRoot) { const hasOwnerMetadata = fiber.hasOwnProperty('_debugOwner'); @@ -2304,6 +2314,14 @@ export function attach( idToDevToolsInstanceMap.set(id, instance); + recordVirtualReconnect(instance, parentInstance, secondaryEnv); + } + + function recordVirtualReconnect( + instance: VirtualInstance, + parentInstance: DevToolsInstance | null, + secondaryEnv: null | string, + ): void { const componentInfo = instance.data; const key = @@ -2355,6 +2373,8 @@ export function attach( const keyString = key === null ? null : String(key); const keyStringID = getStringID(keyString); + const id = instance.id; + pushOperation(TREE_OPERATION_ADD); pushOperation(id); pushOperation(elementType); @@ -2369,14 +2389,23 @@ export function attach( } function recordUnmount(fiberInstance: FiberInstance): void { - const fiber = fiberInstance.data; if (__DEBUG__) { debug('recordUnmount()', fiberInstance, reconcilingParent); } + recordDisconnect(fiberInstance); + + idToDevToolsInstanceMap.delete(fiberInstance.id); + + untrackFiber(fiberInstance, fiberInstance.data); + } + + function recordDisconnect(fiberInstance: FiberInstance): void { + const fiber = fiberInstance.data; + if (trackedPathMatchInstance === fiberInstance) { // We're in the process of trying to restore previous selection. - // If this fiber matched but is being unmounted, there's no use trying. + // If this fiber matched but is being hidden, there's no use trying. // Reset the state so we don't keep holding onto it. setTrackedPath(null); } @@ -2393,10 +2422,6 @@ export function attach( // and later arrange them in the correct order. pendingRealUnmountedIDs.push(id); } - - idToDevToolsInstanceMap.delete(fiberInstance.id); - - untrackFiber(fiberInstance, fiber); } // Running state of the remaining children from the previous version of this parent that @@ -2811,6 +2836,11 @@ export function attach( } function recordVirtualUnmount(instance: VirtualInstance) { + recordVirtualDisconnect(instance); + idToDevToolsInstanceMap.delete(instance.id); + } + + function recordVirtualDisconnect(instance: VirtualInstance) { if (trackedPathMatchInstance === instance) { // We're in the process of trying to restore previous selection. // If this fiber matched but is being unmounted, there's no use trying. @@ -2820,8 +2850,6 @@ export function attach( const id = instance.id; pendingRealUnmountedIDs.push(id); - - idToDevToolsInstanceMap.delete(instance.id); } function getSecondaryEnvironmentName( @@ -3887,21 +3915,28 @@ export function attach( // We don't update any children while they're still hidden. } else if (prevWasHidden && !nextIsHidden) { // We're revealing the hidden children. We now need to update them to the latest state. + if (fiberInstance !== null) { + reconnectChildrenRecursively(fiberInstance); + } if (nextFiber.child !== null) { - mountChildrenRecursively( - nextFiber.child, - traceNearestHostComponentUpdate, - ); - shouldResetChildren = true; + if ( + updateChildrenRecursively( + prevFiber.child, // TODO: This is not safe because it could have updated multiple times before. + nextFiber.child, + traceNearestHostComponentUpdate, + ) + ) { + shouldResetChildren = true; + } } } else if (!prevWasHidden && nextIsHidden) { - // We're hiding the children. We really just unmount them for now. - updateChildrenRecursively( - null, - prevFiber.child, - traceNearestHostComponentUpdate, - ); - shouldResetChildren = true; + // We're hiding the children. Disconnect them from the front end but keep state. + if (fiberInstance !== null) { + disconnectChildrenRecursively(fiberInstance); + fiberInstance.firstChild = remainingReconcilingChildren; // Restore the set, we won't readd them. + remainingReconcilingChildren = null; // Don't delete them. We've consumed them. + fiberInstance.suspendedBy = previousSuspendedBy; // Restore the suspended + } } else { // Common case: Primary -> Primary. // This is the same code path as for non-Suspense fibers. @@ -4004,6 +4039,55 @@ export function attach( } } + function disconnectChildrenRecursively(parentInstance: DevToolsInstance) { + for ( + let child = parentInstance.firstChild; + child !== null; + child = child.nextSibling + ) { + if ( + (child.kind === FIBER_INSTANCE || + child.kind === FILTERED_FIBER_INSTANCE) && + child.data.tag === OffscreenComponent && + child.data.memoizedState !== null + ) { + // This instance's children are already disconnected. + } else { + disconnectChildrenRecursively(child); + } + if (child.kind === FIBER_INSTANCE) { + recordDisconnect(child); + } else if (child.kind === VIRTUAL_INSTANCE) { + recordVirtualDisconnect(child); + } + } + } + + function reconnectChildrenRecursively(parentInstance: DevToolsInstance) { + for ( + let child = parentInstance.firstChild; + child !== null; + child = child.nextSibling + ) { + if (child.kind === FIBER_INSTANCE) { + recordReconnect(child, parentInstance); + } else if (child.kind === VIRTUAL_INSTANCE) { + const secondaryEnv = null; // TODO: We don't have this data anywhere. We could just stash it somewhere. + recordVirtualReconnect(child, parentInstance, secondaryEnv); + } + if ( + (child.kind === FIBER_INSTANCE || + child.kind === FILTERED_FIBER_INSTANCE) && + child.data.tag === OffscreenComponent && + child.data.memoizedState !== null + ) { + // This instance's children should remain disconnected. + } else { + reconnectChildrenRecursively(child); + } + } + } + function cleanup() { isProfiling = false; } From 45a9084c628b7782b8cca3bd5dc24b9da35665e5 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 1 Aug 2025 20:09:37 -0400 Subject: [PATCH 2/8] Keep updating inside disconnected subtrees --- .../src/backend/fiber/renderer.js | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 497603ca3d4fc..62401ed1b132f 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2156,6 +2156,8 @@ export function attach( return id; } + let isInDisconnectedSubtree = false; + function recordMount( fiber: Fiber, parentInstance: DevToolsInstance | null, @@ -3911,17 +3913,34 @@ export function attach( ); shouldResetChildren = true; } - } else if (prevWasHidden && nextIsHidden) { - // We don't update any children while they're still hidden. + } else if (nextIsHidden) { + if (!prevWasHidden) { + // We're hiding the children. Disconnect them from the front end but keep state. + if (fiberInstance !== null && !isInDisconnectedSubtree) { + disconnectChildrenRecursively(fiberInstance); + } + } + // Update children inside the hidden tree if they committed with a new updates. + const stashedDisconnected = isInDisconnectedSubtree; + isInDisconnectedSubtree = true; + try { + updateChildrenRecursively( + prevFiber.child, + nextFiber.child, + traceNearestHostComponentUpdate, + ); + } finally { + isInDisconnectedSubtree = stashedDisconnected; + } } else if (prevWasHidden && !nextIsHidden) { // We're revealing the hidden children. We now need to update them to the latest state. - if (fiberInstance !== null) { + if (fiberInstance !== null && !isInDisconnectedSubtree) { reconnectChildrenRecursively(fiberInstance); } if (nextFiber.child !== null) { if ( updateChildrenRecursively( - prevFiber.child, // TODO: This is not safe because it could have updated multiple times before. + prevFiber.child, nextFiber.child, traceNearestHostComponentUpdate, ) @@ -3929,14 +3948,6 @@ export function attach( shouldResetChildren = true; } } - } else if (!prevWasHidden && nextIsHidden) { - // We're hiding the children. Disconnect them from the front end but keep state. - if (fiberInstance !== null) { - disconnectChildrenRecursively(fiberInstance); - fiberInstance.firstChild = remainingReconcilingChildren; // Restore the set, we won't readd them. - remainingReconcilingChildren = null; // Don't delete them. We've consumed them. - fiberInstance.suspendedBy = previousSuspendedBy; // Restore the suspended - } } else { // Common case: Primary -> Primary. // This is the same code path as for non-Suspense fibers. From 5f3eec0694289c8ed5a4c0d66c4405c930d76eae Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 2 Aug 2025 15:28:09 -0400 Subject: [PATCH 3/8] Don't send commands to store while in a disconnected subtree It's as if it doesn't exist from the store. --- .../src/backend/fiber/renderer.js | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 62401ed1b132f..2ae038052e52f 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2179,13 +2179,18 @@ export function attach( debug('recordMount()', fiberInstance, parentInstance); } - return recordReconnect(fiberInstance, parentInstance); + recordReconnect(fiberInstance, parentInstance); + return fiberInstance; } function recordReconnect( fiberInstance: FiberInstance, parentInstance: DevToolsInstance | null, - ): FiberInstance { + ): void { + if (isInDisconnectedSubtree) { + // We're disconnected. We'll reconnect a hidden mount after the parent reappears. + return; + } const id = fiberInstance.id; const fiber = fiberInstance.data; @@ -2304,7 +2309,6 @@ export function attach( if (isProfilingSupported) { recordProfilingDurations(fiberInstance, null); } - return fiberInstance; } function recordVirtualMount( @@ -2324,6 +2328,10 @@ export function attach( parentInstance: DevToolsInstance | null, secondaryEnv: null | string, ): void { + if (isInDisconnectedSubtree) { + // We're disconnected. We'll reconnect a hidden mount after the parent reappears. + return; + } const componentInfo = instance.data; const key = @@ -2403,6 +2411,10 @@ export function attach( } function recordDisconnect(fiberInstance: FiberInstance): void { + if (isInDisconnectedSubtree) { + // Already disconnected. + return; + } const fiber = fiberInstance.data; if (trackedPathMatchInstance === fiberInstance) { @@ -3099,7 +3111,16 @@ export function attach( } if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) { - // If an Offscreen component is hidden, don't mount its children yet. + // If an Offscreen component is hidden, mount its children as disconnected. + const stashedDisconnected = isInDisconnectedSubtree; + isInDisconnectedSubtree = true; + try { + if (fiber.child !== null) { + mountChildrenRecursively(fiber.child, false); + } + } finally { + isInDisconnectedSubtree = stashedDisconnected; + } } else if (fiber.tag === SuspenseComponent && OffscreenComponent === -1) { // Legacy Suspense without the Offscreen wrapper. For the modern Suspense we just handle the // Offscreen wrapper itself specially. @@ -3924,11 +3945,7 @@ export function attach( const stashedDisconnected = isInDisconnectedSubtree; isInDisconnectedSubtree = true; try { - updateChildrenRecursively( - prevFiber.child, - nextFiber.child, - traceNearestHostComponentUpdate, - ); + updateChildrenRecursively(prevFiber.child, nextFiber.child, false); } finally { isInDisconnectedSubtree = stashedDisconnected; } From 4a8a31775c017e703d6b9bffe3f9125346980d76 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 2 Aug 2025 17:08:34 -0400 Subject: [PATCH 4/8] Fix flipped argument order --- packages/react-devtools-shared/src/backend/fiber/renderer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 2ae038052e52f..e609f7a113b55 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -3945,7 +3945,7 @@ export function attach( const stashedDisconnected = isInDisconnectedSubtree; isInDisconnectedSubtree = true; try { - updateChildrenRecursively(prevFiber.child, nextFiber.child, false); + updateChildrenRecursively(nextFiber.child, prevFiber.child, false); } finally { isInDisconnectedSubtree = stashedDisconnected; } @@ -3957,8 +3957,8 @@ export function attach( if (nextFiber.child !== null) { if ( updateChildrenRecursively( - prevFiber.child, nextFiber.child, + prevFiber.child, traceNearestHostComponentUpdate, ) ) { From e76dbbd209d91bfed2c9c4425f97dc433e28368b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 2 Aug 2025 22:46:38 -0400 Subject: [PATCH 5/8] Disconnect the previous set before updating Reconnect after updating disconnected tree --- .../src/backend/fiber/renderer.js | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index e609f7a113b55..116aab2f168fd 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -3938,7 +3938,7 @@ export function attach( if (!prevWasHidden) { // We're hiding the children. Disconnect them from the front end but keep state. if (fiberInstance !== null && !isInDisconnectedSubtree) { - disconnectChildrenRecursively(fiberInstance); + disconnectChildrenRecursively(remainingReconcilingChildren); } } // Update children inside the hidden tree if they committed with a new updates. @@ -3951,19 +3951,25 @@ export function attach( } } else if (prevWasHidden && !nextIsHidden) { // We're revealing the hidden children. We now need to update them to the latest state. - if (fiberInstance !== null && !isInDisconnectedSubtree) { - reconnectChildrenRecursively(fiberInstance); - } - if (nextFiber.child !== null) { - if ( + // We do this while still in the disconnected state and then we reconnect the new ones. + // This avoids reconnecting things that are about to be removed anyway. + const stashedDisconnected = isInDisconnectedSubtree; + isInDisconnectedSubtree = true; + try { + if (nextFiber.child !== null) { updateChildrenRecursively( nextFiber.child, prevFiber.child, traceNearestHostComponentUpdate, - ) - ) { - shouldResetChildren = true; + ); } + } finally { + isInDisconnectedSubtree = stashedDisconnected; + } + if (fiberInstance !== null && !isInDisconnectedSubtree) { + reconnectChildrenRecursively(fiberInstance); + // Children may have reordered while they were hidden. + shouldResetChildren = true; } } else { // Common case: Primary -> Primary. @@ -4067,12 +4073,8 @@ export function attach( } } - function disconnectChildrenRecursively(parentInstance: DevToolsInstance) { - for ( - let child = parentInstance.firstChild; - child !== null; - child = child.nextSibling - ) { + function disconnectChildrenRecursively(firstChild: null | DevToolsInstance) { + for (let child = firstChild; child !== null; child = child.nextSibling) { if ( (child.kind === FIBER_INSTANCE || child.kind === FILTERED_FIBER_INSTANCE) && @@ -4081,7 +4083,7 @@ export function attach( ) { // This instance's children are already disconnected. } else { - disconnectChildrenRecursively(child); + disconnectChildrenRecursively(child.firstChild); } if (child.kind === FIBER_INSTANCE) { recordDisconnect(child); From 2a00c96d532b8a37220054be2e4f38e4d26d1ee7 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 2 Aug 2025 22:51:09 -0400 Subject: [PATCH 6/8] Ignore children of hidden offscreen when reordering children --- .../react-devtools-shared/src/backend/fiber/renderer.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 116aab2f168fd..7cfed8a6004b2 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -3356,7 +3356,12 @@ export function attach( let child: null | DevToolsInstance = parentInstance.firstChild; while (child !== null) { if (child.kind === FILTERED_FIBER_INSTANCE) { - addUnfilteredChildrenIDs(child, nextChildren); + const fiber = child.data; + if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) { + // The children of this Offscreen are hidden so they don't get added. + } else { + addUnfilteredChildrenIDs(child, nextChildren); + } } else { nextChildren.push(child.id); } From 918e8996691441eb0df4708da83dd3b6f6bbff58 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 2 Aug 2025 23:15:58 -0400 Subject: [PATCH 7/8] Ensure unmounting is executed with the right isInDisconnectedSubtree flag --- .../src/backend/fiber/renderer.js | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 7cfed8a6004b2..8639e85832abf 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2733,10 +2733,31 @@ export function attach( } function unmountRemainingChildren() { - let child = remainingReconcilingChildren; - while (child !== null) { - unmountInstanceRecursively(child); - child = remainingReconcilingChildren; + if ( + reconcilingParent !== null && + (reconcilingParent.kind === FIBER_INSTANCE || + reconcilingParent.kind === FILTERED_FIBER_INSTANCE) && + reconcilingParent.data.tag === OffscreenComponent && + reconcilingParent.data.memoizedState !== null && + !isInDisconnectedSubtree + ) { + // This is a hidden offscreen, we need to execute this in the context of a disconnected subtree. + isInDisconnectedSubtree = true; + try { + let child = remainingReconcilingChildren; + while (child !== null) { + unmountInstanceRecursively(child); + child = remainingReconcilingChildren; + } + } finally { + isInDisconnectedSubtree = false; + } + } else { + let child = remainingReconcilingChildren; + while (child !== null) { + unmountInstanceRecursively(child); + child = remainingReconcilingChildren; + } } } @@ -2855,6 +2876,9 @@ export function attach( } function recordVirtualDisconnect(instance: VirtualInstance) { + if (isInDisconnectedSubtree) { + return; + } if (trackedPathMatchInstance === instance) { // We're in the process of trying to restore previous selection. // If this fiber matched but is being unmounted, there's no use trying. @@ -3962,12 +3986,12 @@ export function attach( isInDisconnectedSubtree = true; try { if (nextFiber.child !== null) { - updateChildrenRecursively( - nextFiber.child, - prevFiber.child, - traceNearestHostComponentUpdate, - ); + updateChildrenRecursively(nextFiber.child, prevFiber.child, false); } + // Ensure we unmount any remaining children inside the isInDisconnectedSubtree flag + // since they should not trigger real deletions. + unmountRemainingChildren(); + remainingReconcilingChildren = null; } finally { isInDisconnectedSubtree = stashedDisconnected; } From 97cb2c10b1717482046123a322785139d019cf1b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 2 Aug 2025 23:56:47 -0400 Subject: [PATCH 8/8] Reconcile fallback children inside the parent's SuspenseNode So that their suspends gets associated with the parent and not itself. --- .../src/backend/fiber/renderer.js | 113 +++++++++++++++--- 1 file changed, 96 insertions(+), 17 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 8639e85832abf..da8ecb023ead0 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2455,11 +2455,6 @@ export function attach( // the current parent here as well. let reconcilingParentSuspenseNode: null | SuspenseNode = null; - function isSuspenseInFallback(suspenseNode: SuspenseNode) { - const fiber = suspenseNode.instance.data; - return fiber.tag === SuspenseComponent && fiber.memoizedState !== null; - } - function ioExistsInSuspenseAncestor( suspenseNode: SuspenseNode, ioInfo: ReactIOInfo, @@ -2475,21 +2470,13 @@ export function attach( } function insertSuspendedBy(asyncInfo: ReactAsyncInfo): void { - let parentSuspenseNode = reconcilingParentSuspenseNode; - while ( - parentSuspenseNode !== null && - isSuspenseInFallback(parentSuspenseNode) - ) { - // If we have something that suspends inside the fallback tree of a Suspense boundary, then - // we bubble that up to the nearest parent Suspense boundary that isn't in fallback mode. - parentSuspenseNode = parentSuspenseNode.parent; - } - if (reconcilingParent === null || parentSuspenseNode === null) { + if (reconcilingParent === null || reconcilingParentSuspenseNode === null) { throw new Error( 'It should not be possible to have suspended data outside the root. ' + 'Even suspending at the first position is still a child of the root.', ); } + const parentSuspenseNode = reconcilingParentSuspenseNode; // Use the nearest unfiltered parent so that there's always some component that has // the entry on it even if you filter, or the root if all are filtered. let parentInstance = reconcilingParent; @@ -3096,10 +3083,12 @@ export function attach( previouslyReconciledSibling = null; remainingReconcilingChildren = null; } + let shouldPopSuspenseNode = false; if (newSuspenseNode !== null) { reconcilingParentSuspenseNode = newSuspenseNode; previouslyReconciledSiblingSuspenseNode = null; remainingReconcilingChildrenSuspenseNodes = null; + shouldPopSuspenseNode = true; } try { if (traceUpdatesEnabled) { @@ -3177,6 +3166,44 @@ export function attach( ); } } + } else if ( + fiber.tag === SuspenseComponent && + OffscreenComponent !== -1 && + newInstance !== null && + newSuspenseNode !== null + ) { + // Modern Suspense path + const contentFiber = fiber.child; + if (contentFiber === null) { + throw new Error( + 'There should always be an Offscreen Fiber child in a Suspense boundary.', + ); + } + const fallbackFiber = contentFiber.sibling; + + // First update only the Offscreen boundary. I.e. the main content. + mountVirtualChildrenRecursively( + contentFiber, + fallbackFiber, + traceNearestHostComponentUpdate, + 0, // first level + ); + + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll + // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode. + // Since the fallback conceptually blocks the parent. + reconcilingParentSuspenseNode = stashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; + shouldPopSuspenseNode = false; + if (fallbackFiber !== null) { + mountVirtualChildrenRecursively( + fallbackFiber, + null, + traceNearestHostComponentUpdate, + 0, // first level + ); + } } else { if (fiber.child !== null) { mountChildrenRecursively( @@ -3191,7 +3218,7 @@ export function attach( previouslyReconciledSibling = stashedPrevious; remainingReconcilingChildren = stashedRemaining; } - if (newSuspenseNode !== null) { + if (shouldPopSuspenseNode) { reconcilingParentSuspenseNode = stashedSuspenseParent; previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; @@ -3817,6 +3844,7 @@ export function attach( const stashedSuspenseParent = reconcilingParentSuspenseNode; const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode; const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes; + let shouldPopSuspenseNode = false; let previousSuspendedBy = null; if (fiberInstance !== null) { previousSuspendedBy = fiberInstance.suspendedBy; @@ -3846,6 +3874,7 @@ export function attach( previouslyReconciledSiblingSuspenseNode = null; remainingReconcilingChildrenSuspenseNodes = suspenseNode.firstChild; suspenseNode.firstChild = null; + shouldPopSuspenseNode = true; } } try { @@ -4000,6 +4029,56 @@ export function attach( // Children may have reordered while they were hidden. shouldResetChildren = true; } + } else if ( + nextFiber.tag === SuspenseComponent && + OffscreenComponent !== -1 && + fiberInstance !== null && + fiberInstance.suspenseNode !== null + ) { + // Modern Suspense path + const prevContentFiber = prevFiber.child; + const nextContentFiber = nextFiber.child; + if (nextContentFiber === null || prevContentFiber === null) { + throw new Error( + 'There should always be an Offscreen Fiber child in a Suspense boundary.', + ); + } + const prevFallbackFiber = prevContentFiber.sibling; + const nextFallbackFiber = nextContentFiber.sibling; + + // First update only the Offscreen boundary. I.e. the main content. + if ( + updateVirtualChildrenRecursively( + nextContentFiber, + nextFallbackFiber, + prevContentFiber, + traceNearestHostComponentUpdate, + 0, + ) + ) { + shouldResetChildren = true; + } + + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll + // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode. + // Since the fallback conceptually blocks the parent. + reconcilingParentSuspenseNode = stashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; + shouldPopSuspenseNode = false; + if (nextFallbackFiber !== null) { + if ( + updateVirtualChildrenRecursively( + nextFallbackFiber, + null, + prevFallbackFiber, + traceNearestHostComponentUpdate, + 0, + ) + ) { + shouldResetChildren = true; + } + } } else { // Common case: Primary -> Primary. // This is the same code path as for non-Suspense fibers. @@ -4093,7 +4172,7 @@ export function attach( reconcilingParent = stashedParent; previouslyReconciledSibling = stashedPrevious; remainingReconcilingChildren = stashedRemaining; - if (fiberInstance.suspenseNode !== null) { + if (shouldPopSuspenseNode) { reconcilingParentSuspenseNode = stashedSuspenseParent; previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;