diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
index 4b647e00032bc..505a100df3aa6 100644
--- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
@@ -308,6 +308,64 @@ describe('ReactDOMServerPartialHydration', () => {
expect(deleted.length).toBe(1);
});
+ it('hydrates an empty suspense boundary', async () => {
+ function App() {
+ return (
+
in ');
+ jest.runAllTimers();
+
+ expect(container.innerHTML).toContain('A');
+ expect(container.innerHTML).not.toContain('B');
+ expect(ref.current).toBe(span);
+ });
+
it('calls the onDeleted hydration callback if the parent gets deleted', async () => {
let suspend = false;
const promise = new Promise(() => {});
diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js
index 79b534d1e1812..8a5417ae49272 100644
--- a/packages/react-dom/src/client/ReactDOMHostConfig.js
+++ b/packages/react-dom/src/client/ReactDOMHostConfig.js
@@ -751,6 +751,9 @@ function getNextHydratable(node) {
) {
break;
}
+ if (nodeData === SUSPENSE_END_DATA) {
+ return null;
+ }
}
}
}
diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js
index a15702985e9a0..dadec516c3a4b 100644
--- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js
+++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js
@@ -1007,16 +1007,16 @@ function completeWork(
if (enableSuspenseServerRenderer) {
if (nextState !== null && nextState.dehydrated !== null) {
+ // We might be inside a hydration state the first time we're picking up this
+ // Suspense boundary, and also after we've reentered it for further hydration.
+ const wasHydrated = popHydrationState(workInProgress);
if (current === null) {
- const wasHydrated = popHydrationState(workInProgress);
-
if (!wasHydrated) {
throw new Error(
'A dehydrated suspense component was completed without a hydrated node. ' +
'This is probably a bug in React.',
);
}
-
prepareToHydrateHostSuspenseInstance(workInProgress);
bubbleProperties(workInProgress);
if (enableProfilerTimer) {
@@ -1034,9 +1034,8 @@ function completeWork(
}
return null;
} else {
- // We should never have been in a hydration state if we didn't have a current.
- // However, in some of those paths, we might have reentered a hydration state
- // and then we might be inside a hydration state. In that case, we'll need to exit out of it.
+ // We might have reentered this boundary to hydrate it. If so, we need to reset the hydration
+ // state since we're now exiting out of it. popHydrationState doesn't do that for us.
resetHydrationState();
if ((workInProgress.flags & DidCapture) === NoFlags) {
// This boundary did not suspend so it's now hydrated and unsuspended.
diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js
index 89b8b980bc3e3..06fbf5abff50f 100644
--- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js
+++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js
@@ -1007,16 +1007,16 @@ function completeWork(
if (enableSuspenseServerRenderer) {
if (nextState !== null && nextState.dehydrated !== null) {
+ // We might be inside a hydration state the first time we're picking up this
+ // Suspense boundary, and also after we've reentered it for further hydration.
+ const wasHydrated = popHydrationState(workInProgress);
if (current === null) {
- const wasHydrated = popHydrationState(workInProgress);
-
if (!wasHydrated) {
throw new Error(
'A dehydrated suspense component was completed without a hydrated node. ' +
'This is probably a bug in React.',
);
}
-
prepareToHydrateHostSuspenseInstance(workInProgress);
bubbleProperties(workInProgress);
if (enableProfilerTimer) {
@@ -1034,9 +1034,8 @@ function completeWork(
}
return null;
} else {
- // We should never have been in a hydration state if we didn't have a current.
- // However, in some of those paths, we might have reentered a hydration state
- // and then we might be inside a hydration state. In that case, we'll need to exit out of it.
+ // We might have reentered this boundary to hydrate it. If so, we need to reset the hydration
+ // state since we're now exiting out of it. popHydrationState doesn't do that for us.
resetHydrationState();
if ((workInProgress.flags & DidCapture) === NoFlags) {
// This boundary did not suspend so it's now hydrated and unsuspended.
diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js
index 6aad7f03339f5..7275f1663cad8 100644
--- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js
+++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js
@@ -261,6 +261,8 @@ function tryHydrate(fiber, nextInstance) {
const instance = canHydrateInstance(nextInstance, type, props);
if (instance !== null) {
fiber.stateNode = (instance: Instance);
+ hydrationParentFiber = fiber;
+ nextHydratableInstance = getFirstHydratableChild(instance);
return true;
}
return false;
@@ -270,6 +272,9 @@ function tryHydrate(fiber, nextInstance) {
const textInstance = canHydrateTextInstance(nextInstance, text);
if (textInstance !== null) {
fiber.stateNode = (textInstance: TextInstance);
+ hydrationParentFiber = fiber;
+ // Text Instances don't have children so there's nothing to hydrate.
+ nextHydratableInstance = null;
return true;
}
return false;
@@ -294,6 +299,10 @@ function tryHydrate(fiber, nextInstance) {
);
dehydratedFragment.return = fiber;
fiber.child = dehydratedFragment;
+ hydrationParentFiber = fiber;
+ // While a Suspense Instance does have children, we won't step into
+ // it during the first pass. Instead, we'll reenter it later.
+ nextHydratableInstance = null;
return true;
}
}
@@ -322,6 +331,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
// We use this as a heuristic. It's based on intuition and not data so it
// might be flawed or unnecessary.
nextInstance = getNextHydratableSibling(firstAttemptedInstance);
+ const prevHydrationParentFiber: Fiber = (hydrationParentFiber: any);
if (!nextInstance || !tryHydrate(fiber, nextInstance)) {
// Nothing to hydrate. Make it an insertion.
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
@@ -333,13 +343,8 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
// superfluous and we'll delete it. Since we can't eagerly delete it
// we'll have to schedule a deletion. To do that, this node needs a dummy
// fiber associated with it.
- deleteHydratableInstance(
- (hydrationParentFiber: any),
- firstAttemptedInstance,
- );
+ deleteHydratableInstance(prevHydrationParentFiber, firstAttemptedInstance);
}
- hydrationParentFiber = fiber;
- nextHydratableInstance = getFirstHydratableChild((nextInstance: any));
}
function prepareToHydrateHostInstance(
diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js
index fd0dd8e99a5b0..654de3f9a2894 100644
--- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js
+++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js
@@ -261,6 +261,8 @@ function tryHydrate(fiber, nextInstance) {
const instance = canHydrateInstance(nextInstance, type, props);
if (instance !== null) {
fiber.stateNode = (instance: Instance);
+ hydrationParentFiber = fiber;
+ nextHydratableInstance = getFirstHydratableChild(instance);
return true;
}
return false;
@@ -270,6 +272,9 @@ function tryHydrate(fiber, nextInstance) {
const textInstance = canHydrateTextInstance(nextInstance, text);
if (textInstance !== null) {
fiber.stateNode = (textInstance: TextInstance);
+ hydrationParentFiber = fiber;
+ // Text Instances don't have children so there's nothing to hydrate.
+ nextHydratableInstance = null;
return true;
}
return false;
@@ -294,6 +299,10 @@ function tryHydrate(fiber, nextInstance) {
);
dehydratedFragment.return = fiber;
fiber.child = dehydratedFragment;
+ hydrationParentFiber = fiber;
+ // While a Suspense Instance does have children, we won't step into
+ // it during the first pass. Instead, we'll reenter it later.
+ nextHydratableInstance = null;
return true;
}
}
@@ -322,6 +331,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
// We use this as a heuristic. It's based on intuition and not data so it
// might be flawed or unnecessary.
nextInstance = getNextHydratableSibling(firstAttemptedInstance);
+ const prevHydrationParentFiber: Fiber = (hydrationParentFiber: any);
if (!nextInstance || !tryHydrate(fiber, nextInstance)) {
// Nothing to hydrate. Make it an insertion.
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
@@ -333,13 +343,8 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
// superfluous and we'll delete it. Since we can't eagerly delete it
// we'll have to schedule a deletion. To do that, this node needs a dummy
// fiber associated with it.
- deleteHydratableInstance(
- (hydrationParentFiber: any),
- firstAttemptedInstance,
- );
+ deleteHydratableInstance(prevHydrationParentFiber, firstAttemptedInstance);
}
- hydrationParentFiber = fiber;
- nextHydratableInstance = getFirstHydratableChild((nextInstance: any));
}
function prepareToHydrateHostInstance(