@samitouri / QOS-React / commits / 2cb8edbb05

[DevTools] Handle dehydrated Suspense boundaries (#34196)

Sebastian "Sebbie" Silbermann committed Aug 16, 2025 at 10:34 UTC 2cb8edbb058431c3ebd2c78661a4819c2e8c62c5
2 files changed +213 -99
packages/react-devtools-shared/src/backend/fiber/renderer.js
+212 -98
@@ -1543,6 +1543,22 @@ export function attach(
1543 return Array.from(knownEnvironmentNames);
1544 }
1545
1546 + function isFiberHydrated(fiber: Fiber): boolean {
1547 + if (OffscreenComponent === -1) {
1548 + throw new Error('not implemented for legacy suspense');
1549 + }
1550 + switch (fiber.tag) {
1551 + case HostRoot:
1552 + const rootState = fiber.memoizedState;
1553 + return !rootState.isDehydrated;
1554 + case SuspenseComponent:
1555 + const suspenseState = fiber.memoizedState;
1556 + return suspenseState === null || suspenseState.dehydrated === null;
1557 + default:
1558 + throw new Error('not implemented for work tag ' + fiber.tag);
1559 + }
1560 + }
1561 +
1562 function shouldFilterVirtual(
1563 data: ReactComponentInfo,
1564 secondaryEnv: null | string,
@@ -3610,6 +3626,50 @@ export function attach(
3626 );
3627 }
3628
3629 + function mountSuspenseChildrenRecursively(
3630 + contentFiber: Fiber,
3631 + traceNearestHostComponentUpdate: boolean,
3632 + stashedSuspenseParent: SuspenseNode | null,
3633 + stashedSuspensePrevious: SuspenseNode | null,
3634 + stashedSuspenseRemaining: SuspenseNode | null,
3635 + ) {
3636 + const fallbackFiber = contentFiber.sibling;
3637 +
3638 + // First update only the Offscreen boundary. I.e. the main content.
3639 + mountVirtualChildrenRecursively(
3640 + contentFiber,
3641 + fallbackFiber,
3642 + traceNearestHostComponentUpdate,
3643 + 0, // first level
3644 + );
3645 +
3646 + if (fallbackFiber !== null) {
3647 + const fallbackStashedSuspenseParent = stashedSuspenseParent;
3648 + const fallbackStashedSuspensePrevious = stashedSuspensePrevious;
3649 + const fallbackStashedSuspenseRemaining = stashedSuspenseRemaining;
3650 + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll
3651 + // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode.
3652 + // Since the fallback conceptually blocks the parent.
3653 + reconcilingParentSuspenseNode = stashedSuspenseParent;
3654 + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
3655 + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
3656 + try {
3657 + mountVirtualChildrenRecursively(
3658 + fallbackFiber,
3659 + null,
3660 + traceNearestHostComponentUpdate,
3661 + 0, // first level
3662 + );
3663 + } finally {
3664 + reconcilingParentSuspenseNode = fallbackStashedSuspenseParent;
3665 + previouslyReconciledSiblingSuspenseNode =
3666 + fallbackStashedSuspensePrevious;
3667 + remainingReconcilingChildrenSuspenseNodes =
3668 + fallbackStashedSuspenseRemaining;
3669 + }
3670 + }
3671 + }
3672 +
3673 function mountFiberRecursively(
3674 fiber: Fiber,
3675 traceNearestHostComponentUpdate: boolean,
@@ -3632,11 +3692,17 @@ export function attach(
3692 newSuspenseNode.rects = measureInstance(newInstance);
3693 }
3694 } else {
3635 - const contentFiber = fiber.child;
3636 - if (contentFiber === null) {
3637 - throw new Error(
3638 - 'There should always be an Offscreen Fiber child in a Suspense boundary.',
3639 - );
3695 + const hydrated = isFiberHydrated(fiber);
3696 + if (hydrated) {
3697 + const contentFiber = fiber.child;
3698 + if (contentFiber === null) {
3699 + throw new Error(
3700 + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.',
3701 + );
3702 + }
3703 + } else {
3704 + // This Suspense Fiber is still dehydrated. It won't have any children
3705 + // until hydration.
3706 }
3707 const isTimedOut = fiber.memoizedState !== null;
3708 if (!isTimedOut) {
@@ -3684,13 +3750,20 @@ export function attach(
3750 newSuspenseNode.rects = measureInstance(newInstance);
3751 }
3752 } else {
3687 - const contentFiber = fiber.child;
3688 - if (contentFiber === null) {
3689 - throw new Error(
3690 - 'There should always be an Offscreen Fiber child in a Suspense boundary.',
3691 - );
3753 + const hydrated = isFiberHydrated(fiber);
3754 + if (hydrated) {
3755 + const contentFiber = fiber.child;
3756 + if (contentFiber === null) {
3757 + throw new Error(
3758 + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.',
3759 + );
3760 + }
3761 + } else {
3762 + // This Suspense Fiber is still dehydrated. It won't have any children
3763 + // until hydration.
3764 }
3693 - const isTimedOut = fiber.memoizedState !== null;
3765 + const suspenseState = fiber.memoizedState;
3766 + const isTimedOut = suspenseState !== null;
3767 if (!isTimedOut) {
3768 newSuspenseNode.rects = measureInstance(newInstance);
3769 }
@@ -3820,38 +3893,26 @@ export function attach(
3893 ) {
3894 // Modern Suspense path
3895 const contentFiber = fiber.child;
3823 - if (contentFiber === null) {
3824 - throw new Error(
3825 - 'There should always be an Offscreen Fiber child in a Suspense boundary.',
3826 - );
3827 - }
3828 -
3829 - trackThrownPromisesFromRetryCache(newSuspenseNode, fiber.stateNode);
3830 -
3831 - const fallbackFiber = contentFiber.sibling;
3896 + const hydrated = isFiberHydrated(fiber);
3897 + if (hydrated) {
3898 + if (contentFiber === null) {
3899 + throw new Error(
3900 + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.',
3901 + );
3902 + }
3903
3833 - // First update only the Offscreen boundary. I.e. the main content.
3834 - mountVirtualChildrenRecursively(
3835 - contentFiber,
3836 - fallbackFiber,
3837 - traceNearestHostComponentUpdate,
3838 - 0, // first level
3839 - );
3904 + trackThrownPromisesFromRetryCache(newSuspenseNode, fiber.stateNode);
3905
3841 - // Next, we'll pop back out of the SuspenseNode that we added above and now we'll
3842 - // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode.
3843 - // Since the fallback conceptually blocks the parent.
3844 - reconcilingParentSuspenseNode = stashedSuspenseParent;
3845 - previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
3846 - remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
3847 - shouldPopSuspenseNode = false;
3848 - if (fallbackFiber !== null) {
3849 - mountVirtualChildrenRecursively(
3850 - fallbackFiber,
3851 - null,
3906 + mountSuspenseChildrenRecursively(
3907 + contentFiber,
3908 traceNearestHostComponentUpdate,
3853 - 0, // first level
3909 + stashedSuspenseParent,
3910 + stashedSuspensePrevious,
3911 + stashedSuspenseRemaining,
3912 );
3913 + } else {
3914 + // This Suspense Fiber is still dehydrated. It won't have any children
3915 + // until hydration.
3916 }
3917 } else {
3918 if (fiber.child !== null) {
@@ -4505,6 +4566,63 @@ export function attach(
4566 );
4567 }
4568
4569 + function updateSuspenseChildrenRecursively(
4570 + nextContentFiber: Fiber,
4571 + prevContentFiber: Fiber,
4572 + traceNearestHostComponentUpdate: boolean,
4573 + stashedSuspenseParent: null | SuspenseNode,
4574 + stashedSuspensePrevious: null | SuspenseNode,
4575 + stashedSuspenseRemaining: null | SuspenseNode,
4576 + ): number {
4577 + let updateFlags = NoUpdate;
4578 + const prevFallbackFiber = prevContentFiber.sibling;
4579 + const nextFallbackFiber = nextContentFiber.sibling;
4580 +
4581 + // First update only the Offscreen boundary. I.e. the main content.
4582 + updateFlags |= updateVirtualChildrenRecursively(
4583 + nextContentFiber,
4584 + nextFallbackFiber,
4585 + prevContentFiber,
4586 + traceNearestHostComponentUpdate,
4587 + 0,
4588 + );
4589 +
4590 + if (prevFallbackFiber !== null || nextFallbackFiber !== null) {
4591 + const fallbackStashedSuspenseParent = reconcilingParentSuspenseNode;
4592 + const fallbackStashedSuspensePrevious =
4593 + previouslyReconciledSiblingSuspenseNode;
4594 + const fallbackStashedSuspenseRemaining =
4595 + remainingReconcilingChildrenSuspenseNodes;
4596 + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll
4597 + // reconcile the fallback, reconciling anything in the context of the parent SuspenseNode.
4598 + // Since the fallback conceptually blocks the parent.
4599 + reconcilingParentSuspenseNode = stashedSuspenseParent;
4600 + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
4601 + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
4602 + try {
4603 + if (nextFallbackFiber === null) {
4604 + unmountRemainingChildren();
4605 + } else {
4606 + updateFlags |= updateVirtualChildrenRecursively(
4607 + nextFallbackFiber,
4608 + null,
4609 + prevFallbackFiber,
4610 + traceNearestHostComponentUpdate,
4611 + 0,
4612 + );
4613 + }
4614 + } finally {
4615 + reconcilingParentSuspenseNode = fallbackStashedSuspenseParent;
4616 + previouslyReconciledSiblingSuspenseNode =
4617 + fallbackStashedSuspensePrevious;
4618 + remainingReconcilingChildrenSuspenseNodes =
4619 + fallbackStashedSuspenseRemaining;
4620 + }
4621 + }
4622 +
4623 + return updateFlags;
4624 + }
4625 +
4626 // Returns whether closest unfiltered fiber parent needs to reset its child list.
4627 function updateFiberRecursively(
4628 fiberInstance: null | FiberInstance | FilteredFiberInstance, // null if this should be filtered
@@ -4765,71 +4883,67 @@ export function attach(
4883 fiberInstance.suspenseNode !== null
4884 ) {
4885 // Modern Suspense path
4886 + const suspenseNode = fiberInstance.suspenseNode;
4887 const prevContentFiber = prevFiber.child;
4888 const nextContentFiber = nextFiber.child;
4770 - if (nextContentFiber === null || prevContentFiber === null) {
4771 - throw new Error(
4772 - 'There should always be an Offscreen Fiber child in a Suspense boundary.',
4773 - );
4774 - }
4775 - const prevFallbackFiber = prevContentFiber.sibling;
4776 - const nextFallbackFiber = nextContentFiber.sibling;
4889 + const previousHydrated = isFiberHydrated(prevFiber);
4890 + const nextHydrated = isFiberHydrated(nextFiber);
4891 + if (previousHydrated && nextHydrated) {
4892 + if (nextContentFiber === null || prevContentFiber === null) {
4893 + throw new Error(
4894 + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.',
4895 + );
4896 + }
4897
4778 - if ((prevFiber.stateNode === null) !== (nextFiber.stateNode === null)) {
4779 - trackThrownPromisesFromRetryCache(
4780 - fiberInstance.suspenseNode,
4781 - nextFiber.stateNode,
4898 + if (
4899 + (prevFiber.stateNode === null) !==
4900 + (nextFiber.stateNode === null)
4901 + ) {
4902 + trackThrownPromisesFromRetryCache(
4903 + suspenseNode,
4904 + nextFiber.stateNode,
4905 + );
4906 + }
4907 +
4908 + shouldMeasureSuspenseNode = false;
4909 + updateFlags |= updateSuspenseChildrenRecursively(
4910 + nextContentFiber,
4911 + prevContentFiber,
4912 + traceNearestHostComponentUpdate,
4913 + stashedSuspenseParent,
4914 + stashedSuspensePrevious,
4915 + stashedSuspenseRemaining,
4916 );
4783 - }
4917 + if (nextFiber.memoizedState === null) {
4918 + // Measure this Suspense node in case it changed. We don't update the rect while
4919 + // we're inside a disconnected subtree nor if we are the Suspense boundary that
4920 + // is suspended. This lets us keep the rectangle of the displayed content while
4921 + // we're suspended to visualize the resulting state.
4922 + shouldMeasureSuspenseNode = !isInDisconnectedSubtree;
4923 + }
4924 + } else if (!previousHydrated && nextHydrated) {
4925 + if (nextContentFiber === null) {
4926 + throw new Error(
4927 + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.',
4928 + );
4929 + }
4930
4785 - // First update only the Offscreen boundary. I.e. the main content.
4786 - updateFlags |= updateVirtualChildrenRecursively(
4787 - nextContentFiber,
4788 - nextFallbackFiber,
4789 - prevContentFiber,
4790 - traceNearestHostComponentUpdate,
4791 - 0,
4792 - );
4931 + trackThrownPromisesFromRetryCache(suspenseNode, nextFiber.stateNode);
4932
4794 - shouldMeasureSuspenseNode = false;
4795 - if (prevFallbackFiber !== null || nextFallbackFiber !== null) {
4796 - const fallbackStashedSuspenseParent = reconcilingParentSuspenseNode;
4797 - const fallbackStashedSuspensePrevious =
4798 - previouslyReconciledSiblingSuspenseNode;
4799 - const fallbackStashedSuspenseRemaining =
4800 - remainingReconcilingChildrenSuspenseNodes;
4801 - // Next, we'll pop back out of the SuspenseNode that we added above and now we'll
4802 - // reconcile the fallback, reconciling anything in the context of the parent SuspenseNode.
4803 - // Since the fallback conceptually blocks the parent.
4804 - reconcilingParentSuspenseNode = stashedSuspenseParent;
4805 - previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
4806 - remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
4807 - try {
4808 - if (nextFallbackFiber === null) {
4809 - unmountRemainingChildren();
4810 - } else {
4811 - updateFlags |= updateVirtualChildrenRecursively(
4812 - nextFallbackFiber,
4813 - null,
4814 - prevFallbackFiber,
4815 - traceNearestHostComponentUpdate,
4816 - 0,
4817 - );
4818 - }
4819 - } finally {
4820 - reconcilingParentSuspenseNode = fallbackStashedSuspenseParent;
4821 - previouslyReconciledSiblingSuspenseNode =
4822 - fallbackStashedSuspensePrevious;
4823 - remainingReconcilingChildrenSuspenseNodes =
4824 - fallbackStashedSuspenseRemaining;
4825 - }
4826 - }
4827 - if (nextFiber.memoizedState === null) {
4828 - // Measure this Suspense node in case it changed. We don't update the rect while
4829 - // we're inside a disconnected subtree nor if we are the Suspense boundary that
4830 - // is suspended. This lets us keep the rectangle of the displayed content while
4831 - // we're suspended to visualize the resulting state.
4832 - shouldMeasureSuspenseNode = !isInDisconnectedSubtree;
4933 + mountSuspenseChildrenRecursively(
4934 + nextContentFiber,
4935 + traceNearestHostComponentUpdate,
4936 + stashedSuspenseParent,
4937 + stashedSuspensePrevious,
4938 + stashedSuspenseRemaining,
4939 + );
4940 + } else if (previousHydrated && !nextHydrated) {
4941 + throw new Error(
4942 + 'Encountered a dehydrated Suspense boundary that was previously hydrated.',
4943 + );
4944 + } else {
4945 + // This Suspense Fiber is still dehydrated. It won't have any children
4946 + // until hydration.
4947 }
4948 } else {
4949 // Common case: Primary -> Primary.
packages/react-reconciler/src/ReactFiberBeginWork.js
+1 -1
@@ -1796,7 +1796,7 @@ function updateHostRoot(
1796 }
1797
1798 const nextProps = workInProgress.pendingProps;
1799 - const prevState = workInProgress.memoizedState;
1799 + const prevState: RootState = workInProgress.memoizedState;
1800 const prevChildren = prevState.element;
1801 cloneUpdateQueue(current, workInProgress);
1802 processUpdateQueue(workInProgress, nextProps, null, renderLanes);