@samitouri / QOS-React-2 / commits / 34e4c97561

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(