Skip to content

Commit 2cb8edb

Browse files
authored
[DevTools] Handle dehydrated Suspense boundaries (facebook#34196)
1 parent 431bb0b commit 2cb8edb

File tree

2 files changed

+213
-99
lines changed

2 files changed

+213
-99
lines changed

packages/react-devtools-shared/src/backend/fiber/renderer.js

Lines changed: 212 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1543,6 +1543,22 @@ export function attach(
15431543
return Array.from(knownEnvironmentNames);
15441544
}
15451545
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+
15461562
function shouldFilterVirtual(
15471563
data: ReactComponentInfo,
15481564
secondaryEnv: null | string,
@@ -3610,6 +3626,50 @@ export function attach(
36103626
);
36113627
}
36123628
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+
36133673
function mountFiberRecursively(
36143674
fiber: Fiber,
36153675
traceNearestHostComponentUpdate: boolean,
@@ -3632,11 +3692,17 @@ export function attach(
36323692
newSuspenseNode.rects = measureInstance(newInstance);
36333693
}
36343694
} 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.
36403706
}
36413707
const isTimedOut = fiber.memoizedState !== null;
36423708
if (!isTimedOut) {
@@ -3684,13 +3750,20 @@ export function attach(
36843750
newSuspenseNode.rects = measureInstance(newInstance);
36853751
}
36863752
} 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.
36923764
}
3693-
const isTimedOut = fiber.memoizedState !== null;
3765+
const suspenseState = fiber.memoizedState;
3766+
const isTimedOut = suspenseState !== null;
36943767
if (!isTimedOut) {
36953768
newSuspenseNode.rects = measureInstance(newInstance);
36963769
}
@@ -3820,38 +3893,26 @@ export function attach(
38203893
) {
38213894
// Modern Suspense path
38223895
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+
}
38323903
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);
38403905
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,
38523908
traceNearestHostComponentUpdate,
3853-
0, // first level
3909+
stashedSuspenseParent,
3910+
stashedSuspensePrevious,
3911+
stashedSuspenseRemaining,
38543912
);
3913+
} else {
3914+
// This Suspense Fiber is still dehydrated. It won't have any children
3915+
// until hydration.
38553916
}
38563917
} else {
38573918
if (fiber.child !== null) {
@@ -4505,6 +4566,63 @@ export function attach(
45054566
);
45064567
}
45074568
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+
45084626
// Returns whether closest unfiltered fiber parent needs to reset its child list.
45094627
function updateFiberRecursively(
45104628
fiberInstance: null | FiberInstance | FilteredFiberInstance, // null if this should be filtered
@@ -4765,71 +4883,67 @@ export function attach(
47654883
fiberInstance.suspenseNode !== null
47664884
) {
47674885
// Modern Suspense path
4886+
const suspenseNode = fiberInstance.suspenseNode;
47684887
const prevContentFiber = prevFiber.child;
47694888
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+
}
47774897
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,
47824916
);
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+
}
47844930
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);
47934932
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.
48334947
}
48344948
} else {
48354949
// Common case: Primary -> Primary.

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1796,7 +1796,7 @@ function updateHostRoot(
17961796
}
17971797

17981798
const nextProps = workInProgress.pendingProps;
1799-
const prevState = workInProgress.memoizedState;
1799+
const prevState: RootState = workInProgress.memoizedState;
18001800
const prevChildren = prevState.element;
18011801
cloneUpdateQueue(current, workInProgress);
18021802
processUpdateQueue(workInProgress, nextProps, null, renderLanes);

0 commit comments

Comments
 (0)