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.
Sebastian Markbåge committed
Oct 19, 2021 at 19:28 UTC
34e4c9756121ef89d4b31e75e590ebdd3f05587b
6 files changed
+93
-24
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+58
@@ -308,6 +308,64 @@ describe('ReactDOMServerPartialHydration', () => {
308
expect(deleted.length).toBe(1);
309
});
310
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
+
369
it('calls the onDeleted hydration callback if the parent gets deleted', async () => {
370
let suspend = false;
371
const promise = new Promise(() => {});
packages/react-dom/src/client/ReactDOMHostConfig.js
+3
@@ -751,6 +751,9 @@ function getNextHydratable(node) {
751
) {
752
break;
753
}
754
+ if (nodeData === SUSPENSE_END_DATA) {
755
+ return null;
756
+ }
757
}
758
}
759
}
packages/react-reconciler/src/ReactFiberCompleteWork.new.js
+5
-6
@@ -1007,16 +1007,16 @@ function completeWork(
1007
1008
if (enableSuspenseServerRenderer) {
1009
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);
1013
if (current === null) {
1011
- const wasHydrated = popHydrationState(workInProgress);
1012
-
1014
if (!wasHydrated) {
1015
throw new Error(
1016
'A dehydrated suspense component was completed without a hydrated node. ' +
1017
'This is probably a bug in React.',
1018
);
1019
}
1019
-
1020
prepareToHydrateHostSuspenseInstance(workInProgress);
1021
bubbleProperties(workInProgress);
1022
if (enableProfilerTimer) {
@@ -1034,9 +1034,8 @@ function completeWork(
1034
}
1035
return null;
1036
} 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.
1039
resetHydrationState();
1040
if ((workInProgress.flags & DidCapture) === NoFlags) {
1041
// This boundary did not suspend so it's now hydrated and unsuspended.
packages/react-reconciler/src/ReactFiberCompleteWork.old.js
+5
-6
@@ -1007,16 +1007,16 @@ function completeWork(
1007
1008
if (enableSuspenseServerRenderer) {
1009
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);
1013
if (current === null) {
1011
- const wasHydrated = popHydrationState(workInProgress);
1012
-
1014
if (!wasHydrated) {
1015
throw new Error(
1016
'A dehydrated suspense component was completed without a hydrated node. ' +
1017
'This is probably a bug in React.',
1018
);
1019
}
1019
-
1020
prepareToHydrateHostSuspenseInstance(workInProgress);
1021
bubbleProperties(workInProgress);
1022
if (enableProfilerTimer) {
@@ -1034,9 +1034,8 @@ function completeWork(
1034
}
1035
return null;
1036
} 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.
1039
resetHydrationState();
1040
if ((workInProgress.flags & DidCapture) === NoFlags) {
1041
// This boundary did not suspend so it's now hydrated and unsuspended.
packages/react-reconciler/src/ReactFiberHydrationContext.new.js
+11
-6
@@ -261,6 +261,8 @@ function tryHydrate(fiber, nextInstance) {
261
const instance = canHydrateInstance(nextInstance, type, props);
262
if (instance !== null) {
263
fiber.stateNode = (instance: Instance);
264
+ hydrationParentFiber = fiber;
265
+ nextHydratableInstance = getFirstHydratableChild(instance);
266
return true;
267
}
268
return false;
@@ -270,6 +272,9 @@ function tryHydrate(fiber, nextInstance) {
272
const textInstance = canHydrateTextInstance(nextInstance, text);
273
if (textInstance !== null) {
274
fiber.stateNode = (textInstance: TextInstance);
275
+ hydrationParentFiber = fiber;
276
+ // Text Instances don't have children so there's nothing to hydrate.
277
+ nextHydratableInstance = null;
278
return true;
279
}
280
return false;
@@ -294,6 +299,10 @@ function tryHydrate(fiber, nextInstance) {
299
);
300
dehydratedFragment.return = fiber;
301
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;
306
return true;
307
}
308
}
@@ -322,6 +331,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
331
// We use this as a heuristic. It's based on intuition and not data so it
332
// might be flawed or unnecessary.
333
nextInstance = getNextHydratableSibling(firstAttemptedInstance);
334
+ const prevHydrationParentFiber: Fiber = (hydrationParentFiber: any);
335
if (!nextInstance || !tryHydrate(fiber, nextInstance)) {
336
// Nothing to hydrate. Make it an insertion.
337
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
@@ -333,13 +343,8 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
343
// superfluous and we'll delete it. Since we can't eagerly delete it
344
// we'll have to schedule a deletion. To do that, this node needs a dummy
345
// fiber associated with it.
336
- deleteHydratableInstance(
337
- (hydrationParentFiber: any),
338
- firstAttemptedInstance,
339
- );
346
+ deleteHydratableInstance(prevHydrationParentFiber, firstAttemptedInstance);
347
}
341
- hydrationParentFiber = fiber;
342
- nextHydratableInstance = getFirstHydratableChild((nextInstance: any));
348
}
349
350
function prepareToHydrateHostInstance(
packages/react-reconciler/src/ReactFiberHydrationContext.old.js
+11
-6
@@ -261,6 +261,8 @@ function tryHydrate(fiber, nextInstance) {
261
const instance = canHydrateInstance(nextInstance, type, props);
262
if (instance !== null) {
263
fiber.stateNode = (instance: Instance);
264
+ hydrationParentFiber = fiber;
265
+ nextHydratableInstance = getFirstHydratableChild(instance);
266
return true;
267
}
268
return false;
@@ -270,6 +272,9 @@ function tryHydrate(fiber, nextInstance) {
272
const textInstance = canHydrateTextInstance(nextInstance, text);
273
if (textInstance !== null) {
274
fiber.stateNode = (textInstance: TextInstance);
275
+ hydrationParentFiber = fiber;
276
+ // Text Instances don't have children so there's nothing to hydrate.
277
+ nextHydratableInstance = null;
278
return true;
279
}
280
return false;
@@ -294,6 +299,10 @@ function tryHydrate(fiber, nextInstance) {
299
);
300
dehydratedFragment.return = fiber;
301
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;
306
return true;
307
}
308
}
@@ -322,6 +331,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
331
// We use this as a heuristic. It's based on intuition and not data so it
332
// might be flawed or unnecessary.
333
nextInstance = getNextHydratableSibling(firstAttemptedInstance);
334
+ const prevHydrationParentFiber: Fiber = (hydrationParentFiber: any);
335
if (!nextInstance || !tryHydrate(fiber, nextInstance)) {
336
// Nothing to hydrate. Make it an insertion.
337
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
@@ -333,13 +343,8 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
343
// superfluous and we'll delete it. Since we can't eagerly delete it
344
// we'll have to schedule a deletion. To do that, this node needs a dummy
345
// fiber associated with it.
336
- deleteHydratableInstance(
337
- (hydrationParentFiber: any),
338
- firstAttemptedInstance,
339
- );
346
+ deleteHydratableInstance(prevHydrationParentFiber, firstAttemptedInstance);
347
}
341
- hydrationParentFiber = fiber;
342
- nextHydratableInstance = getFirstHydratableChild((nextInstance: any));
348
}
349
350
function prepareToHydrateHostInstance(