Skip to content

Commit 3f5ff16

Browse files
authored
[Hydration] Fallback to client render if server rendered extra nodes (#23176)
* rename * rename * replace-fork * rename * warn in a loop
1 parent fa816be commit 3f5ff16

5 files changed

+253
-26
lines changed

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

+117-4
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ describe('ReactDOMServerPartialHydration', () => {
437437
expect(container.innerHTML).toContain('<div>Sibling</div>');
438438
});
439439

440-
it('recovers when server rendered additional nodes', async () => {
440+
it('recovers with client render when server rendered additional nodes at suspense root', async () => {
441441
const ref = React.createRef();
442442
function App({hasB}) {
443443
return (
@@ -462,15 +462,128 @@ describe('ReactDOMServerPartialHydration', () => {
462462
expect(container.innerHTML).toContain('<span>B</span>');
463463
expect(ref.current).toBe(null);
464464

465-
ReactDOM.hydrateRoot(container, <App hasB={false} />);
466465
expect(() => {
467-
Scheduler.unstable_flushAll();
466+
act(() => {
467+
ReactDOM.hydrateRoot(container, <App hasB={false} />);
468+
});
468469
}).toErrorDev('Did not expect server HTML to contain a <span> in <div>');
470+
469471
jest.runAllTimers();
470472

471473
expect(container.innerHTML).toContain('<span>A</span>');
472474
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+
}
474587
});
475588

476589
it('calls the onDeleted hydration callback if the parent gets deleted', async () => {

packages/react-reconciler/src/ReactFiberCompleteWork.new.js

+21-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ import type {
2929
import type {SuspenseContext} from './ReactFiberSuspenseContext.new';
3030
import type {OffscreenState} from './ReactFiberOffscreenComponent';
3131
import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.new';
32-
import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags';
32+
import {
33+
enableClientRenderFallbackOnHydrationMismatch,
34+
enableSuspenseAvoidThisFallback,
35+
} from 'shared/ReactFeatureFlags';
3336

3437
import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.new';
3538

@@ -74,6 +77,9 @@ import {
7477
StaticMask,
7578
MutationMask,
7679
Passive,
80+
Incomplete,
81+
ShouldCapture,
82+
ForceClientRender,
7783
} from './ReactFiberFlags';
7884

7985
import {
@@ -120,9 +126,11 @@ import {
120126
prepareToHydrateHostInstance,
121127
prepareToHydrateHostTextInstance,
122128
prepareToHydrateHostSuspenseInstance,
129+
warnIfUnhydratedTailNodes,
123130
popHydrationState,
124131
resetHydrationState,
125132
getIsHydrating,
133+
hasUnhydratedTailNodes,
126134
} from './ReactFiberHydrationContext.new';
127135
import {
128136
enableSuspenseCallback,
@@ -1021,6 +1029,18 @@ function completeWork(
10211029
const nextState: null | SuspenseState = workInProgress.memoizedState;
10221030

10231031
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+
}
10241044
if (nextState !== null && nextState.dehydrated !== null) {
10251045
// We might be inside a hydration state the first time we're picking up this
10261046
// Suspense boundary, and also after we've reentered it for further hydration.

packages/react-reconciler/src/ReactFiberCompleteWork.old.js

+21-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ import type {
2929
import type {SuspenseContext} from './ReactFiberSuspenseContext.old';
3030
import type {OffscreenState} from './ReactFiberOffscreenComponent';
3131
import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.old';
32-
import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags';
32+
import {
33+
enableClientRenderFallbackOnHydrationMismatch,
34+
enableSuspenseAvoidThisFallback,
35+
} from 'shared/ReactFeatureFlags';
3336

3437
import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.old';
3538

@@ -74,6 +77,9 @@ import {
7477
StaticMask,
7578
MutationMask,
7679
Passive,
80+
Incomplete,
81+
ShouldCapture,
82+
ForceClientRender,
7783
} from './ReactFiberFlags';
7884

7985
import {
@@ -120,9 +126,11 @@ import {
120126
prepareToHydrateHostInstance,
121127
prepareToHydrateHostTextInstance,
122128
prepareToHydrateHostSuspenseInstance,
129+
warnIfUnhydratedTailNodes,
123130
popHydrationState,
124131
resetHydrationState,
125132
getIsHydrating,
133+
hasUnhydratedTailNodes,
126134
} from './ReactFiberHydrationContext.old';
127135
import {
128136
enableSuspenseCallback,
@@ -1021,6 +1029,18 @@ function completeWork(
10211029
const nextState: null | SuspenseState = workInProgress.memoizedState;
10221030

10231031
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+
}
10241044
if (nextState !== null && nextState.dehydrated !== null) {
10251045
// We might be inside a hydration state the first time we're picking up this
10261046
// Suspense boundary, and also after we've reentered it for further hydration.

packages/react-reconciler/src/ReactFiberHydrationContext.new.js

+47-10
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@ import {
2626
HostRoot,
2727
SuspenseComponent,
2828
} from './ReactWorkTags';
29-
import {ChildDeletion, Placement, Hydrating} from './ReactFiberFlags';
29+
import {
30+
ChildDeletion,
31+
Placement,
32+
Hydrating,
33+
NoFlags,
34+
DidCapture,
35+
} from './ReactFiberFlags';
3036

3137
import {
3238
createFiberFromHostInstanceForDeletion,
@@ -121,7 +127,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
121127
return true;
122128
}
123129

124-
function deleteHydratableInstance(
130+
function warnUnhydratedInstance(
125131
returnFiber: Fiber,
126132
instance: HydratableInstance,
127133
) {
@@ -151,7 +157,13 @@ function deleteHydratableInstance(
151157
break;
152158
}
153159
}
160+
}
154161

162+
function deleteHydratableInstance(
163+
returnFiber: Fiber,
164+
instance: HydratableInstance,
165+
) {
166+
warnUnhydratedInstance(returnFiber, instance);
155167
const childToDelete = createFiberFromHostInstanceForDeletion();
156168
childToDelete.stateNode = instance;
157169
childToDelete.return = returnFiber;
@@ -327,11 +339,16 @@ function tryHydrate(fiber, nextInstance) {
327339
}
328340
}
329341

330-
function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) {
331-
if (
342+
function shouldClientRenderOnMismatch(fiber: Fiber) {
343+
return (
332344
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)) {
335352
throw new Error(
336353
'An error occurred during hydration. The server HTML was replaced with client content',
337354
);
@@ -539,12 +556,18 @@ function popHydrationState(fiber: Fiber): boolean {
539556
!shouldSetTextContent(fiber.type, fiber.memoizedProps)))
540557
) {
541558
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+
}
545569
}
546570
}
547-
548571
popToNextHostParent(fiber);
549572
if (fiber.tag === SuspenseComponent) {
550573
nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber);
@@ -556,6 +579,18 @@ function popHydrationState(fiber: Fiber): boolean {
556579
return true;
557580
}
558581

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+
559594
function resetHydrationState(): void {
560595
if (!supportsHydration) {
561596
return;
@@ -581,4 +616,6 @@ export {
581616
prepareToHydrateHostTextInstance,
582617
prepareToHydrateHostSuspenseInstance,
583618
popHydrationState,
619+
hasUnhydratedTailNodes,
620+
warnIfUnhydratedTailNodes,
584621
};

0 commit comments

Comments
 (0)