Skip to content
Merged
44 changes: 16 additions & 28 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1666,8 +1666,6 @@ describe('ReactDOMFizzServer', () => {
// @gate supportsNativeUseSyncExternalStore
// @gate experimental
it('calls getServerSnapshot instead of getSnapshot', async () => {
const ref = React.createRef();

function getServerSnapshot() {
return 'server';
}
Expand All @@ -1692,7 +1690,7 @@ describe('ReactDOMFizzServer', () => {
getServerSnapshot,
);
return (
<div ref={ref}>
<div>
<Child text={value} />
</div>
);
Expand All @@ -1715,25 +1713,19 @@ describe('ReactDOMFizzServer', () => {
});
expect(Scheduler).toHaveYielded(['server']);

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

ReactDOM.hydrateRoot(container, <App />);

// The first paint uses the server snapshot
expect(Scheduler).toFlushUntilNextPaint(['server']);
expect(getVisibleChildren(container)).toEqual(<div>server</div>);
// Hydration succeeded
expect(ref.current).toEqual(serverRenderedDiv);

// Asynchronously we detect that the store has changed on the client,
// and patch up the inconsistency
expect(Scheduler).toFlushUntilNextPaint(['client']);
expect(() => {
// The first paint switches to client rendering due to mismatch
expect(Scheduler).toFlushUntilNextPaint(['client']);
}).toErrorDev(
'Warning: An error occurred during hydration. The server HTML was replaced with client content',
{withoutStack: true},
);
expect(getVisibleChildren(container)).toEqual(<div>client</div>);
expect(ref.current).toEqual(serverRenderedDiv);
});

// The selector implementation uses the lazy ref initialization pattern
// @gate !(enableUseRefAccessWarning && __DEV__)
// @gate supportsNativeUseSyncExternalStore
// @gate experimental
it('calls getServerSnapshot instead of getSnapshot (with selector and isEqual)', async () => {
Expand Down Expand Up @@ -1798,21 +1790,17 @@ describe('ReactDOMFizzServer', () => {
});
expect(Scheduler).toHaveYielded(['server']);

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

ReactDOM.hydrateRoot(container, <App />);

// The first paint uses the server snapshot
expect(Scheduler).toFlushUntilNextPaint(['server']);
expect(getVisibleChildren(container)).toEqual(<div>server</div>);
// Hydration succeeded
expect(ref.current).toEqual(serverRenderedDiv);

// Asynchronously we detect that the store has changed on the client,
// and patch up the inconsistency
expect(Scheduler).toFlushUntilNextPaint(['client']);
// The first paint uses the client due to mismatch forcing client render
expect(() => {
// The first paint switches to client rendering due to mismatch
expect(Scheduler).toFlushUntilNextPaint(['client']);
}).toErrorDev(
'Warning: An error occurred during hydration. The server HTML was replaced with client content',
{withoutStack: true},
);
expect(getVisibleChildren(container)).toEqual(<div>client</div>);
expect(ref.current).toEqual(serverRenderedDiv);
});

// @gate supportsNativeUseSyncExternalStore
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1644,11 +1644,10 @@ describe('ReactDOMServerHooks', () => {

// This is the wrong HTML string
container.innerHTML = '<span></span>';
ReactDOM.createRoot(container, {hydrate: true}).render(<App />);
ReactDOM.hydrateRoot(container, <App />);
expect(() => Scheduler.unstable_flushAll()).toErrorDev(
[
'Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.',
'Warning: Expected server HTML to contain a matching <div> in <div>.',
],
{withoutStack: 1},
);
Expand Down Expand Up @@ -1732,11 +1731,10 @@ describe('ReactDOMServerHooks', () => {

// This is the wrong HTML string
container.innerHTML = '<span></span>';
ReactDOM.createRoot(container, {hydrate: true}).render(<App />);
ReactDOM.hydrateRoot(container, <App />);
expect(() => Scheduler.unstable_flushAll()).toErrorDev(
[
'Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.',
'Warning: Expected server HTML to contain a matching <div> in <div>.',
],
{withoutStack: 1},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,7 @@ describe('ReactDOMServerPartialHydration', () => {
// hydrating anyway.
suspend = true;
ReactDOM.hydrateRoot(container, <App />);
expect(() => {
Scheduler.unstable_flushAll();
}).toErrorDev(
// TODO: This error should not be logged in this case. It's a false positive.
'Did not expect server HTML to contain the text node "Hello" in <div>.',
);
Scheduler.unstable_flushAll();
jest.runAllTimers();

// Expect the server-generated HTML to stay intact.
Expand All @@ -218,6 +213,101 @@ describe('ReactDOMServerPartialHydration', () => {
expect(container.textContent).toBe('HelloHello');
});

it('falls back to client rendering boundary on mismatch', async () => {
let client = false;
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => {
resolve = () => {
suspend = false;
resolvePromise();
};
});
function Child() {
if (suspend) {
Scheduler.unstable_yieldValue('Suspend');
throw promise;
} else {
Scheduler.unstable_yieldValue('Hello');
return 'Hello';
}
}
function Component({shouldMismatch}) {
Scheduler.unstable_yieldValue('Component');
if (shouldMismatch && client) {
return <article>Mismatch</article>;
}
return <div>Component</div>;
}
function App() {
return (
<Suspense fallback="Loading...">
<Child />
<Component />
<Component />
<Component />
<Component shouldMismatch={true} />
</Suspense>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(Scheduler).toHaveYielded([
'Hello',
'Component',
'Component',
'Component',
'Component',
]);

expect(container.innerHTML).toBe(
'<!--$-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/$-->',
);

suspend = true;
client = true;

ReactDOM.hydrateRoot(container, <App />);
expect(Scheduler).toFlushAndYield([
'Suspend',
'Component',
'Component',
'Component',
'Component',
]);
jest.runAllTimers();

// Unchanged
expect(container.innerHTML).toBe(
'<!--$-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/$-->',
);

suspend = false;
resolve();
await promise;

expect(Scheduler).toFlushAndYield([
// first pass, mismatches at end
'Hello',
'Component',
'Component',
'Component',
'Component',
// second pass as client render
'Hello',
'Component',
'Component',
'Component',
'Component',
]);

// Client rendered - suspense comment nodes removed
expect(container.innerHTML).toBe(
'Hello<div>Component</div><div>Component</div><div>Component</div><article>Mismatch</article>',
);
});

it('calls the hydration callbacks after hydration or deletion', async () => {
let suspend = false;
let resolve;
Expand Down
11 changes: 11 additions & 0 deletions packages/react-reconciler/src/ReactFiberHydrationContext.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import type {Fiber} from './ReactInternalTypes';
import {NoMode, ConcurrentMode} from './ReactTypeOfMode';
import type {
Instance,
TextInstance,
Expand Down Expand Up @@ -313,12 +314,21 @@ function tryHydrate(fiber, nextInstance) {
}
}

function throwOnHydrationMismatchIfConcurrentMode(fiber) {
if ((fiber.mode & ConcurrentMode) !== NoMode) {
throw new Error(
'An error occurred during hydration. The server HTML was replaced with client content',
);
}
}

function tryToClaimNextHydratableInstance(fiber: Fiber): void {
if (!isHydrating) {
return;
}
let nextInstance = nextHydratableInstance;
if (!nextInstance) {
throwOnHydrationMismatchIfConcurrentMode(fiber);
// Nothing to hydrate. Make it an insertion.
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
isHydrating = false;
Expand All @@ -327,6 +337,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
}
const firstAttemptedInstance = nextInstance;
if (!tryHydrate(fiber, nextInstance)) {
throwOnHydrationMismatchIfConcurrentMode(fiber);
// If we can't hydrate this instance let's try the next one.
// We use this as a heuristic. It's based on intuition and not data so it
// might be flawed or unnecessary.
Expand Down
11 changes: 11 additions & 0 deletions packages/react-reconciler/src/ReactFiberHydrationContext.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import type {Fiber} from './ReactInternalTypes';
import {NoMode, ConcurrentMode} from './ReactTypeOfMode';
import type {
Instance,
TextInstance,
Expand Down Expand Up @@ -313,12 +314,21 @@ function tryHydrate(fiber, nextInstance) {
}
}

function throwOnHydrationMismatchIfConcurrentMode(fiber) {
if ((fiber.mode & ConcurrentMode) !== NoMode) {
throw new Error(
'An error occurred during hydration. The server HTML was replaced with client content',
);
}
}

function tryToClaimNextHydratableInstance(fiber: Fiber): void {
if (!isHydrating) {
return;
}
let nextInstance = nextHydratableInstance;
if (!nextInstance) {
throwOnHydrationMismatchIfConcurrentMode(fiber);
// Nothing to hydrate. Make it an insertion.
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
isHydrating = false;
Expand All @@ -327,6 +337,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
}
const firstAttemptedInstance = nextInstance;
if (!tryHydrate(fiber, nextInstance)) {
throwOnHydrationMismatchIfConcurrentMode(fiber);
// If we can't hydrate this instance let's try the next one.
// We use this as a heuristic. It's based on intuition and not data so it
// might be flawed or unnecessary.
Expand Down
Loading