Skip to content

Commit a5062b1

Browse files
committed
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.
1 parent fe0356c commit a5062b1

File tree

4 files changed

+71
-12
lines changed

4 files changed

+71
-12
lines changed

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

+58
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

+3
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

+5-6
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

+5-6
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.

0 commit comments

Comments
 (0)