@samitouri / QOS-React-2 / commits / 3f5ff16c1a

[Hydration] Fallback to client render if server rendered extra nodes (#23176)

* rename * rename * replace-fork * rename * warn in a loop

salazarm committed Feb 1, 2022 at 14:52 UTC 3f5ff16c1a743bce3f3d772dc5ac63c5fd2dac0a
5 files changed +253 -26
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+117 -4
@@ -437,7 +437,7 @@ describe('ReactDOMServerPartialHydration', () => {
437 expect(container.innerHTML).toContain('<div>Sibling</div>');
438 });
439
440 - it('recovers when server rendered additional nodes', async () => {
440 + it('recovers with client render when server rendered additional nodes at suspense root', async () => {
441 const ref = React.createRef();
442 function App({hasB}) {
443 return (
@@ -462,15 +462,128 @@ describe('ReactDOMServerPartialHydration', () => {
462 expect(container.innerHTML).toContain('<span>B</span>');
463 expect(ref.current).toBe(null);
464
465 - ReactDOM.hydrateRoot(container, <App hasB={false} />);
465 expect(() => {
467 - Scheduler.unstable_flushAll();
466 + act(() => {
467 + ReactDOM.hydrateRoot(container, <App hasB={false} />);
468 + });
469 }).toErrorDev('Did not expect server HTML to contain a <span> in <div>');
470 +
471 jest.runAllTimers();
472
473 expect(container.innerHTML).toContain('<span>A</span>');
474 expect(container.innerHTML).not.toContain('<span>B</span>');
473 - expect(ref.current).toBe(span);
475 +
476 + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
477 + expect(ref.current).not.toBe(span);
478 + } else {
479 + expect(ref.current).toBe(span);
480 + }
481 + });
482 +
483 + it('recovers with client render when server rendered additional nodes at suspense root after unsuspending', async () => {
484 + spyOnDev(console, 'error');
485 + const ref = React.createRef();
486 + function App({hasB}) {
487 + return (
488 + <div>
489 + <Suspense fallback="Loading...">
490 + <Suspender />
491 + <span ref={ref}>A</span>
492 + {hasB ? <span>B</span> : null}
493 + </Suspense>
494 + <div>Sibling</div>
495 + </div>
496 + );
497 + }
498 +
499 + let shouldSuspend = false;
500 + let resolve;
501 + const promise = new Promise(res => {
502 + resolve = () => {
503 + shouldSuspend = false;
504 + res();
505 + };
506 + });
507 + function Suspender() {
508 + if (shouldSuspend) {
509 + throw promise;
510 + }
511 + return <></>;
512 + }
513 +
514 + const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
515 +
516 + const container = document.createElement('div');
517 + container.innerHTML = finalHTML;
518 +
519 + const span = container.getElementsByTagName('span')[0];
520 +
521 + expect(container.innerHTML).toContain('<span>A</span>');
522 + expect(container.innerHTML).toContain('<span>B</span>');
523 + expect(ref.current).toBe(null);
524 +
525 + shouldSuspend = true;
526 + act(() => {
527 + ReactDOM.hydrateRoot(container, <App hasB={false} />);
528 + });
529 +
530 + // await expect(async () => {
531 + resolve();
532 + await promise;
533 + Scheduler.unstable_flushAll();
534 + await null;
535 + jest.runAllTimers();
536 + // }).toErrorDev('Did not expect server HTML to contain a <span> in <div>');
537 +
538 + expect(container.innerHTML).toContain('<span>A</span>');
539 + expect(container.innerHTML).not.toContain('<span>B</span>');
540 + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
541 + expect(ref.current).not.toBe(span);
542 + } else {
543 + expect(ref.current).toBe(span);
544 + }
545 + });
546 +
547 + it('recovers with client render when server rendered additional nodes deep inside suspense root', async () => {
548 + const ref = React.createRef();
549 + function App({hasB}) {
550 + return (
551 + <div>
552 + <Suspense fallback="Loading...">
553 + <div>
554 + <span ref={ref}>A</span>
555 + {hasB ? <span>B</span> : null}
556 + </div>
557 + </Suspense>
558 + <div>Sibling</div>
559 + </div>
560 + );
561 + }
562 +
563 + const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
564 +
565 + const container = document.createElement('div');
566 + container.innerHTML = finalHTML;
567 +
568 + const span = container.getElementsByTagName('span')[0];
569 +
570 + expect(container.innerHTML).toContain('<span>A</span>');
571 + expect(container.innerHTML).toContain('<span>B</span>');
572 + expect(ref.current).toBe(null);
573 +
574 + expect(() => {
575 + act(() => {
576 + ReactDOM.hydrateRoot(container, <App hasB={false} />);
577 + });
578 + }).toErrorDev('Did not expect server HTML to contain a <span> in <div>');
579 +
580 + expect(container.innerHTML).toContain('<span>A</span>');
581 + expect(container.innerHTML).not.toContain('<span>B</span>');
582 + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
583 + expect(ref.current).not.toBe(span);
584 + } else {
585 + expect(ref.current).toBe(span);
586 + }
587 });
588
589 it('calls the onDeleted hydration callback if the parent gets deleted', async () => {
packages/react-reconciler/src/ReactFiberCompleteWork.new.js
+21 -1
@@ -29,7 +29,10 @@ import type {
29 import type {SuspenseContext} from './ReactFiberSuspenseContext.new';
30 import type {OffscreenState} from './ReactFiberOffscreenComponent';
31 import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.new';
32 -import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags';
32 +import {
33 + enableClientRenderFallbackOnHydrationMismatch,
34 + enableSuspenseAvoidThisFallback,
35 +} from 'shared/ReactFeatureFlags';
36
37 import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.new';
38
@@ -74,6 +77,9 @@ import {
77 StaticMask,
78 MutationMask,
79 Passive,
80 + Incomplete,
81 + ShouldCapture,
82 + ForceClientRender,
83 } from './ReactFiberFlags';
84
85 import {
@@ -120,9 +126,11 @@ import {
126 prepareToHydrateHostInstance,
127 prepareToHydrateHostTextInstance,
128 prepareToHydrateHostSuspenseInstance,
129 + warnIfUnhydratedTailNodes,
130 popHydrationState,
131 resetHydrationState,
132 getIsHydrating,
133 + hasUnhydratedTailNodes,
134 } from './ReactFiberHydrationContext.new';
135 import {
136 enableSuspenseCallback,
@@ -1021,6 +1029,18 @@ function completeWork(
1029 const nextState: null | SuspenseState = workInProgress.memoizedState;
1030
1031 if (enableSuspenseServerRenderer) {
1032 + if (
1033 + enableClientRenderFallbackOnHydrationMismatch &&
1034 + hasUnhydratedTailNodes() &&
1035 + (workInProgress.mode & ConcurrentMode) !== NoMode &&
1036 + (workInProgress.flags & DidCapture) === NoFlags
1037 + ) {
1038 + warnIfUnhydratedTailNodes(workInProgress);
1039 + resetHydrationState();
1040 + workInProgress.flags |=
1041 + ForceClientRender | Incomplete | ShouldCapture;
1042 + return workInProgress;
1043 + }
1044 if (nextState !== null && nextState.dehydrated !== null) {
1045 // We might be inside a hydration state the first time we're picking up this
1046 // Suspense boundary, and also after we've reentered it for further hydration.
packages/react-reconciler/src/ReactFiberCompleteWork.old.js
+21 -1
@@ -29,7 +29,10 @@ import type {
29 import type {SuspenseContext} from './ReactFiberSuspenseContext.old';
30 import type {OffscreenState} from './ReactFiberOffscreenComponent';
31 import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.old';
32 -import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags';
32 +import {
33 + enableClientRenderFallbackOnHydrationMismatch,
34 + enableSuspenseAvoidThisFallback,
35 +} from 'shared/ReactFeatureFlags';
36
37 import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.old';
38
@@ -74,6 +77,9 @@ import {
77 StaticMask,
78 MutationMask,
79 Passive,
80 + Incomplete,
81 + ShouldCapture,
82 + ForceClientRender,
83 } from './ReactFiberFlags';
84
85 import {
@@ -120,9 +126,11 @@ import {
126 prepareToHydrateHostInstance,
127 prepareToHydrateHostTextInstance,
128 prepareToHydrateHostSuspenseInstance,
129 + warnIfUnhydratedTailNodes,
130 popHydrationState,
131 resetHydrationState,
132 getIsHydrating,
133 + hasUnhydratedTailNodes,
134 } from './ReactFiberHydrationContext.old';
135 import {
136 enableSuspenseCallback,
@@ -1021,6 +1029,18 @@ function completeWork(
1029 const nextState: null | SuspenseState = workInProgress.memoizedState;
1030
1031 if (enableSuspenseServerRenderer) {
1032 + if (
1033 + enableClientRenderFallbackOnHydrationMismatch &&
1034 + hasUnhydratedTailNodes() &&
1035 + (workInProgress.mode & ConcurrentMode) !== NoMode &&
1036 + (workInProgress.flags & DidCapture) === NoFlags
1037 + ) {
1038 + warnIfUnhydratedTailNodes(workInProgress);
1039 + resetHydrationState();
1040 + workInProgress.flags |=
1041 + ForceClientRender | Incomplete | ShouldCapture;
1042 + return workInProgress;
1043 + }
1044 if (nextState !== null && nextState.dehydrated !== null) {
1045 // We might be inside a hydration state the first time we're picking up this
1046 // Suspense boundary, and also after we've reentered it for further hydration.
packages/react-reconciler/src/ReactFiberHydrationContext.new.js
+47 -10
@@ -26,7 +26,13 @@ import {
26 HostRoot,
27 SuspenseComponent,
28 } from './ReactWorkTags';
29 -import {ChildDeletion, Placement, Hydrating} from './ReactFiberFlags';
29 +import {
30 + ChildDeletion,
31 + Placement,
32 + Hydrating,
33 + NoFlags,
34 + DidCapture,
35 +} from './ReactFiberFlags';
36
37 import {
38 createFiberFromHostInstanceForDeletion,
@@ -121,7 +127,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
127 return true;
128 }
129
124 -function deleteHydratableInstance(
130 +function warnUnhydratedInstance(
131 returnFiber: Fiber,
132 instance: HydratableInstance,
133 ) {
@@ -151,7 +157,13 @@ function deleteHydratableInstance(
157 break;
158 }
159 }
160 +}
161
162 +function deleteHydratableInstance(
163 + returnFiber: Fiber,
164 + instance: HydratableInstance,
165 +) {
166 + warnUnhydratedInstance(returnFiber, instance);
167 const childToDelete = createFiberFromHostInstanceForDeletion();
168 childToDelete.stateNode = instance;
169 childToDelete.return = returnFiber;
@@ -327,11 +339,16 @@ function tryHydrate(fiber, nextInstance) {
339 }
340 }
341
330 -function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) {
331 - if (
342 +function shouldClientRenderOnMismatch(fiber: Fiber) {
343 + return (
344 enableClientRenderFallbackOnHydrationMismatch &&
333 - (fiber.mode & ConcurrentMode) !== NoMode
334 - ) {
345 + (fiber.mode & ConcurrentMode) !== NoMode &&
346 + (fiber.flags & DidCapture) === NoFlags
347 + );
348 +}
349 +
350 +function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) {
351 + if (shouldClientRenderOnMismatch(fiber)) {
352 throw new Error(
353 'An error occurred during hydration. The server HTML was replaced with client content',
354 );
@@ -539,12 +556,18 @@ function popHydrationState(fiber: Fiber): boolean {
556 !shouldSetTextContent(fiber.type, fiber.memoizedProps)))
557 ) {
558 let nextInstance = nextHydratableInstance;
542 - while (nextInstance) {
543 - deleteHydratableInstance(fiber, nextInstance);
544 - nextInstance = getNextHydratableSibling(nextInstance);
559 + if (nextInstance) {
560 + if (shouldClientRenderOnMismatch(fiber)) {
561 + warnIfUnhydratedTailNodes(fiber);
562 + throwOnHydrationMismatchIfConcurrentMode(fiber);
563 + } else {
564 + while (nextInstance) {
565 + deleteHydratableInstance(fiber, nextInstance);
566 + nextInstance = getNextHydratableSibling(nextInstance);
567 + }
568 + }
569 }
570 }
547 -
571 popToNextHostParent(fiber);
572 if (fiber.tag === SuspenseComponent) {
573 nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber);
@@ -556,6 +579,18 @@ function popHydrationState(fiber: Fiber): boolean {
579 return true;
580 }
581
582 +function hasUnhydratedTailNodes() {
583 + return isHydrating && nextHydratableInstance !== null;
584 +}
585 +
586 +function warnIfUnhydratedTailNodes(fiber: Fiber) {
587 + let nextInstance = nextHydratableInstance;
588 + while (nextInstance) {
589 + warnUnhydratedInstance(fiber, nextInstance);
590 + nextInstance = getNextHydratableSibling(nextInstance);
591 + }
592 +}
593 +
594 function resetHydrationState(): void {
595 if (!supportsHydration) {
596 return;
@@ -581,4 +616,6 @@ export {
616 prepareToHydrateHostTextInstance,
617 prepareToHydrateHostSuspenseInstance,
618 popHydrationState,
619 + hasUnhydratedTailNodes,
620 + warnIfUnhydratedTailNodes,
621 };
packages/react-reconciler/src/ReactFiberHydrationContext.old.js
+47 -10
@@ -26,7 +26,13 @@ import {
26 HostRoot,
27 SuspenseComponent,
28 } from './ReactWorkTags';
29 -import {ChildDeletion, Placement, Hydrating} from './ReactFiberFlags';
29 +import {
30 + ChildDeletion,
31 + Placement,
32 + Hydrating,
33 + NoFlags,
34 + DidCapture,
35 +} from './ReactFiberFlags';
36
37 import {
38 createFiberFromHostInstanceForDeletion,
@@ -121,7 +127,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
127 return true;
128 }
129
124 -function deleteHydratableInstance(
130 +function warnUnhydratedInstance(
131 returnFiber: Fiber,
132 instance: HydratableInstance,
133 ) {
@@ -151,7 +157,13 @@ function deleteHydratableInstance(
157 break;
158 }
159 }
160 +}
161
162 +function deleteHydratableInstance(
163 + returnFiber: Fiber,
164 + instance: HydratableInstance,
165 +) {
166 + warnUnhydratedInstance(returnFiber, instance);
167 const childToDelete = createFiberFromHostInstanceForDeletion();
168 childToDelete.stateNode = instance;
169 childToDelete.return = returnFiber;
@@ -327,11 +339,16 @@ function tryHydrate(fiber, nextInstance) {
339 }
340 }
341
330 -function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) {
331 - if (
342 +function shouldClientRenderOnMismatch(fiber: Fiber) {
343 + return (
344 enableClientRenderFallbackOnHydrationMismatch &&
333 - (fiber.mode & ConcurrentMode) !== NoMode
334 - ) {
345 + (fiber.mode & ConcurrentMode) !== NoMode &&
346 + (fiber.flags & DidCapture) === NoFlags
347 + );
348 +}
349 +
350 +function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) {
351 + if (shouldClientRenderOnMismatch(fiber)) {
352 throw new Error(
353 'An error occurred during hydration. The server HTML was replaced with client content',
354 );
@@ -539,12 +556,18 @@ function popHydrationState(fiber: Fiber): boolean {
556 !shouldSetTextContent(fiber.type, fiber.memoizedProps)))
557 ) {
558 let nextInstance = nextHydratableInstance;
542 - while (nextInstance) {
543 - deleteHydratableInstance(fiber, nextInstance);
544 - nextInstance = getNextHydratableSibling(nextInstance);
559 + if (nextInstance) {
560 + if (shouldClientRenderOnMismatch(fiber)) {
561 + warnIfUnhydratedTailNodes(fiber);
562 + throwOnHydrationMismatchIfConcurrentMode(fiber);
563 + } else {
564 + while (nextInstance) {
565 + deleteHydratableInstance(fiber, nextInstance);
566 + nextInstance = getNextHydratableSibling(nextInstance);
567 + }
568 + }
569 }
570 }
547 -
571 popToNextHostParent(fiber);
572 if (fiber.tag === SuspenseComponent) {
573 nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber);
@@ -556,6 +579,18 @@ function popHydrationState(fiber: Fiber): boolean {
579 return true;
580 }
581
582 +function hasUnhydratedTailNodes() {
583 + return isHydrating && nextHydratableInstance !== null;
584 +}
585 +
586 +function warnIfUnhydratedTailNodes(fiber: Fiber) {
587 + let nextInstance = nextHydratableInstance;
588 + while (nextInstance) {
589 + warnUnhydratedInstance(fiber, nextInstance);
590 + nextInstance = getNextHydratableSibling(nextInstance);
591 + }
592 +}
593 +
594 function resetHydrationState(): void {
595 if (!supportsHydration) {
596 return;
@@ -581,4 +616,6 @@ export {
616 prepareToHydrateHostTextInstance,
617 prepareToHydrateHostSuspenseInstance,
618 popHydrationState,
619 + hasUnhydratedTailNodes,
620 + warnIfUnhydratedTailNodes,
621 };