[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
};