Skip to content

Commit 34e4c97

Browse files
authored
Clear extra nodes if there's a hydration mismatch within a suspense boundary (#22592)
* Clear extra nodes if there's a mismatch within a suspense boundary This usually happens when we exit out a DOM node but a suspense boundary is a virtual DOM node and we didn't do it in that case because we took a short cut by calling resetHydrationState directly since we know we won't need to pop. * Tighten up the types of getFirstHydratableChild We currently call getFirstHydratableChild to step into the children of a suspense boundary. This can be a text node or a suspense boundary which isn't compatible with getFirstHydratableChild, and we cheat the type. This accidentally works because .firstChild always returns null on those nodes in the DOM. This just makes that explicit.
1 parent fe0356c commit 34e4c97

File tree

6 files changed

+93
-24
lines changed

6 files changed

+93
-24
lines changed

packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,64 @@ describe('ReactDOMServerPartialHydration', () => {
308308
expect(deleted.length).toBe(1);
309309
});
310310

311+
it('hydrates an empty suspense boundary', async () => {
312+
function App() {
313+
return (
314+
<div>
315+
<Suspense fallback="Loading..." />
316+
<div>Sibling</div>
317+
</div>
318+
);
319+
}
320+
321+
const finalHTML = ReactDOMServer.renderToString(<App />);
322+
323+
const container = document.createElement('div');
324+
container.innerHTML = finalHTML;
325+
326+
ReactDOM.hydrateRoot(container, <App />);
327+
Scheduler.unstable_flushAll();
328+
jest.runAllTimers();
329+
330+
expect(container.innerHTML).toContain('<div>Sibling</div>');
331+
});
332+
333+
it('recovers when server rendered additional nodes', async () => {
334+
const ref = React.createRef();
335+
function App({hasB}) {
336+
return (
337+
<div>
338+
<Suspense fallback="Loading...">
339+
<span ref={ref}>A</span>
340+
{hasB ? <span>B</span> : null}
341+
</Suspense>
342+
<div>Sibling</div>
343+
</div>
344+
);
345+
}
346+
347+
const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
348+
349+
const container = document.createElement('div');
350+
container.innerHTML = finalHTML;
351+
352+
const span = container.getElementsByTagName('span')[0];
353+
354+
expect(container.innerHTML).toContain('<span>A</span>');
355+
expect(container.innerHTML).toContain('<span>B</span>');
356+
expect(ref.current).toBe(null);
357+
358+
ReactDOM.hydrateRoot(container, <App hasB={false} />);
359+
expect(() => {
360+
Scheduler.unstable_flushAll();
361+
}).toErrorDev('Did not expect server HTML to contain a <span> in <div>');
362+
jest.runAllTimers();
363+
364+
expect(container.innerHTML).toContain('<span>A</span>');
365+
expect(container.innerHTML).not.toContain('<span>B</span>');
366+
expect(ref.current).toBe(span);
367+
});
368+
311369
it('calls the onDeleted hydration callback if the parent gets deleted', async () => {
312370
let suspend = false;
313371
const promise = new Promise(() => {});

packages/react-dom/src/client/ReactDOMHostConfig.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,9 @@ function getNextHydratable(node) {
751751
) {
752752
break;
753753
}
754+
if (nodeData === SUSPENSE_END_DATA) {
755+
return null;
756+
}
754757
}
755758
}
756759
}

packages/react-reconciler/src/ReactFiberCompleteWork.new.js

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,16 +1007,16 @@ function completeWork(
10071007

10081008
if (enableSuspenseServerRenderer) {
10091009
if (nextState !== null && nextState.dehydrated !== null) {
1010+
// We might be inside a hydration state the first time we're picking up this
1011+
// Suspense boundary, and also after we've reentered it for further hydration.
1012+
const wasHydrated = popHydrationState(workInProgress);
10101013
if (current === null) {
1011-
const wasHydrated = popHydrationState(workInProgress);
1012-
10131014
if (!wasHydrated) {
10141015
throw new Error(
10151016
'A dehydrated suspense component was completed without a hydrated node. ' +
10161017
'This is probably a bug in React.',
10171018
);
10181019
}
1019-
10201020
prepareToHydrateHostSuspenseInstance(workInProgress);
10211021
bubbleProperties(workInProgress);
10221022
if (enableProfilerTimer) {
@@ -1034,9 +1034,8 @@ function completeWork(
10341034
}
10351035
return null;
10361036
} else {
1037-
// We should never have been in a hydration state if we didn't have a current.
1038-
// However, in some of those paths, we might have reentered a hydration state
1039-
// and then we might be inside a hydration state. In that case, we'll need to exit out of it.
1037+
// We might have reentered this boundary to hydrate it. If so, we need to reset the hydration
1038+
// state since we're now exiting out of it. popHydrationState doesn't do that for us.
10401039
resetHydrationState();
10411040
if ((workInProgress.flags & DidCapture) === NoFlags) {
10421041
// This boundary did not suspend so it's now hydrated and unsuspended.

packages/react-reconciler/src/ReactFiberCompleteWork.old.js

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,16 +1007,16 @@ function completeWork(
10071007

10081008
if (enableSuspenseServerRenderer) {
10091009
if (nextState !== null && nextState.dehydrated !== null) {
1010+
// We might be inside a hydration state the first time we're picking up this
1011+
// Suspense boundary, and also after we've reentered it for further hydration.
1012+
const wasHydrated = popHydrationState(workInProgress);
10101013
if (current === null) {
1011-
const wasHydrated = popHydrationState(workInProgress);
1012-
10131014
if (!wasHydrated) {
10141015
throw new Error(
10151016
'A dehydrated suspense component was completed without a hydrated node. ' +
10161017
'This is probably a bug in React.',
10171018
);
10181019
}
1019-
10201020
prepareToHydrateHostSuspenseInstance(workInProgress);
10211021
bubbleProperties(workInProgress);
10221022
if (enableProfilerTimer) {
@@ -1034,9 +1034,8 @@ function completeWork(
10341034
}
10351035
return null;
10361036
} else {
1037-
// We should never have been in a hydration state if we didn't have a current.
1038-
// However, in some of those paths, we might have reentered a hydration state
1039-
// and then we might be inside a hydration state. In that case, we'll need to exit out of it.
1037+
// We might have reentered this boundary to hydrate it. If so, we need to reset the hydration
1038+
// state since we're now exiting out of it. popHydrationState doesn't do that for us.
10401039
resetHydrationState();
10411040
if ((workInProgress.flags & DidCapture) === NoFlags) {
10421041
// This boundary did not suspend so it's now hydrated and unsuspended.

packages/react-reconciler/src/ReactFiberHydrationContext.new.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ function tryHydrate(fiber, nextInstance) {
261261
const instance = canHydrateInstance(nextInstance, type, props);
262262
if (instance !== null) {
263263
fiber.stateNode = (instance: Instance);
264+
hydrationParentFiber = fiber;
265+
nextHydratableInstance = getFirstHydratableChild(instance);
264266
return true;
265267
}
266268
return false;
@@ -270,6 +272,9 @@ function tryHydrate(fiber, nextInstance) {
270272
const textInstance = canHydrateTextInstance(nextInstance, text);
271273
if (textInstance !== null) {
272274
fiber.stateNode = (textInstance: TextInstance);
275+
hydrationParentFiber = fiber;
276+
// Text Instances don't have children so there's nothing to hydrate.
277+
nextHydratableInstance = null;
273278
return true;
274279
}
275280
return false;
@@ -294,6 +299,10 @@ function tryHydrate(fiber, nextInstance) {
294299
);
295300
dehydratedFragment.return = fiber;
296301
fiber.child = dehydratedFragment;
302+
hydrationParentFiber = fiber;
303+
// While a Suspense Instance does have children, we won't step into
304+
// it during the first pass. Instead, we'll reenter it later.
305+
nextHydratableInstance = null;
297306
return true;
298307
}
299308
}
@@ -322,6 +331,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
322331
// We use this as a heuristic. It's based on intuition and not data so it
323332
// might be flawed or unnecessary.
324333
nextInstance = getNextHydratableSibling(firstAttemptedInstance);
334+
const prevHydrationParentFiber: Fiber = (hydrationParentFiber: any);
325335
if (!nextInstance || !tryHydrate(fiber, nextInstance)) {
326336
// Nothing to hydrate. Make it an insertion.
327337
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
@@ -333,13 +343,8 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
333343
// superfluous and we'll delete it. Since we can't eagerly delete it
334344
// we'll have to schedule a deletion. To do that, this node needs a dummy
335345
// fiber associated with it.
336-
deleteHydratableInstance(
337-
(hydrationParentFiber: any),
338-
firstAttemptedInstance,
339-
);
346+
deleteHydratableInstance(prevHydrationParentFiber, firstAttemptedInstance);
340347
}
341-
hydrationParentFiber = fiber;
342-
nextHydratableInstance = getFirstHydratableChild((nextInstance: any));
343348
}
344349

345350
function prepareToHydrateHostInstance(

packages/react-reconciler/src/ReactFiberHydrationContext.old.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ function tryHydrate(fiber, nextInstance) {
261261
const instance = canHydrateInstance(nextInstance, type, props);
262262
if (instance !== null) {
263263
fiber.stateNode = (instance: Instance);
264+
hydrationParentFiber = fiber;
265+
nextHydratableInstance = getFirstHydratableChild(instance);
264266
return true;
265267
}
266268
return false;
@@ -270,6 +272,9 @@ function tryHydrate(fiber, nextInstance) {
270272
const textInstance = canHydrateTextInstance(nextInstance, text);
271273
if (textInstance !== null) {
272274
fiber.stateNode = (textInstance: TextInstance);
275+
hydrationParentFiber = fiber;
276+
// Text Instances don't have children so there's nothing to hydrate.
277+
nextHydratableInstance = null;
273278
return true;
274279
}
275280
return false;
@@ -294,6 +299,10 @@ function tryHydrate(fiber, nextInstance) {
294299
);
295300
dehydratedFragment.return = fiber;
296301
fiber.child = dehydratedFragment;
302+
hydrationParentFiber = fiber;
303+
// While a Suspense Instance does have children, we won't step into
304+
// it during the first pass. Instead, we'll reenter it later.
305+
nextHydratableInstance = null;
297306
return true;
298307
}
299308
}
@@ -322,6 +331,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
322331
// We use this as a heuristic. It's based on intuition and not data so it
323332
// might be flawed or unnecessary.
324333
nextInstance = getNextHydratableSibling(firstAttemptedInstance);
334+
const prevHydrationParentFiber: Fiber = (hydrationParentFiber: any);
325335
if (!nextInstance || !tryHydrate(fiber, nextInstance)) {
326336
// Nothing to hydrate. Make it an insertion.
327337
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
@@ -333,13 +343,8 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
333343
// superfluous and we'll delete it. Since we can't eagerly delete it
334344
// we'll have to schedule a deletion. To do that, this node needs a dummy
335345
// fiber associated with it.
336-
deleteHydratableInstance(
337-
(hydrationParentFiber: any),
338-
firstAttemptedInstance,
339-
);
346+
deleteHydratableInstance(prevHydrationParentFiber, firstAttemptedInstance);
340347
}
341-
hydrationParentFiber = fiber;
342-
nextHydratableInstance = getFirstHydratableChild((nextInstance: any));
343348
}
344349

345350
function prepareToHydrateHostInstance(

0 commit comments

Comments
 (0)