Skip to content

Commit

Permalink
[Hydration] Fallback to client render if server rendered extra nodes (#…
Browse files Browse the repository at this point in the history
…23176)

* rename

* rename

* replace-fork

* rename

* warn in a loop
  • Loading branch information
salazarm committed Feb 1, 2022
1 parent fa816be commit 3f5ff16
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ describe('ReactDOMServerPartialHydration', () => {
expect(container.innerHTML).toContain('<div>Sibling</div>');
});

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

ReactDOM.hydrateRoot(container, <App hasB={false} />);
expect(() => {
Scheduler.unstable_flushAll();
act(() => {
ReactDOM.hydrateRoot(container, <App hasB={false} />);
});
}).toErrorDev('Did not expect server HTML to contain a <span> in <div>');

jest.runAllTimers();

expect(container.innerHTML).toContain('<span>A</span>');
expect(container.innerHTML).not.toContain('<span>B</span>');
expect(ref.current).toBe(span);

if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
expect(ref.current).not.toBe(span);
} else {
expect(ref.current).toBe(span);
}
});

it('recovers with client render when server rendered additional nodes at suspense root after unsuspending', async () => {
spyOnDev(console, 'error');
const ref = React.createRef();
function App({hasB}) {
return (
<div>
<Suspense fallback="Loading...">
<Suspender />
<span ref={ref}>A</span>
{hasB ? <span>B</span> : null}
</Suspense>
<div>Sibling</div>
</div>
);
}

let shouldSuspend = false;
let resolve;
const promise = new Promise(res => {
resolve = () => {
shouldSuspend = false;
res();
};
});
function Suspender() {
if (shouldSuspend) {
throw promise;
}
return <></>;
}

const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);

const container = document.createElement('div');
container.innerHTML = finalHTML;

const span = container.getElementsByTagName('span')[0];

expect(container.innerHTML).toContain('<span>A</span>');
expect(container.innerHTML).toContain('<span>B</span>');
expect(ref.current).toBe(null);

shouldSuspend = true;
act(() => {
ReactDOM.hydrateRoot(container, <App hasB={false} />);
});

// await expect(async () => {
resolve();
await promise;
Scheduler.unstable_flushAll();
await null;
jest.runAllTimers();
// }).toErrorDev('Did not expect server HTML to contain a <span> in <div>');

expect(container.innerHTML).toContain('<span>A</span>');
expect(container.innerHTML).not.toContain('<span>B</span>');
if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
expect(ref.current).not.toBe(span);
} else {
expect(ref.current).toBe(span);
}
});

it('recovers with client render when server rendered additional nodes deep inside suspense root', async () => {
const ref = React.createRef();
function App({hasB}) {
return (
<div>
<Suspense fallback="Loading...">
<div>
<span ref={ref}>A</span>
{hasB ? <span>B</span> : null}
</div>
</Suspense>
<div>Sibling</div>
</div>
);
}

const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);

const container = document.createElement('div');
container.innerHTML = finalHTML;

const span = container.getElementsByTagName('span')[0];

expect(container.innerHTML).toContain('<span>A</span>');
expect(container.innerHTML).toContain('<span>B</span>');
expect(ref.current).toBe(null);

expect(() => {
act(() => {
ReactDOM.hydrateRoot(container, <App hasB={false} />);
});
}).toErrorDev('Did not expect server HTML to contain a <span> in <div>');

expect(container.innerHTML).toContain('<span>A</span>');
expect(container.innerHTML).not.toContain('<span>B</span>');
if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
expect(ref.current).not.toBe(span);
} else {
expect(ref.current).toBe(span);
}
});

it('calls the onDeleted hydration callback if the parent gets deleted', async () => {
Expand Down
22 changes: 21 additions & 1 deletion packages/react-reconciler/src/ReactFiberCompleteWork.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import type {
import type {SuspenseContext} from './ReactFiberSuspenseContext.new';
import type {OffscreenState} from './ReactFiberOffscreenComponent';
import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.new';
import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags';
import {
enableClientRenderFallbackOnHydrationMismatch,
enableSuspenseAvoidThisFallback,
} from 'shared/ReactFeatureFlags';

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

Expand Down Expand Up @@ -74,6 +77,9 @@ import {
StaticMask,
MutationMask,
Passive,
Incomplete,
ShouldCapture,
ForceClientRender,
} from './ReactFiberFlags';

import {
Expand Down Expand Up @@ -120,9 +126,11 @@ import {
prepareToHydrateHostInstance,
prepareToHydrateHostTextInstance,
prepareToHydrateHostSuspenseInstance,
warnIfUnhydratedTailNodes,
popHydrationState,
resetHydrationState,
getIsHydrating,
hasUnhydratedTailNodes,
} from './ReactFiberHydrationContext.new';
import {
enableSuspenseCallback,
Expand Down Expand Up @@ -1021,6 +1029,18 @@ function completeWork(
const nextState: null | SuspenseState = workInProgress.memoizedState;

if (enableSuspenseServerRenderer) {
if (
enableClientRenderFallbackOnHydrationMismatch &&
hasUnhydratedTailNodes() &&
(workInProgress.mode & ConcurrentMode) !== NoMode &&
(workInProgress.flags & DidCapture) === NoFlags
) {
warnIfUnhydratedTailNodes(workInProgress);
resetHydrationState();
workInProgress.flags |=
ForceClientRender | Incomplete | ShouldCapture;
return workInProgress;
}
if (nextState !== null && nextState.dehydrated !== null) {
// We might be inside a hydration state the first time we're picking up this
// Suspense boundary, and also after we've reentered it for further hydration.
Expand Down
22 changes: 21 additions & 1 deletion packages/react-reconciler/src/ReactFiberCompleteWork.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import type {
import type {SuspenseContext} from './ReactFiberSuspenseContext.old';
import type {OffscreenState} from './ReactFiberOffscreenComponent';
import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.old';
import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags';
import {
enableClientRenderFallbackOnHydrationMismatch,
enableSuspenseAvoidThisFallback,
} from 'shared/ReactFeatureFlags';

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

Expand Down Expand Up @@ -74,6 +77,9 @@ import {
StaticMask,
MutationMask,
Passive,
Incomplete,
ShouldCapture,
ForceClientRender,
} from './ReactFiberFlags';

import {
Expand Down Expand Up @@ -120,9 +126,11 @@ import {
prepareToHydrateHostInstance,
prepareToHydrateHostTextInstance,
prepareToHydrateHostSuspenseInstance,
warnIfUnhydratedTailNodes,
popHydrationState,
resetHydrationState,
getIsHydrating,
hasUnhydratedTailNodes,
} from './ReactFiberHydrationContext.old';
import {
enableSuspenseCallback,
Expand Down Expand Up @@ -1021,6 +1029,18 @@ function completeWork(
const nextState: null | SuspenseState = workInProgress.memoizedState;

if (enableSuspenseServerRenderer) {
if (
enableClientRenderFallbackOnHydrationMismatch &&
hasUnhydratedTailNodes() &&
(workInProgress.mode & ConcurrentMode) !== NoMode &&
(workInProgress.flags & DidCapture) === NoFlags
) {
warnIfUnhydratedTailNodes(workInProgress);
resetHydrationState();
workInProgress.flags |=
ForceClientRender | Incomplete | ShouldCapture;
return workInProgress;
}
if (nextState !== null && nextState.dehydrated !== null) {
// We might be inside a hydration state the first time we're picking up this
// Suspense boundary, and also after we've reentered it for further hydration.
Expand Down
57 changes: 47 additions & 10 deletions packages/react-reconciler/src/ReactFiberHydrationContext.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ import {
HostRoot,
SuspenseComponent,
} from './ReactWorkTags';
import {ChildDeletion, Placement, Hydrating} from './ReactFiberFlags';
import {
ChildDeletion,
Placement,
Hydrating,
NoFlags,
DidCapture,
} from './ReactFiberFlags';

import {
createFiberFromHostInstanceForDeletion,
Expand Down Expand Up @@ -121,7 +127,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
return true;
}

function deleteHydratableInstance(
function warnUnhydratedInstance(
returnFiber: Fiber,
instance: HydratableInstance,
) {
Expand Down Expand Up @@ -151,7 +157,13 @@ function deleteHydratableInstance(
break;
}
}
}

function deleteHydratableInstance(
returnFiber: Fiber,
instance: HydratableInstance,
) {
warnUnhydratedInstance(returnFiber, instance);
const childToDelete = createFiberFromHostInstanceForDeletion();
childToDelete.stateNode = instance;
childToDelete.return = returnFiber;
Expand Down Expand Up @@ -327,11 +339,16 @@ function tryHydrate(fiber, nextInstance) {
}
}

function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) {
if (
function shouldClientRenderOnMismatch(fiber: Fiber) {
return (
enableClientRenderFallbackOnHydrationMismatch &&
(fiber.mode & ConcurrentMode) !== NoMode
) {
(fiber.mode & ConcurrentMode) !== NoMode &&
(fiber.flags & DidCapture) === NoFlags
);
}

function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) {
if (shouldClientRenderOnMismatch(fiber)) {
throw new Error(
'An error occurred during hydration. The server HTML was replaced with client content',
);
Expand Down Expand Up @@ -539,12 +556,18 @@ function popHydrationState(fiber: Fiber): boolean {
!shouldSetTextContent(fiber.type, fiber.memoizedProps)))
) {
let nextInstance = nextHydratableInstance;
while (nextInstance) {
deleteHydratableInstance(fiber, nextInstance);
nextInstance = getNextHydratableSibling(nextInstance);
if (nextInstance) {
if (shouldClientRenderOnMismatch(fiber)) {
warnIfUnhydratedTailNodes(fiber);
throwOnHydrationMismatchIfConcurrentMode(fiber);
} else {
while (nextInstance) {
deleteHydratableInstance(fiber, nextInstance);
nextInstance = getNextHydratableSibling(nextInstance);
}
}
}
}

popToNextHostParent(fiber);
if (fiber.tag === SuspenseComponent) {
nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber);
Expand All @@ -556,6 +579,18 @@ function popHydrationState(fiber: Fiber): boolean {
return true;
}

function hasUnhydratedTailNodes() {
return isHydrating && nextHydratableInstance !== null;
}

function warnIfUnhydratedTailNodes(fiber: Fiber) {
let nextInstance = nextHydratableInstance;
while (nextInstance) {
warnUnhydratedInstance(fiber, nextInstance);
nextInstance = getNextHydratableSibling(nextInstance);
}
}

function resetHydrationState(): void {
if (!supportsHydration) {
return;
Expand All @@ -581,4 +616,6 @@ export {
prepareToHydrateHostTextInstance,
prepareToHydrateHostSuspenseInstance,
popHydrationState,
hasUnhydratedTailNodes,
warnIfUnhydratedTailNodes,
};
Loading

0 comments on commit 3f5ff16

Please sign in to comment.