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